From 0881ea8563aafee27ab7e8436434dd5694d92ec7 Mon Sep 17 00:00:00 2001 From: ElementalCrisis <9443295+ElementalCrisis@users.noreply.github.com> Date: Thu, 3 Sep 2020 13:57:50 -0700 Subject: [PATCH 0001/1103] Initial commit --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..a5a685c6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Shoko - Anime Cataloging Program + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 6f40400aba26c7f83586bedaa1351277664e046d Mon Sep 17 00:00:00 2001 From: G Harshith Mohan Date: Fri, 4 Sep 2020 23:10:21 +0530 Subject: [PATCH 0002/1103] Actual initial commit.. --- .gitignore | 35 +++++ ShokoJellyfin.sln | 16 ++ .../Configuration/PluginConfiguration.cs | 56 +++++++ ShokoJellyfin/Configuration/configPage.html | 135 +++++++++++++++++ ShokoJellyfin/Plugin.cs | 36 +++++ ShokoJellyfin/Providers/API/Models/ApiKey.cs | 7 + .../Providers/API/Models/BaseModel.cs | 11 ++ ShokoJellyfin/Providers/API/Models/Episode.cs | 73 ++++++++++ ShokoJellyfin/Providers/API/Models/File.cs | 61 ++++++++ ShokoJellyfin/Providers/API/Models/Group.cs | 16 ++ ShokoJellyfin/Providers/API/Models/IDs.cs | 7 + ShokoJellyfin/Providers/API/Models/Image.cs | 18 +++ ShokoJellyfin/Providers/API/Models/Images.cs | 14 ++ ShokoJellyfin/Providers/API/Models/Rating.cs | 15 ++ ShokoJellyfin/Providers/API/Models/Role.cs | 26 ++++ ShokoJellyfin/Providers/API/Models/Series.cs | 98 +++++++++++++ ShokoJellyfin/Providers/API/Models/Sizes.cs | 26 ++++ ShokoJellyfin/Providers/API/Models/Tag.cs | 11 ++ ShokoJellyfin/Providers/API/Models/Title.cs | 13 ++ ShokoJellyfin/Providers/API/ShokoAPI.cs | 137 ++++++++++++++++++ ShokoJellyfin/Providers/EpisodeProvider.cs | 104 +++++++++++++ ShokoJellyfin/Providers/Helper.cs | 43 ++++++ ShokoJellyfin/Providers/ImageProvider.cs | 123 ++++++++++++++++ ShokoJellyfin/Providers/SeriesProvider.cs | 130 +++++++++++++++++ ShokoJellyfin/ShokoJellyfin.csproj | 20 +++ 25 files changed, 1231 insertions(+) create mode 100644 .gitignore create mode 100644 ShokoJellyfin.sln create mode 100644 ShokoJellyfin/Configuration/PluginConfiguration.cs create mode 100644 ShokoJellyfin/Configuration/configPage.html create mode 100644 ShokoJellyfin/Plugin.cs create mode 100644 ShokoJellyfin/Providers/API/Models/ApiKey.cs create mode 100644 ShokoJellyfin/Providers/API/Models/BaseModel.cs create mode 100644 ShokoJellyfin/Providers/API/Models/Episode.cs create mode 100644 ShokoJellyfin/Providers/API/Models/File.cs create mode 100644 ShokoJellyfin/Providers/API/Models/Group.cs create mode 100644 ShokoJellyfin/Providers/API/Models/IDs.cs create mode 100644 ShokoJellyfin/Providers/API/Models/Image.cs create mode 100644 ShokoJellyfin/Providers/API/Models/Images.cs create mode 100644 ShokoJellyfin/Providers/API/Models/Rating.cs create mode 100644 ShokoJellyfin/Providers/API/Models/Role.cs create mode 100644 ShokoJellyfin/Providers/API/Models/Series.cs create mode 100644 ShokoJellyfin/Providers/API/Models/Sizes.cs create mode 100644 ShokoJellyfin/Providers/API/Models/Tag.cs create mode 100644 ShokoJellyfin/Providers/API/Models/Title.cs create mode 100644 ShokoJellyfin/Providers/API/ShokoAPI.cs create mode 100644 ShokoJellyfin/Providers/EpisodeProvider.cs create mode 100644 ShokoJellyfin/Providers/Helper.cs create mode 100644 ShokoJellyfin/Providers/ImageProvider.cs create mode 100644 ShokoJellyfin/Providers/SeriesProvider.cs create mode 100644 ShokoJellyfin/ShokoJellyfin.csproj diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e8f10dc5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ + # Common IntelliJ Platform excludes + +# User specific +**/.idea/**/workspace.xml +**/.idea/**/tasks.xml +**/.idea/shelf/* +**/.idea/dictionaries +**/.idea/httpRequests/ + +# Sensitive or high-churn files +**/.idea/**/dataSources/ +**/.idea/**/dataSources.ids +**/.idea/**/dataSources.xml +**/.idea/**/dataSources.local.xml +**/.idea/**/sqlDataSources.xml +**/.idea/**/dynamic.xml + +# Rider +# Rider auto-generates .iml files, and contentModel.xml +**/.idea/**/*.iml +**/.idea/**/contentModel.xml +**/.idea/**/modules.xml + +*.suo +*.user +.vs/ +[Bb]in/ +[Oo]bj/ +_UpgradeReport_Files/ +[Pp]ackages/ + +Thumbs.db +Desktop.ini +.DS_Store +/.idea/ diff --git a/ShokoJellyfin.sln b/ShokoJellyfin.sln new file mode 100644 index 00000000..4e0699ff --- /dev/null +++ b/ShokoJellyfin.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ShokoJellyfin", "ShokoJellyfin\ShokoJellyfin.csproj", "{1DD876AE-9E68-4867-BDF6-B9050E63E936}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1DD876AE-9E68-4867-BDF6-B9050E63E936}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1DD876AE-9E68-4867-BDF6-B9050E63E936}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1DD876AE-9E68-4867-BDF6-B9050E63E936}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1DD876AE-9E68-4867-BDF6-B9050E63E936}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/ShokoJellyfin/Configuration/PluginConfiguration.cs b/ShokoJellyfin/Configuration/PluginConfiguration.cs new file mode 100644 index 00000000..cf7e15d1 --- /dev/null +++ b/ShokoJellyfin/Configuration/PluginConfiguration.cs @@ -0,0 +1,56 @@ +using MediaBrowser.Model.Plugins; + +namespace ShokoJellyfin.Configuration +{ + public class PluginConfiguration : BasePluginConfiguration + { + public string Host { get; set; } + + public string Port { get; set; } + + public string Username { get; set; } + + public string Password { get; set; } + + public string ApiKey { get; set; } + + public bool UseShokoThumbnails { get; set; } + + public bool HideArtStyleTags { get; set; } + + public bool HideSourceTags { get; set; } + + public bool HideMiscTags { get; set; } + + public bool HidePlotTags { get; set; } + + public bool HideAniDbTags { get; set; } + + public bool SynopsisCleanLinks { get; set; } + + public bool SynopsisCleanMiscLines { get; set; } + + public bool SynopsisRemoveSummary { get; set; } + + public bool SynopsisCleanMultiEmptyLines { get; set; } + + public PluginConfiguration() + { + Host = "127.0.0.1"; + Port = "8111"; + Username = "Default"; + Password = ""; + ApiKey = ""; + UseShokoThumbnails = true; + HideArtStyleTags = false; + HideSourceTags = false; + HideMiscTags = false; + HidePlotTags = true; + HideAniDbTags = true; + SynopsisCleanLinks = true; + SynopsisCleanMiscLines = true; + SynopsisRemoveSummary = true; + SynopsisCleanMultiEmptyLines = true; + } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Configuration/configPage.html b/ShokoJellyfin/Configuration/configPage.html new file mode 100644 index 00000000..607d7b73 --- /dev/null +++ b/ShokoJellyfin/Configuration/configPage.html @@ -0,0 +1,135 @@ + + + + + Shoko + + +
+
+
+
+
+ +
This is the IP address of the server where Shoko is running.
+
+
+ +
This is the port on which Shoko is running.
+
+
+ +
+
+ +
+
+ +
This field is auto-generated using the credentials. Only set this manually if that doesn't work!
+
+ + + + + + + + + + +
+ +
+
+
+
+ +
+ + diff --git a/ShokoJellyfin/Plugin.cs b/ShokoJellyfin/Plugin.cs new file mode 100644 index 00000000..96c54d24 --- /dev/null +++ b/ShokoJellyfin/Plugin.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; +using ShokoJellyfin.Configuration; + +namespace ShokoJellyfin +{ + public class Plugin : BasePlugin, IHasWebPages + { + public override string Name => "Shoko"; + + public override Guid Id => Guid.Parse("5216ccbf-d24a-4eb3-8a7e-7da4230b7052"); + + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + public static Plugin Instance { get; private set; } + + public IEnumerable GetPages() + { + return new[] + { + new PluginPageInfo + { + Name = this.Name, + EmbeddedResourcePath = string.Format("{0}.Configuration.configPage.html", GetType().Namespace) + } + }; + } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/API/Models/ApiKey.cs b/ShokoJellyfin/Providers/API/Models/ApiKey.cs new file mode 100644 index 00000000..d182087c --- /dev/null +++ b/ShokoJellyfin/Providers/API/Models/ApiKey.cs @@ -0,0 +1,7 @@ +namespace ShokoJellyfin.Providers.API.Models +{ + public class ApiKey + { + public string apikey { get; set; } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/API/Models/BaseModel.cs b/ShokoJellyfin/Providers/API/Models/BaseModel.cs new file mode 100644 index 00000000..016b0fca --- /dev/null +++ b/ShokoJellyfin/Providers/API/Models/BaseModel.cs @@ -0,0 +1,11 @@ +namespace ShokoJellyfin.Providers.API.Models +{ + public abstract class BaseModel + { + public string Name { get; set; } + + public int Size { get; set; } + + public Sizes Sizes { get; set; } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/API/Models/Episode.cs b/ShokoJellyfin/Providers/API/Models/Episode.cs new file mode 100644 index 00000000..495d4ce4 --- /dev/null +++ b/ShokoJellyfin/Providers/API/Models/Episode.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; + +namespace ShokoJellyfin.Providers.API.Models +{ + public class Episode : BaseModel + { + public EpisodeIDs IDs { get; set; } + + public DateTime? Watched { get; set; } + + public enum EpisodeType + { + Episode = 1, + Credits = 2, + Special = 3, + Trailer = 4, + Parody = 5, + Other = 6 + } + + public class AniDB + { + public int ID { get; set; } + + public EpisodeType Type { get; set; } + + public int EpisodeNumber { get; set; } + + public DateTime? AirDate { get; set; } + + public List Titles { get; set; } + + public string Description { get; set; } + + public Rating Rating { get; set; } + } + + public class TvDB + { + public int ID { get; set; } + + public int Season { get; set; } + + public int Number { get; set; } + + public int AbsoluteNumber { get; set; } + + public string Title { get; set; } + + public string Description { get; set; } + + 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; } + } + + public class EpisodeIDs : IDs + { + public int AniDB { get; set; } + + public List<int> TvDB { get; set; } = new List<int>(); + } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/API/Models/File.cs b/ShokoJellyfin/Providers/API/Models/File.cs new file mode 100644 index 00000000..3da71fc8 --- /dev/null +++ b/ShokoJellyfin/Providers/API/Models/File.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; + +namespace ShokoJellyfin.Providers.API.Models +{ + public class File + { + public int ID { get; set; } + + public long Size { get; set; } + + public HashesType Hashes { get; set; } + + public List<Location> Locations { get; set; } + + public string RoundedStandardResolution { get; set; } + + public DateTime Created { get; set; } + + public class Location + { + public int ImportFolderID { get; set; } + + public string RelativePath { get; set; } + + public bool Accessible { get; set; } + } + + public class HashesType + { + public string ED2K { get; set; } + + public string SHA1 { get; set; } + + public string CRC32 { get; set; } + + public string MD5 { get; set; } + } + + public class FileDetailed : File + { + public List<SeriesXRefs> SeriesIDs { get; set; } + + public class FileIDs + { + public int AniDB { get; set; } + + public List<int> TvDB { get; set; } + + public int ID { get; set; } + } + + public class SeriesXRefs + { + public FileIDs SeriesID { get; set; } + + public List<FileIDs> EpisodeIDs { get; set; } + } + } + } +} diff --git a/ShokoJellyfin/Providers/API/Models/Group.cs b/ShokoJellyfin/Providers/API/Models/Group.cs new file mode 100644 index 00000000..10ec24eb --- /dev/null +++ b/ShokoJellyfin/Providers/API/Models/Group.cs @@ -0,0 +1,16 @@ +namespace ShokoJellyfin.Providers.API.Models +{ + public class Group : BaseModel + { + public GroupIDs IDs { get; set; } + + public bool HasCustomName { get; set; } + + public class GroupIDs : IDs + { + public int DefaultSeries { get; set; } + + public int ParentGroup { get; set; } + } + } +} diff --git a/ShokoJellyfin/Providers/API/Models/IDs.cs b/ShokoJellyfin/Providers/API/Models/IDs.cs new file mode 100644 index 00000000..2008ca6c --- /dev/null +++ b/ShokoJellyfin/Providers/API/Models/IDs.cs @@ -0,0 +1,7 @@ +namespace ShokoJellyfin.Providers.API.Models +{ + public class IDs + { + public int ID { get; set; } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/API/Models/Image.cs b/ShokoJellyfin/Providers/API/Models/Image.cs new file mode 100644 index 00000000..bc4488c5 --- /dev/null +++ b/ShokoJellyfin/Providers/API/Models/Image.cs @@ -0,0 +1,18 @@ +namespace ShokoJellyfin.Providers.API.Models +{ + public class Image + { + public string Source { get; set; } + + public string Type { get; set; } + + public string ID { get; set; } + + public string RelativeFilepath { get; set; } + + public bool Preferred { get; set; } + + public bool Disabled { get; set; } + + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/API/Models/Images.cs b/ShokoJellyfin/Providers/API/Models/Images.cs new file mode 100644 index 00000000..d3ad0229 --- /dev/null +++ b/ShokoJellyfin/Providers/API/Models/Images.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace ShokoJellyfin.Providers.API.Models +{ + public class Images + { + public List<Image> Posters { get; set; } = new List<Image>(); + + public List<Image> Fanarts { get; set; } = new List<Image>(); + + public List<Image> Banners { get; set; } = new List<Image>(); + + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/API/Models/Rating.cs b/ShokoJellyfin/Providers/API/Models/Rating.cs new file mode 100644 index 00000000..fee31103 --- /dev/null +++ b/ShokoJellyfin/Providers/API/Models/Rating.cs @@ -0,0 +1,15 @@ +namespace ShokoJellyfin.Providers.API.Models +{ + public class Rating + { + public decimal Value { get; set; } + + public int MaxValue { get; set; } + + public string Source { get; set; } + + public int Votes { get; set; } + + public string Type { get; set; } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/API/Models/Role.cs b/ShokoJellyfin/Providers/API/Models/Role.cs new file mode 100644 index 00000000..65f1a062 --- /dev/null +++ b/ShokoJellyfin/Providers/API/Models/Role.cs @@ -0,0 +1,26 @@ +namespace ShokoJellyfin.Providers.API.Models +{ + public class Role + { + public string Language { get; set; } + + public Person Staff { get; set; } + + public Person Character { get; set; } + + public string RoleName { get; set; } + + public string RoleDetails { get; set; } + + public class Person + { + public string Name { get; set; } + + public string AlternateName { get; set; } + + public string Description { get; set; } + + public Image Image { get; set; } + } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/API/Models/Series.cs b/ShokoJellyfin/Providers/API/Models/Series.cs new file mode 100644 index 00000000..9ad5ecfd --- /dev/null +++ b/ShokoJellyfin/Providers/API/Models/Series.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; + +namespace ShokoJellyfin.Providers.API.Models +{ + public class Series : BaseModel + { + public SeriesIDs IDs { get; set; } + + public Images Images { get; set; } + + public Rating UserRating { get; set; } + + public List<Resource> Links { get; set; } + + public DateTime Created { get; set; } + + public DateTime Updated { get; set; } + + public class AniDB + { + public int ID { get; set; } + + public string SeriesType { get; set; } + + public string Title { get; set; } + + public bool Restricted { get; set; } + + public DateTime? AirDate { get; set; } + + public DateTime? EndDate { get; set; } + + public List<Title> Titles { get; set; } + + public string Description { get; set; } + + public Image Poster { get; set; } + + public Rating Rating { get; set; } + + } + + public class TvDB + { + public int ID { get; set; } + + public DateTime? AirDate { get; set; } + + public DateTime? EndDate { get; set; } + + public string Title { get; set; } + + public string Description { get; set; } + + public int? Season { get; set; } + + public List<Image> Posters { get; set; } + + public List<Image> Fanarts { get; set; } + + public List<Image> Banners { get; set; } + + public Rating Rating { get; set; } + } + + public class Resource + { + public string name { get; set; } + + public string url { get; set; } + + public Image image { get; set; } + } + } + + public class SeriesIDs : IDs + { + public int AniDB { get; set; } + + public List<int> TvDB { get; set; } = new List<int>(); + + public List<int> MovieDB { get; set; } = new List<int>(); + + 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 SeriesSearchResult : Series + { + public string Match { get; set; } + + public double Distance { get; set; } + } +} diff --git a/ShokoJellyfin/Providers/API/Models/Sizes.cs b/ShokoJellyfin/Providers/API/Models/Sizes.cs new file mode 100644 index 00000000..9aac90b8 --- /dev/null +++ b/ShokoJellyfin/Providers/API/Models/Sizes.cs @@ -0,0 +1,26 @@ +namespace ShokoJellyfin.Providers.API.Models +{ + public class Sizes + { + public EpisodeCounts Local { get; set; } + + public EpisodeCounts Watched { get; set; } + + public EpisodeCounts Total { get; set; } + + public class EpisodeCounts + { + public int Episodes { get; set; } + + public int Specials { get; set; } + + public int Credits { get; set; } + + public int Trailers { get; set; } + + public int Parodies { get; set; } + + public int Others { get; set; } + } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/API/Models/Tag.cs b/ShokoJellyfin/Providers/API/Models/Tag.cs new file mode 100644 index 00000000..1623a3bc --- /dev/null +++ b/ShokoJellyfin/Providers/API/Models/Tag.cs @@ -0,0 +1,11 @@ +namespace ShokoJellyfin.Providers.API.Models +{ + public class Tag + { + public string Name { get; set; } + + public string Description { get; set; } + + public int Weight { get; set; } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/API/Models/Title.cs b/ShokoJellyfin/Providers/API/Models/Title.cs new file mode 100644 index 00000000..a7a8a75c --- /dev/null +++ b/ShokoJellyfin/Providers/API/Models/Title.cs @@ -0,0 +1,13 @@ +namespace ShokoJellyfin.Providers.API.Models +{ + public class Title + { + public string Name { get; set; } + + public string Language { get; set; } + + public string Type { get; set; } + + public string Source { get; set; } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/API/ShokoAPI.cs b/ShokoJellyfin/Providers/API/ShokoAPI.cs new file mode 100644 index 00000000..3cc1265a --- /dev/null +++ b/ShokoJellyfin/Providers/API/ShokoAPI.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using ShokoJellyfin.Providers.API.Models; +using File = ShokoJellyfin.Providers.API.Models.File; + +namespace ShokoJellyfin.Providers.API +{ + internal class ShokoAPI + { + private static readonly HttpClient _httpClient; + private static string _apiBaseUrl; + + static ShokoAPI() + { + _httpClient = new HttpClient(); + _httpClient.DefaultRequestHeaders.Add("apikey", Plugin.Instance.Configuration.ApiKey); + + _apiBaseUrl = $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}"; + } + + private static async Task<Stream> CallApi(string url) + { + if (!(await CheckApiKey())) return null; + + try + { + var responseStream = await _httpClient.GetStreamAsync($"{_apiBaseUrl}{url}"); + return responseStream; + } + catch (HttpRequestException) + { + return null; + } + } + + private static async Task<bool> CheckApiKey() + { + if (!string.IsNullOrEmpty(Plugin.Instance.Configuration.ApiKey)) return true; + + var apikey = (await GetApiKey())?.apikey; + if (string.IsNullOrEmpty(apikey)) return false; + Plugin.Instance.Configuration.ApiKey = apikey; + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Add("apikey", apikey); + return true; + } + + private static async Task<ApiKey> GetApiKey() + { + var postData = JsonSerializer.Serialize(new Dictionary<string, string> + { + {"user", Plugin.Instance.Configuration.Username}, + {"pass", Plugin.Instance.Configuration.Password}, + {"device", "Shoko Jellyfin Plugin"} + }); + + var response = await _httpClient.PostAsync($"{_apiBaseUrl}/api/auth", new StringContent(postData, Encoding.UTF8, "application/json")); + if (response.StatusCode == HttpStatusCode.OK) + return await JsonSerializer.DeserializeAsync<ApiKey>(response.Content.ReadAsStreamAsync().Result); + + return null; + } + + public static async Task<Episode> GetEpisode(string id) + { + var responseStream = await CallApi($"/api/v3/Episode/{id}"); + return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<Episode>(responseStream) : null; + } + + public static async Task<Episode.AniDB> GetEpisodeAniDb(string id) + { + var responseStream = await CallApi($"/api/v3/Episode/{id}/AniDB"); + return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<Episode.AniDB>(responseStream) : null; + } + + public static async Task<IEnumerable<Episode.TvDB>> GetEpisodeTvDb(string id) + { + var responseStream = await CallApi($"/api/v3/Episode/{id}/TvDB"); + return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<IEnumerable<Episode.TvDB>>(responseStream) : null; + } + + public static async Task<IEnumerable<File.FileDetailed>> GetFilePathEndsWith(string filename) + { + var responseStream = await CallApi($"/api/v3/File/PathEndsWith/{Uri.EscapeDataString(filename)}"); + return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<IEnumerable<File.FileDetailed>>(responseStream) : null; + } + + public static async Task<Series> GetSeries(string id) + { + var responseStream = await CallApi($"/api/v3/Series/{id}"); + return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<Series>(responseStream) : null; + } + + public static async Task<Series.AniDB> GetSeriesAniDb(string id) + { + var responseStream = await CallApi($"/api/v3/Series/{id}/AniDB"); + return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<Series.AniDB>(responseStream) : null; + } + + public static async Task<IEnumerable<Role>> GetSeriesCast(string id) + { + var responseStream = await CallApi($"/api/v3/Series/{id}/Cast"); + return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<IEnumerable<Role>>(responseStream) : null; + } + + public static async Task<Images> GetSeriesImages(string id) + { + var responseStream = await CallApi($"/api/v3/Series/{id}/Images"); + return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<Images>(responseStream) : null; + } + + public static async Task<IEnumerable<Series>> GetSeriesPathEndsWith(string dirname) + { + var responseStream = await CallApi($"/api/v3/Series/PathEndsWith/{dirname}"); + return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<IEnumerable<Series>>(responseStream) : null; + } + + public static async Task<IEnumerable<Tag>> GetSeriesTags(string id, int filter = 0) + { + var responseStream = await CallApi($"/api/v3/Series/{id}/Tags/{filter}"); + if (responseStream == null) return null; + return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<IEnumerable<Tag>>(responseStream) : null; + } + + public static async Task<IEnumerable<SeriesSearchResult>> SeriesSearch(string query) + { + var responseStream = await CallApi($"/api/v3/Series/Search/{Uri.EscapeDataString(query)}"); + return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<IEnumerable<SeriesSearchResult>>(responseStream) : null; + } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/EpisodeProvider.cs b/ShokoJellyfin/Providers/EpisodeProvider.cs new file mode 100644 index 00000000..43b9db27 --- /dev/null +++ b/ShokoJellyfin/Providers/EpisodeProvider.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using ShokoJellyfin.Providers.API; +using EpisodeType = ShokoJellyfin.Providers.API.Models.Episode.EpisodeType; + +namespace ShokoJellyfin.Providers +{ + public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo> + { + public string Name => "Shoko"; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger<EpisodeProvider> _logger; + + public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProvider> logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<Episode>(); + + // TO-DO Check if it can be written in a better way. Parent directory + File Name + var filename = Path.Join(Path.GetDirectoryName(info.Path)?.Split(Path.DirectorySeparatorChar).LastOrDefault(), + Path.GetFileName(info.Path)); + + _logger.LogInformation($"Shoko Scanner... Getting episode ID ({filename})"); + + var apiResponse = await ShokoAPI.GetFilePathEndsWith(filename); + var allIds = apiResponse.FirstOrDefault()?.SeriesIDs.FirstOrDefault()?.EpisodeIDs; + var episodeIDs = allIds?.FirstOrDefault(); + var episodeId = episodeIDs?.ID.ToString(); + + if (string.IsNullOrEmpty(episodeId)) + { + _logger.LogInformation($"Shoko Scanner... Episode not found! ({filename})"); + return result; + } + + _logger.LogInformation($"Shoko Scanner... Getting episode metadata ({filename} - {episodeId})"); + + var episodeInfo = await ShokoAPI.GetEpisodeAniDb(episodeId); + + result.Item = new Episode + { + IndexNumber = episodeInfo.EpisodeNumber, + ParentIndexNumber = GetSeasonNumber(episodeInfo.Type), + Name = episodeInfo.Titles.Find(title => title.Language.Equals("EN"))?.Name, + PremiereDate = episodeInfo.AirDate, + Overview = Helper.SummarySanitizer(episodeInfo.Description), + CommunityRating = (float)((episodeInfo.Rating.Value * 10) / episodeInfo.Rating.MaxValue), + }; + result.Item.SetProviderId("Shoko", episodeId); + result.Item.SetProviderId("AniDB", episodeIDs.AniDB.ToString()); + var tvdbId = episodeIDs.TvDB?.FirstOrDefault(); + if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); + result.HasMetadata = true; + + var episodeNumberEnd = episodeInfo.EpisodeNumber + allIds.Count() - 1; + if (episodeInfo.EpisodeNumber != episodeNumberEnd) result.Item.IndexNumberEnd = episodeNumberEnd; + + return result; + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) + { + // Isn't called from anywhere. If it is called, I don't know from where. + throw new NotImplementedException(); + } + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); + } + + private int GetSeasonNumber(EpisodeType type) + { + switch (type) + { + case EpisodeType.Episode: + return 1; + case EpisodeType.Credits: + return 100; + case EpisodeType.Special: + return 0; + case EpisodeType.Trailer: + return 99; + default: + return 98; + } + } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/Helper.cs b/ShokoJellyfin/Providers/Helper.cs new file mode 100644 index 00000000..9d70abdc --- /dev/null +++ b/ShokoJellyfin/Providers/Helper.cs @@ -0,0 +1,43 @@ +using System; +using System.Text.RegularExpressions; +using ShokoJellyfin.Providers.API.Models; + +namespace ShokoJellyfin.Providers +{ + public class Helper + { + public static string GetImageUrl(Image image) + { + return image != null ? $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}/api/v3/Image/{image.Source}/{image.Type}/{image.ID}" : null; + } + + public static string ReplaceInvalidPathCharacters(string path) + { + string str = path.Replace("*", "★").Replace("|", "¦").Replace("\\", "⧹").Replace("/", "⁄").Replace(":", "։").Replace("\"", "″").Replace(">", "›").Replace("<", "‹").Replace("?", "?").Replace("...", "…"); + if (str.StartsWith(".", StringComparison.Ordinal)) + str = "․" + str.Substring(1, str.Length - 1); + if (str.EndsWith(".", StringComparison.Ordinal)) + str = str.Substring(0, str.Length - 1) + "․"; + return str.Trim(); + } + + public static string SummarySanitizer(string summary) // Based on ShokoMetadata which is based on HAMA's + { + var config = Plugin.Instance.Configuration; + + if (config.SynopsisCleanLinks) + Regex.Replace(summary, @"https?:\/\/\w+.\w+(?:\/?\w+)? \[([^\]]+)\]", ""); + + if (config.SynopsisCleanMiscLines) + Regex.Replace(summary, @"^(\*|--|~) .*", "", RegexOptions.Multiline); + + if (config.SynopsisRemoveSummary) + Regex.Replace(summary, @"\n(Source|Note|Summary):.*", "", RegexOptions.Singleline); + + if (config.SynopsisCleanMultiEmptyLines) + Regex.Replace(summary, @"\n\n+", "", RegexOptions.Singleline); + + return summary; + } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/ImageProvider.cs b/ShokoJellyfin/Providers/ImageProvider.cs new file mode 100644 index 00000000..606e6cad --- /dev/null +++ b/ShokoJellyfin/Providers/ImageProvider.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; + +namespace ShokoJellyfin.Providers +{ + public class ImageProvider : IRemoteImageProvider + { + public string Name => "Shoko"; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger<ImageProvider> _logger; + + public ImageProvider(IHttpClientFactory httpClientFactory, ILogger<ImageProvider> logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) + { + var list = new List<RemoteImageInfo>(); + + if (item is Episode && !Plugin.Instance.Configuration.UseShokoThumbnails) return list; + + var id = item.GetProviderId("Shoko"); + + if (string.IsNullOrEmpty(id)) + { + _logger.LogInformation($"Shoko Scanner... Images not found ({item.Name})"); + return list; + } + + _logger.LogInformation($"Shoko Scanner... Getting images ({item.Name} - {id})"); + + if (item is Episode) + { + var tvdbEpisodeInfo = (await API.ShokoAPI.GetEpisodeTvDb(id)).FirstOrDefault(); + var imageUrl = Helper.GetImageUrl(tvdbEpisodeInfo?.Thumbnail); + if (!string.IsNullOrEmpty(imageUrl)) + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Type = ImageType.Primary, + Url = imageUrl + }); + } + } + + if (item is Series) + { + var images = await API.ShokoAPI.GetSeriesImages(id); + + foreach (var image in images.Posters) + { + var imageUrl = Helper.GetImageUrl(image); + if (!string.IsNullOrEmpty(imageUrl)) + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Type = ImageType.Primary, + Url = imageUrl + }); + } + } + + foreach (var image in images.Fanarts) + { + var imageUrl = Helper.GetImageUrl(image); + if (!string.IsNullOrEmpty(imageUrl)) + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Type = ImageType.Backdrop, + Url = imageUrl + }); + } + } + + foreach (var image in images.Banners) + { + var imageUrl = Helper.GetImageUrl(image); + if (!string.IsNullOrEmpty(imageUrl)) + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Type = ImageType.Banner, + Url = imageUrl + }); + } + } + } + + return list; + } + + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + return new[] { ImageType.Primary, ImageType.Backdrop, ImageType.Banner }; + } + + public bool Supports(BaseItem item) + { + return item is Series || item is Episode; + } + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); + } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/SeriesProvider.cs b/ShokoJellyfin/Providers/SeriesProvider.cs new file mode 100644 index 00000000..519dfc6a --- /dev/null +++ b/ShokoJellyfin/Providers/SeriesProvider.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using ShokoJellyfin.Providers.API; + +namespace ShokoJellyfin.Providers +{ + public class SeriesProvider : IHasOrder, IRemoteMetadataProvider<Series, SeriesInfo> + { + public string Name => "Shoko"; + public int Order => 1; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger<SeriesProvider> _logger; + + public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + } + + public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<Series>(); + + var dirname = Path.DirectorySeparatorChar + info.Path.Split(Path.DirectorySeparatorChar).Last(); + + _logger.LogInformation($"Shoko Scanner... Getting series ID ({dirname})"); + + var apiResponse = await ShokoAPI.GetSeriesPathEndsWith(dirname); + var seriesIDs = apiResponse.FirstOrDefault()?.IDs; + var seriesId = seriesIDs?.ID.ToString(); + + if (string.IsNullOrEmpty(seriesId)) + { + _logger.LogInformation("Shoko Scanner... Series not found!"); + return result; + } + + _logger.LogInformation($"Shoko Scanner... Getting series metadata ({dirname} - {seriesId})"); + + var seriesInfo = await ShokoAPI.GetSeries(seriesId); + var aniDbSeriesInfo = await ShokoAPI.GetSeriesAniDb(seriesId); + var tags = await ShokoAPI.GetSeriesTags(seriesId, GetFlagFilter()); + + result.Item = new Series + { + Name = seriesInfo.Name, + Overview = Helper.SummarySanitizer(aniDbSeriesInfo.Description), + PremiereDate = aniDbSeriesInfo.AirDate, + EndDate = aniDbSeriesInfo.EndDate, + ProductionYear = aniDbSeriesInfo.AirDate?.Year, + Status = aniDbSeriesInfo.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, + Tags = tags?.Select(tag => tag.Name).ToArray() ?? new string[0], + CommunityRating = (float)((aniDbSeriesInfo.Rating.Value * 10) / aniDbSeriesInfo.Rating.MaxValue) + }; + result.Item.SetProviderId("Shoko", seriesId); + result.Item.SetProviderId("AniDB", seriesIDs.AniDB.ToString()); + var tvdbId = seriesIDs.TvDB?.FirstOrDefault(); + if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); + result.HasMetadata = true; + + result.ResetPeople(); + var roles = await ShokoAPI.GetSeriesCast(seriesId); + foreach (var role in roles) + { + result.AddPerson(new PersonInfo + { + Type = PersonType.Actor, + Name = role.Staff.Name, + Role = role.Character.Name, + ImageUrl = Helper.GetImageUrl(role.Staff.Image) + }); + } + + return result; + } + + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) + { + _logger.LogInformation($"Shoko Scanner... Searching Series ({searchInfo.Name})"); + var searchResults = await ShokoAPI.SeriesSearch(searchInfo.Name); + var results = new List<RemoteSearchResult>(); + + foreach (var series in searchResults) + { + var imageUrl = Helper.GetImageUrl(series.Images.Posters.FirstOrDefault()); + _logger.LogInformation(imageUrl); + var parsedSeries = new RemoteSearchResult + { + Name = series.Name, + SearchProviderName = Name, + ImageUrl = imageUrl + }; + parsedSeries.SetProviderId("Shoko", series.IDs.ID.ToString()); + results.Add(parsedSeries); + } + + return results; + } + + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); + } + + private int GetFlagFilter() + { + var config = Plugin.Instance.Configuration; + var filter = 0; + + if (config.HideAniDbTags) filter = 1; + if (config.HideArtStyleTags) filter |= (filter << 1); + if (config.HideSourceTags) filter |= (filter << 2); + if (config.HideMiscTags) filter |= (filter << 3); + if (config.HidePlotTags) filter |= (filter << 4); + + return filter; + } + } +} \ No newline at end of file diff --git a/ShokoJellyfin/ShokoJellyfin.csproj b/ShokoJellyfin/ShokoJellyfin.csproj new file mode 100644 index 00000000..f215209d --- /dev/null +++ b/ShokoJellyfin/ShokoJellyfin.csproj @@ -0,0 +1,20 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>netstandard2.1</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Jellyfin.Common" Version="10.7.0-20200903" /> + <PackageReference Include="Jellyfin.Controller" Version="10.7.0-20200903" /> + <PackageReference Include="Jellyfin.Data" Version="10.7.0-20200903" /> + <PackageReference Include="Jellyfin.Model" Version="10.7.0-20200903" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.7" /> + </ItemGroup> + + <ItemGroup> + <None Remove="Configuration\configPage.html" /> + <EmbeddedResource Include="Configuration\configPage.html" /> + </ItemGroup> + +</Project> From 7da91bd21fc9f1fd5617538981f55fb2f3e4d267 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Fri, 4 Sep 2020 23:13:05 +0530 Subject: [PATCH 0003/1103] Create README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..0a70ad6c --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# ShokoJellyfin +Repo for the Jellyfin Plugin + +Readme content to be added. From a728ae9a294397934af27f3242f4b71eb2995790 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Fri, 4 Sep 2020 23:27:46 +0530 Subject: [PATCH 0004/1103] Create build.yml --- .github/workflows/build.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..350c3893 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,30 @@ +name: Build CLI + +on: + push: + branches: [ master ] +# pull_request: +# branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + # rid: ['win-x64', 'linux-x64'] + dotnet: [ '3.1.x' ] + + name: build + + steps: + - uses: actions/checkout@v2 + - name: Setup dotnet + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ matrix.dotnet }} + - run: dotnet restore ShokoJellyfin/ShokoJellyfin.csproj + - run: dotnet publish -c Release ShokoJellyfin/ShokoJellyfin.csproj + - uses: actions/upload-artifact@v2 + with: + path: bin/* From 9508df7b6938ce3c4af5ef646e03562f1ad00225 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Fri, 4 Sep 2020 23:38:33 +0530 Subject: [PATCH 0005/1103] Add jellyfin unstable nuget source to build.yml --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 350c3893..bc56817a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: ${{ matrix.dotnet }} - - run: dotnet restore ShokoJellyfin/ShokoJellyfin.csproj + - run: dotnet restore ShokoJellyfin/ShokoJellyfin.csproj -s https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/nuget/v3/index.json - run: dotnet publish -c Release ShokoJellyfin/ShokoJellyfin.csproj - uses: actions/upload-artifact@v2 with: From 446f1e8a85a9f7d5481ea81e48d295847d33d0db Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Fri, 4 Sep 2020 23:41:55 +0530 Subject: [PATCH 0006/1103] Fix nuget sources --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bc56817a..b6ae1243 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,7 +23,7 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: ${{ matrix.dotnet }} - - run: dotnet restore ShokoJellyfin/ShokoJellyfin.csproj -s https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/nuget/v3/index.json + - run: dotnet restore ShokoJellyfin/ShokoJellyfin.csproj -s https://api.nuget.org/v3/index.json -s https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/nuget/v3/index.json - run: dotnet publish -c Release ShokoJellyfin/ShokoJellyfin.csproj - uses: actions/upload-artifact@v2 with: From 654c4a22c68c92677b97e7e8a40a82b71931a482 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Fri, 4 Sep 2020 23:46:09 +0530 Subject: [PATCH 0007/1103] Fix path for artifact --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b6ae1243..1737fc4c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,4 +27,4 @@ jobs: - run: dotnet publish -c Release ShokoJellyfin/ShokoJellyfin.csproj - uses: actions/upload-artifact@v2 with: - path: bin/* + path: ShokoJellyfin/bin/Release/netstandard2.1/ShokoJellyfin.dll From 2dfcc77b9b5162d1d9cf38962c43ac9452bdc910 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Fri, 4 Sep 2020 23:56:57 +0530 Subject: [PATCH 0008/1103] Give a name to the artifact and... it works! --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1737fc4c..3f659675 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,4 +27,5 @@ jobs: - run: dotnet publish -c Release ShokoJellyfin/ShokoJellyfin.csproj - uses: actions/upload-artifact@v2 with: + name: ShokoJellyfin path: ShokoJellyfin/bin/Release/netstandard2.1/ShokoJellyfin.dll From 9940225b2f187067a06ed1f2894fe04b74878328 Mon Sep 17 00:00:00 2001 From: G Harshith Mohan <gharshitmohan@ymail.com> Date: Sat, 5 Sep 2020 15:56:53 +0530 Subject: [PATCH 0009/1103] Cleanup code in Helper.cs --- ShokoJellyfin/Providers/Helper.cs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/ShokoJellyfin/Providers/Helper.cs b/ShokoJellyfin/Providers/Helper.cs index 9d70abdc..6c1e6318 100644 --- a/ShokoJellyfin/Providers/Helper.cs +++ b/ShokoJellyfin/Providers/Helper.cs @@ -10,16 +10,6 @@ public static string GetImageUrl(Image image) { return image != null ? $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}/api/v3/Image/{image.Source}/{image.Type}/{image.ID}" : null; } - - public static string ReplaceInvalidPathCharacters(string path) - { - string str = path.Replace("*", "★").Replace("|", "¦").Replace("\\", "⧹").Replace("/", "⁄").Replace(":", "։").Replace("\"", "″").Replace(">", "›").Replace("<", "‹").Replace("?", "?").Replace("...", "…"); - if (str.StartsWith(".", StringComparison.Ordinal)) - str = "․" + str.Substring(1, str.Length - 1); - if (str.EndsWith(".", StringComparison.Ordinal)) - str = str.Substring(0, str.Length - 1) + "․"; - return str.Trim(); - } public static string SummarySanitizer(string summary) // Based on ShokoMetadata which is based on HAMA's { From 286d87429c7f7c33407c19fe87e7add88a77c71a Mon Sep 17 00:00:00 2001 From: G Harshith Mohan <gharshitmohan@ymail.com> Date: Sat, 5 Sep 2020 16:06:02 +0530 Subject: [PATCH 0010/1103] Fix summary sanitizer --- ShokoJellyfin/Providers/Helper.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ShokoJellyfin/Providers/Helper.cs b/ShokoJellyfin/Providers/Helper.cs index 6c1e6318..39556ef3 100644 --- a/ShokoJellyfin/Providers/Helper.cs +++ b/ShokoJellyfin/Providers/Helper.cs @@ -1,4 +1,3 @@ -using System; using System.Text.RegularExpressions; using ShokoJellyfin.Providers.API.Models; @@ -16,16 +15,16 @@ public static string SummarySanitizer(string summary) // Based on ShokoMetadata var config = Plugin.Instance.Configuration; if (config.SynopsisCleanLinks) - Regex.Replace(summary, @"https?:\/\/\w+.\w+(?:\/?\w+)? \[([^\]]+)\]", ""); + summary = Regex.Replace(summary, @"https?:\/\/\w+.\w+(?:\/?\w+)? \[([^\]]+)\]", ""); if (config.SynopsisCleanMiscLines) - Regex.Replace(summary, @"^(\*|--|~) .*", "", RegexOptions.Multiline); + summary = Regex.Replace(summary, @"^(\*|--|~) .*", "", RegexOptions.Multiline); if (config.SynopsisRemoveSummary) - Regex.Replace(summary, @"\n(Source|Note|Summary):.*", "", RegexOptions.Singleline); + summary = Regex.Replace(summary, @"\n(Source|Note|Summary):.*", "", RegexOptions.Singleline); if (config.SynopsisCleanMultiEmptyLines) - Regex.Replace(summary, @"\n\n+", "", RegexOptions.Singleline); + summary = Regex.Replace(summary, @"\n\n+", "", RegexOptions.Singleline); return summary; } From 87b54f205e5a39d64b98cf68ee3db5eb8920e23a Mon Sep 17 00:00:00 2001 From: G Harshith Mohan <gharshitmohan@ymail.com> Date: Sat, 5 Sep 2020 18:18:23 +0530 Subject: [PATCH 0011/1103] Remove a trailing comma --- ShokoJellyfin/Providers/EpisodeProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ShokoJellyfin/Providers/EpisodeProvider.cs b/ShokoJellyfin/Providers/EpisodeProvider.cs index 43b9db27..f7a27887 100644 --- a/ShokoJellyfin/Providers/EpisodeProvider.cs +++ b/ShokoJellyfin/Providers/EpisodeProvider.cs @@ -59,7 +59,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell Name = episodeInfo.Titles.Find(title => title.Language.Equals("EN"))?.Name, PremiereDate = episodeInfo.AirDate, Overview = Helper.SummarySanitizer(episodeInfo.Description), - CommunityRating = (float)((episodeInfo.Rating.Value * 10) / episodeInfo.Rating.MaxValue), + CommunityRating = (float)((episodeInfo.Rating.Value * 10) / episodeInfo.Rating.MaxValue) }; result.Item.SetProviderId("Shoko", episodeId); result.Item.SetProviderId("AniDB", episodeIDs.AniDB.ToString()); From 890f96d2b15e98ec480e0b8eaf132e3a7284e4f5 Mon Sep 17 00:00:00 2001 From: G Harshith Mohan <gharshitmohan@ymail.com> Date: Sat, 5 Sep 2020 18:20:00 +0530 Subject: [PATCH 0012/1103] Add names for Credits, Trailers and Misc seasons --- ShokoJellyfin/Providers/SeasonProvider.cs | 67 +++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 ShokoJellyfin/Providers/SeasonProvider.cs diff --git a/ShokoJellyfin/Providers/SeasonProvider.cs b/ShokoJellyfin/Providers/SeasonProvider.cs new file mode 100644 index 00000000..2507d9ff --- /dev/null +++ b/ShokoJellyfin/Providers/SeasonProvider.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; + +namespace ShokoJellyfin.Providers +{ + public class SeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo> + { + public string Name => "Shoko"; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger<SeasonProvider> _logger; + + public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvider> logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<Season>(); + + var seasonName = GetSeasonName(info.Name); + result.Item = new Season + { + Name = seasonName, + SortName = seasonName, + ForcedSortName = seasonName + }; + result.HasMetadata = true; + + return result; + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchInfo, CancellationToken cancellationToken) + { + // Isn't called from anywhere. If it is called, I don't know from where. + throw new NotImplementedException(); + } + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); + } + + private string GetSeasonName(string season) + { + switch (season) + { + case "Season 100": + return "Credits"; + case "Season 99": + return "Trailers"; + case "Season 98": + return "Misc."; + default: + return season; + } + } + } +} \ No newline at end of file From a1f39f9e524bc5b4ead2571421aaf677ed015fe6 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Sat, 5 Sep 2020 18:22:16 +0530 Subject: [PATCH 0013/1103] Update README.md --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0a70ad6c..b03eee46 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,15 @@ # ShokoJellyfin Repo for the Jellyfin Plugin -Readme content to be added. +## Build Process + +1. Clone or download this repository + +2. Ensure you have .NET Core SDK setup and installed + +3. Build plugin with following command. + +```sh +dotnet publish --configuration Release --output bin +``` +4. Copy the resulting file `bin/ShokoJellyfin.dll` to the `plugins` folder under the Jellyfin program data directory or inside the portable install directory From e17f7f6bd248a8237bd5200dd46def9821f78e6e Mon Sep 17 00:00:00 2001 From: G Harshith Mohan <gharshitmohan@ymail.com> Date: Sat, 5 Sep 2020 18:38:19 +0530 Subject: [PATCH 0014/1103] Add option for TvDB season ordering --- .../Configuration/PluginConfiguration.cs | 3 ++ ShokoJellyfin/Configuration/configPage.html | 6 ++++ ShokoJellyfin/Providers/EpisodeProvider.cs | 30 ++++++++++++++----- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/ShokoJellyfin/Configuration/PluginConfiguration.cs b/ShokoJellyfin/Configuration/PluginConfiguration.cs index cf7e15d1..3491b384 100644 --- a/ShokoJellyfin/Configuration/PluginConfiguration.cs +++ b/ShokoJellyfin/Configuration/PluginConfiguration.cs @@ -14,6 +14,8 @@ public class PluginConfiguration : BasePluginConfiguration public string ApiKey { get; set; } + public bool UseTvDbSeasonOrdering { get; set; } + public bool UseShokoThumbnails { get; set; } public bool HideArtStyleTags { get; set; } @@ -41,6 +43,7 @@ public PluginConfiguration() Username = "Default"; Password = ""; ApiKey = ""; + UseTvDbSeasonOrdering = false; UseShokoThumbnails = true; HideArtStyleTags = false; HideSourceTags = false; diff --git a/ShokoJellyfin/Configuration/configPage.html b/ShokoJellyfin/Configuration/configPage.html index 607d7b73..d7510794 100644 --- a/ShokoJellyfin/Configuration/configPage.html +++ b/ShokoJellyfin/Configuration/configPage.html @@ -27,6 +27,10 @@ <input is="emby-input" type="text" id="ApiKey" label="API Key" /> <div class="fieldDescription">This field is auto-generated using the credentials. Only set this manually if that doesn't work!</div> </div> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="UseTvDbSeasonOrdering" /> + <span>Use season ordering from TvDB. Also makes the shows merge-friendly.</span> + </label> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="UseShokoThumbnails" /> <span>Use thumbnails from Shoko</span> @@ -89,6 +93,7 @@ document.querySelector('#Username').value = config.Username; document.querySelector('#Password').value = config.Password; document.querySelector('#ApiKey').value = config.ApiKey; + document.querySelector('#UseTvDbSeasonOrdering').checked = config.UseTvDbSeasonOrdering; document.querySelector('#UseShokoThumbnails').checked = config.UseShokoThumbnails; document.querySelector('#HideArtStyleTags').checked = config.HideArtStyleTags; document.querySelector('#HideSourceTags').checked = config.HideSourceTags; @@ -113,6 +118,7 @@ config.Username = document.querySelector('#Username').value; config.Password = document.querySelector('#Password').value; config.ApiKey = document.querySelector('#ApiKey').value; + config.UseTvDbSeasonOrdering = document.querySelector('#UseTvDbSeasonOrdering').checked; config.UseShokoThumbnails = document.querySelector('#UseShokoThumbnails').checked; config.HideArtStyleTags = document.querySelector('#HideArtStyleTags').checked; config.HideSourceTags = document.querySelector('#HideSourceTags').checked; diff --git a/ShokoJellyfin/Providers/EpisodeProvider.cs b/ShokoJellyfin/Providers/EpisodeProvider.cs index f7a27887..e30b693a 100644 --- a/ShokoJellyfin/Providers/EpisodeProvider.cs +++ b/ShokoJellyfin/Providers/EpisodeProvider.cs @@ -55,7 +55,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell result.Item = new Episode { IndexNumber = episodeInfo.EpisodeNumber, - ParentIndexNumber = GetSeasonNumber(episodeInfo.Type), + ParentIndexNumber = await GetSeasonNumber(episodeId, episodeInfo.Type), Name = episodeInfo.Titles.Find(title => title.Language.Equals("EN"))?.Name, PremiereDate = episodeInfo.AirDate, Overview = Helper.SummarySanitizer(episodeInfo.Description), @@ -84,21 +84,37 @@ public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); } - private int GetSeasonNumber(EpisodeType type) + private async Task<int> GetSeasonNumber(string episodeId, EpisodeType type) { + var seasonNumber = 0; + switch (type) { case EpisodeType.Episode: - return 1; + seasonNumber = 1; + break; case EpisodeType.Credits: - return 100; + seasonNumber = 100; + break; case EpisodeType.Special: - return 0; + seasonNumber = 0; + break; case EpisodeType.Trailer: - return 99; + seasonNumber = 99; + break; default: - return 98; + seasonNumber = 98; + break; } + + if (Plugin.Instance.Configuration.UseTvDbSeasonOrdering && seasonNumber < 98) + { + var tvdbEpisodeInfo = await ShokoAPI.GetEpisodeTvDb(episodeId); + var tvdbSeason = tvdbEpisodeInfo.FirstOrDefault()?.Season; + return tvdbSeason ?? seasonNumber; + } + + return seasonNumber; } } } \ No newline at end of file From 84f5dddf0b84a66ec17e0ecee417562c9d72fe59 Mon Sep 17 00:00:00 2001 From: G Harshith Mohan <gharshitmohan@ymail.com> Date: Sat, 5 Sep 2020 19:00:31 +0530 Subject: [PATCH 0015/1103] Use series images for season images --- ShokoJellyfin/Providers/ImageProvider.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ShokoJellyfin/Providers/ImageProvider.cs b/ShokoJellyfin/Providers/ImageProvider.cs index 606e6cad..4bc1b6ba 100644 --- a/ShokoJellyfin/Providers/ImageProvider.cs +++ b/ShokoJellyfin/Providers/ImageProvider.cs @@ -32,6 +32,11 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell var id = item.GetProviderId("Shoko"); + if (item is Season) + { + id = item.GetParent().GetProviderId("Shoko"); + } + if (string.IsNullOrEmpty(id)) { _logger.LogInformation($"Shoko Scanner... Images not found ({item.Name})"); @@ -55,7 +60,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell } } - if (item is Series) + if (item is Series || item is Season) { var images = await API.ShokoAPI.GetSeriesImages(id); @@ -112,7 +117,7 @@ public IEnumerable<ImageType> GetSupportedImages(BaseItem item) public bool Supports(BaseItem item) { - return item is Series || item is Episode; + return item is Series || item is Season || item is Episode; } public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) From daca2005758e1b4441a2fc9c2fbb789ee0d8004c Mon Sep 17 00:00:00 2001 From: G Harshith Mohan <gharshitmohan@ymail.com> Date: Sat, 5 Sep 2020 19:30:03 +0530 Subject: [PATCH 0016/1103] Always get thumbs from Shoko, they are not generated automatically. --- ShokoJellyfin/Configuration/PluginConfiguration.cs | 4 ++-- ShokoJellyfin/Configuration/configPage.html | 12 ++++++------ ShokoJellyfin/Providers/ImageProvider.cs | 3 ++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/ShokoJellyfin/Configuration/PluginConfiguration.cs b/ShokoJellyfin/Configuration/PluginConfiguration.cs index 3491b384..b38fc360 100644 --- a/ShokoJellyfin/Configuration/PluginConfiguration.cs +++ b/ShokoJellyfin/Configuration/PluginConfiguration.cs @@ -16,7 +16,7 @@ public class PluginConfiguration : BasePluginConfiguration public bool UseTvDbSeasonOrdering { get; set; } - public bool UseShokoThumbnails { get; set; } + // public bool UseShokoThumbnails { get; set; } public bool HideArtStyleTags { get; set; } @@ -44,7 +44,7 @@ public PluginConfiguration() Password = ""; ApiKey = ""; UseTvDbSeasonOrdering = false; - UseShokoThumbnails = true; + // UseShokoThumbnails = true; HideArtStyleTags = false; HideSourceTags = false; HideMiscTags = false; diff --git a/ShokoJellyfin/Configuration/configPage.html b/ShokoJellyfin/Configuration/configPage.html index d7510794..ff20cc5e 100644 --- a/ShokoJellyfin/Configuration/configPage.html +++ b/ShokoJellyfin/Configuration/configPage.html @@ -31,10 +31,10 @@ <input is="emby-checkbox" type="checkbox" id="UseTvDbSeasonOrdering" /> <span>Use season ordering from TvDB. Also makes the shows merge-friendly.</span> </label> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="UseShokoThumbnails" /> - <span>Use thumbnails from Shoko</span> - </label> +<!-- <label class="checkboxContainer">--> +<!-- <input is="emby-checkbox" type="checkbox" id="UseShokoThumbnails" />--> +<!-- <span>Use thumbnails from Shoko</span>--> +<!-- </label>--> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="HideArtStyleTags" /> <span>Hide art style related tags</span> @@ -94,7 +94,7 @@ document.querySelector('#Password').value = config.Password; document.querySelector('#ApiKey').value = config.ApiKey; document.querySelector('#UseTvDbSeasonOrdering').checked = config.UseTvDbSeasonOrdering; - document.querySelector('#UseShokoThumbnails').checked = config.UseShokoThumbnails; + // document.querySelector('#UseShokoThumbnails').checked = config.UseShokoThumbnails; document.querySelector('#HideArtStyleTags').checked = config.HideArtStyleTags; document.querySelector('#HideSourceTags').checked = config.HideSourceTags; document.querySelector('#HideMiscTags').checked = config.HideMiscTags; @@ -119,7 +119,7 @@ config.Password = document.querySelector('#Password').value; config.ApiKey = document.querySelector('#ApiKey').value; config.UseTvDbSeasonOrdering = document.querySelector('#UseTvDbSeasonOrdering').checked; - config.UseShokoThumbnails = document.querySelector('#UseShokoThumbnails').checked; + // config.UseShokoThumbnails = document.querySelector('#UseShokoThumbnails').checked; config.HideArtStyleTags = document.querySelector('#HideArtStyleTags').checked; config.HideSourceTags = document.querySelector('#HideSourceTags').checked; config.HideMiscTags = document.querySelector('#HideMiscTags').checked; diff --git a/ShokoJellyfin/Providers/ImageProvider.cs b/ShokoJellyfin/Providers/ImageProvider.cs index 4bc1b6ba..a2347eeb 100644 --- a/ShokoJellyfin/Providers/ImageProvider.cs +++ b/ShokoJellyfin/Providers/ImageProvider.cs @@ -28,7 +28,8 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell { var list = new List<RemoteImageInfo>(); - if (item is Episode && !Plugin.Instance.Configuration.UseShokoThumbnails) return list; + // Doesn't seem like Jellyfin can generate thumbs by itself. Keep this option always enabled for now. + // if (item is Episode && !Plugin.Instance.Configuration.UseShokoThumbnails) return list; var id = item.GetProviderId("Shoko"); From 33d5794a2eb4734ac9c04278a501d1609dc2a75a Mon Sep 17 00:00:00 2001 From: G Harshith Mohan <gharshitmohan@ymail.com> Date: Mon, 7 Sep 2020 20:10:41 +0530 Subject: [PATCH 0017/1103] Add build-scripts for creating a jellyfin repo --- build.yaml | 13 +++++++++++ build_plugin.sh | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 1 + 3 files changed, 72 insertions(+) create mode 100644 build.yaml create mode 100644 build_plugin.sh create mode 100644 manifest.json diff --git a/build.yaml b/build.yaml new file mode 100644 index 00000000..79e16598 --- /dev/null +++ b/build.yaml @@ -0,0 +1,13 @@ +name: "ShokoJellyfin" +guid: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052" +targetAbi: "10.7.0.0" +owner: "shoko" +overview: "Manage your anime from Jellyfin using metadata from Shoko" +description: > + This plugin gets metadata for all your anime from your Shoko Server. + The official Anime and TvDB plugins can be used to fill the missing data. +category: "Metadata" +artifacts: +- "ShokoJellyfin.dll" +changelog: > + NA \ No newline at end of file diff --git a/build_plugin.sh b/build_plugin.sh new file mode 100644 index 00000000..bfef89c9 --- /dev/null +++ b/build_plugin.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# +# Copyright (c) 2020 - Odd Strabo <oddstr13@openshell.no> +# +# +# The Unlicense +# ============= +# +# This is free and unencumbered software released into the public domain. +# +# Anyone is free to copy, modify, publish, use, compile, sell, or +# distribute this software, either in source code form or as a compiled +# binary, for any purpose, commercial or non-commercial, and by any +# means. +# +# In jurisdictions that recognize copyright laws, the author or authors +# of this software dedicate any and all copyright interest in the +# software to the public domain. We make this dedication for the benefit +# of the public at large and to the detriment of our heirs and +# successors. We intend this dedication to be an overt act of +# relinquishment in perpetuity of all present and future rights to this +# software under copyright law. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# For more information, please refer to <http://unlicense.org/> +# + +MY=$(dirname $(realpath -s "${0}")) +JPRM="jprm" + +DEFAULT_REPO_DIR="./manifest.json" +DEFAULT_REPO_URL="https://github.com/ShokoAnime/ShokoJellyfin/releases/download" + +PLUGIN=. + +ARTIFACT_DIR=${ARTIFACT_DIR:-"${MY}/artifacts"} +mkdir -p "${ARTIFACT_DIR}" + +JELLYFIN_REPO=${JELLYFIN_REPO:-${DEFAULT_REPO_DIR}} +JELLYFIN_REPO_URL=${JELLYFIN_REPO_URL:-${DEFAULT_REPO_URL}} + +meta_version=$(grep -Po '^ *version: * "*\K[^"$]+' "${PLUGIN}/build.yaml") +VERSION=$1 + +zipfile=$($JPRM --verbosity=debug plugin build "${PLUGIN}" --output="${ARTIFACT_DIR}" --version="${VERSION}") && { + $JPRM repo add --url=${JELLYFIN_REPO_URL} "${JELLYFIN_REPO}" "${zipfile}" +} + +sed -i "s/shokojellyfin\//${VERSION}\//" "${JELLYFIN_REPO}" + +exit $? \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/manifest.json @@ -0,0 +1 @@ +[] \ No newline at end of file From da2642135037b700f752002bc5b846d5c8c708b8 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Mon, 7 Sep 2020 20:12:17 +0530 Subject: [PATCH 0018/1103] Create release.yml --- .github/workflows/release.yml | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..405d568d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +name: Release + +on: + release: + types: + - created + branches: master + +jobs: + build: + runs-on: ubuntu-latest + name: Build & Release + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + ref: master + - name: Setup dotnet + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 3.1.x + - name: Restore nuget packages + run: dotnet restore ShokoJellyfin/ShokoJellyfin.csproj -s https://api.nuget.org/v3/index.json -s https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/nuget/v3/index.json + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install JPRM + run: python -m pip install jprm + - name: Run JPRM + run: bash build_plugin.sh ${GITHUB_REF#refs/*/} + - name: Update release + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ./artifacts/shokojellyfin_*.zip + tag: ${{ github.ref }} + file_glob: true + - name: Update manifest + uses: stefanzweifel/git-auto-commit-action@v4 + with: + branch: master + commit_message: Update repo manifest + file_pattern: manifest.json From 496446812fd23e9c172d3ee0ef7edbe30a7ea1f3 Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Mon, 7 Sep 2020 14:43:46 +0000 Subject: [PATCH 0019/1103] Update repo manifest --- manifest.json | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 0637a088..0953254f 100644 --- a/manifest.json +++ b/manifest.json @@ -1 +1,20 @@ -[] \ No newline at end of file +[ + { + "guid": "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", + "name": "ShokoJellyfin", + "description": "This plugin gets metadata for all your anime from your Shoko Server. The official Anime and TvDB plugins can be used to fill the missing data.\n", + "overview": "Manage your anime from Jellyfin using metadata from Shoko", + "owner": "shoko", + "category": "Metadata", + "versions": [ + { + "version": "1.0.0", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/ShokoJellyfin/releases/download/1.0.0/shokojellyfin_1.0.0.zip", + "checksum": "184c723247ccdc4b0143dc46f5b6d50d", + "timestamp": "2020-09-07T14:43:45Z" + } + ] + } +] \ No newline at end of file From 74fecc9f50c3c1ef1fdbf1fe5e7091a7ac1d725e Mon Sep 17 00:00:00 2001 From: G Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 9 Sep 2020 03:33:29 +0530 Subject: [PATCH 0020/1103] Added scrobbler, better error handling --- .../{Providers => }/API/Models/ApiKey.cs | 2 +- .../{Providers => }/API/Models/BaseModel.cs | 2 +- .../{Providers => }/API/Models/Episode.cs | 2 +- .../{Providers => }/API/Models/File.cs | 2 +- .../{Providers => }/API/Models/Group.cs | 2 +- .../{Providers => }/API/Models/IDs.cs | 2 +- .../{Providers => }/API/Models/Image.cs | 2 +- .../{Providers => }/API/Models/Images.cs | 2 +- .../{Providers => }/API/Models/Rating.cs | 2 +- .../{Providers => }/API/Models/Role.cs | 2 +- .../{Providers => }/API/Models/Series.cs | 2 +- .../{Providers => }/API/Models/Sizes.cs | 2 +- .../{Providers => }/API/Models/Tag.cs | 2 +- .../{Providers => }/API/Models/Title.cs | 2 +- ShokoJellyfin/{Providers => }/API/ShokoAPI.cs | 54 ++++---- .../Configuration/PluginConfiguration.cs | 3 + ShokoJellyfin/Configuration/configPage.html | 6 + ShokoJellyfin/Providers/EpisodeProvider.cs | 87 +++++++------ ShokoJellyfin/Providers/Helper.cs | 2 +- ShokoJellyfin/Providers/ImageProvider.cs | 117 ++++++++++-------- ShokoJellyfin/Providers/SeriesProvider.cs | 107 ++++++++-------- ShokoJellyfin/Scrobbler.cs | 59 +++++++++ 22 files changed, 284 insertions(+), 179 deletions(-) rename ShokoJellyfin/{Providers => }/API/Models/ApiKey.cs (64%) rename ShokoJellyfin/{Providers => }/API/Models/BaseModel.cs (80%) rename ShokoJellyfin/{Providers => }/API/Models/Episode.cs (97%) rename ShokoJellyfin/{Providers => }/API/Models/File.cs (97%) rename ShokoJellyfin/{Providers => }/API/Models/Group.cs (87%) rename ShokoJellyfin/{Providers => }/API/Models/IDs.cs (61%) rename ShokoJellyfin/{Providers => }/API/Models/Image.cs (88%) rename ShokoJellyfin/{Providers => }/API/Models/Images.cs (87%) rename ShokoJellyfin/{Providers => }/API/Models/Rating.cs (86%) rename ShokoJellyfin/{Providers => }/API/Models/Role.cs (92%) rename ShokoJellyfin/{Providers => }/API/Models/Series.cs (98%) rename ShokoJellyfin/{Providers => }/API/Models/Sizes.cs (92%) rename ShokoJellyfin/{Providers => }/API/Models/Tag.cs (80%) rename ShokoJellyfin/{Providers => }/API/Models/Title.cs (83%) rename ShokoJellyfin/{Providers => }/API/ShokoAPI.cs (69%) create mode 100644 ShokoJellyfin/Scrobbler.cs diff --git a/ShokoJellyfin/Providers/API/Models/ApiKey.cs b/ShokoJellyfin/API/Models/ApiKey.cs similarity index 64% rename from ShokoJellyfin/Providers/API/Models/ApiKey.cs rename to ShokoJellyfin/API/Models/ApiKey.cs index d182087c..9678d395 100644 --- a/ShokoJellyfin/Providers/API/Models/ApiKey.cs +++ b/ShokoJellyfin/API/Models/ApiKey.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.Providers.API.Models +namespace ShokoJellyfin.API.Models { public class ApiKey { diff --git a/ShokoJellyfin/Providers/API/Models/BaseModel.cs b/ShokoJellyfin/API/Models/BaseModel.cs similarity index 80% rename from ShokoJellyfin/Providers/API/Models/BaseModel.cs rename to ShokoJellyfin/API/Models/BaseModel.cs index 016b0fca..805dd04d 100644 --- a/ShokoJellyfin/Providers/API/Models/BaseModel.cs +++ b/ShokoJellyfin/API/Models/BaseModel.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.Providers.API.Models +namespace ShokoJellyfin.API.Models { public abstract class BaseModel { diff --git a/ShokoJellyfin/Providers/API/Models/Episode.cs b/ShokoJellyfin/API/Models/Episode.cs similarity index 97% rename from ShokoJellyfin/Providers/API/Models/Episode.cs rename to ShokoJellyfin/API/Models/Episode.cs index 495d4ce4..d7da66bc 100644 --- a/ShokoJellyfin/Providers/API/Models/Episode.cs +++ b/ShokoJellyfin/API/Models/Episode.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace ShokoJellyfin.Providers.API.Models +namespace ShokoJellyfin.API.Models { public class Episode : BaseModel { diff --git a/ShokoJellyfin/Providers/API/Models/File.cs b/ShokoJellyfin/API/Models/File.cs similarity index 97% rename from ShokoJellyfin/Providers/API/Models/File.cs rename to ShokoJellyfin/API/Models/File.cs index 3da71fc8..9c5d1eb7 100644 --- a/ShokoJellyfin/Providers/API/Models/File.cs +++ b/ShokoJellyfin/API/Models/File.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace ShokoJellyfin.Providers.API.Models +namespace ShokoJellyfin.API.Models { public class File { diff --git a/ShokoJellyfin/Providers/API/Models/Group.cs b/ShokoJellyfin/API/Models/Group.cs similarity index 87% rename from ShokoJellyfin/Providers/API/Models/Group.cs rename to ShokoJellyfin/API/Models/Group.cs index 10ec24eb..68356fd0 100644 --- a/ShokoJellyfin/Providers/API/Models/Group.cs +++ b/ShokoJellyfin/API/Models/Group.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.Providers.API.Models +namespace ShokoJellyfin.API.Models { public class Group : BaseModel { diff --git a/ShokoJellyfin/Providers/API/Models/IDs.cs b/ShokoJellyfin/API/Models/IDs.cs similarity index 61% rename from ShokoJellyfin/Providers/API/Models/IDs.cs rename to ShokoJellyfin/API/Models/IDs.cs index 2008ca6c..862de704 100644 --- a/ShokoJellyfin/Providers/API/Models/IDs.cs +++ b/ShokoJellyfin/API/Models/IDs.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.Providers.API.Models +namespace ShokoJellyfin.API.Models { public class IDs { diff --git a/ShokoJellyfin/Providers/API/Models/Image.cs b/ShokoJellyfin/API/Models/Image.cs similarity index 88% rename from ShokoJellyfin/Providers/API/Models/Image.cs rename to ShokoJellyfin/API/Models/Image.cs index bc4488c5..d2b0c9a6 100644 --- a/ShokoJellyfin/Providers/API/Models/Image.cs +++ b/ShokoJellyfin/API/Models/Image.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.Providers.API.Models +namespace ShokoJellyfin.API.Models { public class Image { diff --git a/ShokoJellyfin/Providers/API/Models/Images.cs b/ShokoJellyfin/API/Models/Images.cs similarity index 87% rename from ShokoJellyfin/Providers/API/Models/Images.cs rename to ShokoJellyfin/API/Models/Images.cs index d3ad0229..f25910ec 100644 --- a/ShokoJellyfin/Providers/API/Models/Images.cs +++ b/ShokoJellyfin/API/Models/Images.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace ShokoJellyfin.Providers.API.Models +namespace ShokoJellyfin.API.Models { public class Images { diff --git a/ShokoJellyfin/Providers/API/Models/Rating.cs b/ShokoJellyfin/API/Models/Rating.cs similarity index 86% rename from ShokoJellyfin/Providers/API/Models/Rating.cs rename to ShokoJellyfin/API/Models/Rating.cs index fee31103..27d0de05 100644 --- a/ShokoJellyfin/Providers/API/Models/Rating.cs +++ b/ShokoJellyfin/API/Models/Rating.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.Providers.API.Models +namespace ShokoJellyfin.API.Models { public class Rating { diff --git a/ShokoJellyfin/Providers/API/Models/Role.cs b/ShokoJellyfin/API/Models/Role.cs similarity index 92% rename from ShokoJellyfin/Providers/API/Models/Role.cs rename to ShokoJellyfin/API/Models/Role.cs index 65f1a062..3988f36d 100644 --- a/ShokoJellyfin/Providers/API/Models/Role.cs +++ b/ShokoJellyfin/API/Models/Role.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.Providers.API.Models +namespace ShokoJellyfin.API.Models { public class Role { diff --git a/ShokoJellyfin/Providers/API/Models/Series.cs b/ShokoJellyfin/API/Models/Series.cs similarity index 98% rename from ShokoJellyfin/Providers/API/Models/Series.cs rename to ShokoJellyfin/API/Models/Series.cs index 9ad5ecfd..0e3d7be8 100644 --- a/ShokoJellyfin/Providers/API/Models/Series.cs +++ b/ShokoJellyfin/API/Models/Series.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace ShokoJellyfin.Providers.API.Models +namespace ShokoJellyfin.API.Models { public class Series : BaseModel { diff --git a/ShokoJellyfin/Providers/API/Models/Sizes.cs b/ShokoJellyfin/API/Models/Sizes.cs similarity index 92% rename from ShokoJellyfin/Providers/API/Models/Sizes.cs rename to ShokoJellyfin/API/Models/Sizes.cs index 9aac90b8..246a5dce 100644 --- a/ShokoJellyfin/Providers/API/Models/Sizes.cs +++ b/ShokoJellyfin/API/Models/Sizes.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.Providers.API.Models +namespace ShokoJellyfin.API.Models { public class Sizes { diff --git a/ShokoJellyfin/Providers/API/Models/Tag.cs b/ShokoJellyfin/API/Models/Tag.cs similarity index 80% rename from ShokoJellyfin/Providers/API/Models/Tag.cs rename to ShokoJellyfin/API/Models/Tag.cs index 1623a3bc..6de8bd61 100644 --- a/ShokoJellyfin/Providers/API/Models/Tag.cs +++ b/ShokoJellyfin/API/Models/Tag.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.Providers.API.Models +namespace ShokoJellyfin.API.Models { public class Tag { diff --git a/ShokoJellyfin/Providers/API/Models/Title.cs b/ShokoJellyfin/API/Models/Title.cs similarity index 83% rename from ShokoJellyfin/Providers/API/Models/Title.cs rename to ShokoJellyfin/API/Models/Title.cs index a7a8a75c..74b07dec 100644 --- a/ShokoJellyfin/Providers/API/Models/Title.cs +++ b/ShokoJellyfin/API/Models/Title.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.Providers.API.Models +namespace ShokoJellyfin.API.Models { public class Title { diff --git a/ShokoJellyfin/Providers/API/ShokoAPI.cs b/ShokoJellyfin/API/ShokoAPI.cs similarity index 69% rename from ShokoJellyfin/Providers/API/ShokoAPI.cs rename to ShokoJellyfin/API/ShokoAPI.cs index 3cc1265a..3b04bf23 100644 --- a/ShokoJellyfin/Providers/API/ShokoAPI.cs +++ b/ShokoJellyfin/API/ShokoAPI.cs @@ -6,32 +6,36 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; -using ShokoJellyfin.Providers.API.Models; -using File = ShokoJellyfin.Providers.API.Models.File; +using ShokoJellyfin.API.Models; +using File = ShokoJellyfin.API.Models.File; -namespace ShokoJellyfin.Providers.API +namespace ShokoJellyfin.API { internal class ShokoAPI { private static readonly HttpClient _httpClient; - private static string _apiBaseUrl; static ShokoAPI() { _httpClient = new HttpClient(); _httpClient.DefaultRequestHeaders.Add("apikey", Plugin.Instance.Configuration.ApiKey); - - _apiBaseUrl = $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}"; } - private static async Task<Stream> CallApi(string url) + private static async Task<Stream> CallApi(string url, string requestType = "GET") { if (!(await CheckApiKey())) return null; try { - var responseStream = await _httpClient.GetStreamAsync($"{_apiBaseUrl}{url}"); - return responseStream; + var apiBaseUrl = $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}"; + switch (requestType) + { + case "POST": + var response = await _httpClient.PostAsync($"{apiBaseUrl}{url}", new StringContent("")); + return response.StatusCode == HttpStatusCode.OK ? response.Content.ReadAsStreamAsync().Result : null; + default: + return await _httpClient.GetStreamAsync($"{apiBaseUrl}{url}"); + } } catch (HttpRequestException) { @@ -60,7 +64,8 @@ private static async Task<ApiKey> GetApiKey() {"device", "Shoko Jellyfin Plugin"} }); - var response = await _httpClient.PostAsync($"{_apiBaseUrl}/api/auth", new StringContent(postData, Encoding.UTF8, "application/json")); + var apiBaseUrl = $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}"; + var response = await _httpClient.PostAsync($"{apiBaseUrl}/api/auth", new StringContent(postData, Encoding.UTF8, "application/json")); if (response.StatusCode == HttpStatusCode.OK) return await JsonSerializer.DeserializeAsync<ApiKey>(response.Content.ReadAsStreamAsync().Result); @@ -70,68 +75,73 @@ private static async Task<ApiKey> GetApiKey() public static async Task<Episode> GetEpisode(string id) { var responseStream = await CallApi($"/api/v3/Episode/{id}"); - return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<Episode>(responseStream) : null; + return responseStream != null ? await JsonSerializer.DeserializeAsync<Episode>(responseStream) : null; } public static async Task<Episode.AniDB> GetEpisodeAniDb(string id) { var responseStream = await CallApi($"/api/v3/Episode/{id}/AniDB"); - return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<Episode.AniDB>(responseStream) : null; + return responseStream != null ? await JsonSerializer.DeserializeAsync<Episode.AniDB>(responseStream) : null; } public static async Task<IEnumerable<Episode.TvDB>> GetEpisodeTvDb(string id) { var responseStream = await CallApi($"/api/v3/Episode/{id}/TvDB"); - return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<IEnumerable<Episode.TvDB>>(responseStream) : null; + return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Episode.TvDB>>(responseStream) : null; } public static async Task<IEnumerable<File.FileDetailed>> GetFilePathEndsWith(string filename) { var responseStream = await CallApi($"/api/v3/File/PathEndsWith/{Uri.EscapeDataString(filename)}"); - return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<IEnumerable<File.FileDetailed>>(responseStream) : null; + return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<File.FileDetailed>>(responseStream) : null; } public static async Task<Series> GetSeries(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}"); - return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<Series>(responseStream) : null; + return responseStream != null ? await JsonSerializer.DeserializeAsync<Series>(responseStream) : null; } public static async Task<Series.AniDB> GetSeriesAniDb(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/AniDB"); - return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<Series.AniDB>(responseStream) : null; + return responseStream != null ? await JsonSerializer.DeserializeAsync<Series.AniDB>(responseStream) : null; } public static async Task<IEnumerable<Role>> GetSeriesCast(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/Cast"); - return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<IEnumerable<Role>>(responseStream) : null; + return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Role>>(responseStream) : null; } public static async Task<Images> GetSeriesImages(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/Images"); - return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<Images>(responseStream) : null; + return responseStream != null ? await JsonSerializer.DeserializeAsync<Images>(responseStream) : null; } public static async Task<IEnumerable<Series>> GetSeriesPathEndsWith(string dirname) { var responseStream = await CallApi($"/api/v3/Series/PathEndsWith/{dirname}"); - return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<IEnumerable<Series>>(responseStream) : null; + return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Series>>(responseStream) : null; } public static async Task<IEnumerable<Tag>> GetSeriesTags(string id, int filter = 0) { var responseStream = await CallApi($"/api/v3/Series/{id}/Tags/{filter}"); - if (responseStream == null) return null; - return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<IEnumerable<Tag>>(responseStream) : null; + return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Tag>>(responseStream) : null; + } + + public static async Task<bool> MarkEpisodeWatched(string id) + { + var responseStream = await CallApi($"/api/v3/Episode/{id}/watched/true", "POST"); + return responseStream != null; } public static async Task<IEnumerable<SeriesSearchResult>> SeriesSearch(string query) { var responseStream = await CallApi($"/api/v3/Series/Search/{Uri.EscapeDataString(query)}"); - return responseStream.CanRead ? await JsonSerializer.DeserializeAsync<IEnumerable<SeriesSearchResult>>(responseStream) : null; + return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<SeriesSearchResult>>(responseStream) : null; } } } \ No newline at end of file diff --git a/ShokoJellyfin/Configuration/PluginConfiguration.cs b/ShokoJellyfin/Configuration/PluginConfiguration.cs index b38fc360..c3e054f0 100644 --- a/ShokoJellyfin/Configuration/PluginConfiguration.cs +++ b/ShokoJellyfin/Configuration/PluginConfiguration.cs @@ -14,6 +14,8 @@ public class PluginConfiguration : BasePluginConfiguration public string ApiKey { get; set; } + public bool UpdateWatchedStatus { get; set; } + public bool UseTvDbSeasonOrdering { get; set; } // public bool UseShokoThumbnails { get; set; } @@ -43,6 +45,7 @@ public PluginConfiguration() Username = "Default"; Password = ""; ApiKey = ""; + UpdateWatchedStatus = false; UseTvDbSeasonOrdering = false; // UseShokoThumbnails = true; HideArtStyleTags = false; diff --git a/ShokoJellyfin/Configuration/configPage.html b/ShokoJellyfin/Configuration/configPage.html index ff20cc5e..0624afad 100644 --- a/ShokoJellyfin/Configuration/configPage.html +++ b/ShokoJellyfin/Configuration/configPage.html @@ -27,6 +27,10 @@ <input is="emby-input" type="text" id="ApiKey" label="API Key" /> <div class="fieldDescription">This field is auto-generated using the credentials. Only set this manually if that doesn't work!</div> </div> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="UpdateWatchedStatus" /> + <span>Update watched status on Shoko (Scrobble)</span> + </label> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="UseTvDbSeasonOrdering" /> <span>Use season ordering from TvDB. Also makes the shows merge-friendly.</span> @@ -93,6 +97,7 @@ document.querySelector('#Username').value = config.Username; document.querySelector('#Password').value = config.Password; document.querySelector('#ApiKey').value = config.ApiKey; + document.querySelector('#UpdateWatchedStatus').checked = config.UpdateWatchedStatus; document.querySelector('#UseTvDbSeasonOrdering').checked = config.UseTvDbSeasonOrdering; // document.querySelector('#UseShokoThumbnails').checked = config.UseShokoThumbnails; document.querySelector('#HideArtStyleTags').checked = config.HideArtStyleTags; @@ -118,6 +123,7 @@ config.Username = document.querySelector('#Username').value; config.Password = document.querySelector('#Password').value; config.ApiKey = document.querySelector('#ApiKey').value; + config.UpdateWatchedStatus = document.querySelector('#UpdateWatchedStatus').checked; config.UseTvDbSeasonOrdering = document.querySelector('#UseTvDbSeasonOrdering').checked; // config.UseShokoThumbnails = document.querySelector('#UseShokoThumbnails').checked; config.HideArtStyleTags = document.querySelector('#HideArtStyleTags').checked; diff --git a/ShokoJellyfin/Providers/EpisodeProvider.cs b/ShokoJellyfin/Providers/EpisodeProvider.cs index e30b693a..92067567 100644 --- a/ShokoJellyfin/Providers/EpisodeProvider.cs +++ b/ShokoJellyfin/Providers/EpisodeProvider.cs @@ -10,8 +10,8 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; -using ShokoJellyfin.Providers.API; -using EpisodeType = ShokoJellyfin.Providers.API.Models.Episode.EpisodeType; +using ShokoJellyfin.API; +using EpisodeType = ShokoJellyfin.API.Models.Episode.EpisodeType; namespace ShokoJellyfin.Providers { @@ -29,48 +29,57 @@ public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProv public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) { - var result = new MetadataResult<Episode>(); + try + { + var result = new MetadataResult<Episode>(); + + // TO-DO Check if it can be written in a better way. Parent directory + File Name + var filename = Path.Join( + Path.GetDirectoryName(info.Path)?.Split(Path.DirectorySeparatorChar).LastOrDefault(), + Path.GetFileName(info.Path)); - // TO-DO Check if it can be written in a better way. Parent directory + File Name - var filename = Path.Join(Path.GetDirectoryName(info.Path)?.Split(Path.DirectorySeparatorChar).LastOrDefault(), - Path.GetFileName(info.Path)); - - _logger.LogInformation($"Shoko Scanner... Getting episode ID ({filename})"); - - var apiResponse = await ShokoAPI.GetFilePathEndsWith(filename); - var allIds = apiResponse.FirstOrDefault()?.SeriesIDs.FirstOrDefault()?.EpisodeIDs; - var episodeIDs = allIds?.FirstOrDefault(); - var episodeId = episodeIDs?.ID.ToString(); + _logger.LogInformation($"Shoko Scanner... Getting episode ID ({filename})"); + + var apiResponse = await ShokoAPI.GetFilePathEndsWith(filename); + var allIds = apiResponse.FirstOrDefault()?.SeriesIDs.FirstOrDefault()?.EpisodeIDs; + var episodeIDs = allIds?.FirstOrDefault(); + var episodeId = episodeIDs?.ID.ToString(); + + if (string.IsNullOrEmpty(episodeId)) + { + _logger.LogInformation($"Shoko Scanner... Episode not found! ({filename})"); + return result; + } + + _logger.LogInformation($"Shoko Scanner... Getting episode metadata ({filename} - {episodeId})"); + + var episodeInfo = await ShokoAPI.GetEpisodeAniDb(episodeId); + + result.Item = new Episode + { + IndexNumber = episodeInfo.EpisodeNumber, + ParentIndexNumber = await GetSeasonNumber(episodeId, episodeInfo.Type), + Name = episodeInfo.Titles.Find(title => title.Language.Equals("EN"))?.Name, + PremiereDate = episodeInfo.AirDate, + Overview = Helper.SummarySanitizer(episodeInfo.Description), + CommunityRating = (float) ((episodeInfo.Rating.Value * 10) / episodeInfo.Rating.MaxValue) + }; + result.Item.SetProviderId("Shoko", episodeId); + result.Item.SetProviderId("AniDB", episodeIDs.AniDB.ToString()); + var tvdbId = episodeIDs.TvDB?.FirstOrDefault(); + if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); + result.HasMetadata = true; + + var episodeNumberEnd = episodeInfo.EpisodeNumber + allIds.Count() - 1; + if (episodeInfo.EpisodeNumber != episodeNumberEnd) result.Item.IndexNumberEnd = episodeNumberEnd; - if (string.IsNullOrEmpty(episodeId)) - { - _logger.LogInformation($"Shoko Scanner... Episode not found! ({filename})"); return result; } - - _logger.LogInformation($"Shoko Scanner... Getting episode metadata ({filename} - {episodeId})"); - - var episodeInfo = await ShokoAPI.GetEpisodeAniDb(episodeId); - - result.Item = new Episode + catch (Exception e) { - IndexNumber = episodeInfo.EpisodeNumber, - ParentIndexNumber = await GetSeasonNumber(episodeId, episodeInfo.Type), - Name = episodeInfo.Titles.Find(title => title.Language.Equals("EN"))?.Name, - PremiereDate = episodeInfo.AirDate, - Overview = Helper.SummarySanitizer(episodeInfo.Description), - CommunityRating = (float)((episodeInfo.Rating.Value * 10) / episodeInfo.Rating.MaxValue) - }; - result.Item.SetProviderId("Shoko", episodeId); - result.Item.SetProviderId("AniDB", episodeIDs.AniDB.ToString()); - var tvdbId = episodeIDs.TvDB?.FirstOrDefault(); - if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); - result.HasMetadata = true; - - var episodeNumberEnd = episodeInfo.EpisodeNumber + allIds.Count() - 1; - if (episodeInfo.EpisodeNumber != episodeNumberEnd) result.Item.IndexNumberEnd = episodeNumberEnd; - - return result; + _logger.LogError(e.StackTrace); + return new MetadataResult<Episode>(); + } } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) diff --git a/ShokoJellyfin/Providers/Helper.cs b/ShokoJellyfin/Providers/Helper.cs index 39556ef3..85f8dcf5 100644 --- a/ShokoJellyfin/Providers/Helper.cs +++ b/ShokoJellyfin/Providers/Helper.cs @@ -1,5 +1,5 @@ using System.Text.RegularExpressions; -using ShokoJellyfin.Providers.API.Models; +using ShokoJellyfin.API.Models; namespace ShokoJellyfin.Providers { diff --git a/ShokoJellyfin/Providers/ImageProvider.cs b/ShokoJellyfin/Providers/ImageProvider.cs index a2347eeb..5df3927e 100644 --- a/ShokoJellyfin/Providers/ImageProvider.cs +++ b/ShokoJellyfin/Providers/ImageProvider.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -28,46 +29,30 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell { var list = new List<RemoteImageInfo>(); - // Doesn't seem like Jellyfin can generate thumbs by itself. Keep this option always enabled for now. - // if (item is Episode && !Plugin.Instance.Configuration.UseShokoThumbnails) return list; - - var id = item.GetProviderId("Shoko"); - - if (item is Season) + try { - id = item.GetParent().GetProviderId("Shoko"); - } + // Doesn't seem like Jellyfin can generate thumbs by itself. Keep this option always enabled for now. + // if (item is Episode && !Plugin.Instance.Configuration.UseShokoThumbnails) return list; - if (string.IsNullOrEmpty(id)) - { - _logger.LogInformation($"Shoko Scanner... Images not found ({item.Name})"); - return list; - } - - _logger.LogInformation($"Shoko Scanner... Getting images ({item.Name} - {id})"); + var id = item.GetProviderId("Shoko"); - if (item is Episode) - { - var tvdbEpisodeInfo = (await API.ShokoAPI.GetEpisodeTvDb(id)).FirstOrDefault(); - var imageUrl = Helper.GetImageUrl(tvdbEpisodeInfo?.Thumbnail); - if (!string.IsNullOrEmpty(imageUrl)) + if (item is Season) { - list.Add(new RemoteImageInfo - { - ProviderName = Name, - Type = ImageType.Primary, - Url = imageUrl - }); + id = item.GetParent().GetProviderId("Shoko"); } - } - if (item is Series || item is Season) - { - var images = await API.ShokoAPI.GetSeriesImages(id); + if (string.IsNullOrEmpty(id)) + { + _logger.LogInformation($"Shoko Scanner... Images not found ({item.Name})"); + return list; + } - foreach (var image in images.Posters) + _logger.LogInformation($"Shoko Scanner... Getting images ({item.Name} - {id})"); + + if (item is Episode) { - var imageUrl = Helper.GetImageUrl(image); + var tvdbEpisodeInfo = (await API.ShokoAPI.GetEpisodeTvDb(id)).FirstOrDefault(); + var imageUrl = Helper.GetImageUrl(tvdbEpisodeInfo?.Thumbnail); if (!string.IsNullOrEmpty(imageUrl)) { list.Add(new RemoteImageInfo @@ -78,37 +63,61 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell }); } } - - foreach (var image in images.Fanarts) + + if (item is Series || item is Season) { - var imageUrl = Helper.GetImageUrl(image); - if (!string.IsNullOrEmpty(imageUrl)) + var images = await API.ShokoAPI.GetSeriesImages(id); + + foreach (var image in images.Posters) { - list.Add(new RemoteImageInfo + var imageUrl = Helper.GetImageUrl(image); + if (!string.IsNullOrEmpty(imageUrl)) { - ProviderName = Name, - Type = ImageType.Backdrop, - Url = imageUrl - }); + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Type = ImageType.Primary, + Url = imageUrl + }); + } } - } - - foreach (var image in images.Banners) - { - var imageUrl = Helper.GetImageUrl(image); - if (!string.IsNullOrEmpty(imageUrl)) + + foreach (var image in images.Fanarts) { - list.Add(new RemoteImageInfo + var imageUrl = Helper.GetImageUrl(image); + if (!string.IsNullOrEmpty(imageUrl)) { - ProviderName = Name, - Type = ImageType.Banner, - Url = imageUrl - }); + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Type = ImageType.Backdrop, + Url = imageUrl + }); + } + } + + foreach (var image in images.Banners) + { + var imageUrl = Helper.GetImageUrl(image); + if (!string.IsNullOrEmpty(imageUrl)) + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Type = ImageType.Banner, + Url = imageUrl + }); + } } } - } - return list; + return list; + } + catch (Exception e) + { + _logger.LogError(e.StackTrace); + return list; + } } public IEnumerable<ImageType> GetSupportedImages(BaseItem item) diff --git a/ShokoJellyfin/Providers/SeriesProvider.cs b/ShokoJellyfin/Providers/SeriesProvider.cs index 519dfc6a..d3bdfab0 100644 --- a/ShokoJellyfin/Providers/SeriesProvider.cs +++ b/ShokoJellyfin/Providers/SeriesProvider.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -10,7 +11,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; -using ShokoJellyfin.Providers.API; +using ShokoJellyfin.API; namespace ShokoJellyfin.Providers { @@ -29,59 +30,67 @@ public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvid public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) { - var result = new MetadataResult<Series>(); - - var dirname = Path.DirectorySeparatorChar + info.Path.Split(Path.DirectorySeparatorChar).Last(); - - _logger.LogInformation($"Shoko Scanner... Getting series ID ({dirname})"); - - var apiResponse = await ShokoAPI.GetSeriesPathEndsWith(dirname); - var seriesIDs = apiResponse.FirstOrDefault()?.IDs; - var seriesId = seriesIDs?.ID.ToString(); - - if (string.IsNullOrEmpty(seriesId)) + try { - _logger.LogInformation("Shoko Scanner... Series not found!"); + var result = new MetadataResult<Series>(); + + var dirname = Path.DirectorySeparatorChar + info.Path.Split(Path.DirectorySeparatorChar).Last(); + + _logger.LogInformation($"Shoko Scanner... Getting series ID ({dirname})"); + + var apiResponse = await ShokoAPI.GetSeriesPathEndsWith(dirname); + var seriesIDs = apiResponse.FirstOrDefault()?.IDs; + var seriesId = seriesIDs?.ID.ToString(); + + if (string.IsNullOrEmpty(seriesId)) + { + _logger.LogInformation("Shoko Scanner... Series not found!"); + return result; + } + + _logger.LogInformation($"Shoko Scanner... Getting series metadata ({dirname} - {seriesId})"); + + var seriesInfo = await ShokoAPI.GetSeries(seriesId); + var aniDbSeriesInfo = await ShokoAPI.GetSeriesAniDb(seriesId); + var tags = await ShokoAPI.GetSeriesTags(seriesId, GetFlagFilter()); + + result.Item = new Series + { + Name = seriesInfo.Name, + Overview = Helper.SummarySanitizer(aniDbSeriesInfo.Description), + PremiereDate = aniDbSeriesInfo.AirDate, + EndDate = aniDbSeriesInfo.EndDate, + ProductionYear = aniDbSeriesInfo.AirDate?.Year, + Status = aniDbSeriesInfo.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, + Tags = tags?.Select(tag => tag.Name).ToArray() ?? new string[0], + CommunityRating = (float)((aniDbSeriesInfo.Rating.Value * 10) / aniDbSeriesInfo.Rating.MaxValue) + }; + result.Item.SetProviderId("Shoko", seriesId); + result.Item.SetProviderId("AniDB", seriesIDs.AniDB.ToString()); + var tvdbId = seriesIDs.TvDB?.FirstOrDefault(); + if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); + result.HasMetadata = true; + + result.ResetPeople(); + var roles = await ShokoAPI.GetSeriesCast(seriesId); + foreach (var role in roles) + { + result.AddPerson(new PersonInfo + { + Type = PersonType.Actor, + Name = role.Staff.Name, + Role = role.Character.Name, + ImageUrl = Helper.GetImageUrl(role.Staff.Image) + }); + } + return result; } - - _logger.LogInformation($"Shoko Scanner... Getting series metadata ({dirname} - {seriesId})"); - - var seriesInfo = await ShokoAPI.GetSeries(seriesId); - var aniDbSeriesInfo = await ShokoAPI.GetSeriesAniDb(seriesId); - var tags = await ShokoAPI.GetSeriesTags(seriesId, GetFlagFilter()); - - result.Item = new Series + catch (Exception e) { - Name = seriesInfo.Name, - Overview = Helper.SummarySanitizer(aniDbSeriesInfo.Description), - PremiereDate = aniDbSeriesInfo.AirDate, - EndDate = aniDbSeriesInfo.EndDate, - ProductionYear = aniDbSeriesInfo.AirDate?.Year, - Status = aniDbSeriesInfo.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = tags?.Select(tag => tag.Name).ToArray() ?? new string[0], - CommunityRating = (float)((aniDbSeriesInfo.Rating.Value * 10) / aniDbSeriesInfo.Rating.MaxValue) - }; - result.Item.SetProviderId("Shoko", seriesId); - result.Item.SetProviderId("AniDB", seriesIDs.AniDB.ToString()); - var tvdbId = seriesIDs.TvDB?.FirstOrDefault(); - if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); - result.HasMetadata = true; - - result.ResetPeople(); - var roles = await ShokoAPI.GetSeriesCast(seriesId); - foreach (var role in roles) - { - result.AddPerson(new PersonInfo - { - Type = PersonType.Actor, - Name = role.Staff.Name, - Role = role.Character.Name, - ImageUrl = Helper.GetImageUrl(role.Staff.Image) - }); + _logger.LogError(e.StackTrace); + return new MetadataResult<Series>(); } - - return result; } public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) diff --git a/ShokoJellyfin/Scrobbler.cs b/ShokoJellyfin/Scrobbler.cs new file mode 100644 index 00000000..a45c4f9a --- /dev/null +++ b/ShokoJellyfin/Scrobbler.cs @@ -0,0 +1,59 @@ +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; +using ShokoJellyfin.API; + +namespace ShokoJellyfin +{ + public class Scrobbler : IServerEntryPoint + { + private readonly ISessionManager _sessionManager; + private readonly ILogger<Scrobbler> _logger; + + public Scrobbler(ISessionManager sessionManager, ILogger<Scrobbler> logger) + { + _sessionManager = sessionManager; + _logger = logger; + } + + public Task RunAsync() + { + _sessionManager.PlaybackStopped += KernelPlaybackStopped; + return Task.CompletedTask; + } + + private async void KernelPlaybackStopped(object sender, PlaybackStopEventArgs e) + { + if (!Plugin.Instance.Configuration.UpdateWatchedStatus) return; + + if (e.Item == null) + { + _logger.LogError("Shoko Scrobbler... Event details incomplete. Cannot process current media"); + return; + } + + if (e.Item is Episode episode && e.PlayedToCompletion) + { + var episodeId = episode.GetProviderId("Shoko"); + + _logger.LogInformation("Shoko Scrobbler... Item is played. Marking as watched on Shoko"); + _logger.LogInformation($"{episode.SeriesName} - {episode.Name} ({episodeId})"); + + var result = await ShokoAPI.MarkEpisodeWatched(episodeId); + if (result) + _logger.LogInformation("Shoko Scrobbler... Episode marked as watched!"); + else + _logger.LogError("Shoko Scrobbler... Error marking episode as watched!"); + } + } + + public void Dispose() + { + _sessionManager.PlaybackStopped -= KernelPlaybackStopped; + } + } +} \ No newline at end of file From 867acc40e083d79ad416199491df61cfb8622202 Mon Sep 17 00:00:00 2001 From: G Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 9 Sep 2020 03:35:11 +0530 Subject: [PATCH 0021/1103] Bring back the option to disable custom thumbs. I'm an idiot. --- ShokoJellyfin/Configuration/PluginConfiguration.cs | 4 ++-- ShokoJellyfin/Configuration/configPage.html | 12 ++++++------ ShokoJellyfin/Providers/ImageProvider.cs | 3 +-- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/ShokoJellyfin/Configuration/PluginConfiguration.cs b/ShokoJellyfin/Configuration/PluginConfiguration.cs index c3e054f0..e0143f83 100644 --- a/ShokoJellyfin/Configuration/PluginConfiguration.cs +++ b/ShokoJellyfin/Configuration/PluginConfiguration.cs @@ -18,7 +18,7 @@ public class PluginConfiguration : BasePluginConfiguration public bool UseTvDbSeasonOrdering { get; set; } - // public bool UseShokoThumbnails { get; set; } + public bool UseShokoThumbnails { get; set; } public bool HideArtStyleTags { get; set; } @@ -47,7 +47,7 @@ public PluginConfiguration() ApiKey = ""; UpdateWatchedStatus = false; UseTvDbSeasonOrdering = false; - // UseShokoThumbnails = true; + UseShokoThumbnails = true; HideArtStyleTags = false; HideSourceTags = false; HideMiscTags = false; diff --git a/ShokoJellyfin/Configuration/configPage.html b/ShokoJellyfin/Configuration/configPage.html index 0624afad..2c053383 100644 --- a/ShokoJellyfin/Configuration/configPage.html +++ b/ShokoJellyfin/Configuration/configPage.html @@ -35,10 +35,10 @@ <input is="emby-checkbox" type="checkbox" id="UseTvDbSeasonOrdering" /> <span>Use season ordering from TvDB. Also makes the shows merge-friendly.</span> </label> -<!-- <label class="checkboxContainer">--> -<!-- <input is="emby-checkbox" type="checkbox" id="UseShokoThumbnails" />--> -<!-- <span>Use thumbnails from Shoko</span>--> -<!-- </label>--> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="UseShokoThumbnails" /> + <span>Use thumbnails from Shoko</span> + </label> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="HideArtStyleTags" /> <span>Hide art style related tags</span> @@ -99,7 +99,7 @@ document.querySelector('#ApiKey').value = config.ApiKey; document.querySelector('#UpdateWatchedStatus').checked = config.UpdateWatchedStatus; document.querySelector('#UseTvDbSeasonOrdering').checked = config.UseTvDbSeasonOrdering; - // document.querySelector('#UseShokoThumbnails').checked = config.UseShokoThumbnails; + document.querySelector('#UseShokoThumbnails').checked = config.UseShokoThumbnails; document.querySelector('#HideArtStyleTags').checked = config.HideArtStyleTags; document.querySelector('#HideSourceTags').checked = config.HideSourceTags; document.querySelector('#HideMiscTags').checked = config.HideMiscTags; @@ -125,7 +125,7 @@ config.ApiKey = document.querySelector('#ApiKey').value; config.UpdateWatchedStatus = document.querySelector('#UpdateWatchedStatus').checked; config.UseTvDbSeasonOrdering = document.querySelector('#UseTvDbSeasonOrdering').checked; - // config.UseShokoThumbnails = document.querySelector('#UseShokoThumbnails').checked; + config.UseShokoThumbnails = document.querySelector('#UseShokoThumbnails').checked; config.HideArtStyleTags = document.querySelector('#HideArtStyleTags').checked; config.HideSourceTags = document.querySelector('#HideSourceTags').checked; config.HideMiscTags = document.querySelector('#HideMiscTags').checked; diff --git a/ShokoJellyfin/Providers/ImageProvider.cs b/ShokoJellyfin/Providers/ImageProvider.cs index 5df3927e..a647ad1f 100644 --- a/ShokoJellyfin/Providers/ImageProvider.cs +++ b/ShokoJellyfin/Providers/ImageProvider.cs @@ -31,8 +31,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell try { - // Doesn't seem like Jellyfin can generate thumbs by itself. Keep this option always enabled for now. - // if (item is Episode && !Plugin.Instance.Configuration.UseShokoThumbnails) return list; + if (item is Episode && !Plugin.Instance.Configuration.UseShokoThumbnails) return list; var id = item.GetProviderId("Shoko"); From c0bf96a8951f88ee9fa3b8d96fc90864646c92b5 Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Tue, 8 Sep 2020 22:17:28 +0000 Subject: [PATCH 0022/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index 0953254f..33d5a327 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.1.0", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/ShokoJellyfin/releases/download/1.1.0/shokojellyfin_1.1.0.zip", + "checksum": "610e540182066278d816e06e8f1a01ee", + "timestamp": "2020-09-08T22:17:26Z" + }, { "version": "1.0.0", "changelog": "NA", From 4dcb00be5248f2eae5d1938d9d3f855fba622ed7 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 20 Sep 2020 02:47:04 +0200 Subject: [PATCH 0023/1103] Add display title customization --- .../Configuration/PluginConfiguration.cs | 45 ++++-- ShokoJellyfin/Configuration/configPage.html | 43 +++++- ShokoJellyfin/ExternalIds.cs | 62 +++++++++ ShokoJellyfin/Providers/EpisodeProvider.cs | 15 +- ShokoJellyfin/Providers/Helper.cs | 129 ++++++++++++++++++ ShokoJellyfin/Providers/SeriesProvider.cs | 4 +- 6 files changed, 270 insertions(+), 28 deletions(-) create mode 100644 ShokoJellyfin/ExternalIds.cs diff --git a/ShokoJellyfin/Configuration/PluginConfiguration.cs b/ShokoJellyfin/Configuration/PluginConfiguration.cs index e0143f83..dd634f6d 100644 --- a/ShokoJellyfin/Configuration/PluginConfiguration.cs +++ b/ShokoJellyfin/Configuration/PluginConfiguration.cs @@ -5,39 +5,51 @@ namespace ShokoJellyfin.Configuration public class PluginConfiguration : BasePluginConfiguration { public string Host { get; set; } - + public string Port { get; set; } - + public string Username { get; set; } - + public string Password { get; set; } - + public string ApiKey { get; set; } - + public bool UpdateWatchedStatus { get; set; } - + public bool UseTvDbSeasonOrdering { get; set; } - + public bool UseShokoThumbnails { get; set; } - + public bool HideArtStyleTags { get; set; } - + public bool HideSourceTags { get; set; } - + public bool HideMiscTags { get; set; } - + public bool HidePlotTags { get; set; } public bool HideAniDbTags { get; set; } - + public bool SynopsisCleanLinks { get; set; } - + public bool SynopsisCleanMiscLines { get; set; } - + public bool SynopsisRemoveSummary { get; set; } - + public bool SynopsisCleanMultiEmptyLines { get; set; } + public enum DisplayTitleType { + Default, + Localized, + Origin, + } + + public bool TitleUseAlternate { get; set; } + + public DisplayTitleType TitleMainType { get; set; } + + public DisplayTitleType TitleAlternateType { get; set; } + public PluginConfiguration() { Host = "127.0.0.1"; @@ -57,6 +69,9 @@ public PluginConfiguration() SynopsisCleanMiscLines = true; SynopsisRemoveSummary = true; SynopsisCleanMultiEmptyLines = true; + TitleUseAlternate = true; + TitleMainType = DisplayTitleType.Default; + TitleAlternateType = DisplayTitleType.Origin; } } } \ No newline at end of file diff --git a/ShokoJellyfin/Configuration/configPage.html b/ShokoJellyfin/Configuration/configPage.html index 2c053383..531b6fad 100644 --- a/ShokoJellyfin/Configuration/configPage.html +++ b/ShokoJellyfin/Configuration/configPage.html @@ -27,6 +27,29 @@ <input is="emby-input" type="text" id="ApiKey" label="API Key" /> <div class="fieldDescription">This field is auto-generated using the credentials. Only set this manually if that doesn't work!</div> </div> + <div class="selectContainer"> + <label class="selectLabel" for="TitleMainType">Main Title Language</label> + <select is="emby-select" id="TitleMainType" name="TitleMainType" class="emby-select-withcolor emby-select"> + <option value="Default">Default</option> + <option value="Localized">Use preffered metadata language</option> + <option value="Origin">Language in country of origin</option> + </select> + <div class="fieldDescription">Titles will fallback to Default if not found for the target language.</div> + </div> + <div class="selectContainer"> + <label class="selectLabel" for="TitleAlternateType">Alternate Title Language</label> + <select is="emby-select" id="TitleAlternateType" name="TitleAlternateType" class="emby-select-withcolor emby-select"> + <option value="Default">Default</option> + <option value="Localized">Use preffered metadata language</option> + <option value="Origin">Language in country of origin</option> + </select> + <div class="fieldDescription">Titles >will fallback to Default if not found for the target language.</div> + </div> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="TitleUseAlternate" /> + <span>Include an alternate title</span> + <div class="fieldDescription">Will populate the "Original Title" field with the alternate title.</div> + </label> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="UpdateWatchedStatus" /> <span>Update watched status on Shoko (Scrobble)</span> @@ -87,7 +110,7 @@ var PluginConfig = { pluginId: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052" }; - + document.querySelector('.shokoConfigPage') .addEventListener('pageshow', function () { Dashboard.showLoadingMsg(); @@ -108,15 +131,18 @@ document.querySelector('#SynopsisCleanLinks').checked = config.SynopsisCleanLinks; document.querySelector('#SynopsisCleanMiscLines').checked = config.SynopsisCleanMiscLines; document.querySelector('#SynopsisRemoveSummary').checked = config.SynopsisRemoveSummary; - document.querySelector('#SynopsisCleanMultiEmptyLines').checked = config.SynopsisCleanMultiEmptyLines; + document.querySelector('#SynopsisCleanMultiEmptyLines').checked = config.SynopsisCleanMultiEmptyLines; + document.querySelector('#TitleUseAlternate').checked = config.TitleUseAlternate; + document.querySelector('#TitleMainType').value = config.TitleMainType; + document.querySelector('#TitleAlternateType').value = config.TitleAlternateType; Dashboard.hideLoadingMsg(); }); }); - + document.querySelector('.shokoConfigForm') .addEventListener('submit', function (e) { Dashboard.showLoadingMsg(); - + ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { config.Host = document.querySelector('#Host').value; config.Port = document.querySelector('#Port').value; @@ -124,7 +150,7 @@ config.Password = document.querySelector('#Password').value; config.ApiKey = document.querySelector('#ApiKey').value; config.UpdateWatchedStatus = document.querySelector('#UpdateWatchedStatus').checked; - config.UseTvDbSeasonOrdering = document.querySelector('#UseTvDbSeasonOrdering').checked; + config.UseTvDbSeasonOrdering = document.querySelector('#UseTvDbSeasonOrdering').checked; config.UseShokoThumbnails = document.querySelector('#UseShokoThumbnails').checked; config.HideArtStyleTags = document.querySelector('#HideArtStyleTags').checked; config.HideSourceTags = document.querySelector('#HideSourceTags').checked; @@ -134,10 +160,13 @@ config.SynopsisCleanLinks = document.querySelector('#SynopsisCleanLinks').checked; config.SynopsisCleanMiscLines = document.querySelector('#SynopsisCleanMiscLines').checked; config.SynopsisRemoveSummary = document.querySelector('#SynopsisRemoveSummary').checked; - config.SynopsisCleanMultiEmptyLines = document.querySelector('#SynopsisCleanMultiEmptyLines').checked; + config.SynopsisCleanMultiEmptyLines = document.querySelector('#SynopsisCleanMultiEmptyLines').checked; + config.TitleUseAlternate = document.querySelector('#TitleUseAlternate').checked; + config.TitleMainType = document.querySelector('#TitleMainType').value; + config.TitleAlternateType = document.querySelector('#TitleAlternateType').value; ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); }); - + e.preventDefault(); return false; }); diff --git a/ShokoJellyfin/ExternalIds.cs b/ShokoJellyfin/ExternalIds.cs new file mode 100644 index 00000000..c9a96981 --- /dev/null +++ b/ShokoJellyfin/ExternalIds.cs @@ -0,0 +1,62 @@ +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace ShokoJellyfin +{ + public class AniDbExternalId : IExternalId + { + public bool Supports(IHasProviderIds item) + => item is Series || item is Episode || item is Movie || item is BoxSet; + + public string ProviderName + => "AniDB"; + + public string Key + => "AniDB"; + + public ExternalIdMediaType? Type + => null; + + public string UrlFormatString + => null; + } + + public class ShokoSeriesExternalId : IExternalId + { + public bool Supports(IHasProviderIds item) + => item is Series || item is Episode || item is Movie || item is BoxSet; + + public string ProviderName + => "Shoko Series"; + + public string Key + => "Shoko Series"; + + public ExternalIdMediaType? Type + => null; + + public string UrlFormatString + => null; + } + + public class ShokoEpisodeExternalId : IExternalId + { + public bool Supports(IHasProviderIds item) + => item is Episode || item is Movie; + + public string ProviderName + => "Shoko Episode"; + + public string Key + => "Shoko Episode"; + + public ExternalIdMediaType? Type + => null; + + public string UrlFormatString + => null; + } +} \ No newline at end of file diff --git a/ShokoJellyfin/Providers/EpisodeProvider.cs b/ShokoJellyfin/Providers/EpisodeProvider.cs index 92067567..0cd07aa6 100644 --- a/ShokoJellyfin/Providers/EpisodeProvider.cs +++ b/ShokoJellyfin/Providers/EpisodeProvider.cs @@ -41,8 +41,9 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell _logger.LogInformation($"Shoko Scanner... Getting episode ID ({filename})"); var apiResponse = await ShokoAPI.GetFilePathEndsWith(filename); - var allIds = apiResponse.FirstOrDefault()?.SeriesIDs.FirstOrDefault()?.EpisodeIDs; - var episodeIDs = allIds?.FirstOrDefault(); + var allIds = apiResponse.FirstOrDefault()?.SeriesIDs.FirstOrDefault(); + var seriesId = allIds?.SeriesID.ID.ToString(); + var episodeIDs = allIds?.EpisodeIDs?.FirstOrDefault(); var episodeId = episodeIDs?.ID.ToString(); if (string.IsNullOrEmpty(episodeId)) @@ -53,24 +54,28 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell _logger.LogInformation($"Shoko Scanner... Getting episode metadata ({filename} - {episodeId})"); + var seriesInfo = await ShokoAPI.GetSeriesAniDb(seriesId); var episodeInfo = await ShokoAPI.GetEpisodeAniDb(episodeId); + var ( displayTitle, alternateTitle ) = Helper.GetEpisodeTitles(seriesInfo.Titles, episodeInfo.Titles, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); result.Item = new Episode { IndexNumber = episodeInfo.EpisodeNumber, ParentIndexNumber = await GetSeasonNumber(episodeId, episodeInfo.Type), - Name = episodeInfo.Titles.Find(title => title.Language.Equals("EN"))?.Name, + Name = displayTitle, + OriginalTitle = alternateTitle, PremiereDate = episodeInfo.AirDate, Overview = Helper.SummarySanitizer(episodeInfo.Description), CommunityRating = (float) ((episodeInfo.Rating.Value * 10) / episodeInfo.Rating.MaxValue) }; - result.Item.SetProviderId("Shoko", episodeId); + result.Item.SetProviderId("Shoko Series", seriesId); + result.Item.SetProviderId("Shoko Episode", episodeId); result.Item.SetProviderId("AniDB", episodeIDs.AniDB.ToString()); var tvdbId = episodeIDs.TvDB?.FirstOrDefault(); if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); result.HasMetadata = true; - var episodeNumberEnd = episodeInfo.EpisodeNumber + allIds.Count() - 1; + var episodeNumberEnd = episodeInfo.EpisodeNumber + allIds?.EpisodeIDs.Count() - 1; if (episodeInfo.EpisodeNumber != episodeNumberEnd) result.Item.IndexNumberEnd = episodeNumberEnd; return result; diff --git a/ShokoJellyfin/Providers/Helper.cs b/ShokoJellyfin/Providers/Helper.cs index 85f8dcf5..21d167b8 100644 --- a/ShokoJellyfin/Providers/Helper.cs +++ b/ShokoJellyfin/Providers/Helper.cs @@ -1,5 +1,10 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; using System.Text.RegularExpressions; using ShokoJellyfin.API.Models; +using Title = ShokoJellyfin.API.Models.Title; +using DisplayTitleType = ShokoJellyfin.Configuration.PluginConfiguration.DisplayTitleType; namespace ShokoJellyfin.Providers { @@ -28,5 +33,129 @@ public static string SummarySanitizer(string summary) // Based on ShokoMetadata return summary; } + + // Produce titles for episodes if the series-fallback-title is not provided. + public static ( string, string ) GetEpisodeTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, DisplayTitleType displayTitleType, DisplayTitleType alternateTitleType, string metadataLanguage) + => GetFullTitles(seriesTitles, episodeTitles, null, displayTitleType, alternateTitleType, metadataLanguage); + + // Produce titles for series if episode titles are omitted. + public static ( string, string ) GetSeriesTitles(IEnumerable<Title> seriesTitles, string seriesTitle, DisplayTitleType displayTitleType, DisplayTitleType alternateTitleType, string metadataLanguage) + => GetFullTitles(seriesTitles, null, seriesTitle, displayTitleType, alternateTitleType, metadataLanguage); + + // Produce combined/full titles if both episode titles and a fallback title is provided. + public static ( string, string ) GetFullTitles(IEnumerable<Title> rSeriesTitles, IEnumerable<Title> rEpisodeTitles, string seriesTitle, DisplayTitleType displayTitleType, DisplayTitleType alternateTitleType, string metadataLanguage) + { + // Don't process anything if the series titles are not provided. + if (rSeriesTitles == null) return ( null, null ); + var seriesTitles = (List<Title>)rSeriesTitles; + var episodeTitles = (List<Title>)rEpisodeTitles; + var originLanguage = GuessOriginLanguage(seriesTitles); + var displayLanguage = GuessDisplayLanguage(metadataLanguage); + return ( GetFullTitle(seriesTitles, episodeTitles, seriesTitle, displayTitleType, displayLanguage, originLanguage), GetFullTitle(seriesTitles, episodeTitles, seriesTitle, alternateTitleType, displayLanguage, originLanguage) ); + } + + private static string GetEpisodeTitle(IEnumerable<Title> episodeTitle, DisplayTitleType displayTitleType, string displayLanguage, params string[] originLanguages) + => GetFullTitle(null, episodeTitle, null, displayTitleType, displayLanguage, originLanguages); + + private static string GetSeriesTitle(IEnumerable<Title> seriesTitles, string seriesTitle, DisplayTitleType displayTitleType, string displayLanguage, params string[] originLanguages) + => GetFullTitle(seriesTitles, null, seriesTitle, displayTitleType, displayLanguage, originLanguages); + + private static string GetFullTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, DisplayTitleType displayTitleType, string displayLanguage, params string[] originLanguages) + { + // We need one of them, or it won't work as intended. + if (seriesTitle == null && episodeTitles == null) return null; + switch (displayTitleType) + { + case DisplayTitleType.Default: + // Fallback to preffered series title, but choose the episode title based on this order. + // The "main" title on AniDB is _most_ of the time in english, but we also fallback to romanji (japanese) or pinyin (chinese) in case it is not provided in. + return GetTitle(null, episodeTitles, seriesTitle, "en", "x-jat", "x-zht"); + case DisplayTitleType.Origin: + return GetTitle(seriesTitles, episodeTitles, seriesTitle, originLanguages); + case DisplayTitleType.Localized: + var title = GetTitle(seriesTitles, episodeTitles, seriesTitle, displayLanguage); + if (string.IsNullOrEmpty(title)) + goto case DisplayTitleType.Default; + return title; + default: + return null; + } + } + + private static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, params string[] languageCandidates) + { + if (seriesTitles != null || seriesTitle != null) + { + StringBuilder title = new StringBuilder(); + string mainTitle = GetTitleByTypeAndLanguage(seriesTitles, "official", languageCandidates) ?? seriesTitle; + title.Append(mainTitle); + if (episodeTitles != null) { + var episodeTitle = GetTitleByLanguages(episodeTitles, languageCandidates); + // We could not find the complete title, and no mixed languages (outside the spesified ones), so abort here. + if (episodeTitle == null) + { + // Some movies provide only an english title, so we fallback to english. + episodeTitle = GetTitleByLanguages(episodeTitles, "en"); + if (episodeTitle == null) + return null; + } + if (!string.IsNullOrWhiteSpace(episodeTitle) && episodeTitle != "Complete Movie") + title.Append($": {episodeTitle}"); + } + return title.ToString(); + } + // Will fallback to null if episode titles are null. + return GetTitleByLanguages(episodeTitles, languageCandidates); + } + + private static string GetTitleByTypeAndLanguage(IEnumerable<Title> titles, string type, params string[] langs) + { + if (titles != null) foreach (string lang in langs) + { + string title = titles.FirstOrDefault(s => s.Language == lang && s.Type == type)?.Name; + if (title != null) return title; + } + return null; + } + + private static string GetTitleByLanguages(IEnumerable<Title> titles, params string[] langs) + { + if (titles != null) foreach (string lang in langs) + { + string title = titles.FirstOrDefault(s => s.Language.ToLower() == lang)?.Name; + if (title != null) return title; + } + return null; + } + + // Guess the origin language based on the main title. + private static string[] GuessOriginLanguage(IEnumerable<Title> seriesTitle) + { + string langCode = seriesTitle.FirstOrDefault(t => t?.Type == "main")?.Language.ToLower(); + // Guess the origin language based on the main title. + switch (langCode) + { + case null: // fallback + case "x-other": + case "x-jat": + return new string[] { "ja" }; + case "x-zht": + return new string[] { "zn-hans", "zn-hant", "zn-c-mcm", "zn" }; + default: + return new string[] { langCode }; + } + + } + + private static string GuessDisplayLanguage(string metadataLanguage) + { + switch (metadataLanguage) + { + // TODO: Add more cases, or provide a converter function to country-code. + case null: + default: + return "en"; + } + } } } \ No newline at end of file diff --git a/ShokoJellyfin/Providers/SeriesProvider.cs b/ShokoJellyfin/Providers/SeriesProvider.cs index d3bdfab0..5604fccf 100644 --- a/ShokoJellyfin/Providers/SeriesProvider.cs +++ b/ShokoJellyfin/Providers/SeriesProvider.cs @@ -53,10 +53,12 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat var seriesInfo = await ShokoAPI.GetSeries(seriesId); var aniDbSeriesInfo = await ShokoAPI.GetSeriesAniDb(seriesId); var tags = await ShokoAPI.GetSeriesTags(seriesId, GetFlagFilter()); + var ( displayTitle, alternateTitle ) = Helper.GetSeriesTitles(aniDbSeriesInfo.Titles, aniDbSeriesInfo.Title, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); result.Item = new Series { - Name = seriesInfo.Name, + Name = displayTitle, + OriginalTitle = alternateTitle, Overview = Helper.SummarySanitizer(aniDbSeriesInfo.Description), PremiereDate = aniDbSeriesInfo.AirDate, EndDate = aniDbSeriesInfo.EndDate, From a2552ec0774bea97c0dad1fc2ff763a7d2e8cc9f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 20 Sep 2020 03:09:26 +0200 Subject: [PATCH 0024/1103] Fix missing series provider id (visually) Forgot to rename the field. --- ShokoJellyfin/Providers/SeriesProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ShokoJellyfin/Providers/SeriesProvider.cs b/ShokoJellyfin/Providers/SeriesProvider.cs index 5604fccf..77a1dda1 100644 --- a/ShokoJellyfin/Providers/SeriesProvider.cs +++ b/ShokoJellyfin/Providers/SeriesProvider.cs @@ -67,7 +67,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat Tags = tags?.Select(tag => tag.Name).ToArray() ?? new string[0], CommunityRating = (float)((aniDbSeriesInfo.Rating.Value * 10) / aniDbSeriesInfo.Rating.MaxValue) }; - result.Item.SetProviderId("Shoko", seriesId); + result.Item.SetProviderId("Shoko Series", seriesId); result.Item.SetProviderId("AniDB", seriesIDs.AniDB.ToString()); var tvdbId = seriesIDs.TvDB?.FirstOrDefault(); if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); From 81f9a956e4a02cf24a4def89d23509946f410de2 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 20 Sep 2020 03:15:54 +0200 Subject: [PATCH 0025/1103] No need to guess the display language The metadata language was already using the two letter format. --- ShokoJellyfin/Providers/Helper.cs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/ShokoJellyfin/Providers/Helper.cs b/ShokoJellyfin/Providers/Helper.cs index 21d167b8..1583d7be 100644 --- a/ShokoJellyfin/Providers/Helper.cs +++ b/ShokoJellyfin/Providers/Helper.cs @@ -50,7 +50,7 @@ public static ( string, string ) GetFullTitles(IEnumerable<Title> rSeriesTitles, var seriesTitles = (List<Title>)rSeriesTitles; var episodeTitles = (List<Title>)rEpisodeTitles; var originLanguage = GuessOriginLanguage(seriesTitles); - var displayLanguage = GuessDisplayLanguage(metadataLanguage); + var displayLanguage = metadataLanguage?.ToLower() ?? "en"; return ( GetFullTitle(seriesTitles, episodeTitles, seriesTitle, displayTitleType, displayLanguage, originLanguage), GetFullTitle(seriesTitles, episodeTitles, seriesTitle, alternateTitleType, displayLanguage, originLanguage) ); } @@ -146,16 +146,5 @@ private static string[] GuessOriginLanguage(IEnumerable<Title> seriesTitle) } } - - private static string GuessDisplayLanguage(string metadataLanguage) - { - switch (metadataLanguage) - { - // TODO: Add more cases, or provide a converter function to country-code. - case null: - default: - return "en"; - } - } } } \ No newline at end of file From f61543579d9a199a5197af7d0a99e68314100c90 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 20 Sep 2020 17:47:43 +0200 Subject: [PATCH 0026/1103] Add missing episode check --- ShokoJellyfin/Providers/EpisodeProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ShokoJellyfin/Providers/EpisodeProvider.cs b/ShokoJellyfin/Providers/EpisodeProvider.cs index 0cd07aa6..baef331e 100644 --- a/ShokoJellyfin/Providers/EpisodeProvider.cs +++ b/ShokoJellyfin/Providers/EpisodeProvider.cs @@ -46,7 +46,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell var episodeIDs = allIds?.EpisodeIDs?.FirstOrDefault(); var episodeId = episodeIDs?.ID.ToString(); - if (string.IsNullOrEmpty(episodeId)) + if (string.IsNullOrEmpty(seriesId) || string.IsNullOrEmpty(episodeId)) { _logger.LogInformation($"Shoko Scanner... Episode not found! ({filename})"); return result; From c88361d61403315cb6d8257d95bb49f771f6a8a8 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 20 Sep 2020 23:25:33 +0200 Subject: [PATCH 0027/1103] More fixes --- ShokoJellyfin/Configuration/configPage.html | 10 +++++----- ShokoJellyfin/ExternalIds.cs | 2 +- ShokoJellyfin/Providers/EpisodeProvider.cs | 1 - ShokoJellyfin/Providers/Helper.cs | 6 +++--- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/ShokoJellyfin/Configuration/configPage.html b/ShokoJellyfin/Configuration/configPage.html index 531b6fad..f9b6e72f 100644 --- a/ShokoJellyfin/Configuration/configPage.html +++ b/ShokoJellyfin/Configuration/configPage.html @@ -27,25 +27,25 @@ <input is="emby-input" type="text" id="ApiKey" label="API Key" /> <div class="fieldDescription">This field is auto-generated using the credentials. Only set this manually if that doesn't work!</div> </div> - <div class="selectContainer"> + <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="TitleMainType">Main Title Language</label> <select is="emby-select" id="TitleMainType" name="TitleMainType" class="emby-select-withcolor emby-select"> <option value="Default">Default</option> - <option value="Localized">Use preffered metadata language</option> + <option value="Localized">Use prefered metadata language</option> <option value="Origin">Language in country of origin</option> </select> <div class="fieldDescription">Titles will fallback to Default if not found for the target language.</div> </div> - <div class="selectContainer"> + <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="TitleAlternateType">Alternate Title Language</label> <select is="emby-select" id="TitleAlternateType" name="TitleAlternateType" class="emby-select-withcolor emby-select"> <option value="Default">Default</option> - <option value="Localized">Use preffered metadata language</option> + <option value="Localized">Use prefered metadata language</option> <option value="Origin">Language in country of origin</option> </select> <div class="fieldDescription">Titles >will fallback to Default if not found for the target language.</div> </div> - <label class="checkboxContainer"> + <label class="checkboxContainer checkboxContainer-withDescription"> <input is="emby-checkbox" type="checkbox" id="TitleUseAlternate" /> <span>Include an alternate title</span> <div class="fieldDescription">Will populate the "Original Title" field with the alternate title.</div> diff --git a/ShokoJellyfin/ExternalIds.cs b/ShokoJellyfin/ExternalIds.cs index c9a96981..c5b8f7d8 100644 --- a/ShokoJellyfin/ExternalIds.cs +++ b/ShokoJellyfin/ExternalIds.cs @@ -27,7 +27,7 @@ public string UrlFormatString public class ShokoSeriesExternalId : IExternalId { public bool Supports(IHasProviderIds item) - => item is Series || item is Episode || item is Movie || item is BoxSet; + => item is Series || item is Movie || item is BoxSet; public string ProviderName => "Shoko Series"; diff --git a/ShokoJellyfin/Providers/EpisodeProvider.cs b/ShokoJellyfin/Providers/EpisodeProvider.cs index baef331e..51a455ee 100644 --- a/ShokoJellyfin/Providers/EpisodeProvider.cs +++ b/ShokoJellyfin/Providers/EpisodeProvider.cs @@ -68,7 +68,6 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell Overview = Helper.SummarySanitizer(episodeInfo.Description), CommunityRating = (float) ((episodeInfo.Rating.Value * 10) / episodeInfo.Rating.MaxValue) }; - result.Item.SetProviderId("Shoko Series", seriesId); result.Item.SetProviderId("Shoko Episode", episodeId); result.Item.SetProviderId("AniDB", episodeIDs.AniDB.ToString()); var tvdbId = episodeIDs.TvDB?.FirstOrDefault(); diff --git a/ShokoJellyfin/Providers/Helper.cs b/ShokoJellyfin/Providers/Helper.cs index 1583d7be..0ea5fda0 100644 --- a/ShokoJellyfin/Providers/Helper.cs +++ b/ShokoJellyfin/Providers/Helper.cs @@ -67,8 +67,8 @@ private static string GetFullTitle(IEnumerable<Title> seriesTitles, IEnumerable< switch (displayTitleType) { case DisplayTitleType.Default: - // Fallback to preffered series title, but choose the episode title based on this order. - // The "main" title on AniDB is _most_ of the time in english, but we also fallback to romanji (japanese) or pinyin (chinese) in case it is not provided in. + // Fallback to prefered series title, but choose the episode title based on this order. + // The "main" title on AniDB is _most_ of the time in english, but we also fallback to romaji (japanese) or pinyin (chinese) in case it is not provided. return GetTitle(null, episodeTitles, seriesTitle, "en", "x-jat", "x-zht"); case DisplayTitleType.Origin: return GetTitle(seriesTitles, episodeTitles, seriesTitle, originLanguages); @@ -91,7 +91,7 @@ private static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Titl title.Append(mainTitle); if (episodeTitles != null) { var episodeTitle = GetTitleByLanguages(episodeTitles, languageCandidates); - // We could not find the complete title, and no mixed languages (outside the spesified ones), so abort here. + // We could not create the complete title, and no mixed languages allowed (outside the specified one(s)), so abort here. if (episodeTitle == null) { // Some movies provide only an english title, so we fallback to english. From bcd0cb7c2c53a3dd1a6c0885f3264c30fb43ac9c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 20 Sep 2020 23:29:31 +0200 Subject: [PATCH 0028/1103] Fix another typo --- ShokoJellyfin/Configuration/configPage.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ShokoJellyfin/Configuration/configPage.html b/ShokoJellyfin/Configuration/configPage.html index f9b6e72f..98b17b8a 100644 --- a/ShokoJellyfin/Configuration/configPage.html +++ b/ShokoJellyfin/Configuration/configPage.html @@ -43,7 +43,7 @@ <option value="Localized">Use prefered metadata language</option> <option value="Origin">Language in country of origin</option> </select> - <div class="fieldDescription">Titles >will fallback to Default if not found for the target language.</div> + <div class="fieldDescription">Titles will fallback to Default if not found for the target language.</div> </div> <label class="checkboxContainer checkboxContainer-withDescription"> <input is="emby-checkbox" type="checkbox" id="TitleUseAlternate" /> From ba1d827bf8524af27a87b1e99740115409eb50ba Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 20 Sep 2020 23:33:43 +0200 Subject: [PATCH 0029/1103] Remove AniDBExternalId --- ShokoJellyfin/ExternalIds.cs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/ShokoJellyfin/ExternalIds.cs b/ShokoJellyfin/ExternalIds.cs index c5b8f7d8..583ac071 100644 --- a/ShokoJellyfin/ExternalIds.cs +++ b/ShokoJellyfin/ExternalIds.cs @@ -6,24 +6,6 @@ namespace ShokoJellyfin { - public class AniDbExternalId : IExternalId - { - public bool Supports(IHasProviderIds item) - => item is Series || item is Episode || item is Movie || item is BoxSet; - - public string ProviderName - => "AniDB"; - - public string Key - => "AniDB"; - - public ExternalIdMediaType? Type - => null; - - public string UrlFormatString - => null; - } - public class ShokoSeriesExternalId : IExternalId { public bool Supports(IHasProviderIds item) From 4d38b9e97c4d5503dd269cbd0aaf91bba673ff2e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 20 Sep 2020 23:36:51 +0200 Subject: [PATCH 0030/1103] Not prefered, but preferred. --- ShokoJellyfin/Configuration/configPage.html | 4 ++-- ShokoJellyfin/Providers/Helper.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ShokoJellyfin/Configuration/configPage.html b/ShokoJellyfin/Configuration/configPage.html index 98b17b8a..21e0c24a 100644 --- a/ShokoJellyfin/Configuration/configPage.html +++ b/ShokoJellyfin/Configuration/configPage.html @@ -31,7 +31,7 @@ <label class="selectLabel" for="TitleMainType">Main Title Language</label> <select is="emby-select" id="TitleMainType" name="TitleMainType" class="emby-select-withcolor emby-select"> <option value="Default">Default</option> - <option value="Localized">Use prefered metadata language</option> + <option value="Localized">Use preferred metadata language</option> <option value="Origin">Language in country of origin</option> </select> <div class="fieldDescription">Titles will fallback to Default if not found for the target language.</div> @@ -40,7 +40,7 @@ <label class="selectLabel" for="TitleAlternateType">Alternate Title Language</label> <select is="emby-select" id="TitleAlternateType" name="TitleAlternateType" class="emby-select-withcolor emby-select"> <option value="Default">Default</option> - <option value="Localized">Use prefered metadata language</option> + <option value="Localized">Use preferred metadata language</option> <option value="Origin">Language in country of origin</option> </select> <div class="fieldDescription">Titles will fallback to Default if not found for the target language.</div> diff --git a/ShokoJellyfin/Providers/Helper.cs b/ShokoJellyfin/Providers/Helper.cs index 0ea5fda0..6e0c94c3 100644 --- a/ShokoJellyfin/Providers/Helper.cs +++ b/ShokoJellyfin/Providers/Helper.cs @@ -67,7 +67,7 @@ private static string GetFullTitle(IEnumerable<Title> seriesTitles, IEnumerable< switch (displayTitleType) { case DisplayTitleType.Default: - // Fallback to prefered series title, but choose the episode title based on this order. + // Fallback to preferred series title, but choose the episode title based on this order. // The "main" title on AniDB is _most_ of the time in english, but we also fallback to romaji (japanese) or pinyin (chinese) in case it is not provided. return GetTitle(null, episodeTitles, seriesTitle, "en", "x-jat", "x-zht"); case DisplayTitleType.Origin: From 6c14e1e29027a0de7aea42c86fefac5e7068aa5e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 20 Sep 2020 23:42:50 +0200 Subject: [PATCH 0031/1103] Add file ids or episodes, and for movies in a future PR. --- ShokoJellyfin/ExternalIds.cs | 18 ++++++++++++++++++ ShokoJellyfin/Providers/EpisodeProvider.cs | 13 ++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/ShokoJellyfin/ExternalIds.cs b/ShokoJellyfin/ExternalIds.cs index 583ac071..51a092d0 100644 --- a/ShokoJellyfin/ExternalIds.cs +++ b/ShokoJellyfin/ExternalIds.cs @@ -41,4 +41,22 @@ public ExternalIdMediaType? Type public string UrlFormatString => null; } + + public class ShokoFileExternalId : IExternalId + { + public bool Supports(IHasProviderIds item) + => item is Episode || item is Movie; + + public string ProviderName + => "Shoko File"; + + public string Key + => "Shoko File"; + + public ExternalIdMediaType? Type + => null; + + public string UrlFormatString + => null; + } } \ No newline at end of file diff --git a/ShokoJellyfin/Providers/EpisodeProvider.cs b/ShokoJellyfin/Providers/EpisodeProvider.cs index 51a455ee..ecaee4b1 100644 --- a/ShokoJellyfin/Providers/EpisodeProvider.cs +++ b/ShokoJellyfin/Providers/EpisodeProvider.cs @@ -41,12 +41,14 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell _logger.LogInformation($"Shoko Scanner... Getting episode ID ({filename})"); var apiResponse = await ShokoAPI.GetFilePathEndsWith(filename); - var allIds = apiResponse.FirstOrDefault()?.SeriesIDs.FirstOrDefault(); - var seriesId = allIds?.SeriesID.ID.ToString(); - var episodeIDs = allIds?.EpisodeIDs?.FirstOrDefault(); + var file = apiResponse.FirstOrDefault(); + var fileId = file?.ID.ToString(); + var series = file?.SeriesIDs.FirstOrDefault(); + var seriesId = series?.SeriesID.ID.ToString(); + var episodeIDs = series?.EpisodeIDs?.FirstOrDefault(); var episodeId = episodeIDs?.ID.ToString(); - if (string.IsNullOrEmpty(seriesId) || string.IsNullOrEmpty(episodeId)) + if (string.IsNullOrEmpty(fileId) || string.IsNullOrEmpty(seriesId) || string.IsNullOrEmpty(episodeId)) { _logger.LogInformation($"Shoko Scanner... Episode not found! ({filename})"); return result; @@ -69,12 +71,13 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell CommunityRating = (float) ((episodeInfo.Rating.Value * 10) / episodeInfo.Rating.MaxValue) }; result.Item.SetProviderId("Shoko Episode", episodeId); + result.Item.SetProviderId("Shoko File", fileId); result.Item.SetProviderId("AniDB", episodeIDs.AniDB.ToString()); var tvdbId = episodeIDs.TvDB?.FirstOrDefault(); if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); result.HasMetadata = true; - var episodeNumberEnd = episodeInfo.EpisodeNumber + allIds?.EpisodeIDs.Count() - 1; + var episodeNumberEnd = episodeInfo.EpisodeNumber + series?.EpisodeIDs.Count() - 1; if (episodeInfo.EpisodeNumber != episodeNumberEnd) result.Item.IndexNumberEnd = episodeNumberEnd; return result; From da5171a499324c5005aa6cc97fa3d38718ecfeab Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Sun, 20 Sep 2020 21:52:53 +0000 Subject: [PATCH 0032/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index 33d5a327..2dde2365 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.2.0", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/ShokoJellyfin/releases/download/1.2.0/shokojellyfin_1.2.0.zip", + "checksum": "7e1965987f40e62f9e987c44cf98a6fe", + "timestamp": "2020-09-20T21:52:52Z" + }, { "version": "1.1.0", "changelog": "NA", From f26b13fa4cfd0131ff36dc5a4fd6fe4d8b33415d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 21 Sep 2020 17:03:25 +0200 Subject: [PATCH 0033/1103] Change all references of 'ShokoJellyfin' to 'Shokofin' --- .github/workflows/build.yml | 8 ++++---- .github/workflows/release.yml | 4 ++-- README.md | 5 +++-- ShokoJellyfin.sln => Shokofin.sln | 2 +- {ShokoJellyfin => Shokofin}/API/Models/ApiKey.cs | 2 +- {ShokoJellyfin => Shokofin}/API/Models/BaseModel.cs | 2 +- {ShokoJellyfin => Shokofin}/API/Models/Episode.cs | 2 +- {ShokoJellyfin => Shokofin}/API/Models/File.cs | 2 +- {ShokoJellyfin => Shokofin}/API/Models/Group.cs | 2 +- {ShokoJellyfin => Shokofin}/API/Models/IDs.cs | 2 +- {ShokoJellyfin => Shokofin}/API/Models/Image.cs | 2 +- {ShokoJellyfin => Shokofin}/API/Models/Images.cs | 2 +- {ShokoJellyfin => Shokofin}/API/Models/Rating.cs | 2 +- {ShokoJellyfin => Shokofin}/API/Models/Role.cs | 2 +- {ShokoJellyfin => Shokofin}/API/Models/Series.cs | 2 +- {ShokoJellyfin => Shokofin}/API/Models/Sizes.cs | 2 +- {ShokoJellyfin => Shokofin}/API/Models/Tag.cs | 2 +- {ShokoJellyfin => Shokofin}/API/Models/Title.cs | 2 +- {ShokoJellyfin => Shokofin}/API/ShokoAPI.cs | 6 +++--- .../Configuration/PluginConfiguration.cs | 2 +- {ShokoJellyfin => Shokofin}/Configuration/configPage.html | 0 {ShokoJellyfin => Shokofin}/ExternalIds.cs | 2 +- {ShokoJellyfin => Shokofin}/Plugin.cs | 4 ++-- {ShokoJellyfin => Shokofin}/Providers/EpisodeProvider.cs | 6 +++--- {ShokoJellyfin => Shokofin}/Providers/Helper.cs | 8 ++++---- {ShokoJellyfin => Shokofin}/Providers/ImageProvider.cs | 2 +- {ShokoJellyfin => Shokofin}/Providers/SeasonProvider.cs | 2 +- {ShokoJellyfin => Shokofin}/Providers/SeriesProvider.cs | 4 ++-- {ShokoJellyfin => Shokofin}/Scrobbler.cs | 4 ++-- .../ShokoJellyfin.csproj => Shokofin/Shokofin.csproj | 0 build.yaml | 4 ++-- build_plugin.sh | 4 ++-- manifest.json | 8 ++++---- 33 files changed, 52 insertions(+), 51 deletions(-) rename ShokoJellyfin.sln => Shokofin.sln (81%) rename {ShokoJellyfin => Shokofin}/API/Models/ApiKey.cs (70%) rename {ShokoJellyfin => Shokofin}/API/Models/BaseModel.cs (84%) rename {ShokoJellyfin => Shokofin}/API/Models/Episode.cs (98%) rename {ShokoJellyfin => Shokofin}/API/Models/File.cs (97%) rename {ShokoJellyfin => Shokofin}/API/Models/Group.cs (90%) rename {ShokoJellyfin => Shokofin}/API/Models/IDs.cs (67%) rename {ShokoJellyfin => Shokofin}/API/Models/Image.cs (91%) rename {ShokoJellyfin => Shokofin}/API/Models/Images.cs (89%) rename {ShokoJellyfin => Shokofin}/API/Models/Rating.cs (88%) rename {ShokoJellyfin => Shokofin}/API/Models/Role.cs (94%) rename {ShokoJellyfin => Shokofin}/API/Models/Series.cs (98%) rename {ShokoJellyfin => Shokofin}/API/Models/Sizes.cs (94%) rename {ShokoJellyfin => Shokofin}/API/Models/Tag.cs (83%) rename {ShokoJellyfin => Shokofin}/API/Models/Title.cs (87%) rename {ShokoJellyfin => Shokofin}/API/ShokoAPI.cs (98%) rename {ShokoJellyfin => Shokofin}/Configuration/PluginConfiguration.cs (98%) rename {ShokoJellyfin => Shokofin}/Configuration/configPage.html (100%) rename {ShokoJellyfin => Shokofin}/ExternalIds.cs (98%) rename {ShokoJellyfin => Shokofin}/Plugin.cs (94%) rename {ShokoJellyfin => Shokofin}/Providers/EpisodeProvider.cs (97%) rename {ShokoJellyfin => Shokofin}/Providers/Helper.cs (97%) rename {ShokoJellyfin => Shokofin}/Providers/ImageProvider.cs (99%) rename {ShokoJellyfin => Shokofin}/Providers/SeasonProvider.cs (98%) rename {ShokoJellyfin => Shokofin}/Providers/SeriesProvider.cs (99%) rename {ShokoJellyfin => Shokofin}/Scrobbler.cs (97%) rename ShokoJellyfin/ShokoJellyfin.csproj => Shokofin/Shokofin.csproj (100%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3f659675..b7b7802b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,9 +23,9 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: ${{ matrix.dotnet }} - - run: dotnet restore ShokoJellyfin/ShokoJellyfin.csproj -s https://api.nuget.org/v3/index.json -s https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/nuget/v3/index.json - - run: dotnet publish -c Release ShokoJellyfin/ShokoJellyfin.csproj + - run: dotnet restore Shokofin/Shokofin.csproj -s https://api.nuget.org/v3/index.json -s https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/nuget/v3/index.json + - run: dotnet publish -c Release Shokofin/Shokofin.csproj - uses: actions/upload-artifact@v2 with: - name: ShokoJellyfin - path: ShokoJellyfin/bin/Release/netstandard2.1/ShokoJellyfin.dll + name: Shokofin + path: Shokofin/bin/Release/netstandard2.1/Shokofin.dll diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 405d568d..8069ceb6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: with: dotnet-version: 3.1.x - name: Restore nuget packages - run: dotnet restore ShokoJellyfin/ShokoJellyfin.csproj -s https://api.nuget.org/v3/index.json -s https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/nuget/v3/index.json + run: dotnet restore Shokofin/Shokofin.csproj -s https://api.nuget.org/v3/index.json -s https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/nuget/v3/index.json - name: Setup python uses: actions/setup-python@v2 with: @@ -34,7 +34,7 @@ jobs: uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ./artifacts/shokojellyfin_*.zip + file: ./artifacts/shokofin_*.zip tag: ${{ github.ref }} file_glob: true - name: Update manifest diff --git a/README.md b/README.md index b03eee46..bfb74645 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -# ShokoJellyfin +# Shokofin + Repo for the Jellyfin Plugin ## Build Process @@ -12,4 +13,4 @@ Repo for the Jellyfin Plugin ```sh dotnet publish --configuration Release --output bin ``` -4. Copy the resulting file `bin/ShokoJellyfin.dll` to the `plugins` folder under the Jellyfin program data directory or inside the portable install directory +4. Copy the resulting file `bin/Shokofin.dll` to the `plugins` folder under the Jellyfin program data directory or inside the portable install directory diff --git a/ShokoJellyfin.sln b/Shokofin.sln similarity index 81% rename from ShokoJellyfin.sln rename to Shokofin.sln index 4e0699ff..d0bcca4c 100644 --- a/ShokoJellyfin.sln +++ b/Shokofin.sln @@ -1,6 +1,6 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ShokoJellyfin", "ShokoJellyfin\ShokoJellyfin.csproj", "{1DD876AE-9E68-4867-BDF6-B9050E63E936}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shokofin", "Shokofin\Shokofin.csproj", "{1DD876AE-9E68-4867-BDF6-B9050E63E936}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/ShokoJellyfin/API/Models/ApiKey.cs b/Shokofin/API/Models/ApiKey.cs similarity index 70% rename from ShokoJellyfin/API/Models/ApiKey.cs rename to Shokofin/API/Models/ApiKey.cs index 9678d395..a8fd9d8b 100644 --- a/ShokoJellyfin/API/Models/ApiKey.cs +++ b/Shokofin/API/Models/ApiKey.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.API.Models +namespace Shokofin.API.Models { public class ApiKey { diff --git a/ShokoJellyfin/API/Models/BaseModel.cs b/Shokofin/API/Models/BaseModel.cs similarity index 84% rename from ShokoJellyfin/API/Models/BaseModel.cs rename to Shokofin/API/Models/BaseModel.cs index 805dd04d..6013a9bb 100644 --- a/ShokoJellyfin/API/Models/BaseModel.cs +++ b/Shokofin/API/Models/BaseModel.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.API.Models +namespace Shokofin.API.Models { public abstract class BaseModel { diff --git a/ShokoJellyfin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs similarity index 98% rename from ShokoJellyfin/API/Models/Episode.cs rename to Shokofin/API/Models/Episode.cs index d7da66bc..e473291e 100644 --- a/ShokoJellyfin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace ShokoJellyfin.API.Models +namespace Shokofin.API.Models { public class Episode : BaseModel { diff --git a/ShokoJellyfin/API/Models/File.cs b/Shokofin/API/Models/File.cs similarity index 97% rename from ShokoJellyfin/API/Models/File.cs rename to Shokofin/API/Models/File.cs index 9c5d1eb7..c7c35a94 100644 --- a/ShokoJellyfin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace ShokoJellyfin.API.Models +namespace Shokofin.API.Models { public class File { diff --git a/ShokoJellyfin/API/Models/Group.cs b/Shokofin/API/Models/Group.cs similarity index 90% rename from ShokoJellyfin/API/Models/Group.cs rename to Shokofin/API/Models/Group.cs index 68356fd0..7bb7eeb7 100644 --- a/ShokoJellyfin/API/Models/Group.cs +++ b/Shokofin/API/Models/Group.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.API.Models +namespace Shokofin.API.Models { public class Group : BaseModel { diff --git a/ShokoJellyfin/API/Models/IDs.cs b/Shokofin/API/Models/IDs.cs similarity index 67% rename from ShokoJellyfin/API/Models/IDs.cs rename to Shokofin/API/Models/IDs.cs index 862de704..6911e66a 100644 --- a/ShokoJellyfin/API/Models/IDs.cs +++ b/Shokofin/API/Models/IDs.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.API.Models +namespace Shokofin.API.Models { public class IDs { diff --git a/ShokoJellyfin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs similarity index 91% rename from ShokoJellyfin/API/Models/Image.cs rename to Shokofin/API/Models/Image.cs index d2b0c9a6..3cbcd19e 100644 --- a/ShokoJellyfin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.API.Models +namespace Shokofin.API.Models { public class Image { diff --git a/ShokoJellyfin/API/Models/Images.cs b/Shokofin/API/Models/Images.cs similarity index 89% rename from ShokoJellyfin/API/Models/Images.cs rename to Shokofin/API/Models/Images.cs index f25910ec..64590c48 100644 --- a/ShokoJellyfin/API/Models/Images.cs +++ b/Shokofin/API/Models/Images.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace ShokoJellyfin.API.Models +namespace Shokofin.API.Models { public class Images { diff --git a/ShokoJellyfin/API/Models/Rating.cs b/Shokofin/API/Models/Rating.cs similarity index 88% rename from ShokoJellyfin/API/Models/Rating.cs rename to Shokofin/API/Models/Rating.cs index 27d0de05..b6feb534 100644 --- a/ShokoJellyfin/API/Models/Rating.cs +++ b/Shokofin/API/Models/Rating.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.API.Models +namespace Shokofin.API.Models { public class Rating { diff --git a/ShokoJellyfin/API/Models/Role.cs b/Shokofin/API/Models/Role.cs similarity index 94% rename from ShokoJellyfin/API/Models/Role.cs rename to Shokofin/API/Models/Role.cs index 3988f36d..8a0f879b 100644 --- a/ShokoJellyfin/API/Models/Role.cs +++ b/Shokofin/API/Models/Role.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.API.Models +namespace Shokofin.API.Models { public class Role { diff --git a/ShokoJellyfin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs similarity index 98% rename from ShokoJellyfin/API/Models/Series.cs rename to Shokofin/API/Models/Series.cs index 0e3d7be8..79c32ba9 100644 --- a/ShokoJellyfin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace ShokoJellyfin.API.Models +namespace Shokofin.API.Models { public class Series : BaseModel { diff --git a/ShokoJellyfin/API/Models/Sizes.cs b/Shokofin/API/Models/Sizes.cs similarity index 94% rename from ShokoJellyfin/API/Models/Sizes.cs rename to Shokofin/API/Models/Sizes.cs index 246a5dce..fbc4d66e 100644 --- a/ShokoJellyfin/API/Models/Sizes.cs +++ b/Shokofin/API/Models/Sizes.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.API.Models +namespace Shokofin.API.Models { public class Sizes { diff --git a/ShokoJellyfin/API/Models/Tag.cs b/Shokofin/API/Models/Tag.cs similarity index 83% rename from ShokoJellyfin/API/Models/Tag.cs rename to Shokofin/API/Models/Tag.cs index 6de8bd61..ea4f6f9c 100644 --- a/ShokoJellyfin/API/Models/Tag.cs +++ b/Shokofin/API/Models/Tag.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.API.Models +namespace Shokofin.API.Models { public class Tag { diff --git a/ShokoJellyfin/API/Models/Title.cs b/Shokofin/API/Models/Title.cs similarity index 87% rename from ShokoJellyfin/API/Models/Title.cs rename to Shokofin/API/Models/Title.cs index 74b07dec..c6c8fd3d 100644 --- a/ShokoJellyfin/API/Models/Title.cs +++ b/Shokofin/API/Models/Title.cs @@ -1,4 +1,4 @@ -namespace ShokoJellyfin.API.Models +namespace Shokofin.API.Models { public class Title { diff --git a/ShokoJellyfin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPI.cs similarity index 98% rename from ShokoJellyfin/API/ShokoAPI.cs rename to Shokofin/API/ShokoAPI.cs index 3b04bf23..cce5006d 100644 --- a/ShokoJellyfin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPI.cs @@ -6,10 +6,10 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; -using ShokoJellyfin.API.Models; -using File = ShokoJellyfin.API.Models.File; +using Shokofin.API.Models; +using File = Shokofin.API.Models.File; -namespace ShokoJellyfin.API +namespace Shokofin.API { internal class ShokoAPI { diff --git a/ShokoJellyfin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs similarity index 98% rename from ShokoJellyfin/Configuration/PluginConfiguration.cs rename to Shokofin/Configuration/PluginConfiguration.cs index dd634f6d..77bb09f2 100644 --- a/ShokoJellyfin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,6 +1,6 @@ using MediaBrowser.Model.Plugins; -namespace ShokoJellyfin.Configuration +namespace Shokofin.Configuration { public class PluginConfiguration : BasePluginConfiguration { diff --git a/ShokoJellyfin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html similarity index 100% rename from ShokoJellyfin/Configuration/configPage.html rename to Shokofin/Configuration/configPage.html diff --git a/ShokoJellyfin/ExternalIds.cs b/Shokofin/ExternalIds.cs similarity index 98% rename from ShokoJellyfin/ExternalIds.cs rename to Shokofin/ExternalIds.cs index 51a092d0..6bca3314 100644 --- a/ShokoJellyfin/ExternalIds.cs +++ b/Shokofin/ExternalIds.cs @@ -4,7 +4,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -namespace ShokoJellyfin +namespace Shokofin { public class ShokoSeriesExternalId : IExternalId { diff --git a/ShokoJellyfin/Plugin.cs b/Shokofin/Plugin.cs similarity index 94% rename from ShokoJellyfin/Plugin.cs rename to Shokofin/Plugin.cs index 96c54d24..fa0b97e5 100644 --- a/ShokoJellyfin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -4,9 +4,9 @@ using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; -using ShokoJellyfin.Configuration; +using Shokofin.Configuration; -namespace ShokoJellyfin +namespace Shokofin { public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { diff --git a/ShokoJellyfin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs similarity index 97% rename from ShokoJellyfin/Providers/EpisodeProvider.cs rename to Shokofin/Providers/EpisodeProvider.cs index ecaee4b1..de94fbd9 100644 --- a/ShokoJellyfin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -10,10 +10,10 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; -using ShokoJellyfin.API; -using EpisodeType = ShokoJellyfin.API.Models.Episode.EpisodeType; +using Shokofin.API; +using EpisodeType = Shokofin.API.Models.Episode.EpisodeType; -namespace ShokoJellyfin.Providers +namespace Shokofin.Providers { public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo> { diff --git a/ShokoJellyfin/Providers/Helper.cs b/Shokofin/Providers/Helper.cs similarity index 97% rename from ShokoJellyfin/Providers/Helper.cs rename to Shokofin/Providers/Helper.cs index 6e0c94c3..138aab68 100644 --- a/ShokoJellyfin/Providers/Helper.cs +++ b/Shokofin/Providers/Helper.cs @@ -2,11 +2,11 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; -using ShokoJellyfin.API.Models; -using Title = ShokoJellyfin.API.Models.Title; -using DisplayTitleType = ShokoJellyfin.Configuration.PluginConfiguration.DisplayTitleType; +using Shokofin.API.Models; +using Title = Shokofin.API.Models.Title; +using DisplayTitleType = Shokofin.Configuration.PluginConfiguration.DisplayTitleType; -namespace ShokoJellyfin.Providers +namespace Shokofin.Providers { public class Helper { diff --git a/ShokoJellyfin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs similarity index 99% rename from ShokoJellyfin/Providers/ImageProvider.cs rename to Shokofin/Providers/ImageProvider.cs index a647ad1f..7748e0ec 100644 --- a/ShokoJellyfin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -11,7 +11,7 @@ using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; -namespace ShokoJellyfin.Providers +namespace Shokofin.Providers { public class ImageProvider : IRemoteImageProvider { diff --git a/ShokoJellyfin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs similarity index 98% rename from ShokoJellyfin/Providers/SeasonProvider.cs rename to Shokofin/Providers/SeasonProvider.cs index 2507d9ff..af1edafe 100644 --- a/ShokoJellyfin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -8,7 +8,7 @@ using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; -namespace ShokoJellyfin.Providers +namespace Shokofin.Providers { public class SeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo> { diff --git a/ShokoJellyfin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs similarity index 99% rename from ShokoJellyfin/Providers/SeriesProvider.cs rename to Shokofin/Providers/SeriesProvider.cs index 77a1dda1..109d9eee 100644 --- a/ShokoJellyfin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -11,9 +11,9 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; -using ShokoJellyfin.API; +using Shokofin.API; -namespace ShokoJellyfin.Providers +namespace Shokofin.Providers { public class SeriesProvider : IHasOrder, IRemoteMetadataProvider<Series, SeriesInfo> { diff --git a/ShokoJellyfin/Scrobbler.cs b/Shokofin/Scrobbler.cs similarity index 97% rename from ShokoJellyfin/Scrobbler.cs rename to Shokofin/Scrobbler.cs index a45c4f9a..4076ffef 100644 --- a/ShokoJellyfin/Scrobbler.cs +++ b/Shokofin/Scrobbler.cs @@ -5,9 +5,9 @@ using MediaBrowser.Controller.Session; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; -using ShokoJellyfin.API; +using Shokofin.API; -namespace ShokoJellyfin +namespace Shokofin { public class Scrobbler : IServerEntryPoint { diff --git a/ShokoJellyfin/ShokoJellyfin.csproj b/Shokofin/Shokofin.csproj similarity index 100% rename from ShokoJellyfin/ShokoJellyfin.csproj rename to Shokofin/Shokofin.csproj diff --git a/build.yaml b/build.yaml index 79e16598..88cc70e8 100644 --- a/build.yaml +++ b/build.yaml @@ -1,4 +1,4 @@ -name: "ShokoJellyfin" +name: "Shokofin" guid: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052" targetAbi: "10.7.0.0" owner: "shoko" @@ -8,6 +8,6 @@ description: > The official Anime and TvDB plugins can be used to fill the missing data. category: "Metadata" artifacts: -- "ShokoJellyfin.dll" +- "Shokofin.dll" changelog: > NA \ No newline at end of file diff --git a/build_plugin.sh b/build_plugin.sh index bfef89c9..a9c36a7c 100644 --- a/build_plugin.sh +++ b/build_plugin.sh @@ -36,7 +36,7 @@ MY=$(dirname $(realpath -s "${0}")) JPRM="jprm" DEFAULT_REPO_DIR="./manifest.json" -DEFAULT_REPO_URL="https://github.com/ShokoAnime/ShokoJellyfin/releases/download" +DEFAULT_REPO_URL="https://github.com/ShokoAnime/Shokofin/releases/download" PLUGIN=. @@ -53,6 +53,6 @@ zipfile=$($JPRM --verbosity=debug plugin build "${PLUGIN}" --output="${ARTIFACT_ $JPRM repo add --url=${JELLYFIN_REPO_URL} "${JELLYFIN_REPO}" "${zipfile}" } -sed -i "s/shokojellyfin\//${VERSION}\//" "${JELLYFIN_REPO}" +sed -i "s/shokofin\//${VERSION}\//" "${JELLYFIN_REPO}" exit $? \ No newline at end of file diff --git a/manifest.json b/manifest.json index 2dde2365..56555672 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ [ { "guid": "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", - "name": "ShokoJellyfin", + "name": "Shokofin", "description": "This plugin gets metadata for all your anime from your Shoko Server. The official Anime and TvDB plugins can be used to fill the missing data.\n", "overview": "Manage your anime from Jellyfin using metadata from Shoko", "owner": "shoko", @@ -11,7 +11,7 @@ "version": "1.2.0", "changelog": "NA", "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/ShokoJellyfin/releases/download/1.2.0/shokojellyfin_1.2.0.zip", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.2.0/shokojellyfin_1.2.0.zip", "checksum": "7e1965987f40e62f9e987c44cf98a6fe", "timestamp": "2020-09-20T21:52:52Z" }, @@ -19,7 +19,7 @@ "version": "1.1.0", "changelog": "NA", "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/ShokoJellyfin/releases/download/1.1.0/shokojellyfin_1.1.0.zip", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.1.0/shokojellyfin_1.1.0.zip", "checksum": "610e540182066278d816e06e8f1a01ee", "timestamp": "2020-09-08T22:17:26Z" }, @@ -27,7 +27,7 @@ "version": "1.0.0", "changelog": "NA", "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/ShokoJellyfin/releases/download/1.0.0/shokojellyfin_1.0.0.zip", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.0.0/shokojellyfin_1.0.0.zip", "checksum": "184c723247ccdc4b0143dc46f5b6d50d", "timestamp": "2020-09-07T14:43:45Z" } From 5dd32523cf2aae04d8283bd06ce7aeb8a8ceb4a3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <revam@users.noreply.github.com> Date: Mon, 21 Sep 2020 17:27:24 +0200 Subject: [PATCH 0034/1103] Update README.md --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bfb74645..d9eadf6c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # Shokofin -Repo for the Jellyfin Plugin +Repository for the [Shoko](https://github.com/ShokoAnime/ShokoServer)+[Jellyfin](https://github.com/jellyfin/jellyfin) integration project. -## Build Process +## Install + +### Build Process 1. Clone or download this repository @@ -11,6 +13,8 @@ Repo for the Jellyfin Plugin 3. Build plugin with following command. ```sh -dotnet publish --configuration Release --output bin +$ dotnet restore Shokofin/Shokofin.csproj -s https://api.nuget.org/v3/index.json -s https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/nuget/v3/index.json +$ dotnet publish -c Release Shokofin/Shokofin.csproj ``` + 4. Copy the resulting file `bin/Shokofin.dll` to the `plugins` folder under the Jellyfin program data directory or inside the portable install directory From 4aff372b3f27796aa1825b96cf523494b8a190a5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <revam@users.noreply.github.com> Date: Mon, 21 Sep 2020 18:07:17 +0200 Subject: [PATCH 0035/1103] Update README.md --- README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d9eadf6c..8583106b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,21 @@ # Shokofin -Repository for the [Shoko](https://github.com/ShokoAnime/ShokoServer)+[Jellyfin](https://github.com/jellyfin/jellyfin) integration project. +A plugin to integrate your Shoko database with the Jellyfin media server. ## Install +There are multiple ways to install this plugin, but the recomended way is to use the official Jellyfin repository. + +### Official Repository + +TBD + +### Github Releases + +1. Download the `shokofin_*.zip` file from the latest release from GitHub [here](https://github.com/Shoko/Shokofin/releases/latest). + +2. Extract the contained `Shokofin.dll` and copy it to the `plugins` folder under the Jellyfin program data directory or inside the portable install directory. + ### Build Process 1. Clone or download this repository @@ -17,4 +29,4 @@ $ dotnet restore Shokofin/Shokofin.csproj -s https://api.nuget.org/v3/index.json $ dotnet publish -c Release Shokofin/Shokofin.csproj ``` -4. Copy the resulting file `bin/Shokofin.dll` to the `plugins` folder under the Jellyfin program data directory or inside the portable install directory +4. Copy the resulting file `bin/Shokofin.dll` to the `plugins` folder under the Jellyfin program data directory or inside the portable install directory. From f8d2099d742494f38f447cabb1d67c55ab56c776 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Mon, 21 Sep 2020 23:04:46 +0530 Subject: [PATCH 0036/1103] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8583106b..78ff668e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ TBD 1. Download the `shokofin_*.zip` file from the latest release from GitHub [here](https://github.com/Shoko/Shokofin/releases/latest). -2. Extract the contained `Shokofin.dll` and copy it to the `plugins` folder under the Jellyfin program data directory or inside the portable install directory. +2. Extract the contained `Shokofin.dll` and `meta.json`, place both the files in a folder named `Shokofin` and copy this folder to the `plugins` folder under the Jellyfin program data directory or inside the portable install directory. ### Build Process From 90686c036cf141875df4c520a1489e5fd5c2dcf6 Mon Sep 17 00:00:00 2001 From: G Harshith Mohan <gharshitmohan@ymail.com> Date: Mon, 21 Sep 2020 23:36:40 +0530 Subject: [PATCH 0037/1103] Fix problem with a checkbox in config page --- Shokofin/Configuration/configPage.html | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 21e0c24a..a67a7752 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -45,11 +45,13 @@ </select> <div class="fieldDescription">Titles will fallback to Default if not found for the target language.</div> </div> - <label class="checkboxContainer checkboxContainer-withDescription"> - <input is="emby-checkbox" type="checkbox" id="TitleUseAlternate" /> - <span>Include an alternate title</span> - <div class="fieldDescription">Will populate the "Original Title" field with the alternate title.</div> - </label> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label> + <input is="emby-checkbox" type="checkbox" id="TitleUseAlternate" /> + <span>Include an alternate title</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Will populate the "Original Title" field with the alternate title.</div> + </div> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="UpdateWatchedStatus" /> <span>Update watched status on Shoko (Scrobble)</span> From b3ca1d972044cd3a3dce9e10d87dd76b99f04022 Mon Sep 17 00:00:00 2001 From: G Harshith Mohan <gharshitmohan@ymail.com> Date: Tue, 22 Sep 2020 20:18:33 +0530 Subject: [PATCH 0038/1103] Fix "Default" series title setting --- Shokofin/Providers/SeriesProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 109d9eee..ae071aae 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -53,7 +53,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat var seriesInfo = await ShokoAPI.GetSeries(seriesId); var aniDbSeriesInfo = await ShokoAPI.GetSeriesAniDb(seriesId); var tags = await ShokoAPI.GetSeriesTags(seriesId, GetFlagFilter()); - var ( displayTitle, alternateTitle ) = Helper.GetSeriesTitles(aniDbSeriesInfo.Titles, aniDbSeriesInfo.Title, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); + var ( displayTitle, alternateTitle ) = Helper.GetSeriesTitles(aniDbSeriesInfo.Titles, seriesInfo.Name, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); result.Item = new Series { From 9aa9d471cfa2e172026b6f3832bf936312db8f7e Mon Sep 17 00:00:00 2001 From: G Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 23 Sep 2020 21:29:48 +0530 Subject: [PATCH 0039/1103] Fix missing names in summary when using the "Remove Links" option --- Shokofin/Providers/Helper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Providers/Helper.cs b/Shokofin/Providers/Helper.cs index 138aab68..4c73fd05 100644 --- a/Shokofin/Providers/Helper.cs +++ b/Shokofin/Providers/Helper.cs @@ -20,7 +20,7 @@ public static string SummarySanitizer(string summary) // Based on ShokoMetadata var config = Plugin.Instance.Configuration; if (config.SynopsisCleanLinks) - summary = Regex.Replace(summary, @"https?:\/\/\w+.\w+(?:\/?\w+)? \[([^\]]+)\]", ""); + summary = Regex.Replace(summary, @"https?:\/\/\w+.\w+(?:\/?\w+)? \[([^\]]+)\]", match => match.Groups[1].Value); if (config.SynopsisCleanMiscLines) summary = Regex.Replace(summary, @"^(\*|--|~) .*", "", RegexOptions.Multiline); From 6de28508cdfd96e30f63b1822f62a0724852b691 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 22 Sep 2020 01:27:43 +0200 Subject: [PATCH 0040/1103] Add basic movie provider without pictures --- Shokofin/Providers/BoxSetProvider.cs | 134 ++++++++++++++++++++++++ Shokofin/Providers/Helper.cs | 22 +++- Shokofin/Providers/MovieProvider.cs | 146 +++++++++++++++++++++++++++ Shokofin/Providers/SeriesProvider.cs | 39 +++---- 4 files changed, 311 insertions(+), 30 deletions(-) create mode 100644 Shokofin/Providers/BoxSetProvider.cs create mode 100644 Shokofin/Providers/MovieProvider.cs diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs new file mode 100644 index 00000000..4c341d84 --- /dev/null +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; + +namespace Shokofin.Providers +{ + public class BoxSetProvider : IHasOrder, IRemoteMetadataProvider<BoxSet, BoxSetInfo> + { + public string Name => "Shoko"; + public int Order => 1; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger<BoxSetProvider> _logger; + + public BoxSetProvider(IHttpClientFactory httpClientFactory, ILogger<BoxSetProvider> logger) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + } + + public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) + { + try + { + var result = new MetadataResult<BoxSet>(); + + var dirname = Path.DirectorySeparatorChar + info.Path.Split(Path.DirectorySeparatorChar).Last(); + + _logger.LogInformation($"Shoko Scanner... Getting series ID ({dirname})"); + + var apiResponse = await ShokoAPI.GetSeriesPathEndsWith(dirname); + var seriesIDs = apiResponse.FirstOrDefault()?.IDs; + var seriesId = seriesIDs?.ID.ToString(); + + if (string.IsNullOrEmpty(seriesId)) + { + _logger.LogInformation("Shoko Scanner... BoxSet not found!"); + return result; + } + _logger.LogInformation($"Shoko Scanner... Getting series metadata ({dirname} - {seriesId})"); + + var aniDbInfo = await ShokoAPI.GetSeriesAniDb(seriesId); + if (aniDbInfo.SeriesType != "0" /* Movie */) + { + _logger.LogInformation("Shoko Scanner... series was not a movie! Skipping."); + return result; + } + + var seriesInfo = await ShokoAPI.GetSeries(seriesId); + if (seriesInfo.Sizes.Total.Episodes <= 1) + { + _logger.LogInformation("Shoko Scanner... series did not contain multiple movies! Skipping."); + return result; + } + var tags = await ShokoAPI.GetSeriesTags(seriesId, GetFlagFilter()); + + var ( displayTitle, alternateTitle ) = Helper.GetSeriesTitles(aniDbInfo.Titles, aniDbInfo.Title, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); + result.Item = new BoxSet + { + Name = displayTitle, + OriginalTitle = alternateTitle, + Overview = Helper.SummarySanitizer(aniDbInfo.Description), + PremiereDate = aniDbInfo.AirDate, + EndDate = aniDbInfo.EndDate, + ProductionYear = aniDbInfo.AirDate?.Year, + Tags = tags?.Select(tag => tag.Name).ToArray() ?? new string[0], + CommunityRating = (float)((aniDbInfo.Rating.Value * 10) / aniDbInfo.Rating.MaxValue) + }; + result.Item.SetProviderId("Shoko Series", seriesId); + result.HasMetadata = true; + + return result; + } + catch (Exception e) + { + _logger.LogError(e.StackTrace); + return new MetadataResult<BoxSet>(); + } + } + + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) + { + _logger.LogInformation($"Shoko Scanner... Searching BoxSet ({searchInfo.Name})"); + var searchResults = await ShokoAPI.SeriesSearch(searchInfo.Name); + var results = new List<RemoteSearchResult>(); + + foreach (var series in searchResults) + { + var imageUrl = Helper.GetImageUrl(series.Images.Posters.FirstOrDefault()); + _logger.LogInformation(imageUrl); + var parsedBoxSet = new RemoteSearchResult + { + Name = series.Name, + SearchProviderName = Name, + ImageUrl = imageUrl + }; + parsedBoxSet.SetProviderId("Shoko", series.IDs.ID.ToString()); + results.Add(parsedBoxSet); + } + + return results; + } + + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); + } + + private int GetFlagFilter() + { + var config = Plugin.Instance.Configuration; + var filter = 0; + + if (config.HideAniDbTags) filter = 1; + if (config.HideArtStyleTags) filter |= (filter << 1); + if (config.HideSourceTags) filter |= (filter << 2); + if (config.HideMiscTags) filter |= (filter << 3); + if (config.HidePlotTags) filter |= (filter << 4); + + return filter; + } + } +} \ No newline at end of file diff --git a/Shokofin/Providers/Helper.cs b/Shokofin/Providers/Helper.cs index 4c73fd05..6afe86e0 100644 --- a/Shokofin/Providers/Helper.cs +++ b/Shokofin/Providers/Helper.cs @@ -15,21 +15,35 @@ public static string GetImageUrl(Image image) return image != null ? $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}/api/v3/Image/{image.Source}/{image.Type}/{image.ID}" : null; } + public static int GetFlagFilter() + { + var config = Plugin.Instance.Configuration; + var filter = 0; + + if (config.HideAniDbTags) filter = 1; + if (config.HideArtStyleTags) filter |= (filter << 1); + if (config.HideSourceTags) filter |= (filter << 2); + if (config.HideMiscTags) filter |= (filter << 3); + if (config.HidePlotTags) filter |= (filter << 4); + + return filter; + } + public static string SummarySanitizer(string summary) // Based on ShokoMetadata which is based on HAMA's { var config = Plugin.Instance.Configuration; - + if (config.SynopsisCleanLinks) summary = Regex.Replace(summary, @"https?:\/\/\w+.\w+(?:\/?\w+)? \[([^\]]+)\]", match => match.Groups[1].Value); if (config.SynopsisCleanMiscLines) summary = Regex.Replace(summary, @"^(\*|--|~) .*", "", RegexOptions.Multiline); - + if (config.SynopsisRemoveSummary) summary = Regex.Replace(summary, @"\n(Source|Note|Summary):.*", "", RegexOptions.Singleline); - + if (config.SynopsisCleanMultiEmptyLines) - summary = Regex.Replace(summary, @"\n\n+", "", RegexOptions.Singleline); + summary = Regex.Replace(summary, @"\n\n+", "", RegexOptions.Singleline); return summary; } diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs new file mode 100644 index 00000000..5f66082e --- /dev/null +++ b/Shokofin/Providers/MovieProvider.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using EpisodeType = Shokofin.API.Models.Episode.EpisodeType; + +namespace Shokofin.Providers +{ + public class MovieProvider : IRemoteMetadataProvider<Movie, MovieInfo> + { + public string Name => "Shoko"; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger<MovieProvider> _logger; + + public MovieProvider(IHttpClientFactory httpClientFactory, ILogger<MovieProvider> logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + + public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken) + { + try + { + var result = new MetadataResult<Movie>(); + + // TO-DO Check if it can be written in a better way. Parent directory + File Name + var filename = Path.Join( + Path.GetDirectoryName(info.Path)?.Split(Path.DirectorySeparatorChar).LastOrDefault(), + Path.GetFileName(info.Path)); + + _logger.LogInformation($"Shoko Scanner... Getting movie ID ({filename})"); + + var apiResponse = await ShokoAPI.GetFilePathEndsWith(filename); + var allIds = apiResponse.FirstOrDefault()?.SeriesIDs.FirstOrDefault(); + var episodeIds = allIds?.EpisodeIDs?.FirstOrDefault(); + string seriesId = allIds?.SeriesID.ID.ToString(); + string episodeId = episodeIds?.ID.ToString(); + + if (string.IsNullOrEmpty(episodeId) || string.IsNullOrEmpty(seriesId)) + { + _logger.LogInformation($"Shoko Scanner... File not found! ({filename})"); + return result; + } + + _logger.LogInformation($"Shoko Scanner... Getting movie metadata ({filename} - {episodeId})"); + + var seriesAniDB = await ShokoAPI.GetSeriesAniDb(seriesId); + var series = await ShokoAPI.GetSeries(seriesId); + var episodeAniDB = await ShokoAPI.GetEpisodeAniDb(episodeId); + bool isMultiEntry = series.Sizes.Total.Episodes > 1; + int tvdbId = (isMultiEntry ? episodeIds.TvDB?.FirstOrDefault() : allIds?.SeriesID.TvDB?.FirstOrDefault()) ?? 0; + + if (seriesAniDB?.SeriesType != "0") + { + _logger.LogInformation($"Shoko Scanner... File found, but not a movie! Skipping."); + return result; + } + + var ( displayTitle, alternateTitle ) = Helper.GetFullTitles(seriesAniDB.Titles, episodeAniDB.Titles, seriesAniDB.Title, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); + var tags = await ShokoAPI.GetSeriesTags(seriesId, Helper.GetFlagFilter()); + // Use the file description if collection contains more than one movie, otherwise use the collection description. + string description = (isMultiEntry ? episodeAniDB.Description : seriesAniDB.Description) ?? ""; + float comRat = isMultiEntry ? (float)((episodeAniDB.Rating.Value * 10) / episodeAniDB.Rating.MaxValue) : (float)((seriesAniDB.Rating.Value * 10) / seriesAniDB.Rating.MaxValue); + ExtraType? extraType = GetExtraType(episodeAniDB.Type); + + result.Item = new Movie + { + IndexNumber = episodeAniDB.EpisodeNumber, + Name = displayTitle, + OriginalTitle = alternateTitle, + PremiereDate = isMultiEntry ? episodeAniDB.AirDate : seriesAniDB.AirDate, + Overview = Helper.SummarySanitizer(description), + ProductionYear = isMultiEntry ? episodeAniDB.AirDate?.Year : seriesAniDB.AirDate?.Year, + ExtraType = extraType, + Tags = tags?.Select(tag => tag.Name).ToArray() ?? new string[0], + CommunityRating = comRat, + }; + result.Item.SetProviderId("Shoko Series", seriesId); + result.Item.SetProviderId("Shoko Episode", episodeId); + result.Item.SetProviderId("AniDB", allIds?.SeriesID.AniDB.ToString()); + if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); + result.HasMetadata = true; + + result.ResetPeople(); + var roles = await ShokoAPI.GetSeriesCast(seriesId); + foreach (var role in roles) + { + result.AddPerson(new PersonInfo + { + Type = PersonType.Actor, + Name = role.Staff.Name, + Role = role.Character.Name, + ImageUrl = Helper.GetImageUrl(role.Staff.Image) + }); + } + + return result; + } + catch (Exception e) + { + _logger.LogError(e.Message); + _logger.LogError(e.InnerException?.StackTrace ?? e.StackTrace); + return new MetadataResult<Movie>(); + } + } + + private ExtraType? GetExtraType(EpisodeType type) + { + switch (type) + { + case EpisodeType.Episode: + return null; + case EpisodeType.Trailer: + return ExtraType.Trailer; + case EpisodeType.Special: + return ExtraType.Scene; + default: + return ExtraType.Unknown; + } + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) + { + // Isn't called from anywhere. If it is called, I don't know from where. + throw new NotImplementedException(); + } + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); + } + } +} \ No newline at end of file diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index ae071aae..04a0a764 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -35,26 +35,27 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat var result = new MetadataResult<Series>(); var dirname = Path.DirectorySeparatorChar + info.Path.Split(Path.DirectorySeparatorChar).Last(); - + _logger.LogInformation($"Shoko Scanner... Getting series ID ({dirname})"); - + var apiResponse = await ShokoAPI.GetSeriesPathEndsWith(dirname); var seriesIDs = apiResponse.FirstOrDefault()?.IDs; var seriesId = seriesIDs?.ID.ToString(); - + if (string.IsNullOrEmpty(seriesId)) { _logger.LogInformation("Shoko Scanner... Series not found!"); return result; } - + _logger.LogInformation($"Shoko Scanner... Getting series metadata ({dirname} - {seriesId})"); var seriesInfo = await ShokoAPI.GetSeries(seriesId); var aniDbSeriesInfo = await ShokoAPI.GetSeriesAniDb(seriesId); - var tags = await ShokoAPI.GetSeriesTags(seriesId, GetFlagFilter()); + + var tags = await ShokoAPI.GetSeriesTags(seriesId, Helper.GetFlagFilter()); var ( displayTitle, alternateTitle ) = Helper.GetSeriesTitles(aniDbSeriesInfo.Titles, seriesInfo.Name, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); - + result.Item = new Series { Name = displayTitle, @@ -72,7 +73,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat var tvdbId = seriesIDs.TvDB?.FirstOrDefault(); if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); result.HasMetadata = true; - + result.ResetPeople(); var roles = await ShokoAPI.GetSeriesCast(seriesId); foreach (var role in roles) @@ -85,7 +86,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat ImageUrl = Helper.GetImageUrl(role.Staff.Image) }); } - + return result; } catch (Exception e) @@ -94,7 +95,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat return new MetadataResult<Series>(); } } - + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) { _logger.LogInformation($"Shoko Scanner... Searching Series ({searchInfo.Name})"); @@ -114,28 +115,14 @@ public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo s parsedSeries.SetProviderId("Shoko", series.IDs.ID.ToString()); results.Add(parsedSeries); } - + return results; } - - + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); } - - private int GetFlagFilter() - { - var config = Plugin.Instance.Configuration; - var filter = 0; - - if (config.HideAniDbTags) filter = 1; - if (config.HideArtStyleTags) filter |= (filter << 1); - if (config.HideSourceTags) filter |= (filter << 2); - if (config.HideMiscTags) filter |= (filter << 3); - if (config.HidePlotTags) filter |= (filter << 4); - - return filter; - } } } \ No newline at end of file From 2f79d5a1fbaaeca3caf7b0b7d28caf46c78323fa Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 30 Sep 2020 20:45:07 +0200 Subject: [PATCH 0041/1103] fix movie provider, episode titles and image provider --- Shokofin/Configuration/PluginConfiguration.cs | 12 +- Shokofin/Configuration/configPage.html | 4 +- Shokofin/Providers/EpisodeProvider.cs | 3 +- Shokofin/Providers/Helper.cs | 173 ++++++++++++------ Shokofin/Providers/ImageProvider.cs | 111 ++++------- Shokofin/Providers/MovieProvider.cs | 46 ++--- 6 files changed, 188 insertions(+), 161 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 77bb09f2..57050921 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -38,17 +38,17 @@ public class PluginConfiguration : BasePluginConfiguration public bool SynopsisCleanMultiEmptyLines { get; set; } - public enum DisplayTitleType { + public enum DisplayLanguageType { Default, - Localized, + MetadataPreferred, Origin, } public bool TitleUseAlternate { get; set; } - public DisplayTitleType TitleMainType { get; set; } + public DisplayLanguageType TitleMainType { get; set; } - public DisplayTitleType TitleAlternateType { get; set; } + public DisplayLanguageType TitleAlternateType { get; set; } public PluginConfiguration() { @@ -70,8 +70,8 @@ public PluginConfiguration() SynopsisRemoveSummary = true; SynopsisCleanMultiEmptyLines = true; TitleUseAlternate = true; - TitleMainType = DisplayTitleType.Default; - TitleAlternateType = DisplayTitleType.Origin; + TitleMainType = DisplayLanguageType.Default; + TitleAlternateType = DisplayLanguageType.Origin; } } } \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index a67a7752..03d63297 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -31,7 +31,7 @@ <label class="selectLabel" for="TitleMainType">Main Title Language</label> <select is="emby-select" id="TitleMainType" name="TitleMainType" class="emby-select-withcolor emby-select"> <option value="Default">Default</option> - <option value="Localized">Use preferred metadata language</option> + <option value="MetadataPreferred">Preferred metadata language</option> <option value="Origin">Language in country of origin</option> </select> <div class="fieldDescription">Titles will fallback to Default if not found for the target language.</div> @@ -40,7 +40,7 @@ <label class="selectLabel" for="TitleAlternateType">Alternate Title Language</label> <select is="emby-select" id="TitleAlternateType" name="TitleAlternateType" class="emby-select-withcolor emby-select"> <option value="Default">Default</option> - <option value="Localized">Use preferred metadata language</option> + <option value="MetadataPreferred">Preferred metadata language</option> <option value="Origin">Language in country of origin</option> </select> <div class="fieldDescription">Titles will fallback to Default if not found for the target language.</div> diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index de94fbd9..235af08e 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -57,8 +57,9 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell _logger.LogInformation($"Shoko Scanner... Getting episode metadata ({filename} - {episodeId})"); var seriesInfo = await ShokoAPI.GetSeriesAniDb(seriesId); + var episode = await ShokoAPI.GetEpisode(episodeId); var episodeInfo = await ShokoAPI.GetEpisodeAniDb(episodeId); - var ( displayTitle, alternateTitle ) = Helper.GetEpisodeTitles(seriesInfo.Titles, episodeInfo.Titles, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); + var ( displayTitle, alternateTitle ) = Helper.GetEpisodeTitles(seriesInfo.Titles, episodeInfo.Titles, episode.Name, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); result.Item = new Episode { diff --git a/Shokofin/Providers/Helper.cs b/Shokofin/Providers/Helper.cs index 6afe86e0..ffdb4e77 100644 --- a/Shokofin/Providers/Helper.cs +++ b/Shokofin/Providers/Helper.cs @@ -4,7 +4,10 @@ using System.Text.RegularExpressions; using Shokofin.API.Models; using Title = Shokofin.API.Models.Title; -using DisplayTitleType = Shokofin.Configuration.PluginConfiguration.DisplayTitleType; +using DisplayLanguageType = Shokofin.Configuration.PluginConfiguration.DisplayLanguageType; +using EpisodeType = Shokofin.API.Models.Episode.EpisodeType; +using Models = Shokofin.API.Models; +using MediaBrowser.Model.Entities; namespace Shokofin.Providers { @@ -15,6 +18,69 @@ public static string GetImageUrl(Image image) return image != null ? $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}/api/v3/Image/{image.Source}/{image.Type}/{image.ID}" : null; } + public static (int, int) GetNumbers(Models.Series series, Models.Episode.AniDB episode) + { + return (GetIndexNumber(series, episode), GetMaxNumber(series)); + } + + /// <summary> + /// + /// </summary> + /// <param name="series"></param> + /// <param name="episode"></param> + /// <returns></returns> + public static int GetIndexNumber(Models.Series series, Models.Episode.AniDB episode) + { + int offset = 0; + switch (episode.Type) + { + case EpisodeType.Episode: + break; + case EpisodeType.Special: + offset += series.Sizes.Total.Episodes; + break; // goto case EpisodeType.Episode; + case EpisodeType.Credits: + offset += series.Sizes.Total?.Specials ?? 0; + goto case EpisodeType.Special; + case EpisodeType.Other: + offset += series.Sizes.Total?.Credits ?? 0; + goto case EpisodeType.Credits; + case EpisodeType.Parody: + offset += series.Sizes.Total?.Others ?? 0; + goto case EpisodeType.Other; + case EpisodeType.Trailer: + offset += series.Sizes.Total?.Parodies ?? 0; + goto case EpisodeType.Parody; + } + return offset + episode.EpisodeNumber; + } + + public static int GetMaxNumber(Models.Series series) + { + var dict = series.Sizes.Total; + return dict.Episodes + dict?.Specials ?? 0 + dict?.Credits ?? 0 + dict?.Others ?? 0 + dict?.Parodies ?? 0 + dict?.Trailers ?? 0; + } + + public static ExtraType? GetExtraType(Models.Episode.AniDB episode) + { + switch (episode.Type) + { + case EpisodeType.Episode: + return null; + case EpisodeType.Trailer: + return ExtraType.Trailer; + case EpisodeType.Special: { + var enTitle = Helper.GetTitleByLanguages(episode.Titles, "en"); + if (enTitle != null && (enTitle.Contains("intro") || enTitle.Contains("outro"))) { + return ExtraType.DeletedScene; + } + return ExtraType.Scene; + } + default: + return null; + } + } + public static int GetFlagFilter() { var config = Plugin.Instance.Configuration; @@ -48,16 +114,22 @@ public static string SummarySanitizer(string summary) // Based on ShokoMetadata return summary; } - // Produce titles for episodes if the series-fallback-title is not provided. - public static ( string, string ) GetEpisodeTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, DisplayTitleType displayTitleType, DisplayTitleType alternateTitleType, string metadataLanguage) - => GetFullTitles(seriesTitles, episodeTitles, null, displayTitleType, alternateTitleType, metadataLanguage); + public enum TitleType { + MainTitle = 1, + SubTitle, + FullTitle, + } + + public static ( string, string ) GetEpisodeTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, DisplayLanguageType mainLanguage, DisplayLanguageType alternateLanguage, string metadataLanguage) + => GetTitles(seriesTitles, episodeTitles, null, episodeTitle, mainLanguage, alternateLanguage, TitleType.SubTitle, metadataLanguage); - // Produce titles for series if episode titles are omitted. - public static ( string, string ) GetSeriesTitles(IEnumerable<Title> seriesTitles, string seriesTitle, DisplayTitleType displayTitleType, DisplayTitleType alternateTitleType, string metadataLanguage) - => GetFullTitles(seriesTitles, null, seriesTitle, displayTitleType, alternateTitleType, metadataLanguage); + public static ( string, string ) GetSeriesTitles(IEnumerable<Title> seriesTitles, string seriesTitle, DisplayLanguageType mainLanguage, DisplayLanguageType alternateLanguage, string metadataLanguage) + => GetTitles(seriesTitles, null, seriesTitle, null, mainLanguage, alternateLanguage, TitleType.MainTitle, metadataLanguage); - // Produce combined/full titles if both episode titles and a fallback title is provided. - public static ( string, string ) GetFullTitles(IEnumerable<Title> rSeriesTitles, IEnumerable<Title> rEpisodeTitles, string seriesTitle, DisplayTitleType displayTitleType, DisplayTitleType alternateTitleType, string metadataLanguage) + public static ( string, string ) GetMovieTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayLanguageType mainLanguage, DisplayLanguageType alternateLanguage, string metadataLanguage) + => GetTitles(seriesTitles, episodeTitles, seriesTitle, episodeTitle, mainLanguage, alternateLanguage, TitleType.FullTitle, metadataLanguage); + + public static ( string, string ) GetTitles(IEnumerable<Title> rSeriesTitles, IEnumerable<Title> rEpisodeTitles, string seriesTitle, string episodeTitle, DisplayLanguageType mainLanguage, DisplayLanguageType alternateLanguage, TitleType outputType, string metadataLanguage) { // Don't process anything if the series titles are not provided. if (rSeriesTitles == null) return ( null, null ); @@ -65,64 +137,61 @@ public static ( string, string ) GetFullTitles(IEnumerable<Title> rSeriesTitles, var episodeTitles = (List<Title>)rEpisodeTitles; var originLanguage = GuessOriginLanguage(seriesTitles); var displayLanguage = metadataLanguage?.ToLower() ?? "en"; - return ( GetFullTitle(seriesTitles, episodeTitles, seriesTitle, displayTitleType, displayLanguage, originLanguage), GetFullTitle(seriesTitles, episodeTitles, seriesTitle, alternateTitleType, displayLanguage, originLanguage) ); + return ( GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, mainLanguage, outputType, displayLanguage, originLanguage), GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, alternateLanguage, outputType, displayLanguage, originLanguage) ); } - private static string GetEpisodeTitle(IEnumerable<Title> episodeTitle, DisplayTitleType displayTitleType, string displayLanguage, params string[] originLanguages) - => GetFullTitle(null, episodeTitle, null, displayTitleType, displayLanguage, originLanguages); - - private static string GetSeriesTitle(IEnumerable<Title> seriesTitles, string seriesTitle, DisplayTitleType displayTitleType, string displayLanguage, params string[] originLanguages) - => GetFullTitle(seriesTitles, null, seriesTitle, displayTitleType, displayLanguage, originLanguages); - - private static string GetFullTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, DisplayTitleType displayTitleType, string displayLanguage, params string[] originLanguages) + public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayLanguageType languageType, TitleType outputType, string displayLanguage, params string[] originLanguages) { - // We need one of them, or it won't work as intended. - if (seriesTitle == null && episodeTitles == null) return null; - switch (displayTitleType) + switch (languageType) { - case DisplayTitleType.Default: - // Fallback to preferred series title, but choose the episode title based on this order. - // The "main" title on AniDB is _most_ of the time in english, but we also fallback to romaji (japanese) or pinyin (chinese) in case it is not provided. - return GetTitle(null, episodeTitles, seriesTitle, "en", "x-jat", "x-zht"); - case DisplayTitleType.Origin: - return GetTitle(seriesTitles, episodeTitles, seriesTitle, originLanguages); - case DisplayTitleType.Localized: - var title = GetTitle(seriesTitles, episodeTitles, seriesTitle, displayLanguage); + // Let Shoko decide the title. + case DisplayLanguageType.Default: + return __GetTitle(null, null, seriesTitle, episodeTitle, outputType); + // Display in metadata-preferred language, or fallback to default. + case DisplayLanguageType.MetadataPreferred: + var title = __GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, outputType, displayLanguage); if (string.IsNullOrEmpty(title)) - goto case DisplayTitleType.Default; + goto case DisplayLanguageType.Default; return title; + // Display in origin language without fallback. + case DisplayLanguageType.Origin: + return __GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, outputType, originLanguages); default: return null; } } - private static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, params string[] languageCandidates) + internal static string __GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, TitleType outputType, params string[] languageCandidates) { - if (seriesTitles != null || seriesTitle != null) + // Lazy init string builder when/if we need it. + StringBuilder titleBuilder = null; + switch (outputType) { - StringBuilder title = new StringBuilder(); - string mainTitle = GetTitleByTypeAndLanguage(seriesTitles, "official", languageCandidates) ?? seriesTitle; - title.Append(mainTitle); - if (episodeTitles != null) { - var episodeTitle = GetTitleByLanguages(episodeTitles, languageCandidates); - // We could not create the complete title, and no mixed languages allowed (outside the specified one(s)), so abort here. - if (episodeTitle == null) - { - // Some movies provide only an english title, so we fallback to english. - episodeTitle = GetTitleByLanguages(episodeTitles, "en"); - if (episodeTitle == null) - return null; - } - if (!string.IsNullOrWhiteSpace(episodeTitle) && episodeTitle != "Complete Movie") - title.Append($": {episodeTitle}"); + case TitleType.MainTitle: + case TitleType.FullTitle: { + string title = (GetTitleByTypeAndLanguage(seriesTitles, "official", languageCandidates) ?? seriesTitle)?.Trim(); + // Return series title. + if (outputType == TitleType.MainTitle) + return title; + titleBuilder = new StringBuilder(title); + goto case TitleType.SubTitle; } - return title.ToString(); + case TitleType.SubTitle: { + string title = (GetTitleByLanguages(episodeTitles, languageCandidates) ?? episodeTitle)?.Trim(); + // Return episode title. + if (outputType == TitleType.SubTitle) + return title; + // Ignore sub-title of movie if it strictly equals the text below. + if (title != "Complete Movie") + titleBuilder?.Append($": {title}"); + return titleBuilder?.ToString() ?? ""; + } + default: + return null; } - // Will fallback to null if episode titles are null. - return GetTitleByLanguages(episodeTitles, languageCandidates); } - private static string GetTitleByTypeAndLanguage(IEnumerable<Title> titles, string type, params string[] langs) + public static string GetTitleByTypeAndLanguage(IEnumerable<Title> titles, string type, params string[] langs) { if (titles != null) foreach (string lang in langs) { @@ -132,11 +201,11 @@ private static string GetTitleByTypeAndLanguage(IEnumerable<Title> titles, strin return null; } - private static string GetTitleByLanguages(IEnumerable<Title> titles, params string[] langs) + public static string GetTitleByLanguages(IEnumerable<Title> titles, params string[] langs) { if (titles != null) foreach (string lang in langs) { - string title = titles.FirstOrDefault(s => s.Language.ToLower() == lang)?.Name; + string title = titles.FirstOrDefault(s => lang.Equals(s.Language, System.StringComparison.OrdinalIgnoreCase))?.Name; if (title != null) return title; } return null; diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 7748e0ec..f78e087b 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; @@ -28,88 +29,42 @@ public ImageProvider(IHttpClientFactory httpClientFactory, ILogger<ImageProvider public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var list = new List<RemoteImageInfo>(); - try { - if (item is Episode && !Plugin.Instance.Configuration.UseShokoThumbnails) return list; - - var id = item.GetProviderId("Shoko"); - - if (item is Season) + string episodeId = null; + string seriesId = null; + if (item is Episode) { - id = item.GetParent().GetProviderId("Shoko"); + episodeId = item.GetProviderId("Shoko Episode"); } - - if (string.IsNullOrEmpty(id)) + else if (item is Series || item is BoxSet || item is Movie) { - _logger.LogInformation($"Shoko Scanner... Images not found ({item.Name})"); - return list; + seriesId = item.GetProviderId("Shoko Series"); } - - _logger.LogInformation($"Shoko Scanner... Getting images ({item.Name} - {id})"); - - if (item is Episode) + else if (item is Season) { - var tvdbEpisodeInfo = (await API.ShokoAPI.GetEpisodeTvDb(id)).FirstOrDefault(); - var imageUrl = Helper.GetImageUrl(tvdbEpisodeInfo?.Thumbnail); - if (!string.IsNullOrEmpty(imageUrl)) - { - list.Add(new RemoteImageInfo - { - ProviderName = Name, - Type = ImageType.Primary, - Url = imageUrl - }); - } + seriesId = item.GetParent()?.GetProviderId("Shoko Series"); } + if (episodeId != null) + { + _logger.LogInformation($"Getting episode images ({episodeId} - {item.Name})"); - if (item is Series || item is Season) + var tvdbEpisodeInfo = (await API.ShokoAPI.GetEpisodeTvDb(episodeId)).FirstOrDefault(); + AddImage(ref list, ImageType.Primary, tvdbEpisodeInfo?.Thumbnail); + } + if (seriesId != null) { - var images = await API.ShokoAPI.GetSeriesImages(id); - - foreach (var image in images.Posters) - { - var imageUrl = Helper.GetImageUrl(image); - if (!string.IsNullOrEmpty(imageUrl)) - { - list.Add(new RemoteImageInfo - { - ProviderName = Name, - Type = ImageType.Primary, - Url = imageUrl - }); - } - } - - foreach (var image in images.Fanarts) - { - var imageUrl = Helper.GetImageUrl(image); - if (!string.IsNullOrEmpty(imageUrl)) - { - list.Add(new RemoteImageInfo - { - ProviderName = Name, - Type = ImageType.Backdrop, - Url = imageUrl - }); - } - } - - foreach (var image in images.Banners) - { - var imageUrl = Helper.GetImageUrl(image); - if (!string.IsNullOrEmpty(imageUrl)) - { - list.Add(new RemoteImageInfo - { - ProviderName = Name, - Type = ImageType.Banner, - Url = imageUrl - }); - } - } + _logger.LogInformation($"Getting series images ({seriesId} - {item.Name})"); + var images = await API.ShokoAPI.GetSeriesImages(seriesId); + foreach (var image in images?.Posters) + AddImage(ref list, ImageType.Primary, image); + foreach (var image in images?.Fanarts) + AddImage(ref list, ImageType.Backdrop, image); + foreach (var image in images?.Banners) + AddImage(ref list, ImageType.Banner, image); } + _logger.LogInformation($"List got {list.Count} item(s)."); return list; } catch (Exception e) @@ -119,6 +74,20 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell } } + private void AddImage(ref List<RemoteImageInfo> list, ImageType imageType, API.Models.Image image) + { + var imageUrl = Helper.GetImageUrl(image); + if (!string.IsNullOrEmpty(imageUrl)) + { + list.Add(new RemoteImageInfo + { + ProviderName = Name, + Type = imageType, + Url = imageUrl + }); + } + } + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { return new[] { ImageType.Primary, ImageType.Backdrop, ImageType.Banner }; @@ -126,7 +95,7 @@ public IEnumerable<ImageType> GetSupportedImages(BaseItem item) public bool Supports(BaseItem item) { - return item is Series || item is Season || item is Episode; + return item is Series || item is Season || item is Episode || item is Movie || item is BoxSet; } public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 5f66082e..13d5491e 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -3,7 +3,6 @@ using System.IO; using System.Linq; using System.Net.Http; -using System.Text; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; @@ -13,7 +12,6 @@ using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; -using EpisodeType = Shokofin.API.Models.Episode.EpisodeType; namespace Shokofin.Providers { @@ -44,10 +42,12 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio _logger.LogInformation($"Shoko Scanner... Getting movie ID ({filename})"); var apiResponse = await ShokoAPI.GetFilePathEndsWith(filename); - var allIds = apiResponse.FirstOrDefault()?.SeriesIDs.FirstOrDefault(); - var episodeIds = allIds?.EpisodeIDs?.FirstOrDefault(); - string seriesId = allIds?.SeriesID.ID.ToString(); - string episodeId = episodeIds?.ID.ToString(); + var file = apiResponse?.FirstOrDefault(); + var fileId = file?.ID.ToString(); + var seriesIds = file?.SeriesIDs.FirstOrDefault(); + var seriesId = seriesIds?.SeriesID.ID.ToString(); + var episodeIds = seriesIds?.EpisodeIDs?.FirstOrDefault(); + var episodeId = episodeIds?.ID.ToString(); if (string.IsNullOrEmpty(episodeId) || string.IsNullOrEmpty(seriesId)) { @@ -60,8 +60,10 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio var seriesAniDB = await ShokoAPI.GetSeriesAniDb(seriesId); var series = await ShokoAPI.GetSeries(seriesId); var episodeAniDB = await ShokoAPI.GetEpisodeAniDb(episodeId); + var episode = await ShokoAPI.GetEpisode(episodeId); bool isMultiEntry = series.Sizes.Total.Episodes > 1; - int tvdbId = (isMultiEntry ? episodeIds.TvDB?.FirstOrDefault() : allIds?.SeriesID.TvDB?.FirstOrDefault()) ?? 0; + int aniDBId = (isMultiEntry ? episodeIds?.AniDB : seriesIds?.SeriesID.AniDB) ?? 0; + int tvdbId = (isMultiEntry ? episodeIds?.TvDB?.FirstOrDefault() : seriesIds?.SeriesID.TvDB?.FirstOrDefault()) ?? 0; if (seriesAniDB?.SeriesType != "0") { @@ -69,28 +71,28 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio return result; } - var ( displayTitle, alternateTitle ) = Helper.GetFullTitles(seriesAniDB.Titles, episodeAniDB.Titles, seriesAniDB.Title, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); var tags = await ShokoAPI.GetSeriesTags(seriesId, Helper.GetFlagFilter()); - // Use the file description if collection contains more than one movie, otherwise use the collection description. - string description = (isMultiEntry ? episodeAniDB.Description : seriesAniDB.Description) ?? ""; + var ( displayTitle, alternateTitle ) = Helper.GetMovieTitles(seriesAniDB.Titles, episodeAniDB.Titles, series.Name, episode.Name, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); float comRat = isMultiEntry ? (float)((episodeAniDB.Rating.Value * 10) / episodeAniDB.Rating.MaxValue) : (float)((seriesAniDB.Rating.Value * 10) / seriesAniDB.Rating.MaxValue); - ExtraType? extraType = GetExtraType(episodeAniDB.Type); + ExtraType? extraType = Helper.GetExtraType(episodeAniDB); result.Item = new Movie { - IndexNumber = episodeAniDB.EpisodeNumber, + IndexNumber = Helper.GetIndexNumber(series, episodeAniDB), Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = isMultiEntry ? episodeAniDB.AirDate : seriesAniDB.AirDate, - Overview = Helper.SummarySanitizer(description), + // Use the file description if collection contains more than one movie, otherwise use the collection description. + Overview = Helper.SummarySanitizer((isMultiEntry ? episodeAniDB.Description : seriesAniDB.Description) ?? ""), ProductionYear = isMultiEntry ? episodeAniDB.AirDate?.Year : seriesAniDB.AirDate?.Year, - ExtraType = extraType, + ExtraType = extraType, Tags = tags?.Select(tag => tag.Name).ToArray() ?? new string[0], CommunityRating = comRat, }; + result.Item.SetProviderId("Shoko File", fileId); result.Item.SetProviderId("Shoko Series", seriesId); result.Item.SetProviderId("Shoko Episode", episodeId); - result.Item.SetProviderId("AniDB", allIds?.SeriesID.AniDB.ToString()); + if (aniDBId != 0) result.Item.SetProviderId("AniDB", aniDBId.ToString()); if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); result.HasMetadata = true; @@ -117,20 +119,6 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio } } - private ExtraType? GetExtraType(EpisodeType type) - { - switch (type) - { - case EpisodeType.Episode: - return null; - case EpisodeType.Trailer: - return ExtraType.Trailer; - case EpisodeType.Special: - return ExtraType.Scene; - default: - return ExtraType.Unknown; - } - } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) { From c4d81930bf84132b8f8b97e2bcce48f038e674dc Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 30 Sep 2020 21:32:27 +0200 Subject: [PATCH 0042/1103] Rename `GetFlagFilter` to `GetTagFilter` --- Shokofin/Providers/Helper.cs | 2 +- Shokofin/Providers/MovieProvider.cs | 2 +- Shokofin/Providers/SeriesProvider.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/Providers/Helper.cs b/Shokofin/Providers/Helper.cs index ffdb4e77..c197ec12 100644 --- a/Shokofin/Providers/Helper.cs +++ b/Shokofin/Providers/Helper.cs @@ -81,7 +81,7 @@ public static int GetMaxNumber(Models.Series series) } } - public static int GetFlagFilter() + public static int GetTagFilter() { var config = Plugin.Instance.Configuration; var filter = 0; diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 13d5491e..4e8144dc 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -71,7 +71,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio return result; } - var tags = await ShokoAPI.GetSeriesTags(seriesId, Helper.GetFlagFilter()); + var tags = await ShokoAPI.GetSeriesTags(seriesId, Helper.GetTagFilter()); var ( displayTitle, alternateTitle ) = Helper.GetMovieTitles(seriesAniDB.Titles, episodeAniDB.Titles, series.Name, episode.Name, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); float comRat = isMultiEntry ? (float)((episodeAniDB.Rating.Value * 10) / episodeAniDB.Rating.MaxValue) : (float)((seriesAniDB.Rating.Value * 10) / seriesAniDB.Rating.MaxValue); ExtraType? extraType = Helper.GetExtraType(episodeAniDB); diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 04a0a764..e18a62d8 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -53,7 +53,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat var seriesInfo = await ShokoAPI.GetSeries(seriesId); var aniDbSeriesInfo = await ShokoAPI.GetSeriesAniDb(seriesId); - var tags = await ShokoAPI.GetSeriesTags(seriesId, Helper.GetFlagFilter()); + var tags = await ShokoAPI.GetSeriesTags(seriesId, Helper.GetTagFilter()); var ( displayTitle, alternateTitle ) = Helper.GetSeriesTitles(aniDbSeriesInfo.Titles, seriesInfo.Name, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); result.Item = new Series From ad8533d0f179e21585a65c901ca1e71ff6e151be Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 30 Sep 2020 22:16:23 +0200 Subject: [PATCH 0043/1103] Resolve comments --- Shokofin/Providers/BoxSetProvider.cs | 16 +--------------- Shokofin/Providers/Helper.cs | 17 ++--------------- Shokofin/Providers/MovieProvider.cs | 10 +++++----- 3 files changed, 8 insertions(+), 35 deletions(-) diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 4c341d84..86ee1547 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -62,7 +62,7 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat _logger.LogInformation("Shoko Scanner... series did not contain multiple movies! Skipping."); return result; } - var tags = await ShokoAPI.GetSeriesTags(seriesId, GetFlagFilter()); + var tags = await ShokoAPI.GetSeriesTags(seriesId, Helper.GetTagFilter()); var ( displayTitle, alternateTitle ) = Helper.GetSeriesTitles(aniDbInfo.Titles, aniDbInfo.Title, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); result.Item = new BoxSet @@ -116,19 +116,5 @@ public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken { return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); } - - private int GetFlagFilter() - { - var config = Plugin.Instance.Configuration; - var filter = 0; - - if (config.HideAniDbTags) filter = 1; - if (config.HideArtStyleTags) filter |= (filter << 1); - if (config.HideSourceTags) filter |= (filter << 2); - if (config.HideMiscTags) filter |= (filter << 3); - if (config.HidePlotTags) filter |= (filter << 4); - - return filter; - } } } \ No newline at end of file diff --git a/Shokofin/Providers/Helper.cs b/Shokofin/Providers/Helper.cs index c197ec12..dc381e2d 100644 --- a/Shokofin/Providers/Helper.cs +++ b/Shokofin/Providers/Helper.cs @@ -3,10 +3,8 @@ using System.Text; using System.Text.RegularExpressions; using Shokofin.API.Models; -using Title = Shokofin.API.Models.Title; using DisplayLanguageType = Shokofin.Configuration.PluginConfiguration.DisplayLanguageType; using EpisodeType = Shokofin.API.Models.Episode.EpisodeType; -using Models = Shokofin.API.Models; using MediaBrowser.Model.Entities; namespace Shokofin.Providers @@ -18,18 +16,13 @@ public static string GetImageUrl(Image image) return image != null ? $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}/api/v3/Image/{image.Source}/{image.Type}/{image.ID}" : null; } - public static (int, int) GetNumbers(Models.Series series, Models.Episode.AniDB episode) - { - return (GetIndexNumber(series, episode), GetMaxNumber(series)); - } - /// <summary> /// /// </summary> /// <param name="series"></param> /// <param name="episode"></param> /// <returns></returns> - public static int GetIndexNumber(Models.Series series, Models.Episode.AniDB episode) + public static int GetIndexNumber(Series series, Episode.AniDB episode) { int offset = 0; switch (episode.Type) @@ -55,13 +48,7 @@ public static int GetIndexNumber(Models.Series series, Models.Episode.AniDB epis return offset + episode.EpisodeNumber; } - public static int GetMaxNumber(Models.Series series) - { - var dict = series.Sizes.Total; - return dict.Episodes + dict?.Specials ?? 0 + dict?.Credits ?? 0 + dict?.Others ?? 0 + dict?.Parodies ?? 0 + dict?.Trailers ?? 0; - } - - public static ExtraType? GetExtraType(Models.Episode.AniDB episode) + public static ExtraType? GetExtraType(Episode.AniDB episode) { switch (episode.Type) { diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 4e8144dc..24bfb74f 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -73,7 +73,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio var tags = await ShokoAPI.GetSeriesTags(seriesId, Helper.GetTagFilter()); var ( displayTitle, alternateTitle ) = Helper.GetMovieTitles(seriesAniDB.Titles, episodeAniDB.Titles, series.Name, episode.Name, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); - float comRat = isMultiEntry ? (float)((episodeAniDB.Rating.Value * 10) / episodeAniDB.Rating.MaxValue) : (float)((seriesAniDB.Rating.Value * 10) / seriesAniDB.Rating.MaxValue); + float rating = isMultiEntry ? (float)((episodeAniDB.Rating.Value * 10) / episodeAniDB.Rating.MaxValue) : (float)((seriesAniDB.Rating.Value * 10) / seriesAniDB.Rating.MaxValue); ExtraType? extraType = Helper.GetExtraType(episodeAniDB); result.Item = new Movie @@ -81,13 +81,13 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio IndexNumber = Helper.GetIndexNumber(series, episodeAniDB), Name = displayTitle, OriginalTitle = alternateTitle, - PremiereDate = isMultiEntry ? episodeAniDB.AirDate : seriesAniDB.AirDate, + PremiereDate = episodeAniDB.AirDate, // Use the file description if collection contains more than one movie, otherwise use the collection description. - Overview = Helper.SummarySanitizer((isMultiEntry ? episodeAniDB.Description : seriesAniDB.Description) ?? ""), - ProductionYear = isMultiEntry ? episodeAniDB.AirDate?.Year : seriesAniDB.AirDate?.Year, + Overview = Helper.SummarySanitizer((isMultiEntry ? episodeAniDB.Description ?? seriesAniDB.Description : seriesAniDB.Description) ?? ""), + ProductionYear = episodeAniDB.AirDate?.Year, ExtraType = extraType, Tags = tags?.Select(tag => tag.Name).ToArray() ?? new string[0], - CommunityRating = comRat, + CommunityRating = rating, }; result.Item.SetProviderId("Shoko File", fileId); result.Item.SetProviderId("Shoko Series", seriesId); From f067690b18dcaa04f8164f799f3d60f051a8fe4a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 30 Sep 2020 22:25:09 +0200 Subject: [PATCH 0044/1103] Rename `GetIndexNumber` to `GetAbsoluteIndexNumber` --- Shokofin/Providers/Helper.cs | 8 +++----- Shokofin/Providers/MovieProvider.cs | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Shokofin/Providers/Helper.cs b/Shokofin/Providers/Helper.cs index dc381e2d..3128d624 100644 --- a/Shokofin/Providers/Helper.cs +++ b/Shokofin/Providers/Helper.cs @@ -17,12 +17,10 @@ public static string GetImageUrl(Image image) } /// <summary> - /// + /// Get absolute index number for an episode in a series. /// </summary> - /// <param name="series"></param> - /// <param name="episode"></param> - /// <returns></returns> - public static int GetIndexNumber(Series series, Episode.AniDB episode) + /// <returns>Absolute index.</returns> + public static int GetAbsoluteIndexNumber(Series series, Episode.AniDB episode) { int offset = 0; switch (episode.Type) diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 24bfb74f..609b85ed 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -78,7 +78,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio result.Item = new Movie { - IndexNumber = Helper.GetIndexNumber(series, episodeAniDB), + IndexNumber = Helper.GetAbsoluteIndexNumber(series, episodeAniDB), Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episodeAniDB.AirDate, From 5ea9478e0bcf1c6c94c33733ccdce339eeddacd0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 30 Sep 2020 22:34:21 +0200 Subject: [PATCH 0045/1103] Tweak `GetExtraType` --- Shokofin/Providers/Helper.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Shokofin/Providers/Helper.cs b/Shokofin/Providers/Helper.cs index 3128d624..ab4c5e6f 100644 --- a/Shokofin/Providers/Helper.cs +++ b/Shokofin/Providers/Helper.cs @@ -52,14 +52,20 @@ public static int GetAbsoluteIndexNumber(Series series, Episode.AniDB episode) { case EpisodeType.Episode: return null; + case EpisodeType.Credits: + return ExtraType.ThemeVideo; case EpisodeType.Trailer: return ExtraType.Trailer; case EpisodeType.Special: { - var enTitle = Helper.GetTitleByLanguages(episode.Titles, "en"); - if (enTitle != null && (enTitle.Contains("intro") || enTitle.Contains("outro"))) { - return ExtraType.DeletedScene; - } - return ExtraType.Scene; + var title = Helper.GetTitleByLanguages(episode.Titles, "en") ?? ""; + // Interview + if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) + return ExtraType.Interview; + // Cinema intro/outro + if (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) && + (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase))) + return ExtraType.Clip; + return null; } default: return null; From bacfe10dcf8e4cab7a6a057195b3d2845240a1fa Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Wed, 30 Sep 2020 20:54:32 +0000 Subject: [PATCH 0046/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index 56555672..0507e09f 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.3.0", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.3.0/shokofin_1.3.0.zip", + "checksum": "a30811e8adc9491df0aaa436d6a7cea6", + "timestamp": "2020-09-30T20:54:31Z" + }, { "version": "1.2.0", "changelog": "NA", From d3eaca859b0a2d3bba4eeeba06d77845bde24fa7 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <revam@users.noreply.github.com> Date: Wed, 30 Sep 2020 22:59:09 +0200 Subject: [PATCH 0047/1103] Add warning closes #8 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 78ff668e..3b63c028 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Shokofin +**Warning**: This plugin currently requires an unstable version of Jellyfin (`v10.7.0`) installed. + A plugin to integrate your Shoko database with the Jellyfin media server. ## Install From eb2759f6dbbd580b7276fcfd5b6175ef57f48e4f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 6 Oct 2020 20:09:46 +0200 Subject: [PATCH 0048/1103] Update Scrobbler --- Shokofin/Scrobbler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Scrobbler.cs b/Shokofin/Scrobbler.cs index 4076ffef..ade4bf2e 100644 --- a/Shokofin/Scrobbler.cs +++ b/Shokofin/Scrobbler.cs @@ -38,7 +38,7 @@ private async void KernelPlaybackStopped(object sender, PlaybackStopEventArgs e) if (e.Item is Episode episode && e.PlayedToCompletion) { - var episodeId = episode.GetProviderId("Shoko"); + var episodeId = episode.GetProviderId("Shoko Episode"); _logger.LogInformation("Shoko Scrobbler... Item is played. Marking as watched on Shoko"); _logger.LogInformation($"{episode.SeriesName} - {episode.Name} ({episodeId})"); From f73b93a1b7210b95b66e00f32a9d166179a2f0f0 Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Mon, 12 Oct 2020 14:12:01 +0000 Subject: [PATCH 0049/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index 0507e09f..ad9d7dea 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.3.1", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.3.1/shokofin_1.3.1.zip", + "checksum": "135888e53f702e0b96846ca2ca7201d7", + "timestamp": "2020-10-12T14:11:59Z" + }, { "version": "1.3.0", "changelog": "NA", From 0d2f4e2740198c05c4144cba0d798cc20fb513d6 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <revam@users.noreply.github.com> Date: Sat, 14 Nov 2020 22:32:28 +0100 Subject: [PATCH 0050/1103] Update warning to also mention Shoko --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3b63c028..768207d1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Shokofin -**Warning**: This plugin currently requires an unstable version of Jellyfin (`v10.7.0`) installed. +**Warning**: This plugin currently requires an unstable version of both Jellyfin (`>10.7.0`) and Shoko (`>4.0.1`) installed to work. A plugin to integrate your Shoko database with the Jellyfin media server. From 8ca28607474308c07ed13ec9dfe5b7c968ff5cd4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <revam@users.noreply.github.com> Date: Sat, 14 Nov 2020 22:36:29 +0100 Subject: [PATCH 0051/1103] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 768207d1..72df93fb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Shokofin -**Warning**: This plugin currently requires an unstable version of both Jellyfin (`>10.7.0`) and Shoko (`>4.0.1`) installed to work. +**Warning**: This plugin currently requires an unstable version of Jellyfin (`>10.7.0`) and daily version of Shoko (`>4.0.1`) to be installed to work. A plugin to integrate your Shoko database with the Jellyfin media server. From 913fc5a453d4ca8c177070f9dbe61351d4a1eeca Mon Sep 17 00:00:00 2001 From: Tim Gels <43609220+GrandDynamo@users.noreply.github.com> Date: Tue, 24 Nov 2020 19:25:22 +0100 Subject: [PATCH 0052/1103] Fix broken link Fixes the dead link that should point to the latest shokofin release. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 72df93fb..f717272f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ TBD ### Github Releases -1. Download the `shokofin_*.zip` file from the latest release from GitHub [here](https://github.com/Shoko/Shokofin/releases/latest). +1. Download the `shokofin_*.zip` file from the latest release from GitHub [here](https://github.com/ShokoAnime/shokofin/releases/latest). 2. Extract the contained `Shokofin.dll` and `meta.json`, place both the files in a folder named `Shokofin` and copy this folder to the `plugins` folder under the Jellyfin program data directory or inside the portable install directory. From 87fa09ce25f5009181ae6f5e2fcdc4d69f8c228a Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Thu, 4 Mar 2021 01:33:05 +0530 Subject: [PATCH 0053/1103] Update to net5.0 and update dependencies --- Shokofin/Shokofin.csproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj index f215209d..213e21ab 100644 --- a/Shokofin/Shokofin.csproj +++ b/Shokofin/Shokofin.csproj @@ -1,15 +1,15 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>netstandard2.1</TargetFramework> + <TargetFramework>net5.0</TargetFramework> </PropertyGroup> <ItemGroup> - <PackageReference Include="Jellyfin.Common" Version="10.7.0-20200903" /> - <PackageReference Include="Jellyfin.Controller" Version="10.7.0-20200903" /> - <PackageReference Include="Jellyfin.Data" Version="10.7.0-20200903" /> - <PackageReference Include="Jellyfin.Model" Version="10.7.0-20200903" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="3.1.7" /> + <PackageReference Include="Jellyfin.Common" Version="10.7.0-rc4" /> + <PackageReference Include="Jellyfin.Controller" Version="10.7.0-rc4" /> + <PackageReference Include="Jellyfin.Data" Version="10.7.0-rc4" /> + <PackageReference Include="Jellyfin.Model" Version="10.7.0-rc4" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> </ItemGroup> <ItemGroup> From e55a1035cc485e15905a8af1523eecde32f18352 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Thu, 4 Mar 2021 01:40:40 +0530 Subject: [PATCH 0054/1103] Update episode types for new daily server --- Shokofin/API/Models/Episode.cs | 12 +---------- Shokofin/API/Models/Series.cs | 2 +- Shokofin/Providers/BoxSetProvider.cs | 3 +-- Shokofin/Providers/EpisodeProvider.cs | 11 +++++----- Shokofin/Providers/Helper.cs | 31 +++++++++++++-------------- Shokofin/Providers/MovieProvider.cs | 2 +- 6 files changed, 24 insertions(+), 37 deletions(-) diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index e473291e..7595acc8 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -8,22 +8,12 @@ public class Episode : BaseModel public EpisodeIDs IDs { get; set; } public DateTime? Watched { get; set; } - - public enum EpisodeType - { - Episode = 1, - Credits = 2, - Special = 3, - Trailer = 4, - Parody = 5, - Other = 6 - } public class AniDB { public int ID { get; set; } - public EpisodeType Type { get; set; } + public string Type { get; set; } public int EpisodeNumber { get; set; } diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index 79c32ba9..e394699f 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -21,7 +21,7 @@ public class AniDB { public int ID { get; set; } - public string SeriesType { get; set; } + public string Type { get; set; } public string Title { get; set; } diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 86ee1547..a217bc10 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -5,7 +5,6 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -50,7 +49,7 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat _logger.LogInformation($"Shoko Scanner... Getting series metadata ({dirname} - {seriesId})"); var aniDbInfo = await ShokoAPI.GetSeriesAniDb(seriesId); - if (aniDbInfo.SeriesType != "0" /* Movie */) + if (aniDbInfo.Type != "Movie") { _logger.LogInformation("Shoko Scanner... series was not a movie! Skipping."); return result; diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 235af08e..d85f058e 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -11,7 +11,6 @@ using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; -using EpisodeType = Shokofin.API.Models.Episode.EpisodeType; namespace Shokofin.Providers { @@ -101,22 +100,22 @@ public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); } - private async Task<int> GetSeasonNumber(string episodeId, EpisodeType type) + private async Task<int> GetSeasonNumber(string episodeId, string type) { var seasonNumber = 0; switch (type) { - case EpisodeType.Episode: + case "Normal": seasonNumber = 1; break; - case EpisodeType.Credits: + case "ThemeSong": seasonNumber = 100; break; - case EpisodeType.Special: + case "Special": seasonNumber = 0; break; - case EpisodeType.Trailer: + case "Trailer": seasonNumber = 99; break; default: diff --git a/Shokofin/Providers/Helper.cs b/Shokofin/Providers/Helper.cs index ab4c5e6f..a816b182 100644 --- a/Shokofin/Providers/Helper.cs +++ b/Shokofin/Providers/Helper.cs @@ -4,7 +4,6 @@ using System.Text.RegularExpressions; using Shokofin.API.Models; using DisplayLanguageType = Shokofin.Configuration.PluginConfiguration.DisplayLanguageType; -using EpisodeType = Shokofin.API.Models.Episode.EpisodeType; using MediaBrowser.Model.Entities; namespace Shokofin.Providers @@ -25,23 +24,23 @@ public static int GetAbsoluteIndexNumber(Series series, Episode.AniDB episode) int offset = 0; switch (episode.Type) { - case EpisodeType.Episode: + case "Normal": break; - case EpisodeType.Special: + case "Special": offset += series.Sizes.Total.Episodes; - break; // goto case EpisodeType.Episode; - case EpisodeType.Credits: + break; // goto case "Normal"; + case "ThemeSong": offset += series.Sizes.Total?.Specials ?? 0; - goto case EpisodeType.Special; - case EpisodeType.Other: + goto case "ThemeSong"; + case "Unknown": offset += series.Sizes.Total?.Credits ?? 0; - goto case EpisodeType.Credits; - case EpisodeType.Parody: + goto case "ThemeSong"; + case "Parody": offset += series.Sizes.Total?.Others ?? 0; - goto case EpisodeType.Other; - case EpisodeType.Trailer: + goto case "Unknown"; + case "Trailer": offset += series.Sizes.Total?.Parodies ?? 0; - goto case EpisodeType.Parody; + goto case "Parody"; } return offset + episode.EpisodeNumber; } @@ -50,13 +49,13 @@ public static int GetAbsoluteIndexNumber(Series series, Episode.AniDB episode) { switch (episode.Type) { - case EpisodeType.Episode: + case "Normal": return null; - case EpisodeType.Credits: + case "ThemeSong": return ExtraType.ThemeVideo; - case EpisodeType.Trailer: + case "Trailer": return ExtraType.Trailer; - case EpisodeType.Special: { + case "Special": { var title = Helper.GetTitleByLanguages(episode.Titles, "en") ?? ""; // Interview if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 609b85ed..d07d6511 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -65,7 +65,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio int aniDBId = (isMultiEntry ? episodeIds?.AniDB : seriesIds?.SeriesID.AniDB) ?? 0; int tvdbId = (isMultiEntry ? episodeIds?.TvDB?.FirstOrDefault() : seriesIds?.SeriesID.TvDB?.FirstOrDefault()) ?? 0; - if (seriesAniDB?.SeriesType != "0") + if (seriesAniDB?.Type != "Movie") { _logger.LogInformation($"Shoko Scanner... File found, but not a movie! Skipping."); return result; From cfec64395bef4e80cc0b56494dc6c75f68c75309 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Thu, 4 Mar 2021 01:43:19 +0530 Subject: [PATCH 0055/1103] Update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f717272f..83afc907 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ TBD 3. Build plugin with following command. ```sh -$ dotnet restore Shokofin/Shokofin.csproj -s https://api.nuget.org/v3/index.json -s https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/nuget/v3/index.json +$ dotnet restore Shokofin/Shokofin.csproj $ dotnet publish -c Release Shokofin/Shokofin.csproj ``` From 00d097720149208c6f160882c6fd63f603baf44a Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Thu, 4 Mar 2021 01:46:48 +0530 Subject: [PATCH 0056/1103] Update github workflows --- .github/workflows/build.yml | 6 +++--- .github/workflows/release.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b7b7802b..edf3d1c7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: # rid: ['win-x64', 'linux-x64'] - dotnet: [ '3.1.x' ] + dotnet: [ '5.0.x' ] name: build @@ -23,9 +23,9 @@ jobs: uses: actions/setup-dotnet@v1 with: dotnet-version: ${{ matrix.dotnet }} - - run: dotnet restore Shokofin/Shokofin.csproj -s https://api.nuget.org/v3/index.json -s https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/nuget/v3/index.json + - run: dotnet restore Shokofin/Shokofin.csproj - run: dotnet publish -c Release Shokofin/Shokofin.csproj - uses: actions/upload-artifact@v2 with: name: Shokofin - path: Shokofin/bin/Release/netstandard2.1/Shokofin.dll + path: Shokofin/bin/Release/net5.0/Shokofin.dll diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8069ceb6..9e8df2dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,9 +19,9 @@ jobs: - name: Setup dotnet uses: actions/setup-dotnet@v1 with: - dotnet-version: 3.1.x + dotnet-version: 5.0.x - name: Restore nuget packages - run: dotnet restore Shokofin/Shokofin.csproj -s https://api.nuget.org/v3/index.json -s https://pkgs.dev.azure.com/jellyfin-project/jellyfin/_packaging/unstable/nuget/v3/index.json + run: dotnet restore Shokofin/Shokofin.csproj - name: Setup python uses: actions/setup-python@v2 with: From c3e40a15ad1540bf7356a26770cfe816e0307cc0 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Thu, 4 Mar 2021 02:08:07 +0530 Subject: [PATCH 0057/1103] Update dotnet version in build_plugin.sh --- build_plugin.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build_plugin.sh b/build_plugin.sh index a9c36a7c..82bdce86 100644 --- a/build_plugin.sh +++ b/build_plugin.sh @@ -49,10 +49,10 @@ JELLYFIN_REPO_URL=${JELLYFIN_REPO_URL:-${DEFAULT_REPO_URL}} meta_version=$(grep -Po '^ *version: * "*\K[^"$]+' "${PLUGIN}/build.yaml") VERSION=$1 -zipfile=$($JPRM --verbosity=debug plugin build "${PLUGIN}" --output="${ARTIFACT_DIR}" --version="${VERSION}") && { +zipfile=$($JPRM --verbosity=debug plugin build "${PLUGIN}" --output="${ARTIFACT_DIR}" --version="${VERSION}" --dotnet-framework="net5.0") && { $JPRM repo add --url=${JELLYFIN_REPO_URL} "${JELLYFIN_REPO}" "${zipfile}" } sed -i "s/shokofin\//${VERSION}\//" "${JELLYFIN_REPO}" -exit $? \ No newline at end of file +exit $? From 4a85e76a496e5dc3aaf6ffd94bd7270d43d96b2f Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Wed, 3 Mar 2021 20:39:57 +0000 Subject: [PATCH 0058/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index ad9d7dea..f6b1509e 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.4.0", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.0/shokofin_1.4.0.zip", + "checksum": "57e70a963ef95a8f64bdcc394685f594", + "timestamp": "2021-03-03T20:39:56Z" + }, { "version": "1.3.1", "changelog": "NA", From 3f79b53f57d3bfc4bf0bce90b85ac163f881ddbb Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Tue, 16 Mar 2021 10:53:11 +0530 Subject: [PATCH 0059/1103] Add URL encoding to /Series/PathEndsWith --- Shokofin/API/ShokoAPI.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPI.cs index cce5006d..c0d32ff2 100644 --- a/Shokofin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPI.cs @@ -122,7 +122,7 @@ public static async Task<Images> GetSeriesImages(string id) public static async Task<IEnumerable<Series>> GetSeriesPathEndsWith(string dirname) { - var responseStream = await CallApi($"/api/v3/Series/PathEndsWith/{dirname}"); + var responseStream = await CallApi($"/api/v3/Series/PathEndsWith/{Uri.EscapeDataString(dirname)}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Series>>(responseStream) : null; } @@ -144,4 +144,4 @@ public static async Task<IEnumerable<SeriesSearchResult>> SeriesSearch(string qu return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<SeriesSearchResult>>(responseStream) : null; } } -} \ No newline at end of file +} From d148ecd5cb4481f40a98e04e14881252e4567e05 Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Tue, 16 Mar 2021 15:01:13 +0000 Subject: [PATCH 0060/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index f6b1509e..7db999f4 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.4.1", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.1/shokofin_1.4.1.zip", + "checksum": "77bc01b63d8dde14401ba8060dd46b38", + "timestamp": "2021-03-16T15:01:11Z" + }, { "version": "1.4.0", "changelog": "NA", From 99d3a906a176a6a33d84c5bfcd79dc08d2aec9e9 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 17 Mar 2021 12:51:57 +0530 Subject: [PATCH 0061/1103] Update jellyfin nuget packages --- Shokofin/Shokofin.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj index 213e21ab..a8bea081 100644 --- a/Shokofin/Shokofin.csproj +++ b/Shokofin/Shokofin.csproj @@ -5,10 +5,10 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Jellyfin.Common" Version="10.7.0-rc4" /> - <PackageReference Include="Jellyfin.Controller" Version="10.7.0-rc4" /> - <PackageReference Include="Jellyfin.Data" Version="10.7.0-rc4" /> - <PackageReference Include="Jellyfin.Model" Version="10.7.0-rc4" /> + <PackageReference Include="Jellyfin.Common" Version="10.7.0" /> + <PackageReference Include="Jellyfin.Controller" Version="10.7.0" /> + <PackageReference Include="Jellyfin.Data" Version="10.7.0" /> + <PackageReference Include="Jellyfin.Model" Version="10.7.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> </ItemGroup> From e52956a05fdc079e6e21b8a2410c6d0a0d9e1553 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 17 Mar 2021 12:58:58 +0530 Subject: [PATCH 0062/1103] Fix series search.. maybe --- Shokofin/API/ShokoAPI.cs | 6 ++++++ Shokofin/Providers/BoxSetProvider.cs | 3 +++ Shokofin/Providers/SeriesProvider.cs | 3 +++ 3 files changed, 12 insertions(+) diff --git a/Shokofin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPI.cs index c0d32ff2..54b23aae 100644 --- a/Shokofin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPI.cs @@ -143,5 +143,11 @@ public static async Task<IEnumerable<SeriesSearchResult>> SeriesSearch(string qu var responseStream = await CallApi($"/api/v3/Series/Search/{Uri.EscapeDataString(query)}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<SeriesSearchResult>>(responseStream) : null; } + + public static async Task<IEnumerable<SeriesSearchResult>> SeriesStartsWith(string query) + { + var responseStream = await CallApi($"/api/v3/Series/StartsWith/{Uri.EscapeDataString(query)}"); + return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<SeriesSearchResult>>(responseStream) : null; + } } } diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index a217bc10..5abc3a14 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -91,6 +91,9 @@ public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo s { _logger.LogInformation($"Shoko Scanner... Searching BoxSet ({searchInfo.Name})"); var searchResults = await ShokoAPI.SeriesSearch(searchInfo.Name); + + if (searchResults.Count() == 0) searchResults = await ShokoAPI.SeriesStartsWith(searchInfo.Name); + var results = new List<RemoteSearchResult>(); foreach (var series in searchResults) diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index e18a62d8..a6c6b78c 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -100,6 +100,9 @@ public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo s { _logger.LogInformation($"Shoko Scanner... Searching Series ({searchInfo.Name})"); var searchResults = await ShokoAPI.SeriesSearch(searchInfo.Name); + + if (searchResults.Count() == 0) searchResults = await ShokoAPI.SeriesStartsWith(searchInfo.Name); + var results = new List<RemoteSearchResult>(); foreach (var series in searchResults) From c9e829d1b9d2f11ba1edf1a7b01f367b6dbc9448 Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Wed, 17 Mar 2021 07:31:30 +0000 Subject: [PATCH 0063/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index 7db999f4..4f9499fe 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.4.2", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.2/shokofin_1.4.2.zip", + "checksum": "2b90bac9df30315240802d0aa23a706c", + "timestamp": "2021-03-17T07:31:27Z" + }, { "version": "1.4.1", "changelog": "NA", From 22fef9c48c2d1a96038c99e256ced0307e3ed641 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <revam@users.noreply.github.com> Date: Thu, 18 Mar 2021 18:31:58 +0100 Subject: [PATCH 0064/1103] Update EpisodeProvider.cs Update log point to include error message, and not just the trace... --- Shokofin/Providers/EpisodeProvider.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index d85f058e..6bf7cc7a 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -84,7 +84,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell } catch (Exception e) { - _logger.LogError(e.StackTrace); + _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); return new MetadataResult<Episode>(); } } @@ -133,4 +133,4 @@ private async Task<int> GetSeasonNumber(string episodeId, string type) return seasonNumber; } } -} \ No newline at end of file +} From 1d5683cba229eef34ea0b23e08b1f4d67903dc2b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <revam@users.noreply.github.com> Date: Thu, 18 Mar 2021 18:32:50 +0100 Subject: [PATCH 0065/1103] Update SeriesProvider.cs Update log point to include error message, and not just the trace... --- Shokofin/Providers/SeriesProvider.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index a6c6b78c..7e175905 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -91,7 +91,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat } catch (Exception e) { - _logger.LogError(e.StackTrace); + _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); return new MetadataResult<Series>(); } } @@ -128,4 +128,4 @@ public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } -} \ No newline at end of file +} From c8660336dc2b9b7d9308c6104548bfc01a596e66 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 18 Mar 2021 17:38:51 +0000 Subject: [PATCH 0066/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index 4f9499fe..a86712b9 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.4.3", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.3/shokofin_1.4.3.zip", + "checksum": "ee2b4d8b79dcd1edf524bd81e2ef7290", + "timestamp": "2021-03-18T17:38:49Z" + }, { "version": "1.4.2", "changelog": "NA", From 3dd0db3676514878496c541ab3333a0850f2655c Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 24 Mar 2021 15:02:57 +0530 Subject: [PATCH 0067/1103] Fix "Invalid image received" error --- Shokofin/Providers/Helper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Providers/Helper.cs b/Shokofin/Providers/Helper.cs index a816b182..5c21bfd3 100644 --- a/Shokofin/Providers/Helper.cs +++ b/Shokofin/Providers/Helper.cs @@ -12,7 +12,7 @@ public class Helper { public static string GetImageUrl(Image image) { - return image != null ? $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}/api/v3/Image/{image.Source}/{image.Type}/{image.ID}" : null; + return !image.RelativeFilepath.Equals("/") ? $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}/api/v3/Image/{image.Source}/{image.Type}/{image.ID}" : null; } /// <summary> From 6ad2b0e65174d0552e02c2acd3845fb63e0dfffd Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Wed, 24 Mar 2021 09:41:29 +0000 Subject: [PATCH 0068/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index a86712b9..8b15ada4 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.4.4", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.4/shokofin_1.4.4.zip", + "checksum": "475abde06b9f67a131bb2737d126d052", + "timestamp": "2021-03-24T09:41:27Z" + }, { "version": "1.4.3", "changelog": "NA", From dfd693fca272b34779a0e9f4131ea8e0a2fbf577 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 24 Mar 2021 23:46:37 +0530 Subject: [PATCH 0069/1103] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 83afc907..e4e5be60 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Shokofin -**Warning**: This plugin currently requires an unstable version of Jellyfin (`>10.7.0`) and daily version of Shoko (`>4.0.1`) to be installed to work. +**Warning**: This plugin currently requires the latest version of Jellyfin (`>10.7.0`) and daily version of Shoko (`>4.1.0`) to be installed to work. A plugin to integrate your Shoko database with the Jellyfin media server. From 1734c3ccf8e633f748e28634df1eb037bcea9137 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 24 Mar 2021 23:56:59 +0530 Subject: [PATCH 0070/1103] Update README.md --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e4e5be60..ca1c5c69 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,12 @@ There are multiple ways to install this plugin, but the recomended way is to use ### Official Repository -TBD +1. Go to Dashboard -> Plugins -> Repositories +2. Add new repository with the following details + * Repository Name: `Shokofin` + * Repository URL: `https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest.json` +3. Go to the catalog in the plugins page +4. Find and install Shokofin from the Metadata section ### Github Releases From 0e1fb14f29458a0a670658409ae0c8bbfcdb3e73 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 24 Mar 2021 23:57:17 +0530 Subject: [PATCH 0071/1103] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ca1c5c69..c91089ff 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ There are multiple ways to install this plugin, but the recomended way is to use 1. Go to Dashboard -> Plugins -> Repositories 2. Add new repository with the following details - * Repository Name: `Shokofin` - * Repository URL: `https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest.json` + * Repository Name: `Shokofin` + * Repository URL: `https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest.json` 3. Go to the catalog in the plugins page 4. Find and install Shokofin from the Metadata section From 980cfa983978aff393fd07a063e0eae8f0781c0b Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Thu, 25 Mar 2021 18:36:53 +0530 Subject: [PATCH 0072/1103] Re-Add null check for image --- Shokofin/Providers/Helper.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Shokofin/Providers/Helper.cs b/Shokofin/Providers/Helper.cs index 5c21bfd3..9c266543 100644 --- a/Shokofin/Providers/Helper.cs +++ b/Shokofin/Providers/Helper.cs @@ -12,7 +12,8 @@ public class Helper { public static string GetImageUrl(Image image) { - return !image.RelativeFilepath.Equals("/") ? $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}/api/v3/Image/{image.Source}/{image.Type}/{image.ID}" : null; + if (image == null || !image.RelativeFilepath.Equals("/")) return null; // No image found + return $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}/api/v3/Image/{image.Source}/{image.Type}/{image.ID}"; } /// <summary> From 277871cbeac21e8be9788c740902378dc7c77f0d Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Thu, 25 Mar 2021 13:10:38 +0000 Subject: [PATCH 0073/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index 8b15ada4..77f945a0 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.4.5", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.5/shokofin_1.4.5.zip", + "checksum": "ec0638fc707dfe2450dc47b3e161d042", + "timestamp": "2021-03-25T13:10:36Z" + }, { "version": "1.4.4", "changelog": "NA", From a3028c0b7bf604397339930f51386f3291c0c955 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Fri, 26 Mar 2021 11:33:59 +0530 Subject: [PATCH 0074/1103] Update Helper.cs --- Shokofin/Providers/Helper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Providers/Helper.cs b/Shokofin/Providers/Helper.cs index 9c266543..a79c9f6c 100644 --- a/Shokofin/Providers/Helper.cs +++ b/Shokofin/Providers/Helper.cs @@ -12,7 +12,7 @@ public class Helper { public static string GetImageUrl(Image image) { - if (image == null || !image.RelativeFilepath.Equals("/")) return null; // No image found + if (image == null || image.RelativeFilepath.Equals("/")) return null; // No image found return $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}/api/v3/Image/{image.Source}/{image.Type}/{image.ID}"; } @@ -221,4 +221,4 @@ private static string[] GuessOriginLanguage(IEnumerable<Title> seriesTitle) } } -} \ No newline at end of file +} From abc5ab1d5c7f9db94150d1ca12d07da79f732999 Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Fri, 26 Mar 2021 06:05:27 +0000 Subject: [PATCH 0075/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index 77f945a0..4cc5fc10 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.4.5.1", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.5.1/shokofin_1.4.5.1.zip", + "checksum": "1a5005234208d651e194ac9987c2ddcd", + "timestamp": "2021-03-26T06:05:25Z" + }, { "version": "1.4.5", "changelog": "NA", From a2b7d3d3c01a725c5a80c9118d36e3c9a7185ad0 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Fri, 26 Mar 2021 18:26:43 +0530 Subject: [PATCH 0076/1103] Update Plugin.cs --- Shokofin/Plugin.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index fa0b97e5..64871715 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -10,7 +10,7 @@ namespace Shokofin { public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { - public override string Name => "Shoko"; + public override string Name => "Shokofin"; public override Guid Id => Guid.Parse("5216ccbf-d24a-4eb3-8a7e-7da4230b7052"); @@ -33,4 +33,4 @@ public IEnumerable<PluginPageInfo> GetPages() }; } } -} \ No newline at end of file +} From 0209a886a0838704048891d80c89363260fb0c0a Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Mon, 31 May 2021 22:53:58 +0530 Subject: [PATCH 0077/1103] Stop setting TvDB ID --- Shokofin/Providers/EpisodeProvider.cs | 2 -- Shokofin/Providers/MovieProvider.cs | 2 -- Shokofin/Providers/SeriesProvider.cs | 2 -- 3 files changed, 6 deletions(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 6bf7cc7a..bcecdb6c 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -73,8 +73,6 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell result.Item.SetProviderId("Shoko Episode", episodeId); result.Item.SetProviderId("Shoko File", fileId); result.Item.SetProviderId("AniDB", episodeIDs.AniDB.ToString()); - var tvdbId = episodeIDs.TvDB?.FirstOrDefault(); - if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); result.HasMetadata = true; var episodeNumberEnd = episodeInfo.EpisodeNumber + series?.EpisodeIDs.Count() - 1; diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index d07d6511..657e033d 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -63,7 +63,6 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio var episode = await ShokoAPI.GetEpisode(episodeId); bool isMultiEntry = series.Sizes.Total.Episodes > 1; int aniDBId = (isMultiEntry ? episodeIds?.AniDB : seriesIds?.SeriesID.AniDB) ?? 0; - int tvdbId = (isMultiEntry ? episodeIds?.TvDB?.FirstOrDefault() : seriesIds?.SeriesID.TvDB?.FirstOrDefault()) ?? 0; if (seriesAniDB?.Type != "Movie") { @@ -93,7 +92,6 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio result.Item.SetProviderId("Shoko Series", seriesId); result.Item.SetProviderId("Shoko Episode", episodeId); if (aniDBId != 0) result.Item.SetProviderId("AniDB", aniDBId.ToString()); - if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); result.HasMetadata = true; result.ResetPeople(); diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 7e175905..b9c3031c 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -70,8 +70,6 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat }; result.Item.SetProviderId("Shoko Series", seriesId); result.Item.SetProviderId("AniDB", seriesIDs.AniDB.ToString()); - var tvdbId = seriesIDs.TvDB?.FirstOrDefault(); - if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); result.HasMetadata = true; result.ResetPeople(); From ed2783b3c902f6559cfa94e36498980fac609995 Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Mon, 31 May 2021 17:29:07 +0000 Subject: [PATCH 0078/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index 4cc5fc10..2c32e04d 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.4.6", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.6/shokofin_1.4.6.zip", + "checksum": "be14a632a9ff59baccc8d78d6ca1809c", + "timestamp": "2021-05-31T17:29:05Z" + }, { "version": "1.4.5.1", "changelog": "NA", From be6b833ed8b69451c7a4c4a9ee28e02b7477ec2d Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Tue, 1 Jun 2021 22:33:21 +0530 Subject: [PATCH 0079/1103] Fix weird function names --- Shokofin/Scrobbler.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/Scrobbler.cs b/Shokofin/Scrobbler.cs index ade4bf2e..9c19c526 100644 --- a/Shokofin/Scrobbler.cs +++ b/Shokofin/Scrobbler.cs @@ -22,11 +22,11 @@ public Scrobbler(ISessionManager sessionManager, ILogger<Scrobbler> logger) public Task RunAsync() { - _sessionManager.PlaybackStopped += KernelPlaybackStopped; + _sessionManager.PlaybackStopped += OnPlaybackStopped; return Task.CompletedTask; } - private async void KernelPlaybackStopped(object sender, PlaybackStopEventArgs e) + private async void OnPlaybackStopped(object sender, PlaybackStopEventArgs e) { if (!Plugin.Instance.Configuration.UpdateWatchedStatus) return; @@ -53,7 +53,7 @@ private async void KernelPlaybackStopped(object sender, PlaybackStopEventArgs e) public void Dispose() { - _sessionManager.PlaybackStopped -= KernelPlaybackStopped; + _sessionManager.PlaybackStopped -= OnPlaybackStopped; } } } \ No newline at end of file From 05b6f462b0deb7a4bd4defc034332b0b1776bf4b Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Tue, 1 Jun 2021 22:34:51 +0530 Subject: [PATCH 0080/1103] Add season and episode number to scrobbler logs --- Shokofin/Scrobbler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Scrobbler.cs b/Shokofin/Scrobbler.cs index 9c19c526..fefa6bce 100644 --- a/Shokofin/Scrobbler.cs +++ b/Shokofin/Scrobbler.cs @@ -41,7 +41,7 @@ private async void OnPlaybackStopped(object sender, PlaybackStopEventArgs e) var episodeId = episode.GetProviderId("Shoko Episode"); _logger.LogInformation("Shoko Scrobbler... Item is played. Marking as watched on Shoko"); - _logger.LogInformation($"{episode.SeriesName} - {episode.Name} ({episodeId})"); + _logger.LogInformation($"{episode.SeriesName} S{episode.Season.IndexNumber}E{episode.IndexNumber} - {episode.Name} ({episodeId})"); var result = await ShokoAPI.MarkEpisodeWatched(episodeId); if (result) From fece5cfaffd06b96af226529c2bc90dc74c86375 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Tue, 1 Jun 2021 22:41:05 +0530 Subject: [PATCH 0081/1103] Run scrobbler only for Shoko recognized files --- Shokofin/Scrobbler.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Shokofin/Scrobbler.cs b/Shokofin/Scrobbler.cs index fefa6bce..0ac1f4dc 100644 --- a/Shokofin/Scrobbler.cs +++ b/Shokofin/Scrobbler.cs @@ -36,6 +36,12 @@ private async void OnPlaybackStopped(object sender, PlaybackStopEventArgs e) return; } + if (!e.Item.HasProviderId("Shoko Episode")) + { + _logger.LogError("Shoko Scrobbler... Unrecognized file"); + return; // Skip if file does exist in Shoko + } + if (e.Item is Episode episode && e.PlayedToCompletion) { var episodeId = episode.GetProviderId("Shoko Episode"); From 7d43cbdb89319351daeb9935f879783053e75998 Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Tue, 1 Jun 2021 17:14:43 +0000 Subject: [PATCH 0082/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index 2c32e04d..b0c5fda7 100644 --- a/manifest.json +++ b/manifest.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.4.7", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.7/shokofin_1.4.7.zip", + "checksum": "5a9e396ac1775d61cb14796eae6e8f8a", + "timestamp": "2021-06-01T17:14:41Z" + }, { "version": "1.4.6", "changelog": "NA", From ce0275d2142d9700dcc4bced8a68e8688c2a26c7 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Tue, 1 Jun 2021 22:59:00 +0530 Subject: [PATCH 0083/1103] Remove log prefixes --- Shokofin/Providers/BoxSetProvider.cs | 12 ++++++------ Shokofin/Providers/EpisodeProvider.cs | 6 +++--- Shokofin/Providers/MovieProvider.cs | 8 ++++---- Shokofin/Providers/SeriesProvider.cs | 8 ++++---- Shokofin/Scrobbler.cs | 10 +++++----- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 5abc3a14..2676464a 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -35,7 +35,7 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat var dirname = Path.DirectorySeparatorChar + info.Path.Split(Path.DirectorySeparatorChar).Last(); - _logger.LogInformation($"Shoko Scanner... Getting series ID ({dirname})"); + _logger.LogInformation($"Getting series ID ({dirname})"); var apiResponse = await ShokoAPI.GetSeriesPathEndsWith(dirname); var seriesIDs = apiResponse.FirstOrDefault()?.IDs; @@ -43,22 +43,22 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat if (string.IsNullOrEmpty(seriesId)) { - _logger.LogInformation("Shoko Scanner... BoxSet not found!"); + _logger.LogInformation("BoxSet not found!"); return result; } - _logger.LogInformation($"Shoko Scanner... Getting series metadata ({dirname} - {seriesId})"); + _logger.LogInformation($"Getting series metadata ({dirname} - {seriesId})"); var aniDbInfo = await ShokoAPI.GetSeriesAniDb(seriesId); if (aniDbInfo.Type != "Movie") { - _logger.LogInformation("Shoko Scanner... series was not a movie! Skipping."); + _logger.LogInformation("series was not a movie! Skipping."); return result; } var seriesInfo = await ShokoAPI.GetSeries(seriesId); if (seriesInfo.Sizes.Total.Episodes <= 1) { - _logger.LogInformation("Shoko Scanner... series did not contain multiple movies! Skipping."); + _logger.LogInformation("series did not contain multiple movies! Skipping."); return result; } var tags = await ShokoAPI.GetSeriesTags(seriesId, Helper.GetTagFilter()); @@ -89,7 +89,7 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) { - _logger.LogInformation($"Shoko Scanner... Searching BoxSet ({searchInfo.Name})"); + _logger.LogInformation($"Searching BoxSet ({searchInfo.Name})"); var searchResults = await ShokoAPI.SeriesSearch(searchInfo.Name); if (searchResults.Count() == 0) searchResults = await ShokoAPI.SeriesStartsWith(searchInfo.Name); diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index bcecdb6c..f6f4d881 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -37,7 +37,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell Path.GetDirectoryName(info.Path)?.Split(Path.DirectorySeparatorChar).LastOrDefault(), Path.GetFileName(info.Path)); - _logger.LogInformation($"Shoko Scanner... Getting episode ID ({filename})"); + _logger.LogInformation($"Getting episode ID ({filename})"); var apiResponse = await ShokoAPI.GetFilePathEndsWith(filename); var file = apiResponse.FirstOrDefault(); @@ -49,11 +49,11 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell if (string.IsNullOrEmpty(fileId) || string.IsNullOrEmpty(seriesId) || string.IsNullOrEmpty(episodeId)) { - _logger.LogInformation($"Shoko Scanner... Episode not found! ({filename})"); + _logger.LogInformation($"Episode not found! ({filename})"); return result; } - _logger.LogInformation($"Shoko Scanner... Getting episode metadata ({filename} - {episodeId})"); + _logger.LogInformation($"Getting episode metadata ({filename} - {episodeId})"); var seriesInfo = await ShokoAPI.GetSeriesAniDb(seriesId); var episode = await ShokoAPI.GetEpisode(episodeId); diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 657e033d..4d9e9d63 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -39,7 +39,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio Path.GetDirectoryName(info.Path)?.Split(Path.DirectorySeparatorChar).LastOrDefault(), Path.GetFileName(info.Path)); - _logger.LogInformation($"Shoko Scanner... Getting movie ID ({filename})"); + _logger.LogInformation($"Getting movie ID ({filename})"); var apiResponse = await ShokoAPI.GetFilePathEndsWith(filename); var file = apiResponse?.FirstOrDefault(); @@ -51,11 +51,11 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio if (string.IsNullOrEmpty(episodeId) || string.IsNullOrEmpty(seriesId)) { - _logger.LogInformation($"Shoko Scanner... File not found! ({filename})"); + _logger.LogInformation($"File not found! ({filename})"); return result; } - _logger.LogInformation($"Shoko Scanner... Getting movie metadata ({filename} - {episodeId})"); + _logger.LogInformation($"Getting movie metadata ({filename} - {episodeId})"); var seriesAniDB = await ShokoAPI.GetSeriesAniDb(seriesId); var series = await ShokoAPI.GetSeries(seriesId); @@ -66,7 +66,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio if (seriesAniDB?.Type != "Movie") { - _logger.LogInformation($"Shoko Scanner... File found, but not a movie! Skipping."); + _logger.LogInformation($"File found, but not a movie! Skipping."); return result; } diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index b9c3031c..291c766d 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -36,7 +36,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat var dirname = Path.DirectorySeparatorChar + info.Path.Split(Path.DirectorySeparatorChar).Last(); - _logger.LogInformation($"Shoko Scanner... Getting series ID ({dirname})"); + _logger.LogInformation($"Getting series ID ({dirname})"); var apiResponse = await ShokoAPI.GetSeriesPathEndsWith(dirname); var seriesIDs = apiResponse.FirstOrDefault()?.IDs; @@ -44,11 +44,11 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat if (string.IsNullOrEmpty(seriesId)) { - _logger.LogInformation("Shoko Scanner... Series not found!"); + _logger.LogInformation("Series not found!"); return result; } - _logger.LogInformation($"Shoko Scanner... Getting series metadata ({dirname} - {seriesId})"); + _logger.LogInformation($"Getting series metadata ({dirname} - {seriesId})"); var seriesInfo = await ShokoAPI.GetSeries(seriesId); var aniDbSeriesInfo = await ShokoAPI.GetSeriesAniDb(seriesId); @@ -96,7 +96,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) { - _logger.LogInformation($"Shoko Scanner... Searching Series ({searchInfo.Name})"); + _logger.LogInformation($"Searching Series ({searchInfo.Name})"); var searchResults = await ShokoAPI.SeriesSearch(searchInfo.Name); if (searchResults.Count() == 0) searchResults = await ShokoAPI.SeriesStartsWith(searchInfo.Name); diff --git a/Shokofin/Scrobbler.cs b/Shokofin/Scrobbler.cs index 0ac1f4dc..c90a8b46 100644 --- a/Shokofin/Scrobbler.cs +++ b/Shokofin/Scrobbler.cs @@ -32,13 +32,13 @@ private async void OnPlaybackStopped(object sender, PlaybackStopEventArgs e) if (e.Item == null) { - _logger.LogError("Shoko Scrobbler... Event details incomplete. Cannot process current media"); + _logger.LogError("Event details incomplete. Cannot process current media"); return; } if (!e.Item.HasProviderId("Shoko Episode")) { - _logger.LogError("Shoko Scrobbler... Unrecognized file"); + _logger.LogError("Unrecognized file"); return; // Skip if file does exist in Shoko } @@ -46,14 +46,14 @@ private async void OnPlaybackStopped(object sender, PlaybackStopEventArgs e) { var episodeId = episode.GetProviderId("Shoko Episode"); - _logger.LogInformation("Shoko Scrobbler... Item is played. Marking as watched on Shoko"); + _logger.LogInformation("Item is played. Marking as watched on Shoko"); _logger.LogInformation($"{episode.SeriesName} S{episode.Season.IndexNumber}E{episode.IndexNumber} - {episode.Name} ({episodeId})"); var result = await ShokoAPI.MarkEpisodeWatched(episodeId); if (result) - _logger.LogInformation("Shoko Scrobbler... Episode marked as watched!"); + _logger.LogInformation("Episode marked as watched!"); else - _logger.LogError("Shoko Scrobbler... Error marking episode as watched!"); + _logger.LogError("Error marking episode as watched!"); } } From 1d1a3d8ae51e2cfa1dbf4dd71b10cb976d9aca3c Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 2 Jun 2021 15:08:15 +0530 Subject: [PATCH 0084/1103] Release dailies automatically through manifest-unstable.json --- .github/workflows/build.yml | 31 --------------- .github/workflows/release-daily.yml | 58 +++++++++++++++++++++++++++++ .github/workflows/release.yml | 2 +- build_plugin.py | 35 +++++++++++++++++ build_plugin.sh | 58 ----------------------------- manifest-unstable.json | 11 ++++++ 6 files changed, 105 insertions(+), 90 deletions(-) delete mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release-daily.yml create mode 100644 build_plugin.py delete mode 100644 build_plugin.sh create mode 100644 manifest-unstable.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index edf3d1c7..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Build CLI - -on: - push: - branches: [ master ] -# pull_request: -# branches: [ master ] - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - # rid: ['win-x64', 'linux-x64'] - dotnet: [ '5.0.x' ] - - name: build - - steps: - - uses: actions/checkout@v2 - - name: Setup dotnet - uses: actions/setup-dotnet@v1 - with: - dotnet-version: ${{ matrix.dotnet }} - - run: dotnet restore Shokofin/Shokofin.csproj - - run: dotnet publish -c Release Shokofin/Shokofin.csproj - - uses: actions/upload-artifact@v2 - with: - name: Shokofin - path: Shokofin/bin/Release/net5.0/Shokofin.dll diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml new file mode 100644 index 00000000..e13e95ea --- /dev/null +++ b/.github/workflows/release-daily.yml @@ -0,0 +1,58 @@ +name: Daily Release + +on: + push: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + name: Build & Release Daily Version + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Setup dotnet + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 5.0.x + + - name: Restore nuget packages + run: dotnet restore Shokofin/Shokofin.csproj + + - name: Setup python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install JPRM + run: python -m pip install jprm + + - name: Get previous release version + id: previoustag + uses: "WyriHaximus/github-action-get-previous-tag@v1" + with: + fallback: 1.0.0 # Optional fallback tag to use when no tag can be found + + - name: Run JPRM + run: echo "NEW_VERSION=$(python build_plugin.py --version=${{ steps.previoustag.outputs.tag }} --prerelease=True)" >> $GITHUB_ENV + + - name: Release + uses: softprops/action-gh-release@v1 + with: + files: ./artifacts/shokofin_*.zip + tag_name: ${{ env.NEW_VERSION }} + prerelease: true + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update manifest + uses: stefanzweifel/git-auto-commit-action@v4 + with: + branch: master + commit_message: Update unstable repo manifest + file_pattern: manifest-unstable.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e8df2dc..2befd2fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - name: Install JPRM run: python -m pip install jprm - name: Run JPRM - run: bash build_plugin.sh ${GITHUB_REF#refs/*/} + run: python build_plugin.py --version=${GITHUB_REF#refs/*/} - name: Update release uses: svenstaro/upload-release-action@v2 with: diff --git a/build_plugin.py b/build_plugin.py new file mode 100644 index 00000000..8d8a1e39 --- /dev/null +++ b/build_plugin.py @@ -0,0 +1,35 @@ +import os +import sys +import argparse + +parser = argparse.ArgumentParser() +parser.add_argument('--version', required=True) +parser.add_argument('--prerelease') +opts = parser.parse_args() + +version = opts.version +prerelease = bool(opts.prerelease) + +artifact_dir = os.path.join(os.getcwd(), 'artifacts') +os.mkdir(artifact_dir) + +if prerelease: + jellyfin_repo_file="./manifest-unstable.json" + version_list = version.split('.') + if len(version_list) == 3: + version_list.append('1') + else: + version_list[3] = str(int(version_list[3]) + 1) + version = '.'.join(version_list) +else: + jellyfin_repo_file="./manifest.json" + +jellyfin_repo_url="https://github.com/ShokoAnime/Shokofin/releases/download" + +zipfile=os.popen('jprm --verbosity=debug plugin build "." --output="%s" --version="%s" --dotnet-framework="net5.0"' % (artifact_dir, version)).read().strip() + +os.system('jprm repo add --url=%s %s %s' % (jellyfin_repo_url, jellyfin_repo_file, zipfile)) + +os.system('sed -i "s/shokofin\//%s\//" %s' % (version, jellyfin_repo_file)) + +print(version) diff --git a/build_plugin.sh b/build_plugin.sh deleted file mode 100644 index 82bdce86..00000000 --- a/build_plugin.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash -# -# Copyright (c) 2020 - Odd Strabo <oddstr13@openshell.no> -# -# -# The Unlicense -# ============= -# -# This is free and unencumbered software released into the public domain. -# -# Anyone is free to copy, modify, publish, use, compile, sell, or -# distribute this software, either in source code form or as a compiled -# binary, for any purpose, commercial or non-commercial, and by any -# means. -# -# In jurisdictions that recognize copyright laws, the author or authors -# of this software dedicate any and all copyright interest in the -# software to the public domain. We make this dedication for the benefit -# of the public at large and to the detriment of our heirs and -# successors. We intend this dedication to be an overt act of -# relinquishment in perpetuity of all present and future rights to this -# software under copyright law. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -# OTHER DEALINGS IN THE SOFTWARE. -# -# For more information, please refer to <http://unlicense.org/> -# - -MY=$(dirname $(realpath -s "${0}")) -JPRM="jprm" - -DEFAULT_REPO_DIR="./manifest.json" -DEFAULT_REPO_URL="https://github.com/ShokoAnime/Shokofin/releases/download" - -PLUGIN=. - -ARTIFACT_DIR=${ARTIFACT_DIR:-"${MY}/artifacts"} -mkdir -p "${ARTIFACT_DIR}" - -JELLYFIN_REPO=${JELLYFIN_REPO:-${DEFAULT_REPO_DIR}} -JELLYFIN_REPO_URL=${JELLYFIN_REPO_URL:-${DEFAULT_REPO_URL}} - -meta_version=$(grep -Po '^ *version: * "*\K[^"$]+' "${PLUGIN}/build.yaml") -VERSION=$1 - -zipfile=$($JPRM --verbosity=debug plugin build "${PLUGIN}" --output="${ARTIFACT_DIR}" --version="${VERSION}" --dotnet-framework="net5.0") && { - $JPRM repo add --url=${JELLYFIN_REPO_URL} "${JELLYFIN_REPO}" "${zipfile}" -} - -sed -i "s/shokofin\//${VERSION}\//" "${JELLYFIN_REPO}" - -exit $? diff --git a/manifest-unstable.json b/manifest-unstable.json new file mode 100644 index 00000000..29a80de0 --- /dev/null +++ b/manifest-unstable.json @@ -0,0 +1,11 @@ +[ + { + "guid": "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", + "name": "Shokofin", + "description": "This plugin gets metadata for all your anime from your Shoko Server. The official Anime and TvDB plugins can be used to fill the missing data.\n", + "overview": "Manage your anime from Jellyfin using metadata from Shoko", + "owner": "shoko", + "category": "Metadata", + "versions": [] + } +] \ No newline at end of file From 933cc66b7924eed5e50e25dc732b8d4540a146b8 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 2 Jun 2021 15:19:47 +0530 Subject: [PATCH 0085/1103] Update manifest properly for daily release --- .github/workflows/release-daily.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index e13e95ea..782c8704 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -56,3 +56,4 @@ jobs: branch: master commit_message: Update unstable repo manifest file_pattern: manifest-unstable.json + skip_fetch: true From 52365bcb499da8327d40f8cce20ece02b547edd9 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 2 Jun 2021 15:28:49 +0530 Subject: [PATCH 0086/1103] Fix manifest-unstable.json(?) --- manifest-unstable.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 29a80de0..1378a50e 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -1,11 +1,11 @@ -[ - { - "guid": "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", - "name": "Shokofin", - "description": "This plugin gets metadata for all your anime from your Shoko Server. The official Anime and TvDB plugins can be used to fill the missing data.\n", - "overview": "Manage your anime from Jellyfin using metadata from Shoko", - "owner": "shoko", - "category": "Metadata", - "versions": [] - } +[ + { + "guid": "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", + "name": "Shokofin", + "description": "This plugin gets metadata for all your anime from your Shoko Server. The official Anime and TvDB plugins can be used to fill the missing data.\n", + "overview": "Manage your anime from Jellyfin using metadata from Shoko", + "owner": "shoko", + "category": "Metadata", + "versions": [] + } ] \ No newline at end of file From f729a6bbadb668d06601904b16abcd9321b3e824 Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Wed, 2 Jun 2021 10:00:44 +0000 Subject: [PATCH 0087/1103] Update unstable repo manifest --- manifest-unstable.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 1378a50e..134e954f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -6,6 +6,15 @@ "overview": "Manage your anime from Jellyfin using metadata from Shoko", "owner": "shoko", "category": "Metadata", - "versions": [] + "versions": [ + { + "version": "1.4.7.3", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.7.3/shokofin_1.4.7.3.zip", + "checksum": "d505259dbce9deebe9aeea1cf15ea661", + "timestamp": "2021-06-02T10:00:43Z" + } + ] } ] \ No newline at end of file From 51a495035a662b9f88e18a321355ad507f55d90f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 6 Oct 2020 20:09:46 +0200 Subject: [PATCH 0088/1103] Refactor and added features ## Changes - Refactored Helper.cs into three new seperate classes, under a new namespace `Shokofin.Utils`. Streamlined how to get data from the api, by going through new helpers instead of directly using the api _everytime_. - `Shokofin.Utils.DataUtil` - holds helpers for getting data - `Shokofin.Utils.OrderingUtil` - holds helpers for ordering episodes - `Shokofin.Utils.TextUtil` - holds helpers related to text - Updated the configuration page layout - Added Series grouping based on Shoko's group feature - Seasons in the grouped series can be ordered by release-date or in chonological order. ## To-do - Depends on missing feature: relations in group --- Shokofin/API/Models/Group.cs | 4 +- Shokofin/API/Models/Relation.cs | 94 +++++ Shokofin/API/ShokoAPI.cs | 44 +- Shokofin/Configuration/PluginConfiguration.cs | 20 +- Shokofin/Configuration/configPage.html | 257 ++++++------ Shokofin/ExternalIds.cs | 18 + Shokofin/Providers/BoxSetProvider.cs | 57 ++- Shokofin/Providers/EpisodeProvider.cs | 97 ++--- Shokofin/Providers/Helper.cs | 224 ---------- Shokofin/Providers/ImageProvider.cs | 53 +-- Shokofin/Providers/MovieProvider.cs | 88 ++-- Shokofin/Providers/SeasonProvider.cs | 65 +++ Shokofin/Providers/SeriesProvider.cs | 144 ++++--- Shokofin/Utils/DataUtil.cs | 389 ++++++++++++++++++ Shokofin/Utils/OrderingUtil.cs | 153 +++++++ Shokofin/Utils/TextUtil.cs | 176 ++++++++ 16 files changed, 1292 insertions(+), 591 deletions(-) create mode 100644 Shokofin/API/Models/Relation.cs delete mode 100644 Shokofin/Providers/Helper.cs create mode 100644 Shokofin/Utils/DataUtil.cs create mode 100644 Shokofin/Utils/OrderingUtil.cs create mode 100644 Shokofin/Utils/TextUtil.cs diff --git a/Shokofin/API/Models/Group.cs b/Shokofin/API/Models/Group.cs index 7bb7eeb7..51e88a19 100644 --- a/Shokofin/API/Models/Group.cs +++ b/Shokofin/API/Models/Group.cs @@ -8,9 +8,9 @@ public class Group : BaseModel public class GroupIDs : IDs { - public int DefaultSeries { get; set; } + public int? DefaultSeries { get; set; } - public int ParentGroup { get; set; } + public int? ParentGroup { get; set; } } } } diff --git a/Shokofin/API/Models/Relation.cs b/Shokofin/API/Models/Relation.cs new file mode 100644 index 00000000..4e3abe21 --- /dev/null +++ b/Shokofin/API/Models/Relation.cs @@ -0,0 +1,94 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models +{ + /// <summary> + /// Describes relations between series. + /// </summary> + public class Relation + { + /// <summary> + /// Relation from ID + /// </summary> + public int FromID { get; set; } + + /// <summary> + /// Relation to ID + /// </summary> + public int ToID { get; set; } + + /// <summary> + /// The relation between `FromID` and `ToID` + /// </summary> + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public RelationType Type { get; set; } + + /// <summary> + /// If the relation is valid both ways, or if the relation is only valid one way + /// </summary> + /// <value></value> + [Required] + public bool IsBiDirectional { get; set; } + + /// <summary> + /// AniDB, etc + /// </summary> + [Required] + public string Source { get; set; } + + + + /// <summary> + /// Explains how the first entry relates to the second entry. + /// </summary> + public enum RelationType + { + /// <summary> + /// The relation between the entries cannot be explained in simple terms. + /// </summary> + Other = 1, + + /// <summary> + /// The entries use the same setting, but follow different stories. + /// </summary> + SameSetting = 2, + + /// <summary> + /// The entries use the same base story, but is set in alternate settings. + /// </summary> + AlternativeSetting = 3, + + /// <summary> + /// The entries tell different stories but shares some character(s). + /// </summary> + SharedCharacters = 4, + + /// <summary> + /// The entries tell the same story, with their differences. + /// </summary> + AlternativeVersion = 5, + + /// <summary> + /// The second entry either continues, or expands upon the story of the first entry. + /// </summary> + Sequel = 50, + + /// <summary> + /// The second entry is a side-story for the first entry, which is the main-story. + /// </summary> + SideStory = 51, + + /// <summary> + /// The second entry summerizes the events of the story in the first entry. + /// </summary> + Summary = 52, + + /// <summary> + /// The second entry is a later production of the story in the first story, often + /// </summary> + Reboot = 53, + } + } +} \ No newline at end of file diff --git a/Shokofin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPI.cs index 54b23aae..26364b94 100644 --- a/Shokofin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPI.cs @@ -78,6 +78,12 @@ public static async Task<Episode> GetEpisode(string id) return responseStream != null ? await JsonSerializer.DeserializeAsync<Episode>(responseStream) : null; } + public static async Task<List<Episode>> GetEpisodeFromFile(string id) + { + var responseStream = await CallApi($"/api/v3/File/{id}/Episode"); + return responseStream != null ? await JsonSerializer.DeserializeAsync<List<Episode>>(responseStream) : null; + } + public static async Task<Episode.AniDB> GetEpisodeAniDb(string id) { var responseStream = await CallApi($"/api/v3/Episode/{id}/AniDB"); @@ -89,8 +95,14 @@ public static async Task<Episode> GetEpisode(string id) var responseStream = await CallApi($"/api/v3/Episode/{id}/TvDB"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Episode.TvDB>>(responseStream) : null; } + + public static async Task<File> GetFile(string id) + { + var responseStream = await CallApi($"/api/v3/File/{id}"); + return responseStream != null ? await JsonSerializer.DeserializeAsync<File>(responseStream) : null; + } - public static async Task<IEnumerable<File.FileDetailed>> GetFilePathEndsWith(string filename) + public static async Task<IEnumerable<File.FileDetailed>> GetFileByPath(string filename) { var responseStream = await CallApi($"/api/v3/File/PathEndsWith/{Uri.EscapeDataString(filename)}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<File.FileDetailed>>(responseStream) : null; @@ -101,6 +113,18 @@ public static async Task<Series> GetSeries(string id) var responseStream = await CallApi($"/api/v3/Series/{id}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<Series>(responseStream) : null; } + + public static async Task<Series> GetSeriesFromEpisode(string id) + { + var responseStream = await CallApi($"/api/v3/Episode/{id}/Series"); + return responseStream != null ? await JsonSerializer.DeserializeAsync<Series>(responseStream) : null; + } + + public static async Task<List<Series>> GetSeriesInGroup(string id) + { + var responseStream = await CallApi($"/api/v3/Filter/0/Group/{id}/Series"); + return responseStream != null ? await JsonSerializer.DeserializeAsync<List<Series>>(responseStream) : null; + } public static async Task<Series.AniDB> GetSeriesAniDb(string id) { @@ -131,6 +155,24 @@ public static async Task<IEnumerable<Tag>> GetSeriesTags(string id, int filter = var responseStream = await CallApi($"/api/v3/Series/{id}/Tags/{filter}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Tag>>(responseStream) : null; } + + public static async Task<Group> GetGroup(string id) + { + var responseStream = await CallApi($"/api/v3/Group/{id}"); + return responseStream != null ? await JsonSerializer.DeserializeAsync<Group>(responseStream) : null; + } + + public static async Task<Group> GetGroupFromSeries(string id) + { + var responseStream = await CallApi($"/api/v3/Series/{id}/Group"); + return responseStream != null ? await JsonSerializer.DeserializeAsync<Group>(responseStream) : null; + } + + public static async Task<List<Relation>> GetRelationsInGroup(string id) + { + var responseStream = await CallApi($"/api/v3/Filter/0/Group/{id}/Relations"); + return responseStream != null ? await JsonSerializer.DeserializeAsync<List<Relation>>(responseStream) : null; + } public static async Task<bool> MarkEpisodeWatched(string id) { diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 57050921..4140377a 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,4 +1,7 @@ using MediaBrowser.Model.Plugins; +using DisplayLanguageType = Shokofin.Utils.TextUtil.DisplayLanguageType; +using SeriesGroupType = Shokofin.Utils.OrderingUtil.SeriesGroupType; +using SeasonOrderType = Shokofin.Utils.OrderingUtil.SeasonOrderType; namespace Shokofin.Configuration { @@ -16,10 +19,6 @@ public class PluginConfiguration : BasePluginConfiguration public bool UpdateWatchedStatus { get; set; } - public bool UseTvDbSeasonOrdering { get; set; } - - public bool UseShokoThumbnails { get; set; } - public bool HideArtStyleTags { get; set; } public bool HideSourceTags { get; set; } @@ -38,13 +37,9 @@ public class PluginConfiguration : BasePluginConfiguration public bool SynopsisCleanMultiEmptyLines { get; set; } - public enum DisplayLanguageType { - Default, - MetadataPreferred, - Origin, - } + public SeriesGroupType SeriesGrouping { get; set; } - public bool TitleUseAlternate { get; set; } + public SeasonOrderType SeasonOrdering { get; set; } public DisplayLanguageType TitleMainType { get; set; } @@ -58,8 +53,6 @@ public PluginConfiguration() Password = ""; ApiKey = ""; UpdateWatchedStatus = false; - UseTvDbSeasonOrdering = false; - UseShokoThumbnails = true; HideArtStyleTags = false; HideSourceTags = false; HideMiscTags = false; @@ -69,9 +62,10 @@ public PluginConfiguration() SynopsisCleanMiscLines = true; SynopsisRemoveSummary = true; SynopsisCleanMultiEmptyLines = true; - TitleUseAlternate = true; TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; + SeriesGrouping = SeriesGroupType.Default; + SeasonOrdering = SeasonOrderType.ReleaseDate; } } } \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 03d63297..19aa5f1a 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -5,115 +5,128 @@ <title>Shoko -
-
-
-
-
- -
This is the IP address of the server where Shoko is running.
-
-
- -
This is the port on which Shoko is running.
-
-
- -
-
- -
-
- -
This field is auto-generated using the credentials. Only set this manually if that doesn't work!
-
-
- - -
Titles will fallback to Default if not found for the target language.
-
-
- - -
Titles will fallback to Default if not found for the target language.
-
-
- -
Will populate the "Original Title" field with the alternate title.
-
- - - - - - - - - - - - -
- -
-
+
+
+
+
+
+
+

Shoko

+ Help +
+

Connection Options

+
+ +
This is the IP address of the server where Shoko is running.
+
+
+ +
This is the port on which Shoko is running.
+
+
+ +
+
+ +
+
+ +
This field is auto-generated using the credentials. Only set this manually if that doesn't work!
+
+

Title Options

+
+ + +
Titles will fallback to Default if not found for the target language.
+
+
+ + +
Titles will fallback to Default if not found for the target language.
+
+

Synopsis Options

+ + + + +

Library Options

+
+ + +
+
+ + +
+ +

Tag Options

+ + + + + +
+ +
+
+
+
-
- -
+
diff --git a/Shokofin/ExternalIds.cs b/Shokofin/ExternalIds.cs index 6bca3314..6b9a387b 100644 --- a/Shokofin/ExternalIds.cs +++ b/Shokofin/ExternalIds.cs @@ -6,6 +6,24 @@ namespace Shokofin { + public class ShokoGroupExternalId : IExternalId + { + public bool Supports(IHasProviderIds item) + => item is Series; + + public string ProviderName + => "Shoko Group"; + + public string Key + => "Shoko Group"; + + public ExternalIdMediaType? Type + => null; + + public string UrlFormatString + => null; + } + public class ShokoSeriesExternalId : IExternalId { public bool Supports(IHasProviderIds item) diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 2676464a..3036e456 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Net.Http; using System.Threading; @@ -11,6 +10,7 @@ using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; +using Shokofin.Utils; namespace Shokofin.Providers { @@ -32,50 +32,45 @@ public async Task> GetMetadata(BoxSetInfo info, Cancellat try { var result = new MetadataResult(); + var (id, series) = await DataUtil.GetSeriesInfoByPath(info.Path); - var dirname = Path.DirectorySeparatorChar + info.Path.Split(Path.DirectorySeparatorChar).Last(); - - _logger.LogInformation($"Getting series ID ({dirname})"); - - var apiResponse = await ShokoAPI.GetSeriesPathEndsWith(dirname); - var seriesIDs = apiResponse.FirstOrDefault()?.IDs; - var seriesId = seriesIDs?.ID.ToString(); - - if (string.IsNullOrEmpty(seriesId)) + if (series == null) { - _logger.LogInformation("BoxSet not found!"); + _logger.LogWarning($"Unable to find series info for path {info.Path}"); return result; } - _logger.LogInformation($"Getting series metadata ({dirname} - {seriesId})"); + _logger.LogInformation($"Getting series metadata ({info.Path} - {series.ID})"); + + int aniDBId = series.AniDB.ID; + var tvdbId = series?.TvDBID; - var aniDbInfo = await ShokoAPI.GetSeriesAniDb(seriesId); - if (aniDbInfo.Type != "Movie") + if (series.AniDB.Type != "Movie") { - _logger.LogInformation("series was not a movie! Skipping."); + _logger.LogWarning("Series found, but not a movie! Skipping."); return result; } - var seriesInfo = await ShokoAPI.GetSeries(seriesId); - if (seriesInfo.Sizes.Total.Episodes <= 1) + if (series.Shoko.Sizes.Total.Episodes <= 1) { - _logger.LogInformation("series did not contain multiple movies! Skipping."); + _logger.LogWarning("Series did not contain multiple movies! Skipping."); return result; } - var tags = await ShokoAPI.GetSeriesTags(seriesId, Helper.GetTagFilter()); - var ( displayTitle, alternateTitle ) = Helper.GetSeriesTitles(aniDbInfo.Titles, aniDbInfo.Title, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); + var ( displayTitle, alternateTitle ) = TextUtil.GetSeriesTitles(series.AniDB.Titles, series.AniDB.Title, info.MetadataLanguage); + var tags = await DataUtil.GetTags(series.ID); + result.Item = new BoxSet { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Helper.SummarySanitizer(aniDbInfo.Description), - PremiereDate = aniDbInfo.AirDate, - EndDate = aniDbInfo.EndDate, - ProductionYear = aniDbInfo.AirDate?.Year, - Tags = tags?.Select(tag => tag.Name).ToArray() ?? new string[0], - CommunityRating = (float)((aniDbInfo.Rating.Value * 10) / aniDbInfo.Rating.MaxValue) + Overview = TextUtil.SummarySanitizer(series.AniDB.Description), + PremiereDate = series.AniDB.AirDate, + EndDate = series.AniDB.EndDate, + ProductionYear = series.AniDB.AirDate?.Year, + Tags = tags, + CommunityRating = (float)((series.AniDB.Rating.Value * 10) / series.AniDB.Rating.MaxValue) }; - result.Item.SetProviderId("Shoko Series", seriesId); + result.Item.SetProviderId("Shoko Series", series.ID); result.HasMetadata = true; return result; @@ -91,14 +86,14 @@ public async Task> GetSearchResults(BoxSetInfo s { _logger.LogInformation($"Searching BoxSet ({searchInfo.Name})"); var searchResults = await ShokoAPI.SeriesSearch(searchInfo.Name); - + if (searchResults.Count() == 0) searchResults = await ShokoAPI.SeriesStartsWith(searchInfo.Name); - + var results = new List(); foreach (var series in searchResults) { - var imageUrl = Helper.GetImageUrl(series.Images.Posters.FirstOrDefault()); + var imageUrl = DataUtil.GetImageUrl(series.Images.Posters.FirstOrDefault()); _logger.LogInformation(imageUrl); var parsedBoxSet = new RemoteSearchResult { @@ -119,4 +114,4 @@ public Task GetImageResponse(string url, CancellationToken return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } -} \ No newline at end of file +} diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index f6f4d881..8d66373b 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -10,7 +8,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; -using Shokofin.API; +using Shokofin.Utils; namespace Shokofin.Providers { @@ -31,52 +29,48 @@ public async Task> GetMetadata(EpisodeInfo info, Cancell try { var result = new MetadataResult(); - - // TO-DO Check if it can be written in a better way. Parent directory + File Name - var filename = Path.Join( - Path.GetDirectoryName(info.Path)?.Split(Path.DirectorySeparatorChar).LastOrDefault(), - Path.GetFileName(info.Path)); - _logger.LogInformation($"Getting episode ID ({filename})"); + var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == OrderingUtil.SeriesGroupType.ShokoGroup; + var (id, file, episode, series, group) = await DataUtil.GetFileInfoByPath(info.Path, includeGroup); - var apiResponse = await ShokoAPI.GetFilePathEndsWith(filename); - var file = apiResponse.FirstOrDefault(); - var fileId = file?.ID.ToString(); - var series = file?.SeriesIDs.FirstOrDefault(); - var seriesId = series?.SeriesID.ID.ToString(); - var episodeIDs = series?.EpisodeIDs?.FirstOrDefault(); - var episodeId = episodeIDs?.ID.ToString(); - - if (string.IsNullOrEmpty(fileId) || string.IsNullOrEmpty(seriesId) || string.IsNullOrEmpty(episodeId)) + if (file == null) // if file is null then series and episode is also null. { - _logger.LogInformation($"Episode not found! ({filename})"); + _logger.LogWarning($"Unable to find file info for path {id}"); return result; } + _logger.LogInformation($"Found file info for path {id}"); - _logger.LogInformation($"Getting episode metadata ({filename} - {episodeId})"); + var extraType = OrderingUtil.GetExtraType(episode.AniDB); + if (extraType != null) + { + _logger.LogDebug($"Not a normal or special episode, skipping path {id}"); + result.HasMetadata = false; + return result; + } + _logger.LogInformation($"Getting episode metadata ({info.Path} - {episode.ID})"); - var seriesInfo = await ShokoAPI.GetSeriesAniDb(seriesId); - var episode = await ShokoAPI.GetEpisode(episodeId); - var episodeInfo = await ShokoAPI.GetEpisodeAniDb(episodeId); - var ( displayTitle, alternateTitle ) = Helper.GetEpisodeTitles(seriesInfo.Titles, episodeInfo.Titles, episode.Name, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); + var ( displayTitle, alternateTitle ) = TextUtil.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, episode.Shoko.Name, info.MetadataLanguage); + int aniDBId = episode.AniDB.ID; + int tvdbId = episode?.TvDB?.ID ?? 0; result.Item = new Episode { - IndexNumber = episodeInfo.EpisodeNumber, - ParentIndexNumber = await GetSeasonNumber(episodeId, episodeInfo.Type), + IndexNumber = OrderingUtil.GetIndexNumber(series, episode), + ParentIndexNumber = OrderingUtil.GetSeasonNumber(group, series, episode), Name = displayTitle, OriginalTitle = alternateTitle, - PremiereDate = episodeInfo.AirDate, - Overview = Helper.SummarySanitizer(episodeInfo.Description), - CommunityRating = (float) ((episodeInfo.Rating.Value * 10) / episodeInfo.Rating.MaxValue) + PremiereDate = episode.AniDB.AirDate, + Overview = TextUtil.SummarySanitizer(episode.AniDB.Description), + CommunityRating = (float) ((episode.AniDB.Rating.Value * 10) / episode.AniDB.Rating.MaxValue) }; - result.Item.SetProviderId("Shoko Episode", episodeId); - result.Item.SetProviderId("Shoko File", fileId); - result.Item.SetProviderId("AniDB", episodeIDs.AniDB.ToString()); + result.Item.SetProviderId("Shoko Episode", episode.ID); + result.Item.SetProviderId("Shoko File", file.ID); + result.Item.SetProviderId("AniDB", aniDBId.ToString()); + if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); result.HasMetadata = true; - var episodeNumberEnd = episodeInfo.EpisodeNumber + series?.EpisodeIDs.Count() - 1; - if (episodeInfo.EpisodeNumber != episodeNumberEnd) result.Item.IndexNumberEnd = episodeNumberEnd; + var episodeNumberEnd = episode.AniDB.EpisodeNumber + episode.OtherEpisodesCount; + if (episode.AniDB.EpisodeNumber != episodeNumberEnd) result.Item.IndexNumberEnd = episodeNumberEnd; return result; } @@ -92,43 +86,10 @@ public Task> GetSearchResults(EpisodeInfo search // Isn't called from anywhere. If it is called, I don't know from where. throw new NotImplementedException(); } - + public Task GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); } - - private async Task GetSeasonNumber(string episodeId, string type) - { - var seasonNumber = 0; - - switch (type) - { - case "Normal": - seasonNumber = 1; - break; - case "ThemeSong": - seasonNumber = 100; - break; - case "Special": - seasonNumber = 0; - break; - case "Trailer": - seasonNumber = 99; - break; - default: - seasonNumber = 98; - break; - } - - if (Plugin.Instance.Configuration.UseTvDbSeasonOrdering && seasonNumber < 98) - { - var tvdbEpisodeInfo = await ShokoAPI.GetEpisodeTvDb(episodeId); - var tvdbSeason = tvdbEpisodeInfo.FirstOrDefault()?.Season; - return tvdbSeason ?? seasonNumber; - } - - return seasonNumber; - } } } diff --git a/Shokofin/Providers/Helper.cs b/Shokofin/Providers/Helper.cs deleted file mode 100644 index a79c9f6c..00000000 --- a/Shokofin/Providers/Helper.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using Shokofin.API.Models; -using DisplayLanguageType = Shokofin.Configuration.PluginConfiguration.DisplayLanguageType; -using MediaBrowser.Model.Entities; - -namespace Shokofin.Providers -{ - public class Helper - { - public static string GetImageUrl(Image image) - { - if (image == null || image.RelativeFilepath.Equals("/")) return null; // No image found - return $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}/api/v3/Image/{image.Source}/{image.Type}/{image.ID}"; - } - - /// - /// Get absolute index number for an episode in a series. - /// - /// Absolute index. - public static int GetAbsoluteIndexNumber(Series series, Episode.AniDB episode) - { - int offset = 0; - switch (episode.Type) - { - case "Normal": - break; - case "Special": - offset += series.Sizes.Total.Episodes; - break; // goto case "Normal"; - case "ThemeSong": - offset += series.Sizes.Total?.Specials ?? 0; - goto case "ThemeSong"; - case "Unknown": - offset += series.Sizes.Total?.Credits ?? 0; - goto case "ThemeSong"; - case "Parody": - offset += series.Sizes.Total?.Others ?? 0; - goto case "Unknown"; - case "Trailer": - offset += series.Sizes.Total?.Parodies ?? 0; - goto case "Parody"; - } - return offset + episode.EpisodeNumber; - } - - public static ExtraType? GetExtraType(Episode.AniDB episode) - { - switch (episode.Type) - { - case "Normal": - return null; - case "ThemeSong": - return ExtraType.ThemeVideo; - case "Trailer": - return ExtraType.Trailer; - case "Special": { - var title = Helper.GetTitleByLanguages(episode.Titles, "en") ?? ""; - // Interview - if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) - return ExtraType.Interview; - // Cinema intro/outro - if (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) && - (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase))) - return ExtraType.Clip; - return null; - } - default: - return null; - } - } - - public static int GetTagFilter() - { - var config = Plugin.Instance.Configuration; - var filter = 0; - - if (config.HideAniDbTags) filter = 1; - if (config.HideArtStyleTags) filter |= (filter << 1); - if (config.HideSourceTags) filter |= (filter << 2); - if (config.HideMiscTags) filter |= (filter << 3); - if (config.HidePlotTags) filter |= (filter << 4); - - return filter; - } - - public static string SummarySanitizer(string summary) // Based on ShokoMetadata which is based on HAMA's - { - var config = Plugin.Instance.Configuration; - - if (config.SynopsisCleanLinks) - summary = Regex.Replace(summary, @"https?:\/\/\w+.\w+(?:\/?\w+)? \[([^\]]+)\]", match => match.Groups[1].Value); - - if (config.SynopsisCleanMiscLines) - summary = Regex.Replace(summary, @"^(\*|--|~) .*", "", RegexOptions.Multiline); - - if (config.SynopsisRemoveSummary) - summary = Regex.Replace(summary, @"\n(Source|Note|Summary):.*", "", RegexOptions.Singleline); - - if (config.SynopsisCleanMultiEmptyLines) - summary = Regex.Replace(summary, @"\n\n+", "", RegexOptions.Singleline); - - return summary; - } - - public enum TitleType { - MainTitle = 1, - SubTitle, - FullTitle, - } - - public static ( string, string ) GetEpisodeTitles(IEnumerable seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, DisplayLanguageType mainLanguage, DisplayLanguageType alternateLanguage, string metadataLanguage) - => GetTitles(seriesTitles, episodeTitles, null, episodeTitle, mainLanguage, alternateLanguage, TitleType.SubTitle, metadataLanguage); - - public static ( string, string ) GetSeriesTitles(IEnumerable<Title> seriesTitles, string seriesTitle, DisplayLanguageType mainLanguage, DisplayLanguageType alternateLanguage, string metadataLanguage) - => GetTitles(seriesTitles, null, seriesTitle, null, mainLanguage, alternateLanguage, TitleType.MainTitle, metadataLanguage); - - public static ( string, string ) GetMovieTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayLanguageType mainLanguage, DisplayLanguageType alternateLanguage, string metadataLanguage) - => GetTitles(seriesTitles, episodeTitles, seriesTitle, episodeTitle, mainLanguage, alternateLanguage, TitleType.FullTitle, metadataLanguage); - - public static ( string, string ) GetTitles(IEnumerable<Title> rSeriesTitles, IEnumerable<Title> rEpisodeTitles, string seriesTitle, string episodeTitle, DisplayLanguageType mainLanguage, DisplayLanguageType alternateLanguage, TitleType outputType, string metadataLanguage) - { - // Don't process anything if the series titles are not provided. - if (rSeriesTitles == null) return ( null, null ); - var seriesTitles = (List<Title>)rSeriesTitles; - var episodeTitles = (List<Title>)rEpisodeTitles; - var originLanguage = GuessOriginLanguage(seriesTitles); - var displayLanguage = metadataLanguage?.ToLower() ?? "en"; - return ( GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, mainLanguage, outputType, displayLanguage, originLanguage), GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, alternateLanguage, outputType, displayLanguage, originLanguage) ); - } - - public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayLanguageType languageType, TitleType outputType, string displayLanguage, params string[] originLanguages) - { - switch (languageType) - { - // Let Shoko decide the title. - case DisplayLanguageType.Default: - return __GetTitle(null, null, seriesTitle, episodeTitle, outputType); - // Display in metadata-preferred language, or fallback to default. - case DisplayLanguageType.MetadataPreferred: - var title = __GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, outputType, displayLanguage); - if (string.IsNullOrEmpty(title)) - goto case DisplayLanguageType.Default; - return title; - // Display in origin language without fallback. - case DisplayLanguageType.Origin: - return __GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, outputType, originLanguages); - default: - return null; - } - } - - internal static string __GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, TitleType outputType, params string[] languageCandidates) - { - // Lazy init string builder when/if we need it. - StringBuilder titleBuilder = null; - switch (outputType) - { - case TitleType.MainTitle: - case TitleType.FullTitle: { - string title = (GetTitleByTypeAndLanguage(seriesTitles, "official", languageCandidates) ?? seriesTitle)?.Trim(); - // Return series title. - if (outputType == TitleType.MainTitle) - return title; - titleBuilder = new StringBuilder(title); - goto case TitleType.SubTitle; - } - case TitleType.SubTitle: { - string title = (GetTitleByLanguages(episodeTitles, languageCandidates) ?? episodeTitle)?.Trim(); - // Return episode title. - if (outputType == TitleType.SubTitle) - return title; - // Ignore sub-title of movie if it strictly equals the text below. - if (title != "Complete Movie") - titleBuilder?.Append($": {title}"); - return titleBuilder?.ToString() ?? ""; - } - default: - return null; - } - } - - public static string GetTitleByTypeAndLanguage(IEnumerable<Title> titles, string type, params string[] langs) - { - if (titles != null) foreach (string lang in langs) - { - string title = titles.FirstOrDefault(s => s.Language == lang && s.Type == type)?.Name; - if (title != null) return title; - } - return null; - } - - public static string GetTitleByLanguages(IEnumerable<Title> titles, params string[] langs) - { - if (titles != null) foreach (string lang in langs) - { - string title = titles.FirstOrDefault(s => lang.Equals(s.Language, System.StringComparison.OrdinalIgnoreCase))?.Name; - if (title != null) return title; - } - return null; - } - - // Guess the origin language based on the main title. - private static string[] GuessOriginLanguage(IEnumerable<Title> seriesTitle) - { - string langCode = seriesTitle.FirstOrDefault(t => t?.Type == "main")?.Language.ToLower(); - // Guess the origin language based on the main title. - switch (langCode) - { - case null: // fallback - case "x-other": - case "x-jat": - return new string[] { "ja" }; - case "x-zht": - return new string[] { "zn-hans", "zn-hant", "zn-c-mcm", "zn" }; - default: - return new string[] { langCode }; - } - - } - } -} diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index f78e087b..07d429db 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -11,6 +10,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; +using Shokofin.Utils; namespace Shokofin.Providers { @@ -31,31 +31,41 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell var list = new List<RemoteImageInfo>(); try { - string episodeId = null; - string seriesId = null; + DataUtil.EpisodeInfo episode = null; + DataUtil.SeriesInfo series = null; if (item is Episode) { - episodeId = item.GetProviderId("Shoko Episode"); + episode = await DataUtil.GetEpisodeInfo(item.GetProviderId("Shoko Episode")); } - else if (item is Series || item is BoxSet || item is Movie) + else if (item is Series) { - seriesId = item.GetProviderId("Shoko Series"); + var groupId = item.GetProviderId("Shoko Group"); + if (string.IsNullOrEmpty(groupId)) + { + series = await DataUtil.GetSeriesInfo(item.GetProviderId("Shoko Series")); + } + else { + series = (await DataUtil.GetGroupInfo(groupId))?.DefaultSeries; + } + } + else if (item is BoxSet || item is Movie) + { + series = await DataUtil.GetSeriesInfo(item.GetProviderId("Shoko Series")); } else if (item is Season) { - seriesId = item.GetParent()?.GetProviderId("Shoko Series"); + series = await DataUtil.GetSeriesInfoFromGroup(item.GetParent()?.GetProviderId("Shoko Group"), item.IndexNumber ?? 1); } - if (episodeId != null) + if (episode != null) { - _logger.LogInformation($"Getting episode images ({episodeId} - {item.Name})"); - - var tvdbEpisodeInfo = (await API.ShokoAPI.GetEpisodeTvDb(episodeId)).FirstOrDefault(); - AddImage(ref list, ImageType.Primary, tvdbEpisodeInfo?.Thumbnail); + _logger.LogInformation($"Getting episode images ({episode.ID} - {item.Name})"); + AddImage(ref list, ImageType.Primary, episode?.TvDB?.Thumbnail); } - if (seriesId != null) + if (series != null) { - _logger.LogInformation($"Getting series images ({seriesId} - {item.Name})"); - var images = await API.ShokoAPI.GetSeriesImages(seriesId); + _logger.LogInformation($"Getting series images ({series.ID} - {item.Name})"); + var images = series.Shoko.Images; + AddImage(ref list, ImageType.Primary, series.AniDB.Poster); foreach (var image in images?.Posters) AddImage(ref list, ImageType.Primary, image); foreach (var image in images?.Fanarts) @@ -76,16 +86,9 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell private void AddImage(ref List<RemoteImageInfo> list, ImageType imageType, API.Models.Image image) { - var imageUrl = Helper.GetImageUrl(image); - if (!string.IsNullOrEmpty(imageUrl)) - { - list.Add(new RemoteImageInfo - { - ProviderName = Name, - Type = imageType, - Url = imageUrl - }); - } + var imageInfo = DataUtil.GetImage(image, imageType); + if (imageInfo != null) + list.Add(imageInfo); } public IEnumerable<ImageType> GetSupportedImages(BaseItem item) diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 4d9e9d63..234ab856 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -1,17 +1,14 @@ using System; using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; -using Shokofin.API; +using Shokofin.Utils; namespace Shokofin.Providers { @@ -34,85 +31,64 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio { var result = new MetadataResult<Movie>(); - // TO-DO Check if it can be written in a better way. Parent directory + File Name - var filename = Path.Join( - Path.GetDirectoryName(info.Path)?.Split(Path.DirectorySeparatorChar).LastOrDefault(), - Path.GetFileName(info.Path)); + var (id, file, episode, series, _group) = await DataUtil.GetFileInfoByPath(info.Path); - _logger.LogInformation($"Getting movie ID ({filename})"); - - var apiResponse = await ShokoAPI.GetFilePathEndsWith(filename); - var file = apiResponse?.FirstOrDefault(); - var fileId = file?.ID.ToString(); - var seriesIds = file?.SeriesIDs.FirstOrDefault(); - var seriesId = seriesIds?.SeriesID.ID.ToString(); - var episodeIds = seriesIds?.EpisodeIDs?.FirstOrDefault(); - var episodeId = episodeIds?.ID.ToString(); - - if (string.IsNullOrEmpty(episodeId) || string.IsNullOrEmpty(seriesId)) + if (file == null) // if file is null then series and episode is also null. { - _logger.LogInformation($"File not found! ({filename})"); + _logger.LogWarning($"Unable to find file info for path {info.Path}"); return result; } - _logger.LogInformation($"Getting movie metadata ({filename} - {episodeId})"); + bool isMultiEntry = series.Shoko.Sizes.Total.Episodes > 1; + int aniDBId = isMultiEntry ? episode.AniDB.ID : series.AniDB.ID; + var tvdbId = (isMultiEntry ? episode?.TvDB == null ? null : episode.TvDB.ID.ToString() : series?.TvDBID); - var seriesAniDB = await ShokoAPI.GetSeriesAniDb(seriesId); - var series = await ShokoAPI.GetSeries(seriesId); - var episodeAniDB = await ShokoAPI.GetEpisodeAniDb(episodeId); - var episode = await ShokoAPI.GetEpisode(episodeId); - bool isMultiEntry = series.Sizes.Total.Episodes > 1; - int aniDBId = (isMultiEntry ? episodeIds?.AniDB : seriesIds?.SeriesID.AniDB) ?? 0; + if (series.AniDB.Type != "Movie") + { + _logger.LogWarning($"File found, but not a movie! Skipping path {id}"); + return result; + } - if (seriesAniDB?.Type != "Movie") + var extraType = OrderingUtil.GetExtraType(episode.AniDB); + if (extraType != null) { - _logger.LogInformation($"File found, but not a movie! Skipping."); + _logger.LogWarning($"File found, but not a movie! Skipping path {id}"); return result; } - var tags = await ShokoAPI.GetSeriesTags(seriesId, Helper.GetTagFilter()); - var ( displayTitle, alternateTitle ) = Helper.GetMovieTitles(seriesAniDB.Titles, episodeAniDB.Titles, series.Name, episode.Name, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); - float rating = isMultiEntry ? (float)((episodeAniDB.Rating.Value * 10) / episodeAniDB.Rating.MaxValue) : (float)((seriesAniDB.Rating.Value * 10) / seriesAniDB.Rating.MaxValue); - ExtraType? extraType = Helper.GetExtraType(episodeAniDB); + var tags = await DataUtil.GetTags(series.ID); + var ( displayTitle, alternateTitle ) = TextUtil.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); + var rating = DataUtil.GetRating(isMultiEntry ? episode.AniDB.Rating : series.AniDB.Rating); result.Item = new Movie { - IndexNumber = Helper.GetAbsoluteIndexNumber(series, episodeAniDB), + IndexNumber = OrderingUtil.GetIndexNumber(series, episode), Name = displayTitle, OriginalTitle = alternateTitle, - PremiereDate = episodeAniDB.AirDate, + PremiereDate = episode.AniDB.AirDate, // Use the file description if collection contains more than one movie, otherwise use the collection description. - Overview = Helper.SummarySanitizer((isMultiEntry ? episodeAniDB.Description ?? seriesAniDB.Description : seriesAniDB.Description) ?? ""), - ProductionYear = episodeAniDB.AirDate?.Year, - ExtraType = extraType, - Tags = tags?.Select(tag => tag.Name).ToArray() ?? new string[0], + Overview = TextUtil.SummarySanitizer((isMultiEntry ? episode.AniDB.Description ?? series.AniDB.Description : series.AniDB.Description) ?? ""), + ProductionYear = episode.AniDB.AirDate?.Year, + ExtraType = extraType, + Tags = tags, CommunityRating = rating, }; - result.Item.SetProviderId("Shoko File", fileId); - result.Item.SetProviderId("Shoko Series", seriesId); - result.Item.SetProviderId("Shoko Episode", episodeId); + result.Item.SetProviderId("Shoko File", file.ID); + result.Item.SetProviderId("Shoko Series", series.ID); + result.Item.SetProviderId("Shoko Episode", episode.ID); if (aniDBId != 0) result.Item.SetProviderId("AniDB", aniDBId.ToString()); + if (!string.IsNullOrEmpty(tvdbId)) result.Item.SetProviderId("Tvdb", tvdbId); result.HasMetadata = true; result.ResetPeople(); - var roles = await ShokoAPI.GetSeriesCast(seriesId); - foreach (var role in roles) - { - result.AddPerson(new PersonInfo - { - Type = PersonType.Actor, - Name = role.Staff.Name, - Role = role.Character.Name, - ImageUrl = Helper.GetImageUrl(role.Staff.Image) - }); - } + foreach (var person in await DataUtil.GetPeople(series.ID)) + result.AddPerson(person); return result; } catch (Exception e) { - _logger.LogError(e.Message); - _logger.LogError(e.InnerException?.StackTrace ?? e.StackTrace); + _logger.LogError($"{e.Message}\n{e.StackTrace}"); return new MetadataResult<Movie>(); } } @@ -129,4 +105,4 @@ public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } -} \ No newline at end of file +} diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index af1edafe..de48b41b 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -7,6 +7,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; +using Shokofin.Utils; namespace Shokofin.Providers { @@ -23,6 +24,25 @@ public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvid } public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) + { + try + { + switch (Plugin.Instance.Configuration.SeriesGrouping) + { + default: + return GetDefaultMetadata(info, cancellationToken); + case OrderingUtil.SeriesGroupType.ShokoGroup: + return await GetShokoGroupedMetadata(info, cancellationToken); + } + } + catch (Exception e) + { + _logger.LogError($"{e.Message}\n{e.StackTrace}"); + return new MetadataResult<Season>(); + } + } + + private MetadataResult<Season> GetDefaultMetadata(SeasonInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<Season>(); @@ -38,6 +58,51 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat return result; } + private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<Season>(); + + if (!info.SeriesProviderIds.ContainsKey("Shoko Group")) + { + _logger.LogWarning($"Shoko Scanner... Shoko Group id not stored for series"); + return result; + } + + var groupId = info.SeriesProviderIds["Shoko Group"]; + var seasonNumber = info.IndexNumber ?? 1; + var series = await DataUtil.GetSeriesInfoFromGroup(groupId, seasonNumber); + if (series == null) + { + _logger.LogWarning($"Shoko Scanner... Unable to find series info for G{groupId}:S{seasonNumber}"); + return result; + } + _logger.LogInformation($"Shoko Scanner... Found series info for G{groupId}:S{seasonNumber}"); + + var tags = await DataUtil.GetTags(series.ID); + var ( displayTitle, alternateTitle ) = TextUtil.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); + + result.Item = new Season + { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = seasonNumber, + Overview = TextUtil.SummarySanitizer(series.AniDB.Description), + PremiereDate = series.AniDB.AirDate, + EndDate = series.AniDB.EndDate, + ProductionYear = series.AniDB.AirDate?.Year, + Tags = tags, + CommunityRating = DataUtil.GetRating(series.AniDB.Rating), + }; + + result.HasMetadata = true; + + result.ResetPeople(); + foreach (var person in await DataUtil.GetPeople(series.ID)) + result.AddPerson(person); + + return result; + } + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchInfo, CancellationToken cancellationToken) { // Isn't called from anywhere. If it is called, I don't know from where. diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 291c766d..96491a0a 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -1,17 +1,16 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; +using Shokofin.Utils; namespace Shokofin.Providers { @@ -32,66 +31,101 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat { try { - var result = new MetadataResult<Series>(); - - var dirname = Path.DirectorySeparatorChar + info.Path.Split(Path.DirectorySeparatorChar).Last(); - - _logger.LogInformation($"Getting series ID ({dirname})"); - - var apiResponse = await ShokoAPI.GetSeriesPathEndsWith(dirname); - var seriesIDs = apiResponse.FirstOrDefault()?.IDs; - var seriesId = seriesIDs?.ID.ToString(); - - if (string.IsNullOrEmpty(seriesId)) + switch (Plugin.Instance.Configuration.SeriesGrouping) { - _logger.LogInformation("Series not found!"); - return result; + default: + return await GetDefaultMetadata(info, cancellationToken); + case OrderingUtil.SeriesGroupType.ShokoGroup: + return await GetShokoGroupedMetadata(info, cancellationToken); } + } + catch (Exception e) + { + _logger.LogError($"{e.Message}\n{e.StackTrace}"); + return new MetadataResult<Series>(); + } + } - _logger.LogInformation($"Getting series metadata ({dirname} - {seriesId})"); - - var seriesInfo = await ShokoAPI.GetSeries(seriesId); - var aniDbSeriesInfo = await ShokoAPI.GetSeriesAniDb(seriesId); + private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<Series>(); + var (id, series) = await DataUtil.GetSeriesInfoByPath(info.Path); + if (series == null) + { + _logger.LogWarning($"Unable to find series info for path {id}"); + return result; + } + _logger.LogInformation($"Found series info for path {id}"); - var tags = await ShokoAPI.GetSeriesTags(seriesId, Helper.GetTagFilter()); - var ( displayTitle, alternateTitle ) = Helper.GetSeriesTitles(aniDbSeriesInfo.Titles, seriesInfo.Name, Plugin.Instance.Configuration.TitleMainType, Plugin.Instance.Configuration.TitleAlternateType, info.MetadataLanguage); + var tags = await DataUtil.GetTags(series.ID); + var ( displayTitle, alternateTitle ) = TextUtil.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); - result.Item = new Series - { - Name = displayTitle, - OriginalTitle = alternateTitle, - Overview = Helper.SummarySanitizer(aniDbSeriesInfo.Description), - PremiereDate = aniDbSeriesInfo.AirDate, - EndDate = aniDbSeriesInfo.EndDate, - ProductionYear = aniDbSeriesInfo.AirDate?.Year, - Status = aniDbSeriesInfo.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = tags?.Select(tag => tag.Name).ToArray() ?? new string[0], - CommunityRating = (float)((aniDbSeriesInfo.Rating.Value * 10) / aniDbSeriesInfo.Rating.MaxValue) - }; - result.Item.SetProviderId("Shoko Series", seriesId); - result.Item.SetProviderId("AniDB", seriesIDs.AniDB.ToString()); - result.HasMetadata = true; - - result.ResetPeople(); - var roles = await ShokoAPI.GetSeriesCast(seriesId); - foreach (var role in roles) - { - result.AddPerson(new PersonInfo - { - Type = PersonType.Actor, - Name = role.Staff.Name, - Role = role.Character.Name, - ImageUrl = Helper.GetImageUrl(role.Staff.Image) - }); - } + result.Item = new Series + { + Name = displayTitle, + OriginalTitle = alternateTitle, + Overview = TextUtil.SummarySanitizer(series.AniDB.Description), + PremiereDate = series.AniDB.AirDate, + EndDate = series.AniDB.EndDate, + ProductionYear = series.AniDB.AirDate?.Year, + Status = series.AniDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, + Tags = tags, + CommunityRating = (float)((series.AniDB.Rating.Value * 10) / series.AniDB.Rating.MaxValue) + }; + + result.Item.SetProviderId("Shoko Series", series.ID); + result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); + if (!string.IsNullOrEmpty(series.TvDBID)) result.Item.SetProviderId("Tvdb", series.TvDBID); + result.HasMetadata = true; + + result.ResetPeople(); + foreach (var person in await DataUtil.GetPeople(series.ID)) + result.AddPerson(person); + + return result; + } + private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<Series>(); + var (id, group) = await DataUtil.GetGroupInfoByPath(info.Path); + if (group == null) + { + _logger.LogWarning($"Unable to find series info for path {id}"); return result; } - catch (Exception e) + _logger.LogInformation($"Found series info for path {id}"); + + var series = group.DefaultSeries; + var tvdbId = series?.TvDBID; + + var tags = await DataUtil.GetTags(series.ID); + var ( displayTitle, alternateTitle ) = TextUtil.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); + + result.Item = new Series { - _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); - return new MetadataResult<Series>(); - } + Name = displayTitle, + OriginalTitle = alternateTitle, + Overview = TextUtil.SummarySanitizer(series.AniDB.Description), + PremiereDate = series.AniDB.AirDate, + EndDate = series.AniDB.EndDate, + ProductionYear = series.AniDB.AirDate?.Year, + Status = series.AniDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, + Tags = tags, + CommunityRating = (float)((series.AniDB.Rating.Value * 10) / series.AniDB.Rating.MaxValue) + }; + + result.Item.SetProviderId("Shoko Series", series.ID); + result.Item.SetProviderId("Shoko Group", group.ID); + result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); + if (!string.IsNullOrEmpty(tvdbId)) result.Item.SetProviderId("Tvdb", tvdbId); + result.HasMetadata = true; + + result.ResetPeople(); + foreach (var person in await DataUtil.GetPeople(series.ID)) + result.AddPerson(person); + + return result; } public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) @@ -100,12 +134,12 @@ public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo s var searchResults = await ShokoAPI.SeriesSearch(searchInfo.Name); if (searchResults.Count() == 0) searchResults = await ShokoAPI.SeriesStartsWith(searchInfo.Name); - + var results = new List<RemoteSearchResult>(); foreach (var series in searchResults) { - var imageUrl = Helper.GetImageUrl(series.Images.Posters.FirstOrDefault()); + var imageUrl = DataUtil.GetImageUrl(series.Images.Posters.FirstOrDefault()); _logger.LogInformation(imageUrl); var parsedSeries = new RemoteSearchResult { diff --git a/Shokofin/Utils/DataUtil.cs b/Shokofin/Utils/DataUtil.cs new file mode 100644 index 00000000..49cc570a --- /dev/null +++ b/Shokofin/Utils/DataUtil.cs @@ -0,0 +1,389 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Shokofin.API; +using Shokofin.API.Models; +using Path = System.IO.Path; +using MediaBrowser.Model.Providers; + +namespace Shokofin.Utils +{ + public class DataUtil + { + internal static string GetImageUrl(Image image) + { + return image != null || !image.RelativeFilepath.Equals("/") ? $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}/api/v3/Image/{image.Source}/{image.Type}/{image.ID}" : null; + } + + public static RemoteImageInfo GetImage(Image image, ImageType imageType) + { + + var imageUrl = GetImageUrl(image); + if (string.IsNullOrEmpty(imageUrl)) + return null; + return new RemoteImageInfo + { + ProviderName = "Shoko", + Type = imageType, + Url = imageUrl + }; + } + + public static float GetRating(Rating rating) + { + return rating == null ? 0 : (float) ((rating.Value * 10) / rating.MaxValue); + } + + public static async Task<IEnumerable<PersonInfo>> GetPeople(string seriesId) + { + var list = new List<PersonInfo>(); + var roles = await ShokoAPI.GetSeriesCast(seriesId); + foreach (var role in roles) + { + list.Add(new PersonInfo + { + Type = PersonType.Actor, + Name = role.Staff.Name, + Role = role.Character.Name, + ImageUrl = GetImageUrl(role.Staff.Image) + }); + } + return list; + } + + #region File Info + + public class FileInfo + { + public string ID; + public File Shoko; + } + + public static async Task<(string, FileInfo, EpisodeInfo, SeriesInfo, GroupInfo)> GetFileInfoByPath(string path, bool includeGroup = true) + { + // TODO: Check if it can be written in a better way. Parent directory + File Name + var id = Path.Join( + Path.GetDirectoryName(path)?.Split(Path.DirectorySeparatorChar).LastOrDefault(), + Path.GetFileName(path)); + var result = await ShokoAPI.GetFileByPath(id); + + var file = result?.FirstOrDefault(); + if (file == null) + return (id, null, null, null, null); + + var series = file?.SeriesIDs.FirstOrDefault(); + var seriesId = series?.SeriesID.ID.ToString(); + var episodes = series?.EpisodeIDs?.FirstOrDefault(); + var episodeId = episodes?.ID.ToString(); + var otherEpisodesCount = series?.EpisodeIDs.Count() - 1 ?? 0; + if (string.IsNullOrEmpty(seriesId) || string.IsNullOrEmpty(episodeId)) + return (id, null, null, null, null); + + var episodeInfo = await GetEpisodeInfo(episodeId); + if (episodeInfo == null) + return (id, null, null, null, null); + + var seriesInfo = await GetSeriesInfo(seriesId); + if (episodeInfo == null) + return (id, null, null, null, null); + + GroupInfo groupInfo = null; + if (includeGroup) + { + groupInfo = await GetGroupInfoForSeries(seriesId); + if (groupInfo == null) + return (id, null, null, null, null); + } + + var fileInfo = new FileInfo + { + ID = file.ID.ToString(), + Shoko = file, + }; + + return (id, fileInfo, episodeInfo, seriesInfo, groupInfo); + } + + public static async Task<(string, FileInfo, EpisodeInfo, SeriesInfo, GroupInfo)> GetFileInfoByID(string fileId, string id = null) + { + var file = await ShokoAPI.GetFile(fileId); + if (file == null) + return (id, null, null, null, null); + var fileInfo = new FileInfo + { + ID = fileId, + Shoko = file, + }; + + var episodes = await ShokoAPI.GetEpisodeFromFile(fileId); + var episodeInfo = await CreateEpisodeInfo(episodes[0], null, episodes?.Count ?? 0 - 1); + if (episodeInfo == null) + return (id, null, null, null, null); + + var seriesInfo = await CreateSeriesInfo(await ShokoAPI.GetSeriesFromEpisode(episodeInfo.ID)); + if (seriesInfo == null) + return (id, null, null, null, null); + + var groupInfo = await GetGroupInfoForSeries(seriesInfo.ID); + if (groupInfo == null) + return (id, null, null, null, null); + + return (id, fileInfo, episodeInfo, seriesInfo, groupInfo); + } + + #endregion + #region Episode Info + + public class EpisodeInfo + { + public string ID; + public Episode Shoko; + public Episode.AniDB AniDB; + public Episode.TvDB TvDB; + public int OtherEpisodesCount; + } + + public static async Task<EpisodeInfo> GetEpisodeInfo(string episodeId, int otherEpisodesCount = 0) + { + var episode = await ShokoAPI.GetEpisode(episodeId); + return await CreateEpisodeInfo(episode, episodeId, otherEpisodesCount); + } + + public static async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string episodeId = null, int otherEpisodesCount = 0) + { + if (episode == null) + return null; + if (string.IsNullOrEmpty(episodeId)) + episodeId = episode.IDs.ID.ToString(); + return new EpisodeInfo + { + ID = episodeId, + Shoko = await ShokoAPI.GetEpisode(episodeId), + AniDB = await ShokoAPI.GetEpisodeAniDb(episodeId), + TvDB = (await ShokoAPI.GetEpisodeTvDb(episodeId))?.FirstOrDefault(), + OtherEpisodesCount = otherEpisodesCount, + }; + } + + #endregion + #region Series Info + + public class SeriesInfo + { + public string ID; + public Series Shoko; + public Series.AniDB AniDB; + public string TvDBID; + } + + public static async Task<(string, SeriesInfo)> GetSeriesInfoByPath(string path) + { + var id = Path.DirectorySeparatorChar + path.Split(Path.DirectorySeparatorChar).Last(); + var result = await ShokoAPI.GetSeriesPathEndsWith(id); + + var seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); + if (string.IsNullOrEmpty(seriesId)) + return (id, null); + + return (id, await GetSeriesInfo(seriesId)); + } + + public static async Task<SeriesInfo> GetSeriesInfoFromGroup(string groupId, int seasonNumber) + { + var groupInfo = await GetGroupInfo(groupId); + if (groupInfo == null) + return null; + int seriesIndex = seasonNumber > 0 ? seasonNumber - 1 : seasonNumber; + var index = groupInfo.DefaultSeriesIndex + seriesIndex; + var seriesInfo = groupInfo.SeriesList[index]; + if (seriesInfo == null) + return null; + + return seriesInfo; + } + + public static async Task<SeriesInfo> GetSeriesInfo(string seriesId) + { + var series = await ShokoAPI.GetSeries(seriesId); + return await CreateSeriesInfo(series, seriesId); + } + + private static async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = null) + { + if (series == null) + return null; + if (string.IsNullOrEmpty(seriesId)) + seriesId = series.IDs.ID.ToString(); + return new SeriesInfo + { + ID = seriesId, + Shoko = series, + AniDB = await ShokoAPI.GetSeriesAniDb(seriesId), + TvDBID = series.IDs.TvDB.Count > 0 ? series.IDs.TvDB.FirstOrDefault().ToString() : null, + }; + } + + #endregion + #region Group Info + + public class GroupInfo + { + public string ID; + public List<SeriesInfo> SeriesList; + public SeriesInfo DefaultSeries; + public int DefaultSeriesIndex; + } + + public static async Task<(string, GroupInfo)> GetGroupInfoByPath(string path) + { + var id = Path.DirectorySeparatorChar + path.Split(Path.DirectorySeparatorChar).Last(); + var result = await ShokoAPI.GetSeriesPathEndsWith(id); + + var seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); + if (string.IsNullOrEmpty(seriesId)) + return (id, null); + + var groupInfo = await GetGroupInfoForSeries(seriesId); + if (groupInfo == null) + return (id, null); + + return (id, groupInfo); + } + + public static async Task<GroupInfo> GetGroupInfo(string groupId) + { + if (string.IsNullOrEmpty(groupId)) + return null; + + var group = await ShokoAPI.GetGroup(groupId); + return await CreateGroupInfo(group, groupId); + } + + public static async Task<GroupInfo> GetGroupInfoForSeries(string seriesId) + { + var group = await ShokoAPI.GetGroupFromSeries(seriesId); + return await CreateGroupInfo(group); + } + + private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId = null) + { + if (group == null) + return null; + + if (string.IsNullOrEmpty(groupId)) + groupId = group.IDs.ID.ToString(); + + var seriesList = await ShokoAPI.GetSeriesInGroup(groupId) + .ContinueWith(async task => await Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s)))).Unwrap() + .ContinueWith(l => l.Result.ToList()); + if (seriesList == null || seriesList.Count == 0) + return null; + // Map + int foundIndex = -1; + int targetId = (group.IDs.DefaultSeries ?? 0); + // Sort list + var orderingType = Plugin.Instance.Configuration.SeasonOrdering; + switch (orderingType) + { + case OrderingUtil.SeasonOrderType.ReleaseDate: + seriesList.OrderBy(s => s?.AniDB?.AirDate ?? System.DateTime.MaxValue); + break; + case OrderingUtil.SeasonOrderType.Chronological: + await DefaultSeriesRelationSorter.MapRelations(groupId); + seriesList.Sort(DefaultSeriesRelationSorter); + break; + } + // Select the targeted id if a group spesify a default series. + if (targetId != 0) + foundIndex = seriesList.FindIndex(s => s.Shoko.IDs.ID == targetId); + // Else select the default series as first-to-be-released. + else switch (orderingType) + { + // The list is already sorted by release date, so just return the first index. + case OrderingUtil.SeasonOrderType.ReleaseDate: + foundIndex = 0; + break; + // We can't be sure that the the series in the list was _released_ chronologically, so find the earliest series, and use that as a base. + case OrderingUtil.SeasonOrderType.Chronological: { + var earliestSeries = seriesList.Aggregate((cur, nxt) => (cur == null || (nxt?.AniDB.AirDate ?? System.DateTime.MaxValue) < (cur.AniDB.AirDate ?? System.DateTime.MaxValue)) ? nxt : cur); + foundIndex = seriesList.FindIndex(s => s == earliestSeries); + break; + } + } + + // Return if we can't get a base point for seasons. + if (foundIndex == -1) + return null; + + return new GroupInfo + { + ID = groupId, + SeriesList = seriesList, + DefaultSeries = seriesList[foundIndex], + DefaultSeriesIndex = foundIndex, + }; + } + + private static SeriesRelationSorter DefaultSeriesRelationSorter = new SeriesRelationSorter(); + + private class SeriesRelationSorter : IComparer<SeriesInfo> + { + private readonly Dictionary<int, Dictionary<int, int>> _relationMap; + public SeriesRelationSorter() + { + _relationMap = new Dictionary<int, Dictionary<int, int>>(); + } + + public async Task MapRelations(string groupId) { + _relationMap.Clear(); + var relations = await ShokoAPI.GetRelationsInGroup(groupId); + foreach (var relation in relations) + { + MapRelation(relation.FromID, relation.ToID, relation.Type, false); + MapRelation(relation.ToID, relation.FromID, relation.Type, true); + } + } + + private void MapRelation(int fromId, int toId, Relation.RelationType relation, bool isReverseRelation) + { + if (!_relationMap.ContainsKey(fromId)) { + _relationMap[fromId] = new Dictionary<int, int>(); + } + var fromMap = _relationMap[fromId]; + } + + public int Compare(SeriesInfo x, SeriesInfo y) + { + // TODO: Sort based on 1) relation between series and 2) relase year if both series have equal relations to anchestors/decendants + return 0; + } + } + + #endregion + + public static async Task<string[]> GetTags(string seriesId) + { + return (await ShokoAPI.GetSeriesTags(seriesId, DataUtil.GetTagFilter()))?.Select(tag => tag.Name).ToArray() ?? new string[0]; + } + + /// <summary> + /// Get the tag filter + /// </summary> + /// <returns></returns> + private static int GetTagFilter() + { + var config = Plugin.Instance.Configuration; + var filter = 0; + + if (config.HideAniDbTags) filter = 1; + if (config.HideArtStyleTags) filter |= (filter << 1); + if (config.HideSourceTags) filter |= (filter << 2); + if (config.HideMiscTags) filter |= (filter << 3); + if (config.HidePlotTags) filter |= (filter << 4); + + return filter; + } + } +} diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs new file mode 100644 index 00000000..5a5ae673 --- /dev/null +++ b/Shokofin/Utils/OrderingUtil.cs @@ -0,0 +1,153 @@ +using MediaBrowser.Model.Entities; +using Shokofin.API.Models; + +namespace Shokofin.Utils +{ + public class OrderingUtil + { + + /// <summary> + /// Group series as + /// </summary> + public enum SeriesGroupType + { + /// <summary> + /// No grouping. All series will have their own entry. + /// </summary> + Default = 0, + /// <summary> + /// Don't group, but make series merge-friendly by using the season numbers from TvDB. + /// </summary> + TvDB = 1, + /// <summary> + /// Group seris based on Shoko's default group filter. + /// </summary> + ShokoGroup = 2, + } + + /// <summary> + /// Season ordering when grouping series using Shoko's groups. + /// </summary> + public enum SeasonOrderType + { + /// <summary> + /// Let Shoko decide the order. + /// </summary> + Default = 0, + /// <summary> + /// Order seasons by release date. + /// </summary> + ReleaseDate = 1, + /// <summary> + /// Order seasons based on the chronological order of relations. + /// </summary> + Chronological = 2, + } + + /// <summary> + /// Get index number for an episode in a series. + /// </summary> + /// <returns>Absolute index.</returns> + public static int GetIndexNumber(DataUtil.SeriesInfo series, DataUtil.EpisodeInfo episode) + { + switch (Plugin.Instance.Configuration.SeriesGrouping) + { + default: + case SeriesGroupType.Default: + return episode.AniDB.EpisodeNumber; + case SeriesGroupType.TvDB: { + var epNum = episode?.TvDB.Number ?? 0; + if (epNum == 0) + goto case SeriesGroupType.Default; + return epNum; + } + case SeriesGroupType.ShokoGroup: { + int offset = 0; + var sizes = series.Shoko.Sizes; + switch (episode.AniDB.Type) + { + case "Normal": + break; + case "Special": + offset += sizes.Total.Episodes; + break; // goto case "Normal"; + case "Other": + offset += sizes.Total?.Specials ?? 0; + goto case "Special"; + case "Parody": + offset += sizes.Total?.Others ?? 0; + goto case "Other"; + } + return offset + episode.AniDB.EpisodeNumber; + } + } + } + + /// <summary> + /// Get season number for an episode in a series. + /// </summary> + /// <param name="group"></param> + /// <param name="series"></param> + /// <param name="episode"></param> + /// <returns></returns> + public static int GetSeasonNumber(DataUtil.GroupInfo group, DataUtil.SeriesInfo series, DataUtil.EpisodeInfo episode) + { + switch (Plugin.Instance.Configuration.SeriesGrouping) + { + default: + case SeriesGroupType.Default: + switch (episode.AniDB.Type) + { + case "Normal": + return 1; + case "Special": + return 0; + default: + return 98; + } + case SeriesGroupType.TvDB: { + var seasonNumber = episode?.TvDB?.Season; + if (seasonNumber == null) + goto case SeriesGroupType.Default; + return seasonNumber ?? 1; + } + case SeriesGroupType.ShokoGroup: { + var id = series.ID; + if (series == group.DefaultSeries) + return 1; + var index = group.SeriesList.FindIndex(s => s.ID == id); + if (index == -1) + goto case SeriesGroupType.Default; + var value = index - group.DefaultSeriesIndex; + return value < 0 ? value : value + 1; + } + } + } + + public static ExtraType? GetExtraType(Episode.AniDB episode) + { + switch (episode.Type) + { + case "Normal": + return null; + case "ThemeSong": + return ExtraType.ThemeVideo; + case "Trailer": + return ExtraType.Trailer; + case "Special": { + var title = TextUtil.GetTitleByLanguages(episode.Titles, "en") ?? ""; + // Interview + if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) + return ExtraType.Interview; + // Cinema intro/outro + if (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) && + (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase))) + return ExtraType.Clip; + return null; + } + default: + return ExtraType.Unknown; + } + } + } +} diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs new file mode 100644 index 00000000..b599594d --- /dev/null +++ b/Shokofin/Utils/TextUtil.cs @@ -0,0 +1,176 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Shokofin.API.Models; + +namespace Shokofin.Utils +{ + public class TextUtil + { + public enum DisplayLanguageType { + Default = 1, + MetadataPreferred, + Origin, + Ignore + } + + public enum DisplyTitleType { + MainTitle = 1, + SubTitle, + FullTitle, + } + + public static string SummarySanitizer(string summary) // Based on ShokoMetadata which is based on HAMA's + { + var config = Plugin.Instance.Configuration; + + if (config.SynopsisCleanLinks) + summary = Regex.Replace(summary, @"https?:\/\/\w+.\w+(?:\/?\w+)? \[([^\]]+)\]", match => match.Groups[1].Value); + + if (config.SynopsisCleanMiscLines) + summary = Regex.Replace(summary, @"^(\*|--|~) .*", "", RegexOptions.Multiline); + + if (config.SynopsisRemoveSummary) + summary = Regex.Replace(summary, @"\n(Source|Note|Summary):.*", "", RegexOptions.Singleline); + + if (config.SynopsisCleanMultiEmptyLines) + summary = Regex.Replace(summary, @"\n\n+", "", RegexOptions.Singleline); + + return summary; + } + + public static ( string, string ) GetEpisodeTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) + => GetTitles(seriesTitles, episodeTitles, null, episodeTitle, DisplyTitleType.SubTitle, metadataLanguage); + + public static ( string, string ) GetSeriesTitles(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) + => GetTitles(seriesTitles, null, seriesTitle, null, DisplyTitleType.MainTitle, metadataLanguage); + + public static ( string, string ) GetMovieTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) + => GetTitles(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplyTitleType.FullTitle, metadataLanguage); + + public static ( string, string ) GetTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplyTitleType outputType, string metadataLanguage) + { + // Don't process anything if the series titles are not provided. + if (seriesTitles == null) return ( null, null ); + var originLanguage = GuessOriginLanguage(seriesTitles); + return ( + GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage, originLanguage), + GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleAlternateType, outputType, metadataLanguage, originLanguage) + ); + } + + public static string GetEpisodeTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) + => GetTitle(seriesTitles, episodeTitles, null, episodeTitle, DisplyTitleType.SubTitle, metadataLanguage); + + public static string GetSeriesTitle(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) + => GetTitle(seriesTitles, null, seriesTitle, null, DisplyTitleType.MainTitle, metadataLanguage); + + public static string GetMovieTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) + => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplyTitleType.FullTitle, metadataLanguage); + + public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplyTitleType outputType, string metadataLanguage, params string[] originLanguages) + => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage, originLanguages); + + public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayLanguageType languageType, DisplyTitleType outputType, string displayLanguage, params string[] originLanguages) + { + // Don't process anything if the series titles are not provided. + if (seriesTitles == null) + return null; + // Guess origin language if not provided. + if (originLanguages.Length == 0) + originLanguages = GuessOriginLanguage(seriesTitles); + switch (languageType) + { + // Let Shoko decide the title. + case DisplayLanguageType.Default: + return __GetTitle(null, null, seriesTitle, episodeTitle, outputType); + // Display in metadata-preferred language, or fallback to default. + case DisplayLanguageType.MetadataPreferred: + var title = __GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, outputType, displayLanguage); + if (string.IsNullOrEmpty(title)) + goto case DisplayLanguageType.Default; + return title; + // Display in origin language without fallback. + case DisplayLanguageType.Origin: + return __GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, outputType, originLanguages); + // 'Ignore' will always return null, and all other values will also return null. + case DisplayLanguageType.Ignore: + default: + return null; + } + } + + private static string __GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplyTitleType outputType, params string[] languageCandidates) + { + // Lazy init string builder when/if we need it. + StringBuilder titleBuilder = null; + switch (outputType) + { + case DisplyTitleType.MainTitle: + case DisplyTitleType.FullTitle: { + string title = (GetTitleByTypeAndLanguage(seriesTitles, "official", languageCandidates) ?? seriesTitle)?.Trim(); + // Return series title. + if (outputType == DisplyTitleType.MainTitle) + return title; + titleBuilder = new StringBuilder(title); + goto case DisplyTitleType.SubTitle; + } + case DisplyTitleType.SubTitle: { + string title = (GetTitleByLanguages(episodeTitles, languageCandidates) ?? episodeTitle)?.Trim(); + // Return episode title. + if (outputType == DisplyTitleType.SubTitle) + return title; + // Ignore sub-title of movie if it strictly equals the text below. + if (title != "Complete Movie") + titleBuilder?.Append($": {title}"); + return titleBuilder?.ToString() ?? ""; + } + default: + return null; + } + } + + public static string GetTitleByTypeAndLanguage(IEnumerable<Title> titles, string type, params string[] langs) + { + if (titles != null) foreach (string lang in langs) + { + string title = titles.FirstOrDefault(s => s.Language == lang && s.Type == type)?.Name; + if (title != null) return title; + } + return null; + } + + public static string GetTitleByLanguages(IEnumerable<Title> titles, params string[] langs) + { + if (titles != null) foreach (string lang in langs) + { + string title = titles.FirstOrDefault(s => lang.Equals(s.Language, System.StringComparison.OrdinalIgnoreCase))?.Name; + if (title != null) return title; + } + return null; + } + + /// <summary> + /// Guess the origin language based on the main title. + /// </summary> + /// <returns></returns> + private static string[] GuessOriginLanguage(IEnumerable<Title> titles) + { + string langCode = titles.FirstOrDefault(t => t?.Type == "main")?.Language.ToLower(); + // Guess the origin language based on the main title. + switch (langCode) + { + case null: // fallback + case "x-other": + case "x-jat": + return new string[] { "ja" }; + case "x-zht": + return new string[] { "zn-hans", "zn-hant", "zn-c-mcm", "zn" }; + default: + return new string[] { langCode }; + } + + } + } +} \ No newline at end of file From b204e9f9e537bef763d064a20f0c4a3bd7b7b40a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 14 Nov 2020 23:42:15 +0100 Subject: [PATCH 0089/1103] Update error messages when throwing errors --- Shokofin/Providers/BoxSetProvider.cs | 2 +- Shokofin/Providers/ImageProvider.cs | 2 +- Shokofin/Providers/MovieProvider.cs | 2 +- Shokofin/Providers/SeasonProvider.cs | 2 +- Shokofin/Providers/SeriesProvider.cs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 3036e456..61ab5283 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -77,7 +77,7 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat } catch (Exception e) { - _logger.LogError(e.StackTrace); + _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); return new MetadataResult<BoxSet>(); } } diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 07d429db..d4ed2f71 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -79,7 +79,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell } catch (Exception e) { - _logger.LogError(e.StackTrace); + _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); return list; } } diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 234ab856..b01c2664 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -88,7 +88,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio } catch (Exception e) { - _logger.LogError($"{e.Message}\n{e.StackTrace}"); + _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); return new MetadataResult<Movie>(); } } diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index de48b41b..f0ef5968 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -37,7 +37,7 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat } catch (Exception e) { - _logger.LogError($"{e.Message}\n{e.StackTrace}"); + _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); return new MetadataResult<Season>(); } } diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 96491a0a..399f2cc1 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -41,7 +41,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat } catch (Exception e) { - _logger.LogError($"{e.Message}\n{e.StackTrace}"); + _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); return new MetadataResult<Series>(); } } From 9f23fc8fa858fcff8fdb304b62e97492b05aa571 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 15 Nov 2020 16:30:47 +0100 Subject: [PATCH 0090/1103] Remove WIP chronological ordering # Changes - Commented out reference to Chronological ordering in the html config template - Removed the api call for group relations (that currently does not exist in Shoko master branch) - Removed the incomplete chronological sorter - Made the "default" (AKA let shoko decide) ordering the default --- Shokofin/API/ShokoAPI.cs | 6 --- Shokofin/Configuration/PluginConfiguration.cs | 2 +- Shokofin/Configuration/configPage.html | 3 +- Shokofin/Utils/DataUtil.cs | 44 +++---------------- 4 files changed, 9 insertions(+), 46 deletions(-) diff --git a/Shokofin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPI.cs index 26364b94..cdbefe49 100644 --- a/Shokofin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPI.cs @@ -168,12 +168,6 @@ public static async Task<Group> GetGroupFromSeries(string id) return responseStream != null ? await JsonSerializer.DeserializeAsync<Group>(responseStream) : null; } - public static async Task<List<Relation>> GetRelationsInGroup(string id) - { - var responseStream = await CallApi($"/api/v3/Filter/0/Group/{id}/Relations"); - return responseStream != null ? await JsonSerializer.DeserializeAsync<List<Relation>>(responseStream) : null; - } - public static async Task<bool> MarkEpisodeWatched(string id) { var responseStream = await CallApi($"/api/v3/Episode/{id}/watched/true", "POST"); diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 4140377a..663f4033 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -65,7 +65,7 @@ public PluginConfiguration() TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; SeriesGrouping = SeriesGroupType.Default; - SeasonOrdering = SeasonOrderType.ReleaseDate; + SeasonOrdering = SeasonOrderType.Default; } } } \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 19aa5f1a..c0b0d585 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -83,8 +83,9 @@ <h3>Library Options</h3> <div id="SeasonOrderingItem" class="selectContainer"> <label class="selectLabel" for="SeasonOrdering">Season ordering</label> <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select"> + <option value="Default">Let Shoko decide</option> <option value="ReleaseDate">By release date</option> - <option value="Chronological">Chronological order</option> + <!--<option value="Chronological">Chronological order</option>--> </select> </div> <label class="checkboxContainer"> diff --git a/Shokofin/Utils/DataUtil.cs b/Shokofin/Utils/DataUtil.cs index 49cc570a..97590eb1 100644 --- a/Shokofin/Utils/DataUtil.cs +++ b/Shokofin/Utils/DataUtil.cs @@ -287,13 +287,14 @@ private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId var orderingType = Plugin.Instance.Configuration.SeasonOrdering; switch (orderingType) { + case OrderingUtil.SeasonOrderType.Default: + break; case OrderingUtil.SeasonOrderType.ReleaseDate: seriesList.OrderBy(s => s?.AniDB?.AirDate ?? System.DateTime.MaxValue); break; + // Should not be selectable unless a user fidles with DevTools in the browser to select the option. case OrderingUtil.SeasonOrderType.Chronological: - await DefaultSeriesRelationSorter.MapRelations(groupId); - seriesList.Sort(DefaultSeriesRelationSorter); - break; + throw new System.Exception("Not implemented yet"); } // Select the targeted id if a group spesify a default series. if (targetId != 0) @@ -305,6 +306,8 @@ private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId case OrderingUtil.SeasonOrderType.ReleaseDate: foundIndex = 0; break; + // We don't know how Shoko may have sorted it, so just find the earliest series + case OrderingUtil.SeasonOrderType.Default: // We can't be sure that the the series in the list was _released_ chronologically, so find the earliest series, and use that as a base. case OrderingUtil.SeasonOrderType.Chronological: { var earliestSeries = seriesList.Aggregate((cur, nxt) => (cur == null || (nxt?.AniDB.AirDate ?? System.DateTime.MaxValue) < (cur.AniDB.AirDate ?? System.DateTime.MaxValue)) ? nxt : cur); @@ -326,41 +329,6 @@ private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId }; } - private static SeriesRelationSorter DefaultSeriesRelationSorter = new SeriesRelationSorter(); - - private class SeriesRelationSorter : IComparer<SeriesInfo> - { - private readonly Dictionary<int, Dictionary<int, int>> _relationMap; - public SeriesRelationSorter() - { - _relationMap = new Dictionary<int, Dictionary<int, int>>(); - } - - public async Task MapRelations(string groupId) { - _relationMap.Clear(); - var relations = await ShokoAPI.GetRelationsInGroup(groupId); - foreach (var relation in relations) - { - MapRelation(relation.FromID, relation.ToID, relation.Type, false); - MapRelation(relation.ToID, relation.FromID, relation.Type, true); - } - } - - private void MapRelation(int fromId, int toId, Relation.RelationType relation, bool isReverseRelation) - { - if (!_relationMap.ContainsKey(fromId)) { - _relationMap[fromId] = new Dictionary<int, int>(); - } - var fromMap = _relationMap[fromId]; - } - - public int Compare(SeriesInfo x, SeriesInfo y) - { - // TODO: Sort based on 1) relation between series and 2) relase year if both series have equal relations to anchestors/decendants - return 0; - } - } - #endregion public static async Task<string[]> GetTags(string seriesId) From 553b7fe394dbdbf8785f2702e89fff20b6dc739b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 13 Dec 2020 19:16:28 +0100 Subject: [PATCH 0091/1103] Fix link to documention in the config page --- 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 c0b0d585..5bd9da52 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -12,7 +12,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/">Help</a> + <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> <h3>Connection Options</h3> <div class="inputContainer"> From 6a6563be660bed5cfc8a268fe664da89dc711d6e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 13 Dec 2020 19:28:24 +0100 Subject: [PATCH 0092/1103] Remove Port setting, and make Host the leading url to the shoko server (e.g. so proxies are supported) --- Shokofin/API/Models/Image.cs | 4 ++++ Shokofin/API/ShokoAPI.cs | 5 ++-- Shokofin/Configuration/PluginConfiguration.cs | 5 +--- Shokofin/Configuration/configPage.html | 8 +------ Shokofin/Providers/ImageProvider.cs | 23 +++++++++++++++---- Shokofin/Utils/DataUtil.cs | 22 +----------------- 6 files changed, 27 insertions(+), 40 deletions(-) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index 3cbcd19e..ad48b192 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -14,5 +14,9 @@ public class Image public bool Disabled { get; set; } + public string ToURLString() + { + return $"{Plugin.Instance.Configuration.Host}/api/v3/Image/{Source}/{Type}/{ID}"; + } } } \ No newline at end of file diff --git a/Shokofin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPI.cs index cdbefe49..5dd57b9c 100644 --- a/Shokofin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPI.cs @@ -27,7 +27,7 @@ private static async Task<Stream> CallApi(string url, string requestType = "GET" try { - var apiBaseUrl = $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}"; + var apiBaseUrl = Plugin.Instance.Configuration.Host; switch (requestType) { case "POST": @@ -63,8 +63,7 @@ private static async Task<ApiKey> GetApiKey() {"pass", Plugin.Instance.Configuration.Password}, {"device", "Shoko Jellyfin Plugin"} }); - - var apiBaseUrl = $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}"; + var apiBaseUrl = Plugin.Instance.Configuration.Host; var response = await _httpClient.PostAsync($"{apiBaseUrl}/api/auth", new StringContent(postData, Encoding.UTF8, "application/json")); if (response.StatusCode == HttpStatusCode.OK) return await JsonSerializer.DeserializeAsync<ApiKey>(response.Content.ReadAsStreamAsync().Result); diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 663f4033..86877607 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -9,8 +9,6 @@ public class PluginConfiguration : BasePluginConfiguration { public string Host { get; set; } - public string Port { get; set; } - public string Username { get; set; } public string Password { get; set; } @@ -47,8 +45,7 @@ public class PluginConfiguration : BasePluginConfiguration public PluginConfiguration() { - Host = "127.0.0.1"; - Port = "8111"; + Host = "http://127.0.0.1:8111"; Username = "Default"; Password = ""; ApiKey = ""; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 5bd9da52..0a926b0c 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -17,11 +17,7 @@ <h2 class="sectionTitle">Shoko</h2> <h3>Connection Options</h3> <div class="inputContainer"> <input is="emby-input" type="text" id="Host" required label="Host" /> - <div class="fieldDescription">This is the IP address of the server where Shoko is running.</div> - </div> - <div class="inputContainer"> - <input is="emby-input" type="text" id="Port" required label="Port" /> - <div class="fieldDescription">This is the port on which Shoko is running.</div> + <div class="fieldDescription">This is the URL leading to where Shoko is running.</div> </div> <div class="inputContainer"> <input is="emby-input" type="text" id="Username" required label="Username" /> @@ -132,7 +128,6 @@ <h3>Tag Options</h3> Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { document.querySelector('#Host').value = config.Host; - document.querySelector('#Port').value = config.Port; document.querySelector('#Username').value = config.Username; document.querySelector('#Password').value = config.Password; document.querySelector('#ApiKey').value = config.ApiKey; @@ -175,7 +170,6 @@ <h3>Tag Options</h3> ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { config.Host = document.querySelector('#Host').value; - config.Port = document.querySelector('#Port').value; config.Username = document.querySelector('#Username').value; config.Password = document.querySelector('#Password').value; config.ApiKey = document.querySelector('#ApiKey').value; diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index d4ed2f71..f294dd06 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -19,7 +19,7 @@ public class ImageProvider : IRemoteImageProvider public string Name => "Shoko"; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger<ImageProvider> _logger; - + public ImageProvider(IHttpClientFactory httpClientFactory, ILogger<ImageProvider> logger) { _httpClientFactory = httpClientFactory; @@ -86,24 +86,37 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell private void AddImage(ref List<RemoteImageInfo> list, ImageType imageType, API.Models.Image image) { - var imageInfo = DataUtil.GetImage(image, imageType); + var imageInfo = GetImage(image, imageType); if (imageInfo != null) list.Add(imageInfo); } + private RemoteImageInfo GetImage(API.Models.Image image, ImageType imageType) + { + var imageUrl = image?.ToURLString(); + if (string.IsNullOrEmpty(imageUrl) || image.RelativeFilepath.Equals("/")) + return null; + return new RemoteImageInfo + { + ProviderName = "Shoko", + Type = imageType, + Url = imageUrl + }; + } + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { return new[] { ImageType.Primary, ImageType.Backdrop, ImageType.Banner }; } - + public bool Supports(BaseItem item) { return item is Series || item is Season || item is Episode || item is Movie || item is BoxSet; } - + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } -} \ No newline at end of file +} diff --git a/Shokofin/Utils/DataUtil.cs b/Shokofin/Utils/DataUtil.cs index 97590eb1..980abf45 100644 --- a/Shokofin/Utils/DataUtil.cs +++ b/Shokofin/Utils/DataUtil.cs @@ -6,31 +6,11 @@ using Shokofin.API; using Shokofin.API.Models; using Path = System.IO.Path; -using MediaBrowser.Model.Providers; namespace Shokofin.Utils { public class DataUtil { - internal static string GetImageUrl(Image image) - { - return image != null || !image.RelativeFilepath.Equals("/") ? $"http://{Plugin.Instance.Configuration.Host}:{Plugin.Instance.Configuration.Port}/api/v3/Image/{image.Source}/{image.Type}/{image.ID}" : null; - } - - public static RemoteImageInfo GetImage(Image image, ImageType imageType) - { - - var imageUrl = GetImageUrl(image); - if (string.IsNullOrEmpty(imageUrl)) - return null; - return new RemoteImageInfo - { - ProviderName = "Shoko", - Type = imageType, - Url = imageUrl - }; - } - public static float GetRating(Rating rating) { return rating == null ? 0 : (float) ((rating.Value * 10) / rating.MaxValue); @@ -47,7 +27,7 @@ public static async Task<IEnumerable<PersonInfo>> GetPeople(string seriesId) Type = PersonType.Actor, Name = role.Staff.Name, Role = role.Character.Name, - ImageUrl = GetImageUrl(role.Staff.Image) + ImageUrl = role.Staff.Image?.ToURLString(), }); } return list; From 37145ba2c11f438fe9e8a14d61d103f5e8954bfd Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 13 Dec 2020 19:29:46 +0100 Subject: [PATCH 0093/1103] fix: don't append if the sub-title is empty Don't append the ": " if the episode title is empty in the current language for movies --- Shokofin/Utils/TextUtil.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index b599594d..f5a599ed 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -122,7 +122,7 @@ private static string __GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Ti if (outputType == DisplyTitleType.SubTitle) return title; // Ignore sub-title of movie if it strictly equals the text below. - if (title != "Complete Movie") + if (title != "Complete Movie" && !string.IsNullOrEmpty(title?.Trim())) titleBuilder?.Append($": {title}"); return titleBuilder?.ToString() ?? ""; } From 262138cf7772e7f50e6d4792f72c6cf75a862d65 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 13 Dec 2020 20:28:50 +0100 Subject: [PATCH 0094/1103] Add simple client-side caching of _some_ data (i.e. anidb, tvdb) --- Shokofin/Utils/DataUtil.cs | 79 ++++++++++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/Shokofin/Utils/DataUtil.cs b/Shokofin/Utils/DataUtil.cs index 980abf45..1e7f0215 100644 --- a/Shokofin/Utils/DataUtil.cs +++ b/Shokofin/Utils/DataUtil.cs @@ -6,15 +6,18 @@ using Shokofin.API; using Shokofin.API.Models; using Path = System.IO.Path; +using FileSystemMetadata = MediaBrowser.Model.IO.FileSystemMetadata; +using Microsoft.Extensions.Caching.Memory; namespace Shokofin.Utils { public class DataUtil { - public static float GetRating(Rating rating) - { - return rating == null ? 0 : (float) ((rating.Value * 10) / rating.MaxValue); - } + private static IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions() { + ExpirationScanFrequency = new System.TimeSpan(0, 3, 0), + }); + + private static System.TimeSpan DefaultTimeSpan = new System.TimeSpan(0, 5, 0); public static async Task<IEnumerable<PersonInfo>> GetPeople(string seriesId) { @@ -52,6 +55,11 @@ public class FileInfo var file = result?.FirstOrDefault(); if (file == null) return (id, null, null, null, null); + var fileInfo = new FileInfo + { + ID = file.ID.ToString(), + Shoko = file, + }; var series = file?.SeriesIDs.FirstOrDefault(); var seriesId = series?.SeriesID.ID.ToString(); @@ -77,12 +85,6 @@ public class FileInfo return (id, null, null, null, null); } - var fileInfo = new FileInfo - { - ID = file.ID.ToString(), - Shoko = file, - }; - return (id, fileInfo, episodeInfo, seriesInfo, groupInfo); } @@ -127,6 +129,10 @@ public class EpisodeInfo public static async Task<EpisodeInfo> GetEpisodeInfo(string episodeId, int otherEpisodesCount = 0) { + if (string.IsNullOrEmpty(episodeId)) + return null; + if (_cache.TryGetValue<EpisodeInfo>($"episode:{otherEpisodesCount.ToString()}:{episodeId}", out var info)) + return info; var episode = await ShokoAPI.GetEpisode(episodeId); return await CreateEpisodeInfo(episode, episodeId, otherEpisodesCount); } @@ -137,14 +143,20 @@ public static async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string return null; if (string.IsNullOrEmpty(episodeId)) episodeId = episode.IDs.ID.ToString(); - return new EpisodeInfo + var cacheKey = $"episode:{otherEpisodesCount.ToString()}:{episodeId}"; + EpisodeInfo info = null; + if (_cache.TryGetValue<EpisodeInfo>(cacheKey, out info)) + return info; + info = new EpisodeInfo { ID = episodeId, - Shoko = await ShokoAPI.GetEpisode(episodeId), - AniDB = await ShokoAPI.GetEpisodeAniDb(episodeId), - TvDB = (await ShokoAPI.GetEpisodeTvDb(episodeId))?.FirstOrDefault(), + Shoko = (await ShokoAPI.GetEpisode(episodeId)), + AniDB = (await ShokoAPI.GetEpisodeAniDb(episodeId)), + TvDB = ((await ShokoAPI.GetEpisodeTvDb(episodeId))?.FirstOrDefault()), OtherEpisodesCount = otherEpisodesCount, }; + _cache.Set<EpisodeInfo>(cacheKey, info, DefaultTimeSpan); + return info; } #endregion @@ -186,6 +198,10 @@ public static async Task<SeriesInfo> GetSeriesInfoFromGroup(string groupId, int public static async Task<SeriesInfo> GetSeriesInfo(string seriesId) { + if (string.IsNullOrEmpty(seriesId)) + return null; + if (_cache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) + return info; var series = await ShokoAPI.GetSeries(seriesId); return await CreateSeriesInfo(series, seriesId); } @@ -196,13 +212,20 @@ private static async Task<SeriesInfo> CreateSeriesInfo(Series series, string ser return null; if (string.IsNullOrEmpty(seriesId)) seriesId = series.IDs.ID.ToString(); - return new SeriesInfo + SeriesInfo info = null; + var cacheKey = $"series:{seriesId}"; + if (_cache.TryGetValue<SeriesInfo>(cacheKey, out info)) + return info; + + info = new SeriesInfo { ID = seriesId, Shoko = series, - AniDB = await ShokoAPI.GetSeriesAniDb(seriesId), + AniDB = (await ShokoAPI.GetSeriesAniDb(seriesId)), TvDBID = series.IDs.TvDB.Count > 0 ? series.IDs.TvDB.FirstOrDefault().ToString() : null, }; + _cache.Set<SeriesInfo>(cacheKey, info, DefaultTimeSpan); + return info; } #endregion @@ -236,7 +259,8 @@ public static async Task<GroupInfo> GetGroupInfo(string groupId) { if (string.IsNullOrEmpty(groupId)) return null; - + if (_cache.TryGetValue<GroupInfo>($"group:{groupId}", out var info)) + return info; var group = await ShokoAPI.GetGroup(groupId); return await CreateGroupInfo(group, groupId); } @@ -244,7 +268,16 @@ public static async Task<GroupInfo> GetGroupInfo(string groupId) public static async Task<GroupInfo> GetGroupInfoForSeries(string seriesId) { var group = await ShokoAPI.GetGroupFromSeries(seriesId); - return await CreateGroupInfo(group); + if (group == null) + return null; + var groupId = group.IDs.ID.ToString(); + GroupInfo info = null; + var cacheKey = $"group-by-series:{seriesId}"; + if (_cache.TryGetValue<GroupInfo>(cacheKey, out info)) + return info; + info = await GetGroupInfo(groupId); + _cache.Set<GroupInfo>(cacheKey, info, DefaultTimeSpan); + return info; } private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId = null) @@ -255,6 +288,11 @@ private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId if (string.IsNullOrEmpty(groupId)) groupId = group.IDs.ID.ToString(); + var cacheKey = $"group:{groupId}"; + GroupInfo info = null; + if (_cache.TryGetValue<GroupInfo>(cacheKey, out info)) + return info; + var seriesList = await ShokoAPI.GetSeriesInGroup(groupId) .ContinueWith(async task => await Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s)))).Unwrap() .ContinueWith(l => l.Result.ToList()); @@ -299,14 +337,15 @@ private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId // Return if we can't get a base point for seasons. if (foundIndex == -1) return null; - - return new GroupInfo + info = new GroupInfo { ID = groupId, SeriesList = seriesList, DefaultSeries = seriesList[foundIndex], DefaultSeriesIndex = foundIndex, }; + _cache.Set<GroupInfo>(cacheKey, info, DefaultTimeSpan); + return info; } #endregion From 7329fdc605baa4e6be5cb10e40d266efa03c70c4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 13 Dec 2020 20:41:58 +0100 Subject: [PATCH 0095/1103] Add setting to mark specials episodes when grouped. A workaround, for now. --- Shokofin/Configuration/PluginConfiguration.cs | 3 +++ Shokofin/Configuration/configPage.html | 10 ++++++++++ Shokofin/Providers/EpisodeProvider.cs | 5 ++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 86877607..32f1dae6 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -39,6 +39,8 @@ public class PluginConfiguration : BasePluginConfiguration public SeasonOrderType SeasonOrdering { get; set; } + public bool MarkSpecialsWhenGrouped { get; set; } + public DisplayLanguageType TitleMainType { get; set; } public DisplayLanguageType TitleAlternateType { get; set; } @@ -63,6 +65,7 @@ public PluginConfiguration() TitleAlternateType = DisplayLanguageType.Origin; SeriesGrouping = SeriesGroupType.Default; SeasonOrdering = SeasonOrderType.Default; + MarkSpecialsWhenGrouped = true; } } } \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 0a926b0c..28e5f34e 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -84,6 +84,10 @@ <h3>Library Options</h3> <!--<option value="Chronological">Chronological order</option>--> </select> </div> + <label id="MarkSpecialsWhenGroupedItem" class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> + <span>Mark special episodes with <stong>SP</stong> and the special number in the seasons they belong to</span> + </label> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="UpdateWatchedStatus" /> <span>Update watched status on Shoko (Scrobble)</span> @@ -145,11 +149,14 @@ <h3>Tag Options</h3> document.querySelector('#TitleAlternateType').value = config.TitleAlternateType; document.querySelector('#SeriesGrouping').value = config.SeriesGrouping; document.querySelector('#SeasonOrdering').value = config.SeasonOrdering; + document.querySelector('#MarkSpecialsWhenGrouped').checked = config.MarkSpecialsWhenGrouped; if (config.SeriesGrouping === "ShokoGroup") { document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); + document.querySelector('#MarkSpecialsWhenGroupedItem').removeAttribute("hidden"); } else { document.querySelector('#SeasonOrderingItem').setAttribute("hidden", ""); + document.querySelector('#MarkSpecialsWhenGroupedItem').setAttribute("hidden", ""); } Dashboard.hideLoadingMsg(); }); @@ -158,9 +165,11 @@ <h3>Tag Options</h3> .addEventListener('input', function () { if (document.querySelector('#SeriesGrouping').value === "ShokoGroup") { document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); + document.querySelector('#MarkSpecialsWhenGroupedItem').removeAttribute("hidden"); } else { document.querySelector('#SeasonOrderingItem').setAttribute("hidden", ""); + document.querySelector('#MarkSpecialsWhenGroupedItem').setAttribute("hidden", ""); } }); @@ -187,6 +196,7 @@ <h3>Tag Options</h3> config.TitleAlternateType = document.querySelector('#TitleAlternateType').value; config.SeriesGrouping = document.querySelector('#SeriesGrouping').value; config.SeasonOrdering = document.querySelector('#SeasonOrdering').value; + config.MarkSpecialsWhenGrouped = document.querySelector('#MarkSpecialsWhenGrouped').checked; ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); }); e.preventDefault(); diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 8d66373b..b7b09b5b 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -52,7 +52,10 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell var ( displayTitle, alternateTitle ) = TextUtil.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, episode.Shoko.Name, info.MetadataLanguage); int aniDBId = episode.AniDB.ID; int tvdbId = episode?.TvDB?.ID ?? 0; - + if (group != null && episode.AniDB.Type != EpisodeType.Episode && Plugin.Instance.Configuration.MarkSpecialsWhenGrouped) { + displayTitle = $"SP {episode.AniDB.EpisodeNumber} {displayTitle}"; + alternateTitle = $"SP {episode.AniDB.EpisodeNumber} {alternateTitle}"; + } result.Item = new Episode { IndexNumber = OrderingUtil.GetIndexNumber(series, episode), From 2f16211cab827fb200ecc71a99a87ca27425cff4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 13 Dec 2020 21:25:37 +0100 Subject: [PATCH 0096/1103] Add movie ordering and library seperation --- Shokofin/API/Models/Rating.cs | 5 + Shokofin/API/ShokoAPI.cs | 6 + Shokofin/Configuration/PluginConfiguration.cs | 21 ++- Shokofin/Configuration/configPage.html | 46 ++++- Shokofin/ExternalIds.cs | 2 +- Shokofin/Providers/BoxSetProvider.cs | 175 ++++++++++++++---- Shokofin/Providers/EpisodeProvider.cs | 62 +++++-- Shokofin/Providers/MovieProvider.cs | 71 +++++-- Shokofin/Providers/SeasonProvider.cs | 4 +- Shokofin/Providers/SeriesProvider.cs | 65 ++++++- Shokofin/Utils/DataUtil.cs | 132 ++++++------- Shokofin/Utils/OrderingUtil.cs | 106 +++++++++-- 12 files changed, 523 insertions(+), 172 deletions(-) diff --git a/Shokofin/API/Models/Rating.cs b/Shokofin/API/Models/Rating.cs index b6feb534..7226fb93 100644 --- a/Shokofin/API/Models/Rating.cs +++ b/Shokofin/API/Models/Rating.cs @@ -11,5 +11,10 @@ public class Rating public int Votes { get; set; } public string Type { get; set; } + + public float ToFloat(uint scale = 1) + { + return (float)((Value * scale) / MaxValue); + } } } \ No newline at end of file diff --git a/Shokofin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPI.cs index 5dd57b9c..cac0eb42 100644 --- a/Shokofin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPI.cs @@ -77,6 +77,12 @@ public static async Task<Episode> GetEpisode(string id) return responseStream != null ? await JsonSerializer.DeserializeAsync<Episode>(responseStream) : null; } + public static async Task<List<Episode>> GetEpisodesFromSeries(string seriesId) + { + var responseStream = await CallApi($"/api/v3/Series/{seriesId}/Episode"); + return responseStream != null ? await JsonSerializer.DeserializeAsync<List<Episode>>(responseStream) : null; + } + public static async Task<List<Episode>> GetEpisodeFromFile(string id) { var responseStream = await CallApi($"/api/v3/File/{id}/Episode"); diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 32f1dae6..4d34602a 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,7 +1,7 @@ using MediaBrowser.Model.Plugins; using DisplayLanguageType = Shokofin.Utils.TextUtil.DisplayLanguageType; -using SeriesGroupType = Shokofin.Utils.OrderingUtil.SeriesGroupType; -using SeasonOrderType = Shokofin.Utils.OrderingUtil.SeasonOrderType; +using SeriesAndBoxSetGroupType = Shokofin.Utils.OrderingUtil.SeriesOrBoxSetGroupType; +using SeasonAndMovieOrderType = Shokofin.Utils.OrderingUtil.SeasonAndMovieOrderType; namespace Shokofin.Configuration { @@ -35,12 +35,18 @@ public class PluginConfiguration : BasePluginConfiguration public bool SynopsisCleanMultiEmptyLines { get; set; } - public SeriesGroupType SeriesGrouping { get; set; } + public SeriesAndBoxSetGroupType SeriesGrouping { get; set; } - public SeasonOrderType SeasonOrdering { get; set; } + public SeasonAndMovieOrderType SeasonOrdering { get; set; } public bool MarkSpecialsWhenGrouped { get; set; } + public SeriesAndBoxSetGroupType BoxSetGrouping { get; set; } + + public SeasonAndMovieOrderType MovieOrdering { get; set; } + + public bool SeperateLibraries { get; set; } + public DisplayLanguageType TitleMainType { get; set; } public DisplayLanguageType TitleAlternateType { get; set; } @@ -63,9 +69,12 @@ public PluginConfiguration() SynopsisCleanMultiEmptyLines = true; TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; - SeriesGrouping = SeriesGroupType.Default; - SeasonOrdering = SeasonOrderType.Default; + SeriesGrouping = SeriesAndBoxSetGroupType.Default; + SeasonOrdering = SeasonAndMovieOrderType.Default; MarkSpecialsWhenGrouped = true; + BoxSetGrouping = SeriesAndBoxSetGroupType.Default; + MovieOrdering = SeasonAndMovieOrderType.Default; + SeperateLibraries = false; } } } \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 28e5f34e..0fedb0b6 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -70,9 +70,8 @@ <h3>Library Options</h3> <div class="selectContainer"> <label class="selectLabel" for="SeriesGrouping">Series grouping</label> <select is="emby-select" id="SeriesGrouping" name="SeriesGrouping" class="emby-select-withcolor emby-select"> - <option value="Default" selected>No series grouping</option> - <option value="TvDB">No series grouping, but make series merge-friendly with TvDB - </option> + <option value="Default" selected>Do not group series</option> + <option value="MergeFriendly">Make series merge-friendly with TvDB/TMDB</option> <option value="ShokoGroup">Group series based on Shoko's group feature</option> </select> </div> @@ -88,6 +87,26 @@ <h3>Library Options</h3> <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> <span>Mark special episodes with <stong>SP</stong> and the special number in the seasons they belong to</span> </label> + <div class="selectContainer"> + <label class="selectLabel" for="BoxSetGrouping">Box-set grouping</label> + <select is="emby-select" id="BoxSetGrouping" name="BoxSetGrouping" class="emby-select-withcolor emby-select"> + <option value="Default" selected>Do not group movies</option> + <option value="ShokoSeries">Group movies within Shoko's series only</option> + <option value="ShokoGroup">Group movies in Shoko's groups and series</option> + </select> + </div> + <div id="MovieOrderingItem" class="selectContainer"> + <label class="selectLabel" for="MovieOrdering">Movie ordering</label> + <select is="emby-select" id="MovieOrdering" name="MovieOrdering" class="emby-select-withcolor emby-select"> + <option value="Default">Let Shoko decide</option> + <option value="ReleaseDate">By release date</option> + <!--<option value="Chronological">Chronological order</option>--> + </select> + </div> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="SeperateLibraries" /> + <span>Disallow overlap of Movies and Series libraries</span> + </label> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="UpdateWatchedStatus" /> <span>Update watched status on Shoko (Scrobble)</span> @@ -149,7 +168,10 @@ <h3>Tag Options</h3> document.querySelector('#TitleAlternateType').value = config.TitleAlternateType; document.querySelector('#SeriesGrouping').value = config.SeriesGrouping; document.querySelector('#SeasonOrdering').value = config.SeasonOrdering; + document.querySelector('#BoxSetGrouping').value = config.BoxSetGrouping; + document.querySelector('#MovieOrdering').value = config.MovieOrdering; document.querySelector('#MarkSpecialsWhenGrouped').checked = config.MarkSpecialsWhenGrouped; + document.querySelector('#SeperateLibraries').checked = config.SeperateLibraries; if (config.SeriesGrouping === "ShokoGroup") { document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); document.querySelector('#MarkSpecialsWhenGroupedItem').removeAttribute("hidden"); @@ -158,6 +180,12 @@ <h3>Tag Options</h3> document.querySelector('#SeasonOrderingItem').setAttribute("hidden", ""); document.querySelector('#MarkSpecialsWhenGroupedItem').setAttribute("hidden", ""); } + if (config.BoxSetGrouping === "ShokoGroup") { + document.querySelector('#MovieOrderingItem').removeAttribute("hidden"); + } + else { + document.querySelector('#MovieOrderingItem').setAttribute("hidden", ""); + } Dashboard.hideLoadingMsg(); }); }); @@ -172,6 +200,15 @@ <h3>Tag Options</h3> document.querySelector('#MarkSpecialsWhenGroupedItem').setAttribute("hidden", ""); } }); + document.querySelector('#BoxSetGrouping') + .addEventListener('input', function () { + if (document.querySelector('#BoxSetGrouping').value === "ShokoGroup") { + document.querySelector('#MovieOrderingItem').removeAttribute("hidden"); + } + else { + document.querySelector('#MovieOrderingItem').setAttribute("hidden", ""); + } + }); document.querySelector('.shokoConfigForm') .addEventListener('submit', function (e) { @@ -196,7 +233,10 @@ <h3>Tag Options</h3> config.TitleAlternateType = document.querySelector('#TitleAlternateType').value; config.SeriesGrouping = document.querySelector('#SeriesGrouping').value; config.SeasonOrdering = document.querySelector('#SeasonOrdering').value; + config.BoxSetGrouping = document.querySelector('#BoxSetGrouping').value; + config.MovieOrdering = document.querySelector('#MovieOrdering').value; config.MarkSpecialsWhenGrouped = document.querySelector('#MarkSpecialsWhenGrouped').checked; + config.SeperateLibraries = document.querySelector('#SeperateLibraries').checked; ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); }); e.preventDefault(); diff --git a/Shokofin/ExternalIds.cs b/Shokofin/ExternalIds.cs index 6b9a387b..d87e38b2 100644 --- a/Shokofin/ExternalIds.cs +++ b/Shokofin/ExternalIds.cs @@ -9,7 +9,7 @@ namespace Shokofin public class ShokoGroupExternalId : IExternalId { public bool Supports(IHasProviderIds item) - => item is Series; + => item is Series || item is BoxSet; public string ProviderName => "Shoko Group"; diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 61ab5283..f4f8779d 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -12,76 +13,140 @@ using Shokofin.API; using Shokofin.Utils; +using IResolverIgnoreRule = MediaBrowser.Controller.Resolvers.IResolverIgnoreRule; +using ILibraryManager = MediaBrowser.Controller.Library.ILibraryManager; + namespace Shokofin.Providers { - public class BoxSetProvider : IHasOrder, IRemoteMetadataProvider<BoxSet, BoxSetInfo> + public class BoxSetProvider : IHasOrder, IRemoteMetadataProvider<BoxSet, BoxSetInfo>, IResolverIgnoreRule { public string Name => "Shoko"; public int Order => 1; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger<BoxSetProvider> _logger; - public BoxSetProvider(IHttpClientFactory httpClientFactory, ILogger<BoxSetProvider> logger) + private readonly ILibraryManager _library; + + public BoxSetProvider(IHttpClientFactory httpClientFactory, ILogger<BoxSetProvider> logger, ILibraryManager library) { _logger = logger; _httpClientFactory = httpClientFactory; + _library = library; } public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) { try { - var result = new MetadataResult<BoxSet>(); - var (id, series) = await DataUtil.GetSeriesInfoByPath(info.Path); - - if (series == null) + switch (Plugin.Instance.Configuration.BoxSetGrouping) { - _logger.LogWarning($"Unable to find series info for path {info.Path}"); - return result; + default: + return await GetDefaultMetadata(info, cancellationToken); + case OrderingUtil.SeriesOrBoxSetGroupType.ShokoGroup: + return await GetShokoGroupedMetadata(info, cancellationToken); } - _logger.LogInformation($"Getting series metadata ({info.Path} - {series.ID})"); + } + catch (Exception e) + { + _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); + return new MetadataResult<BoxSet>(); + } + } - int aniDBId = series.AniDB.ID; - var tvdbId = series?.TvDBID; + public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<BoxSet>(); + var (id, series) = await DataUtil.GetSeriesInfoByPath(info.Path); - if (series.AniDB.Type != "Movie") - { - _logger.LogWarning("Series found, but not a movie! Skipping."); - return result; - } + if (series == null) + { + _logger.LogWarning($"Shoko Scanner... Unable to find series info for path {info.Path}"); + return result; + } - if (series.Shoko.Sizes.Total.Episodes <= 1) - { - _logger.LogWarning("Series did not contain multiple movies! Skipping."); - return result; - } + int aniDBId = series.AniDB.ID; + var tvdbId = series?.TvDBID; - var ( displayTitle, alternateTitle ) = TextUtil.GetSeriesTitles(series.AniDB.Titles, series.AniDB.Title, info.MetadataLanguage); - var tags = await DataUtil.GetTags(series.ID); + if (series.AniDB.Type != "Movie") + { + _logger.LogWarning($"Shoko Scanner... File found, but not a movie! Skipping."); + return result; + } - result.Item = new BoxSet - { - Name = displayTitle, - OriginalTitle = alternateTitle, - Overview = TextUtil.SummarySanitizer(series.AniDB.Description), - PremiereDate = series.AniDB.AirDate, - EndDate = series.AniDB.EndDate, - ProductionYear = series.AniDB.AirDate?.Year, - Tags = tags, - CommunityRating = (float)((series.AniDB.Rating.Value * 10) / series.AniDB.Rating.MaxValue) - }; - result.Item.SetProviderId("Shoko Series", series.ID); - result.HasMetadata = true; + if (series.Shoko.Sizes.Total.Episodes <= 1) + { + _logger.LogWarning("Shoko Scanner... series did not contain multiple movies! Skipping."); + return result; + } + var ( displayTitle, alternateTitle ) = TextUtil.GetSeriesTitles(series.AniDB.Titles, series.AniDB.Title, info.MetadataLanguage); + var tags = await DataUtil.GetTags(series.ID); + + result.Item = new BoxSet + { + Name = displayTitle, + OriginalTitle = alternateTitle, + Overview = TextUtil.SummarySanitizer(series.AniDB.Description), + PremiereDate = series.AniDB.AirDate, + EndDate = series.AniDB.EndDate, + ProductionYear = series.AniDB.AirDate?.Year, + Tags = tags, + CommunityRating = series.AniDB.Rating.ToFloat(10), + }; + result.Item.SetProviderId("Shoko Series", series.ID); + result.HasMetadata = true; + + return result; + } + + private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<BoxSet>(); + var (id, group) = await DataUtil.GetGroupInfoByPath(info.Path, true); + if (group == null) + { + _logger.LogWarning($"Shoko Scanner... Unable to find box-set info for path {id}"); return result; } - catch (Exception e) + + var series = group.DefaultSeries; + var tvdbId = series?.TvDBID; + + if (series.AniDB.Type != "Movie") { - _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); - return new MetadataResult<BoxSet>(); + _logger.LogWarning($"Shoko Scanner... File found, but not a movie! Skipping."); + return result; } + + var tags = await DataUtil.GetTags(series.ID); + var ( displayTitle, alternateTitle ) = TextUtil.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); + + result.Item = new BoxSet + { + Name = displayTitle, + OriginalTitle = alternateTitle, + Overview = TextUtil.SummarySanitizer(series.AniDB.Description), + PremiereDate = series.AniDB.AirDate, + EndDate = series.AniDB.EndDate, + ProductionYear = series.AniDB.AirDate?.Year, + Tags = tags, + CommunityRating = (float)((series.AniDB.Rating.Value * 10) / series.AniDB.Rating.MaxValue) + }; + + result.Item.SetProviderId("Shoko Series", series.ID); + result.Item.SetProviderId("Shoko Group", group.ID); + result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); + if (!string.IsNullOrEmpty(tvdbId)) result.Item.SetProviderId("Tvdb", tvdbId); + result.HasMetadata = true; + + result.ResetPeople(); + foreach (var person in await DataUtil.GetPeople(series.ID)) + result.AddPerson(person); + + return result; } + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) { _logger.LogInformation($"Searching BoxSet ({searchInfo.Name})"); @@ -93,7 +158,7 @@ public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo s foreach (var series in searchResults) { - var imageUrl = DataUtil.GetImageUrl(series.Images.Posters.FirstOrDefault()); + var imageUrl = series.Images.Posters.FirstOrDefault()?.ToURLString(); _logger.LogInformation(imageUrl); var parsedBoxSet = new RemoteSearchResult { @@ -113,5 +178,35 @@ public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken { return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); } + + public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, BaseItem parent) + { + // Skip this handler if one of these requirements are met + if (fileInfo == null || parent == null || !fileInfo.IsDirectory || !fileInfo.Exists || !(parent is Folder)) + return false; + var libType = _library.GetInheritedContentType(parent); + if (libType != "movies") { + return false; + } + try { + var (id, series) = DataUtil.GetSeriesInfoByPathSync(fileInfo); + if (series == null) + { + _logger.LogWarning($"Shoko Scanner... Unable to find series info for path {id}"); + return false; + } + _logger.LogInformation($"Shoko Filter... Found series info for path {id}"); + // Ignore series if we want to sperate our libraries + if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type != "Movie") + return true; + return false; + } + catch (System.Exception e) + { + if (!(e is System.Net.Http.HttpRequestException && e.Message.Contains("Connection refused"))) + _logger.LogError(e, "Threw unexpectedly"); + return false; + } + } } } diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index b7b09b5b..a0479c85 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -10,18 +11,26 @@ using Microsoft.Extensions.Logging; using Shokofin.Utils; +using IResolverIgnoreRule = MediaBrowser.Controller.Resolvers.IResolverIgnoreRule; +using ILibraryManager = MediaBrowser.Controller.Library.ILibraryManager; + namespace Shokofin.Providers { - public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo> + public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo>, IResolverIgnoreRule { public string Name => "Shoko"; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger<EpisodeProvider> _logger; - public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProvider> logger) + private readonly ILibraryManager _library; + + public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProvider> logger, ILibraryManager library) { _httpClientFactory = httpClientFactory; _logger = logger; + _library = library; } public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) @@ -30,7 +39,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell { var result = new MetadataResult<Episode>(); - var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == OrderingUtil.SeriesGroupType.ShokoGroup; + var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == OrderingUtil.SeriesOrBoxSetGroupType.ShokoGroup; var (id, file, episode, series, group) = await DataUtil.GetFileInfoByPath(info.Path, includeGroup); if (file == null) // if file is null then series and episode is also null. @@ -40,19 +49,10 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell } _logger.LogInformation($"Found file info for path {id}"); - var extraType = OrderingUtil.GetExtraType(episode.AniDB); - if (extraType != null) - { - _logger.LogDebug($"Not a normal or special episode, skipping path {id}"); - result.HasMetadata = false; - return result; - } - _logger.LogInformation($"Getting episode metadata ({info.Path} - {episode.ID})"); - var ( displayTitle, alternateTitle ) = TextUtil.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, episode.Shoko.Name, info.MetadataLanguage); int aniDBId = episode.AniDB.ID; int tvdbId = episode?.TvDB?.ID ?? 0; - if (group != null && episode.AniDB.Type != EpisodeType.Episode && Plugin.Instance.Configuration.MarkSpecialsWhenGrouped) { + if (group != null && episode.AniDB.Type != "Normal" && Plugin.Instance.Configuration.MarkSpecialsWhenGrouped) { displayTitle = $"SP {episode.AniDB.EpisodeNumber} {displayTitle}"; alternateTitle = $"SP {episode.AniDB.EpisodeNumber} {alternateTitle}"; } @@ -72,7 +72,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); result.HasMetadata = true; - var episodeNumberEnd = episode.AniDB.EpisodeNumber + episode.OtherEpisodesCount; + var episodeNumberEnd = episode.AniDB.EpisodeNumber + file.EpisodesCount - 1; if (episode.AniDB.EpisodeNumber != episodeNumberEnd) result.Item.IndexNumberEnd = episodeNumberEnd; return result; @@ -94,5 +94,39 @@ public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken { return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); } + + public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, BaseItem parent) + { + // Skip this handler if one of these requirements are met + if (fileInfo == null || parent == null || fileInfo.IsDirectory || !fileInfo.Exists || !(parent is Series || parent is Season)) + return false; + var libType = _library.GetInheritedContentType(parent); + if (libType != "tvshows") { + return false; + } + try { + var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == OrderingUtil.SeriesOrBoxSetGroupType.ShokoGroup; + var (id, file, episode, series, group) = DataUtil.GetFileInfoByPath(fileInfo, includeGroup); + if (file == null) // if file is null then series and episode is also null. + { + _logger.LogWarning($"Shoko Filter... Unable to find file info for path {id}"); + return true; + } + _logger.LogInformation($"Shoko Filter... Found file info for path {id}"); + var extraType = OrderingUtil.GetExtraType(episode.AniDB); + if (extraType != null) + { + _logger.LogDebug($"Shoko Filter... Not a normal or special episode, skipping path {id}"); + return true; + } + return false; + } + catch (System.Exception e) + { + if (!(e is System.Net.Http.HttpRequestException && e.Message.Contains("Connection refused"))) + _logger.LogError(e, "Threw unexpectedly"); + return false; + } + } } } diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index b01c2664..4f682cc2 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -10,28 +11,34 @@ using Microsoft.Extensions.Logging; using Shokofin.Utils; +using IResolverIgnoreRule = MediaBrowser.Controller.Resolvers.IResolverIgnoreRule; +using ILibraryManager = MediaBrowser.Controller.Library.ILibraryManager; + namespace Shokofin.Providers { - public class MovieProvider : IRemoteMetadataProvider<Movie, MovieInfo> + public class MovieProvider : IRemoteMetadataProvider<Movie, MovieInfo>, IResolverIgnoreRule { public string Name => "Shoko"; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger<MovieProvider> _logger; - public MovieProvider(IHttpClientFactory httpClientFactory, ILogger<MovieProvider> logger) + private readonly ILibraryManager _library; + + public MovieProvider(IHttpClientFactory httpClientFactory, ILogger<MovieProvider> logger, ILibraryManager library) { - _httpClientFactory = httpClientFactory; _logger = logger; + _httpClientFactory = httpClientFactory; + _library = library; } - public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken) { try { var result = new MetadataResult<Movie>(); - var (id, file, episode, series, _group) = await DataUtil.GetFileInfoByPath(info.Path); + var includeGroup = Plugin.Instance.Configuration.BoxSetGrouping == OrderingUtil.SeriesOrBoxSetGroupType.ShokoGroup; + var (id, file, episode, series, group) = await DataUtil.GetFileInfoByPath(info.Path, includeGroup, true); if (file == null) // if file is null then series and episode is also null. { @@ -49,35 +56,29 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio return result; } - var extraType = OrderingUtil.GetExtraType(episode.AniDB); - if (extraType != null) - { - _logger.LogWarning($"File found, but not a movie! Skipping path {id}"); - return result; - } - var tags = await DataUtil.GetTags(series.ID); var ( displayTitle, alternateTitle ) = TextUtil.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); - var rating = DataUtil.GetRating(isMultiEntry ? episode.AniDB.Rating : series.AniDB.Rating); + var rating = isMultiEntry ? episode.AniDB.Rating.ToFloat(10) : series.AniDB.Rating.ToFloat(10); result.Item = new Movie { - IndexNumber = OrderingUtil.GetIndexNumber(series, episode), + IndexNumber = OrderingUtil.GetMovieIndexNumber(group, series, episode), Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, // Use the file description if collection contains more than one movie, otherwise use the collection description. Overview = TextUtil.SummarySanitizer((isMultiEntry ? episode.AniDB.Description ?? series.AniDB.Description : series.AniDB.Description) ?? ""), ProductionYear = episode.AniDB.AirDate?.Year, - ExtraType = extraType, Tags = tags, CommunityRating = rating, }; result.Item.SetProviderId("Shoko File", file.ID); result.Item.SetProviderId("Shoko Series", series.ID); result.Item.SetProviderId("Shoko Episode", episode.ID); - if (aniDBId != 0) result.Item.SetProviderId("AniDB", aniDBId.ToString()); - if (!string.IsNullOrEmpty(tvdbId)) result.Item.SetProviderId("Tvdb", tvdbId); + if (aniDBId != 0) + result.Item.SetProviderId("AniDB", aniDBId.ToString()); + if (!string.IsNullOrEmpty(tvdbId)) + result.Item.SetProviderId("Tvdb", tvdbId); result.HasMetadata = true; result.ResetPeople(); @@ -104,5 +105,41 @@ public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken { return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); } + + public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, BaseItem parent) + { + // Skip this handler if one of these requirements are met + if (fileInfo == null || parent == null || fileInfo.IsDirectory || !fileInfo.Exists) + return false; + var libType = _library.GetInheritedContentType(parent); + if (libType != "movies") { + return false; + } + try { + var (id, file, episode, series, _group) = DataUtil.GetFileInfoByPath(fileInfo); + if (file == null) + { + _logger.LogWarning($"Shoko Scanner... Unable to find series info for path {id}"); + return false; + } + _logger.LogInformation($"Shoko Filter... Found series info for path {id}"); + if (series.AniDB.Type != "Movie") { + return true; + } + var extraType = OrderingUtil.GetExtraType(episode.AniDB); + if (extraType != null) { + _logger.LogInformation($"Shoko Filter... File was not a 'normal' episode for path, skipping! {id}"); + return true; + } + // Ignore everything except movies + return false; + } + catch (System.Exception e) + { + if (!(e is System.Net.Http.HttpRequestException && e.Message.Contains("Connection refused"))) + _logger.LogError(e, "Threw unexpectedly"); + return false; + } + } } } diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index f0ef5968..7af3af16 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -31,7 +31,7 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat { default: return GetDefaultMetadata(info, cancellationToken); - case OrderingUtil.SeriesGroupType.ShokoGroup: + case OrderingUtil.SeriesOrBoxSetGroupType.ShokoGroup: return await GetShokoGroupedMetadata(info, cancellationToken); } } @@ -91,7 +91,7 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, Tags = tags, - CommunityRating = DataUtil.GetRating(series.AniDB.Rating), + CommunityRating = series.AniDB.Rating?.ToFloat(10), }; result.HasMetadata = true; diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 399f2cc1..ae01f127 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -12,19 +13,28 @@ using Shokofin.API; using Shokofin.Utils; +using IResolverIgnoreRule = MediaBrowser.Controller.Resolvers.IResolverIgnoreRule; +using ILibraryManager = MediaBrowser.Controller.Library.ILibraryManager; + namespace Shokofin.Providers { - public class SeriesProvider : IHasOrder, IRemoteMetadataProvider<Series, SeriesInfo> + public class SeriesProvider : IHasOrder, IRemoteMetadataProvider<Series, SeriesInfo>, IResolverIgnoreRule { public string Name => "Shoko"; + public int Order => 1; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger<SeriesProvider> _logger; - public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger) + private readonly ILibraryManager _library; + + public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ILibraryManager library) { _logger = logger; _httpClientFactory = httpClientFactory; + _library = library; } public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) @@ -35,7 +45,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat { default: return await GetDefaultMetadata(info, cancellationToken); - case OrderingUtil.SeriesGroupType.ShokoGroup: + case OrderingUtil.SeriesOrBoxSetGroupType.ShokoGroup: return await GetShokoGroupedMetadata(info, cancellationToken); } } @@ -60,6 +70,12 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C var tags = await DataUtil.GetTags(series.ID); var ( displayTitle, alternateTitle ) = TextUtil.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); + if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type == "Movie") + { + _logger.LogWarning($"Shoko Scanner... Separate libraries are on, skipping {id}"); + return result; + } + result.Item = new Series { Name = displayTitle, @@ -70,7 +86,7 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C ProductionYear = series.AniDB.AirDate?.Year, Status = series.AniDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, Tags = tags, - CommunityRating = (float)((series.AniDB.Rating.Value * 10) / series.AniDB.Rating.MaxValue) + CommunityRating = series.AniDB.Rating.ToFloat(10) }; result.Item.SetProviderId("Shoko Series", series.ID); @@ -97,7 +113,11 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in _logger.LogInformation($"Found series info for path {id}"); var series = group.DefaultSeries; - var tvdbId = series?.TvDBID; + if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type == "Movie") + { + _logger.LogWarning($"Shoko Scanner... Separate libraries are on, skipping {id}"); + return result; + } var tags = await DataUtil.GetTags(series.ID); var ( displayTitle, alternateTitle ) = TextUtil.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); @@ -112,12 +132,13 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in ProductionYear = series.AniDB.AirDate?.Year, Status = series.AniDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, Tags = tags, - CommunityRating = (float)((series.AniDB.Rating.Value * 10) / series.AniDB.Rating.MaxValue) + CommunityRating = series.AniDB.Rating.ToFloat(10), }; result.Item.SetProviderId("Shoko Series", series.ID); result.Item.SetProviderId("Shoko Group", group.ID); result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); + var tvdbId = series?.TvDBID; if (!string.IsNullOrEmpty(tvdbId)) result.Item.SetProviderId("Tvdb", tvdbId); result.HasMetadata = true; @@ -139,7 +160,7 @@ public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo s foreach (var series in searchResults) { - var imageUrl = DataUtil.GetImageUrl(series.Images.Posters.FirstOrDefault()); + var imageUrl = series.Images.Posters.FirstOrDefault()?.ToURLString(); _logger.LogInformation(imageUrl); var parsedSeries = new RemoteSearchResult { @@ -159,5 +180,35 @@ public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken { return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); } + + public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, BaseItem parent) + { + // Skip this handler if one of these requirements are met + if (fileInfo == null || parent == null || !fileInfo.IsDirectory || !fileInfo.Exists || !(parent is Folder)) + return false; + var libType = _library.GetInheritedContentType(parent); + if (libType != "tvshows") { + return false; + } + try { + var (id, series) = DataUtil.GetSeriesInfoByPathSync(fileInfo); + if (series == null) + { + _logger.LogWarning($"Shoko Scanner... Unable to find series info for path {id}"); + return false; + } + _logger.LogInformation($"Shoko Filter... Found series info for path {id}"); + // Ignore movies if we want to sperate our libraries + if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type == "Movie") + return true; + return false; + } + catch (System.Exception e) + { + if (!(e is System.Net.Http.HttpRequestException && e.Message.Contains("Connection refused"))) + _logger.LogError(e, "Threw unexpectedly"); + return false; + } + } } } diff --git a/Shokofin/Utils/DataUtil.cs b/Shokofin/Utils/DataUtil.cs index 1e7f0215..42acf970 100644 --- a/Shokofin/Utils/DataUtil.cs +++ b/Shokofin/Utils/DataUtil.cs @@ -42,9 +42,15 @@ public class FileInfo { public string ID; public File Shoko; + public int EpisodesCount; } - public static async Task<(string, FileInfo, EpisodeInfo, SeriesInfo, GroupInfo)> GetFileInfoByPath(string path, bool includeGroup = true) + public static (string, FileInfo, EpisodeInfo, SeriesInfo, GroupInfo) GetFileInfoByPath(FileSystemMetadata metadata, bool includeGroup = true, bool onlyMovies = false) + { + return GetFileInfoByPath(Path.Join(metadata.DirectoryName, metadata.FullName), includeGroup, onlyMovies).GetAwaiter().GetResult(); + } + + public static async Task<(string, FileInfo, EpisodeInfo, SeriesInfo, GroupInfo)> GetFileInfoByPath(string path, bool includeGroup = true, bool onlyMovies = false) { // TODO: Check if it can be written in a better way. Parent directory + File Name var id = Path.Join( @@ -55,61 +61,34 @@ public class FileInfo var file = result?.FirstOrDefault(); if (file == null) return (id, null, null, null, null); + var series = file?.SeriesIDs.FirstOrDefault(); var fileInfo = new FileInfo { ID = file.ID.ToString(), Shoko = file, + EpisodesCount = series?.EpisodeIDs?.Count ?? 0, }; - var series = file?.SeriesIDs.FirstOrDefault(); var seriesId = series?.SeriesID.ID.ToString(); var episodes = series?.EpisodeIDs?.FirstOrDefault(); var episodeId = episodes?.ID.ToString(); - var otherEpisodesCount = series?.EpisodeIDs.Count() - 1 ?? 0; if (string.IsNullOrEmpty(seriesId) || string.IsNullOrEmpty(episodeId)) return (id, null, null, null, null); - var episodeInfo = await GetEpisodeInfo(episodeId); - if (episodeInfo == null) - return (id, null, null, null, null); - - var seriesInfo = await GetSeriesInfo(seriesId); - if (episodeInfo == null) - return (id, null, null, null, null); - GroupInfo groupInfo = null; if (includeGroup) { - groupInfo = await GetGroupInfoForSeries(seriesId); + groupInfo = await GetGroupInfoForSeries(seriesId, onlyMovies); if (groupInfo == null) return (id, null, null, null, null); } - return (id, fileInfo, episodeInfo, seriesInfo, groupInfo); - } - - public static async Task<(string, FileInfo, EpisodeInfo, SeriesInfo, GroupInfo)> GetFileInfoByID(string fileId, string id = null) - { - var file = await ShokoAPI.GetFile(fileId); - if (file == null) - return (id, null, null, null, null); - var fileInfo = new FileInfo - { - ID = fileId, - Shoko = file, - }; - - var episodes = await ShokoAPI.GetEpisodeFromFile(fileId); - var episodeInfo = await CreateEpisodeInfo(episodes[0], null, episodes?.Count ?? 0 - 1); - if (episodeInfo == null) - return (id, null, null, null, null); - - var seriesInfo = await CreateSeriesInfo(await ShokoAPI.GetSeriesFromEpisode(episodeInfo.ID)); + var seriesInfo = await GetSeriesInfo(seriesId); if (seriesInfo == null) return (id, null, null, null, null); - var groupInfo = await GetGroupInfoForSeries(seriesInfo.ID); - if (groupInfo == null) + var episodeInfo = await GetEpisodeInfo(episodeId); + if (episodeInfo == null) return (id, null, null, null, null); return (id, fileInfo, episodeInfo, seriesInfo, groupInfo); @@ -124,26 +103,25 @@ public class EpisodeInfo public Episode Shoko; public Episode.AniDB AniDB; public Episode.TvDB TvDB; - public int OtherEpisodesCount; } - public static async Task<EpisodeInfo> GetEpisodeInfo(string episodeId, int otherEpisodesCount = 0) + public static async Task<EpisodeInfo> GetEpisodeInfo(string episodeId) { if (string.IsNullOrEmpty(episodeId)) return null; - if (_cache.TryGetValue<EpisodeInfo>($"episode:{otherEpisodesCount.ToString()}:{episodeId}", out var info)) + if (_cache.TryGetValue<EpisodeInfo>($"episode:{episodeId}", out var info)) return info; var episode = await ShokoAPI.GetEpisode(episodeId); - return await CreateEpisodeInfo(episode, episodeId, otherEpisodesCount); + return await CreateEpisodeInfo(episode, episodeId); } - public static async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string episodeId = null, int otherEpisodesCount = 0) + public static async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string episodeId = null) { if (episode == null) return null; if (string.IsNullOrEmpty(episodeId)) episodeId = episode.IDs.ID.ToString(); - var cacheKey = $"episode:{otherEpisodesCount.ToString()}:{episodeId}"; + var cacheKey = $"episode:{episodeId}"; EpisodeInfo info = null; if (_cache.TryGetValue<EpisodeInfo>(cacheKey, out info)) return info; @@ -153,7 +131,6 @@ public static async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string Shoko = (await ShokoAPI.GetEpisode(episodeId)), AniDB = (await ShokoAPI.GetEpisodeAniDb(episodeId)), TvDB = ((await ShokoAPI.GetEpisodeTvDb(episodeId))?.FirstOrDefault()), - OtherEpisodesCount = otherEpisodesCount, }; _cache.Set<EpisodeInfo>(cacheKey, info, DefaultTimeSpan); return info; @@ -168,6 +145,20 @@ public class SeriesInfo public Series Shoko; public Series.AniDB AniDB; public string TvDBID; + /// <summary> + /// All episodes (of all type) that belong to this series. + /// </summary> + public List<EpisodeInfo> EpisodeList; + /// <summary> + /// A pre-filtered list of special episodes without an ExtraType + /// attached. + /// </summary> + public List<EpisodeInfo> FilteredSpecialEpisodesList; + } + + public static (string, SeriesInfo) GetSeriesInfoByPathSync(FileSystemMetadata metadata) + { + return GetSeriesInfoByPath(metadata.FullName).GetAwaiter().GetResult(); } public static async Task<(string, SeriesInfo)> GetSeriesInfoByPath(string path) @@ -184,7 +175,7 @@ public class SeriesInfo public static async Task<SeriesInfo> GetSeriesInfoFromGroup(string groupId, int seasonNumber) { - var groupInfo = await GetGroupInfo(groupId); + var groupInfo = await GetGroupInfo(groupId, false); if (groupInfo == null) return null; int seriesIndex = seasonNumber > 0 ? seasonNumber - 1 : seasonNumber; @@ -210,19 +201,28 @@ private static async Task<SeriesInfo> CreateSeriesInfo(Series series, string ser { if (series == null) return null; + if (string.IsNullOrEmpty(seriesId)) seriesId = series.IDs.ID.ToString(); + SeriesInfo info = null; var cacheKey = $"series:{seriesId}"; if (_cache.TryGetValue<SeriesInfo>(cacheKey, out info)) return info; + var aniDb = await ShokoAPI.GetSeriesAniDb(seriesId); + var episodeList = await ShokoAPI.GetEpisodesFromSeries(seriesId) + .ContinueWith(async task => await Task.WhenAll(task.Result.Select(e => CreateEpisodeInfo(e)))).Unwrap() + .ContinueWith(l => l.Result.Where(s => s != null).ToList()); + var filteredSpecialEpisodesList = episodeList.Where(e => e.AniDB.Type == "Special" && OrderingUtil.GetExtraType(e.AniDB) != null).ToList(); info = new SeriesInfo { ID = seriesId, Shoko = series, - AniDB = (await ShokoAPI.GetSeriesAniDb(seriesId)), + AniDB = aniDb, TvDBID = series.IDs.TvDB.Count > 0 ? series.IDs.TvDB.FirstOrDefault().ToString() : null, + EpisodeList = episodeList, + FilteredSpecialEpisodesList = filteredSpecialEpisodesList, }; _cache.Set<SeriesInfo>(cacheKey, info, DefaultTimeSpan); return info; @@ -239,7 +239,7 @@ public class GroupInfo public int DefaultSeriesIndex; } - public static async Task<(string, GroupInfo)> GetGroupInfoByPath(string path) + public static async Task<(string, GroupInfo)> GetGroupInfoByPath(string path, bool onlyMovies = false) { var id = Path.DirectorySeparatorChar + path.Split(Path.DirectorySeparatorChar).Last(); var result = await ShokoAPI.GetSeriesPathEndsWith(id); @@ -248,39 +248,40 @@ public class GroupInfo if (string.IsNullOrEmpty(seriesId)) return (id, null); - var groupInfo = await GetGroupInfoForSeries(seriesId); + var groupInfo = await GetGroupInfoForSeries(seriesId, onlyMovies); if (groupInfo == null) return (id, null); return (id, groupInfo); } - public static async Task<GroupInfo> GetGroupInfo(string groupId) + public static async Task<GroupInfo> GetGroupInfo(string groupId, bool onlyMovies = false) { if (string.IsNullOrEmpty(groupId)) return null; - if (_cache.TryGetValue<GroupInfo>($"group:{groupId}", out var info)) + if (_cache.TryGetValue<GroupInfo>($"group:{(onlyMovies ? "movies" : "all")}:{groupId}", out var info)) return info; var group = await ShokoAPI.GetGroup(groupId); - return await CreateGroupInfo(group, groupId); + return await CreateGroupInfo(group, groupId, onlyMovies); } - public static async Task<GroupInfo> GetGroupInfoForSeries(string seriesId) + public static async Task<GroupInfo> GetGroupInfoForSeries(string seriesId, bool onlyMovies = false) { + // TODO: Find a way to remove the double requests for group info. var group = await ShokoAPI.GetGroupFromSeries(seriesId); if (group == null) return null; var groupId = group.IDs.ID.ToString(); GroupInfo info = null; - var cacheKey = $"group-by-series:{seriesId}"; + var cacheKey = $"group-by-series:{(onlyMovies ? "movies" : "all")}:{seriesId}"; if (_cache.TryGetValue<GroupInfo>(cacheKey, out info)) return info; - info = await GetGroupInfo(groupId); + info = await GetGroupInfo(groupId, onlyMovies); _cache.Set<GroupInfo>(cacheKey, info, DefaultTimeSpan); return info; } - private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId = null) + private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, bool onlyMovies) { if (group == null) return null; @@ -288,30 +289,32 @@ private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId if (string.IsNullOrEmpty(groupId)) groupId = group.IDs.ID.ToString(); - var cacheKey = $"group:{groupId}"; + var cacheKey = $"group:{(onlyMovies ? "movies" : "all")}:{groupId}"; GroupInfo info = null; if (_cache.TryGetValue<GroupInfo>(cacheKey, out info)) return info; var seriesList = await ShokoAPI.GetSeriesInGroup(groupId) .ContinueWith(async task => await Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s)))).Unwrap() - .ContinueWith(l => l.Result.ToList()); + .ContinueWith(l => l.Result.Where(s => s != null).ToList()); + if (onlyMovies && seriesList != null && seriesList.Count > 0) + seriesList = seriesList.Where(s => s.AniDB.Type == "Movie").ToList(); if (seriesList == null || seriesList.Count == 0) return null; // Map int foundIndex = -1; int targetId = (group.IDs.DefaultSeries ?? 0); // Sort list - var orderingType = Plugin.Instance.Configuration.SeasonOrdering; + var orderingType = onlyMovies ? Plugin.Instance.Configuration.MovieOrdering : Plugin.Instance.Configuration.SeasonOrdering; switch (orderingType) { - case OrderingUtil.SeasonOrderType.Default: + case OrderingUtil.SeasonAndMovieOrderType.Default: break; - case OrderingUtil.SeasonOrderType.ReleaseDate: - seriesList.OrderBy(s => s?.AniDB?.AirDate ?? System.DateTime.MaxValue); + case OrderingUtil.SeasonAndMovieOrderType.ReleaseDate: + seriesList = seriesList.OrderBy(s => s?.AniDB?.AirDate ?? System.DateTime.MaxValue).ToList(); break; // Should not be selectable unless a user fidles with DevTools in the browser to select the option. - case OrderingUtil.SeasonOrderType.Chronological: + case OrderingUtil.SeasonAndMovieOrderType.Chronological: throw new System.Exception("Not implemented yet"); } // Select the targeted id if a group spesify a default series. @@ -321,22 +324,23 @@ private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId else switch (orderingType) { // The list is already sorted by release date, so just return the first index. - case OrderingUtil.SeasonOrderType.ReleaseDate: + case OrderingUtil.SeasonAndMovieOrderType.ReleaseDate: foundIndex = 0; break; // We don't know how Shoko may have sorted it, so just find the earliest series - case OrderingUtil.SeasonOrderType.Default: + case OrderingUtil.SeasonAndMovieOrderType.Default: // We can't be sure that the the series in the list was _released_ chronologically, so find the earliest series, and use that as a base. - case OrderingUtil.SeasonOrderType.Chronological: { + case OrderingUtil.SeasonAndMovieOrderType.Chronological: { var earliestSeries = seriesList.Aggregate((cur, nxt) => (cur == null || (nxt?.AniDB.AirDate ?? System.DateTime.MaxValue) < (cur.AniDB.AirDate ?? System.DateTime.MaxValue)) ? nxt : cur); foundIndex = seriesList.FindIndex(s => s == earliestSeries); break; } } - // Return if we can't get a base point for seasons. + // Throw if we can't get a base point for seasons. if (foundIndex == -1) - return null; + throw new System.Exception("Unable to get a base-point for seasions withing the group"); + info = new GroupInfo { ID = groupId, diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 5a5ae673..194cdb1f 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -7,9 +7,9 @@ public class OrderingUtil { /// <summary> - /// Group series as + /// Group series or movie box-sets /// </summary> - public enum SeriesGroupType + public enum SeriesOrBoxSetGroupType { /// <summary> /// No grouping. All series will have their own entry. @@ -18,17 +18,21 @@ public enum SeriesGroupType /// <summary> /// Don't group, but make series merge-friendly by using the season numbers from TvDB. /// </summary> - TvDB = 1, + MergeFriendly = 1, /// <summary> /// Group seris based on Shoko's default group filter. /// </summary> ShokoGroup = 2, + /// <summary> + /// Group movies based on Shoko's series. + /// </summary> + ShokoSeries = 3, } /// <summary> - /// Season ordering when grouping series using Shoko's groups. + /// Season or movie ordering when grouping series/box-sets using Shoko's groups. /// </summary> - public enum SeasonOrderType + public enum SeasonAndMovieOrderType { /// <summary> /// Let Shoko decide the order. @@ -44,6 +48,62 @@ public enum SeasonOrderType Chronological = 2, } + /// <summary> + /// Get index number for a movie in a box-set. + /// </summary> + /// <returns>Absoute index.</returns> + public static int GetMovieIndexNumber(DataUtil.GroupInfo group, DataUtil.SeriesInfo series, DataUtil.EpisodeInfo episode) + { + switch (Plugin.Instance.Configuration.BoxSetGrouping) + { + default: + case SeriesOrBoxSetGroupType.Default: + return 1; + case SeriesOrBoxSetGroupType.ShokoSeries: + return episode.AniDB.EpisodeNumber; + case SeriesOrBoxSetGroupType.ShokoGroup: + { + int offset = 0; + foreach (DataUtil.SeriesInfo s in group.SeriesList) + { + var sizes = s.Shoko.Sizes.Total; + if (s != series) + { + if (episode.AniDB.Type == "Special") + { + var index = series.FilteredSpecialEpisodesList.FindIndex(e => string.Equals(e.ID, episode.ID)); + if (index == -1) + throw new System.IndexOutOfRangeException("Episode not in filtered list"); + return offset - (index + 1); + } + switch (episode.AniDB.Type) + { + case "Normal": + // offset += 0; + break; + case "Parody": + offset += sizes?.Episodes ?? 0; + goto case "Normal"; + case "Other": + offset += sizes?.Parodies ?? 0; + goto case "Parody"; + } + return offset + episode.AniDB.EpisodeNumber; + } + else + { + if (episode.AniDB.Type == "Special") { + offset -= series.FilteredSpecialEpisodesList.Count; + } + offset += (sizes?.Episodes ?? 0) + (sizes?.Parodies ?? 0) + (sizes?.Others ?? 0); + } + } + break; + } + } + return 0; + } + /// <summary> /// Get index number for an episode in a series. /// </summary> @@ -53,29 +113,38 @@ public static int GetIndexNumber(DataUtil.SeriesInfo series, DataUtil.EpisodeInf switch (Plugin.Instance.Configuration.SeriesGrouping) { default: - case SeriesGroupType.Default: + case SeriesOrBoxSetGroupType.Default: return episode.AniDB.EpisodeNumber; - case SeriesGroupType.TvDB: { + case SeriesOrBoxSetGroupType.MergeFriendly: + { var epNum = episode?.TvDB.Number ?? 0; if (epNum == 0) - goto case SeriesGroupType.Default; + goto case SeriesOrBoxSetGroupType.Default; return epNum; } - case SeriesGroupType.ShokoGroup: { + case SeriesOrBoxSetGroupType.ShokoGroup: + { + if (episode.AniDB.Type == "Special") + { + var index = series.FilteredSpecialEpisodesList.FindIndex(e => string.Equals(e.ID, episode.ID)); + if (index == -1) + throw new System.IndexOutOfRangeException("Episode not in filtered list"); + return -(index + 1); + } int offset = 0; - var sizes = series.Shoko.Sizes; + var sizes = series.Shoko.Sizes.Total; switch (episode.AniDB.Type) { case "Normal": break; case "Special": - offset += sizes.Total.Episodes; + offset += sizes?.Episodes ?? 0; break; // goto case "Normal"; case "Other": - offset += sizes.Total?.Specials ?? 0; + offset += sizes?.Specials ?? 0; goto case "Special"; case "Parody": - offset += sizes.Total?.Others ?? 0; + offset += sizes?.Others ?? 0; goto case "Other"; } return offset + episode.AniDB.EpisodeNumber; @@ -95,7 +164,7 @@ public static int GetSeasonNumber(DataUtil.GroupInfo group, DataUtil.SeriesInfo switch (Plugin.Instance.Configuration.SeriesGrouping) { default: - case SeriesGroupType.Default: + case SeriesOrBoxSetGroupType.Default: switch (episode.AniDB.Type) { case "Normal": @@ -105,19 +174,19 @@ public static int GetSeasonNumber(DataUtil.GroupInfo group, DataUtil.SeriesInfo default: return 98; } - case SeriesGroupType.TvDB: { + case SeriesOrBoxSetGroupType.MergeFriendly: { var seasonNumber = episode?.TvDB?.Season; if (seasonNumber == null) - goto case SeriesGroupType.Default; + goto case SeriesOrBoxSetGroupType.Default; return seasonNumber ?? 1; } - case SeriesGroupType.ShokoGroup: { + case SeriesOrBoxSetGroupType.ShokoGroup: { var id = series.ID; if (series == group.DefaultSeries) return 1; var index = group.SeriesList.FindIndex(s => s.ID == id); if (index == -1) - goto case SeriesGroupType.Default; + goto case SeriesOrBoxSetGroupType.Default; var value = index - group.DefaultSeriesIndex; return value < 0 ? value : value + 1; } @@ -129,6 +198,7 @@ public static int GetSeasonNumber(DataUtil.GroupInfo group, DataUtil.SeriesInfo switch (episode.Type) { case "Normal": + case "Other": return null; case "ThemeSong": return ExtraType.ThemeVideo; From 2b83ebd4eaeebc7638b6a3a02bf14679d96c4f1b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 14 Dec 2020 18:02:23 +0100 Subject: [PATCH 0097/1103] Set index number for default season provider --- Shokofin/Providers/SeasonProvider.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 7af3af16..35f7ef2c 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -50,6 +50,7 @@ private MetadataResult<Season> GetDefaultMetadata(SeasonInfo info, CancellationT result.Item = new Season { Name = seasonName, + IndexNumber = info.IndexNumber, SortName = seasonName, ForcedSortName = seasonName }; From d6977009bae88e7f4935a374a22757f3d304ab2e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 14 Dec 2020 18:27:08 +0100 Subject: [PATCH 0098/1103] Update the plugin configuration page Add a warning for series grouping using shoko's groups, change "Default" to "Let Shoko decide", and correct "libraries" to "library types" --- Shokofin/Configuration/configPage.html | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 0fedb0b6..a031f2e6 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -33,7 +33,7 @@ <h3>Title Options</h3> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="TitleMainType">Main Title</label> <select is="emby-select" id="TitleMainType" name="TitleMainType" class="emby-select-withcolor emby-select"> - <option value="Default">Default</option> + <option value="Default">Let Shoko decide</option> <option value="MetadataPreferred">Preferred metadata language</option> <option value="Origin">Language in country of origin</option> </select> @@ -42,7 +42,7 @@ <h3>Title Options</h3> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="TitleAlternateType">Alternate Title</label> <select is="emby-select" id="TitleAlternateType" name="TitleAlternateType" class="emby-select-withcolor emby-select"> - <option value="Default">Default</option> + <option value="Default">Let Shoko decide</option> <option value="MetadataPreferred">Preferred metadata language</option> <option value="Origin">Language in country of origin</option> <option value="Ignore">Do not use alternate titles</option> @@ -74,6 +74,7 @@ <h3>Library Options</h3> <option value="MergeFriendly">Make series merge-friendly with TvDB/TMDB</option> <option value="ShokoGroup">Group series based on Shoko's group feature</option> </select> + <div id="SG_ShokoGroup_Warning" class="fieldDescription"><strong>Warning:</strong> Series merging must be enabled in the <strong>library settings</strong> when using this option.</div> </div> <div id="SeasonOrderingItem" class="selectContainer"> <label class="selectLabel" for="SeasonOrdering">Season ordering</label> @@ -90,7 +91,7 @@ <h3>Library Options</h3> <div class="selectContainer"> <label class="selectLabel" for="BoxSetGrouping">Box-set grouping</label> <select is="emby-select" id="BoxSetGrouping" name="BoxSetGrouping" class="emby-select-withcolor emby-select"> - <option value="Default" selected>Do not group movies</option> + <option value="Default" selected>Do not group movies into box-sets</option> <option value="ShokoSeries">Group movies within Shoko's series only</option> <option value="ShokoGroup">Group movies in Shoko's groups and series</option> </select> @@ -105,7 +106,7 @@ <h3>Library Options</h3> </div> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="SeperateLibraries" /> - <span>Disallow overlap of Movies and Series libraries</span> + <span>Disallow overlap of Movies and Series library types</span> </label> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="UpdateWatchedStatus" /> @@ -173,10 +174,12 @@ <h3>Tag Options</h3> document.querySelector('#MarkSpecialsWhenGrouped').checked = config.MarkSpecialsWhenGrouped; document.querySelector('#SeperateLibraries').checked = config.SeperateLibraries; if (config.SeriesGrouping === "ShokoGroup") { + document.querySelector('#SG_ShokoGroup_Warning').removeAttribute("hidden"); document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); document.querySelector('#MarkSpecialsWhenGroupedItem').removeAttribute("hidden"); } else { + document.querySelector('#SG_ShokoGroup_Warning').setAttribute("hidden", ""); document.querySelector('#SeasonOrderingItem').setAttribute("hidden", ""); document.querySelector('#MarkSpecialsWhenGroupedItem').setAttribute("hidden", ""); } @@ -192,10 +195,12 @@ <h3>Tag Options</h3> document.querySelector('#SeriesGrouping') .addEventListener('input', function () { if (document.querySelector('#SeriesGrouping').value === "ShokoGroup") { + document.querySelector('#SG_ShokoGroup_Warning').removeAttribute("hidden"); document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); document.querySelector('#MarkSpecialsWhenGroupedItem').removeAttribute("hidden"); } else { + document.querySelector('#SG_ShokoGroup_Warning').setAttribute("hidden", ""); document.querySelector('#SeasonOrderingItem').setAttribute("hidden", ""); document.querySelector('#MarkSpecialsWhenGroupedItem').setAttribute("hidden", ""); } From 70effa125f82e847f057cb922d5ea125e8983f03 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 11 Mar 2021 00:49:16 +0100 Subject: [PATCH 0099/1103] Refactor changes in PR Don't return id from *ByPath endoints, renamed and/or moved some classes added in this PR, etc. See the commit for all changes. --- .../{Utils/DataUtil.cs => API/DataFetcher.cs} | 195 +++++++++--------- Shokofin/API/Info/EpisodeInfo.cs | 12 ++ Shokofin/API/Info/FileInfo.cs | 11 + Shokofin/API/Info/GroupInfo.cs | 16 ++ Shokofin/API/Info/SeriesInfo.cs | 22 ++ Shokofin/API/Models/Episode.cs | 101 +++++++-- Shokofin/API/Models/Relation.cs | 94 --------- Shokofin/API/Models/Series.cs | 98 ++++++--- Shokofin/API/ShokoAPI.cs | 21 +- Shokofin/Configuration/PluginConfiguration.cs | 8 +- Shokofin/Providers/BoxSetProvider.cs | 41 ++-- Shokofin/Providers/EpisodeProvider.cs | 29 +-- Shokofin/Providers/ImageProvider.cs | 16 +- Shokofin/Providers/MovieProvider.cs | 33 +-- Shokofin/Providers/SeasonProvider.cs | 21 +- Shokofin/Providers/SeriesProvider.cs | 51 ++--- Shokofin/Scrobbler.cs | 10 +- Shokofin/Utils/OrderingUtil.cs | 64 +++--- Shokofin/Utils/TextUtil.cs | 4 +- 19 files changed, 458 insertions(+), 389 deletions(-) rename Shokofin/{Utils/DataUtil.cs => API/DataFetcher.cs} (76%) create mode 100644 Shokofin/API/Info/EpisodeInfo.cs create mode 100644 Shokofin/API/Info/FileInfo.cs create mode 100644 Shokofin/API/Info/GroupInfo.cs create mode 100644 Shokofin/API/Info/SeriesInfo.cs delete mode 100644 Shokofin/API/Models/Relation.cs diff --git a/Shokofin/Utils/DataUtil.cs b/Shokofin/API/DataFetcher.cs similarity index 76% rename from Shokofin/Utils/DataUtil.cs rename to Shokofin/API/DataFetcher.cs index 42acf970..8bad7643 100644 --- a/Shokofin/Utils/DataUtil.cs +++ b/Shokofin/API/DataFetcher.cs @@ -1,23 +1,25 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Caching.Memory; +using Shokofin.API.Info; +using Shokofin.API.Models; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Shokofin.API; -using Shokofin.API.Models; + using Path = System.IO.Path; -using FileSystemMetadata = MediaBrowser.Model.IO.FileSystemMetadata; -using Microsoft.Extensions.Caching.Memory; -namespace Shokofin.Utils +namespace Shokofin.API { - public class DataUtil + public class DataFetcher { - private static IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions() { + private static readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions() { ExpirationScanFrequency = new System.TimeSpan(0, 3, 0), }); - private static System.TimeSpan DefaultTimeSpan = new System.TimeSpan(0, 5, 0); + private static readonly System.TimeSpan DefaultTimeSpan = new System.TimeSpan(0, 5, 0); + + #region People public static async Task<IEnumerable<PersonInfo>> GetPeople(string seriesId) { @@ -36,21 +38,41 @@ public static async Task<IEnumerable<PersonInfo>> GetPeople(string seriesId) return list; } - #region File Info + #endregion + #region Tags - public class FileInfo + public static async Task<string[]> GetTags(string seriesId) { - public string ID; - public File Shoko; - public int EpisodesCount; + return (await ShokoAPI.GetSeriesTags(seriesId, DataFetcher.GetTagFilter()))?.Select(tag => tag.Name).ToArray() ?? new string[0]; } - public static (string, FileInfo, EpisodeInfo, SeriesInfo, GroupInfo) GetFileInfoByPath(FileSystemMetadata metadata, bool includeGroup = true, bool onlyMovies = false) + /// <summary> + /// Get the tag filter + /// </summary> + /// <returns></returns> + private static int GetTagFilter() { - return GetFileInfoByPath(Path.Join(metadata.DirectoryName, metadata.FullName), includeGroup, onlyMovies).GetAwaiter().GetResult(); + var config = Plugin.Instance.Configuration; + var filter = 0; + + if (config.HideAniDbTags) filter = 1; + if (config.HideArtStyleTags) filter |= (filter << 1); + if (config.HideSourceTags) filter |= (filter << 2); + if (config.HideMiscTags) filter |= (filter << 3); + if (config.HidePlotTags) filter |= (filter << 4); + + return filter; + } + + #endregion + #region File Info + + public static (FileInfo, EpisodeInfo, SeriesInfo, GroupInfo) GetFileInfoByPathSync(string path, bool includeGroup = true, bool onlyMovies = false) + { + return GetFileInfoByPath(path, includeGroup, onlyMovies).GetAwaiter().GetResult(); } - public static async Task<(string, FileInfo, EpisodeInfo, SeriesInfo, GroupInfo)> GetFileInfoByPath(string path, bool includeGroup = true, bool onlyMovies = false) + public static async Task<(FileInfo, EpisodeInfo, SeriesInfo, GroupInfo)> GetFileInfoByPath(string path, bool includeGroup = true, bool onlyMovies = false) { // TODO: Check if it can be written in a better way. Parent directory + File Name var id = Path.Join( @@ -60,51 +82,67 @@ public static (string, FileInfo, EpisodeInfo, SeriesInfo, GroupInfo) GetFileInfo var file = result?.FirstOrDefault(); if (file == null) - return (id, null, null, null, null); - var series = file?.SeriesIDs.FirstOrDefault(); - var fileInfo = new FileInfo - { - ID = file.ID.ToString(), - Shoko = file, - EpisodesCount = series?.EpisodeIDs?.Count ?? 0, - }; + return (null, null, null, null); + var series = file?.SeriesIDs.FirstOrDefault(); var seriesId = series?.SeriesID.ID.ToString(); var episodes = series?.EpisodeIDs?.FirstOrDefault(); var episodeId = episodes?.ID.ToString(); if (string.IsNullOrEmpty(seriesId) || string.IsNullOrEmpty(episodeId)) - return (id, null, null, null, null); + return (null, null, null, null); GroupInfo groupInfo = null; if (includeGroup) { groupInfo = await GetGroupInfoForSeries(seriesId, onlyMovies); if (groupInfo == null) - return (id, null, null, null, null); + return (null, null, null, null); } var seriesInfo = await GetSeriesInfo(seriesId); if (seriesInfo == null) - return (id, null, null, null, null); + return (null, null, null, null); var episodeInfo = await GetEpisodeInfo(episodeId); if (episodeInfo == null) - return (id, null, null, null, null); + return (null, null, null, null); + + var fileInfo = CreateFileInfo(file, file.ID.ToString(), series?.EpisodeIDs?.Count ?? 0); - return (id, fileInfo, episodeInfo, seriesInfo, groupInfo); + return (fileInfo, episodeInfo, seriesInfo, groupInfo); } - #endregion - #region Episode Info + public async Task<FileInfo> GetFileInfo(string fileId) + { + var file = await ShokoAPI.GetFile(fileId); + if (file == null) + return null; + return CreateFileInfo(file); + } - public class EpisodeInfo + private static FileInfo CreateFileInfo(File file, string fileId = null, int episodeCount = 0) { - public string ID; - public Episode Shoko; - public Episode.AniDB AniDB; - public Episode.TvDB TvDB; + if (file == null) + return null; + if (string.IsNullOrEmpty(fileId)) + fileId = file.ID.ToString(); + var cacheKey = $"file:{fileId}:{episodeCount}"; + FileInfo info = null; + if (_cache.TryGetValue<FileInfo>(cacheKey, out info)) + return info; + info = new FileInfo + { + ID = fileId, + Shoko = file, + + }; + _cache.Set<FileInfo>(cacheKey, info, DefaultTimeSpan); + return info; } + #endregion + #region Episode Info + public static async Task<EpisodeInfo> GetEpisodeInfo(string episodeId) { if (string.IsNullOrEmpty(episodeId)) @@ -115,7 +153,7 @@ public static async Task<EpisodeInfo> GetEpisodeInfo(string episodeId) return await CreateEpisodeInfo(episode, episodeId); } - public static async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string episodeId = null) + private static async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string episodeId = null) { if (episode == null) return null; @@ -138,39 +176,21 @@ public static async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string #endregion #region Series Info - - public class SeriesInfo - { - public string ID; - public Series Shoko; - public Series.AniDB AniDB; - public string TvDBID; - /// <summary> - /// All episodes (of all type) that belong to this series. - /// </summary> - public List<EpisodeInfo> EpisodeList; - /// <summary> - /// A pre-filtered list of special episodes without an ExtraType - /// attached. - /// </summary> - public List<EpisodeInfo> FilteredSpecialEpisodesList; - } - - public static (string, SeriesInfo) GetSeriesInfoByPathSync(FileSystemMetadata metadata) + public static SeriesInfo GetSeriesInfoByPathSync(string path) { - return GetSeriesInfoByPath(metadata.FullName).GetAwaiter().GetResult(); + return GetSeriesInfoByPath(path).GetAwaiter().GetResult(); } - public static async Task<(string, SeriesInfo)> GetSeriesInfoByPath(string path) + public static async Task<SeriesInfo> GetSeriesInfoByPath(string path) { var id = Path.DirectorySeparatorChar + path.Split(Path.DirectorySeparatorChar).Last(); var result = await ShokoAPI.GetSeriesPathEndsWith(id); var seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); if (string.IsNullOrEmpty(seriesId)) - return (id, null); + return null; - return (id, await GetSeriesInfo(seriesId)); + return await GetSeriesInfo(seriesId); } public static async Task<SeriesInfo> GetSeriesInfoFromGroup(string groupId, int seasonNumber) @@ -214,7 +234,7 @@ private static async Task<SeriesInfo> CreateSeriesInfo(Series series, string ser var episodeList = await ShokoAPI.GetEpisodesFromSeries(seriesId) .ContinueWith(async task => await Task.WhenAll(task.Result.Select(e => CreateEpisodeInfo(e)))).Unwrap() .ContinueWith(l => l.Result.Where(s => s != null).ToList()); - var filteredSpecialEpisodesList = episodeList.Where(e => e.AniDB.Type == "Special" && OrderingUtil.GetExtraType(e.AniDB) != null).ToList(); + var filteredSpecialEpisodesList = episodeList.Where(e => e.AniDB.Type == EpisodeType.Special && Shokofin.Utils.Ordering.GetExtraType(e.AniDB) != null).ToList(); info = new SeriesInfo { ID = seriesId, @@ -231,28 +251,25 @@ private static async Task<SeriesInfo> CreateSeriesInfo(Series series, string ser #endregion #region Group Info - public class GroupInfo + public static GroupInfo GetGroupInfoByPathSync(string path, bool onlyMovies = false) { - public string ID; - public List<SeriesInfo> SeriesList; - public SeriesInfo DefaultSeries; - public int DefaultSeriesIndex; + return GetGroupInfoByPath(path, onlyMovies).GetAwaiter().GetResult(); } - public static async Task<(string, GroupInfo)> GetGroupInfoByPath(string path, bool onlyMovies = false) + public static async Task<GroupInfo> GetGroupInfoByPath(string path, bool onlyMovies = false) { var id = Path.DirectorySeparatorChar + path.Split(Path.DirectorySeparatorChar).Last(); var result = await ShokoAPI.GetSeriesPathEndsWith(id); var seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); if (string.IsNullOrEmpty(seriesId)) - return (id, null); + return null; var groupInfo = await GetGroupInfoForSeries(seriesId, onlyMovies); if (groupInfo == null) - return (id, null); + return null; - return (id, groupInfo); + return groupInfo; } public static async Task<GroupInfo> GetGroupInfo(string groupId, bool onlyMovies = false) @@ -298,7 +315,7 @@ private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId .ContinueWith(async task => await Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s)))).Unwrap() .ContinueWith(l => l.Result.Where(s => s != null).ToList()); if (onlyMovies && seriesList != null && seriesList.Count > 0) - seriesList = seriesList.Where(s => s.AniDB.Type == "Movie").ToList(); + seriesList = seriesList.Where(s => s.AniDB.Type == SeriesType.Movie).ToList(); if (seriesList == null || seriesList.Count == 0) return null; // Map @@ -308,13 +325,13 @@ private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId var orderingType = onlyMovies ? Plugin.Instance.Configuration.MovieOrdering : Plugin.Instance.Configuration.SeasonOrdering; switch (orderingType) { - case OrderingUtil.SeasonAndMovieOrderType.Default: + case Shokofin.Utils.Ordering.SeasonAndMovieOrderType.Default: break; - case OrderingUtil.SeasonAndMovieOrderType.ReleaseDate: + case Shokofin.Utils.Ordering.SeasonAndMovieOrderType.ReleaseDate: seriesList = seriesList.OrderBy(s => s?.AniDB?.AirDate ?? System.DateTime.MaxValue).ToList(); break; // Should not be selectable unless a user fidles with DevTools in the browser to select the option. - case OrderingUtil.SeasonAndMovieOrderType.Chronological: + case Shokofin.Utils.Ordering.SeasonAndMovieOrderType.Chronological: throw new System.Exception("Not implemented yet"); } // Select the targeted id if a group spesify a default series. @@ -324,13 +341,13 @@ private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId else switch (orderingType) { // The list is already sorted by release date, so just return the first index. - case OrderingUtil.SeasonAndMovieOrderType.ReleaseDate: + case Shokofin.Utils.Ordering.SeasonAndMovieOrderType.ReleaseDate: foundIndex = 0; break; // We don't know how Shoko may have sorted it, so just find the earliest series - case OrderingUtil.SeasonAndMovieOrderType.Default: + case Shokofin.Utils.Ordering.SeasonAndMovieOrderType.Default: // We can't be sure that the the series in the list was _released_ chronologically, so find the earliest series, and use that as a base. - case OrderingUtil.SeasonAndMovieOrderType.Chronological: { + case Shokofin.Utils.Ordering.SeasonAndMovieOrderType.Chronological: { var earliestSeries = seriesList.Aggregate((cur, nxt) => (cur == null || (nxt?.AniDB.AirDate ?? System.DateTime.MaxValue) < (cur.AniDB.AirDate ?? System.DateTime.MaxValue)) ? nxt : cur); foundIndex = seriesList.FindIndex(s => s == earliestSeries); break; @@ -344,6 +361,7 @@ private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId info = new GroupInfo { ID = groupId, + Shoko = group, SeriesList = seriesList, DefaultSeries = seriesList[foundIndex], DefaultSeriesIndex = foundIndex, @@ -353,28 +371,5 @@ private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId } #endregion - - public static async Task<string[]> GetTags(string seriesId) - { - return (await ShokoAPI.GetSeriesTags(seriesId, DataUtil.GetTagFilter()))?.Select(tag => tag.Name).ToArray() ?? new string[0]; - } - - /// <summary> - /// Get the tag filter - /// </summary> - /// <returns></returns> - private static int GetTagFilter() - { - var config = Plugin.Instance.Configuration; - var filter = 0; - - if (config.HideAniDbTags) filter = 1; - if (config.HideArtStyleTags) filter |= (filter << 1); - if (config.HideSourceTags) filter |= (filter << 2); - if (config.HideMiscTags) filter |= (filter << 3); - if (config.HidePlotTags) filter |= (filter << 4); - - return filter; - } } } diff --git a/Shokofin/API/Info/EpisodeInfo.cs b/Shokofin/API/Info/EpisodeInfo.cs new file mode 100644 index 00000000..f8b9cdf5 --- /dev/null +++ b/Shokofin/API/Info/EpisodeInfo.cs @@ -0,0 +1,12 @@ +using Shokofin.API.Models; + +namespace Shokofin.API.Info +{ + public class EpisodeInfo + { + public string ID; + public Episode Shoko; + public Episode.AniDB AniDB; + public Episode.TvDB TvDB; + } +} diff --git a/Shokofin/API/Info/FileInfo.cs b/Shokofin/API/Info/FileInfo.cs new file mode 100644 index 00000000..ef9cf634 --- /dev/null +++ b/Shokofin/API/Info/FileInfo.cs @@ -0,0 +1,11 @@ +using Shokofin.API.Models; + +namespace Shokofin.API.Info +{ + public class FileInfo + { + public string ID; + public File Shoko; + public int EpisodesCount; + } +} diff --git a/Shokofin/API/Info/GroupInfo.cs b/Shokofin/API/Info/GroupInfo.cs new file mode 100644 index 00000000..f4713785 --- /dev/null +++ b/Shokofin/API/Info/GroupInfo.cs @@ -0,0 +1,16 @@ + +using System.Collections.Generic; +using Shokofin.API.Models; + +namespace Shokofin.API.Info +{ + public class GroupInfo + { + public string ID; + public Group Shoko; + public List<SeriesInfo> SeriesList; + public SeriesInfo DefaultSeries; + public int DefaultSeriesIndex; + } + +} diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs new file mode 100644 index 00000000..920211c5 --- /dev/null +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using Shokofin.API.Models; + +namespace Shokofin.API.Info +{ + public class SeriesInfo + { + public string ID; + public Series Shoko; + public Series.AniDB AniDB; + public string TvDBID; + /// <summary> + /// All episodes (of all type) that belong to this series. + /// </summary> + public List<EpisodeInfo> EpisodeList; + /// <summary> + /// A pre-filtered list of special episodes without an ExtraType + /// attached. + /// </summary> + public List<EpisodeInfo> FilteredSpecialEpisodesList; + } +} diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index 7595acc8..99b47098 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -6,58 +6,117 @@ namespace Shokofin.API.Models public class Episode : BaseModel { public EpisodeIDs IDs { get; set; } - + public DateTime? Watched { get; set; } public class AniDB { public int ID { get; set; } - - public string Type { get; set; } - + + public EpisodeType Type { get; set; } + public int EpisodeNumber { get; set; } - + public DateTime? AirDate { get; set; } - + public List<Title> Titles { get; set; } - + public string Description { get; set; } - + public Rating Rating { get; set; } } public class TvDB { public int ID { get; set; } - + public int Season { get; set; } - + public int Number { get; set; } - + public int AbsoluteNumber { get; set; } - + public string Title { get; set; } - + public string Description { get; set; } - + 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; } } public class EpisodeIDs : IDs { public int AniDB { get; set; } - + public List<int> TvDB { get; set; } = new List<int>(); } } -} \ No newline at end of file + + + public enum EpisodeType + { + /// <summary> + /// The episode type is unknown. + /// </summary> + Unknown = 0, + + /// <summary> + /// A catch-all type for future extensions when a provider can't use a current episode type, but knows what the future type should be. + /// </summary> + Other = 1, + + /// <summary> + /// A normal episode. + /// </summary> + Normal = 2, + + /// <summary> + /// A special episode. + /// </summary> + Special = 3, + + /// <summary> + /// A trailer. + /// </summary> + Trailer = 4, + + /// <summary> + /// Either an opening-song, or an ending-song. + /// </summary> + ThemeSong = 5, + + /// <summary> + /// Intro, and/or opening-song. + /// </summary> + OpeningSong = 6, + + /// <summary> + /// Outro, end-roll, credits, and/or ending-song. + /// </summary> + EndingSong = 7, + + /// <summary> + /// AniDB parody type. Where else would this be useful? + /// </summary> + Parody = 8, + + /// <summary> + /// A interview tied to the series. + /// </summary> + Interview = 9, + + /// <summary> + /// A DVD or BD extra, e.g. BD-menu or deleted scenes. + /// </summary> + Extra = 10, + } +} diff --git a/Shokofin/API/Models/Relation.cs b/Shokofin/API/Models/Relation.cs deleted file mode 100644 index 4e3abe21..00000000 --- a/Shokofin/API/Models/Relation.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.Json.Serialization; - -namespace Shokofin.API.Models -{ - /// <summary> - /// Describes relations between series. - /// </summary> - public class Relation - { - /// <summary> - /// Relation from ID - /// </summary> - public int FromID { get; set; } - - /// <summary> - /// Relation to ID - /// </summary> - public int ToID { get; set; } - - /// <summary> - /// The relation between `FromID` and `ToID` - /// </summary> - [Required] - [JsonConverter(typeof(JsonStringEnumConverter))] - public RelationType Type { get; set; } - - /// <summary> - /// If the relation is valid both ways, or if the relation is only valid one way - /// </summary> - /// <value></value> - [Required] - public bool IsBiDirectional { get; set; } - - /// <summary> - /// AniDB, etc - /// </summary> - [Required] - public string Source { get; set; } - - - - /// <summary> - /// Explains how the first entry relates to the second entry. - /// </summary> - public enum RelationType - { - /// <summary> - /// The relation between the entries cannot be explained in simple terms. - /// </summary> - Other = 1, - - /// <summary> - /// The entries use the same setting, but follow different stories. - /// </summary> - SameSetting = 2, - - /// <summary> - /// The entries use the same base story, but is set in alternate settings. - /// </summary> - AlternativeSetting = 3, - - /// <summary> - /// The entries tell different stories but shares some character(s). - /// </summary> - SharedCharacters = 4, - - /// <summary> - /// The entries tell the same story, with their differences. - /// </summary> - AlternativeVersion = 5, - - /// <summary> - /// The second entry either continues, or expands upon the story of the first entry. - /// </summary> - Sequel = 50, - - /// <summary> - /// The second entry is a side-story for the first entry, which is the main-story. - /// </summary> - SideStory = 51, - - /// <summary> - /// The second entry summerizes the events of the story in the first entry. - /// </summary> - Summary = 52, - - /// <summary> - /// The second entry is a later production of the story in the first story, often - /// </summary> - Reboot = 53, - } - } -} \ No newline at end of file diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index e394699f..dded5db1 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -6,70 +6,70 @@ namespace Shokofin.API.Models public class Series : BaseModel { public SeriesIDs IDs { get; set; } - + public Images Images { get; set; } - + public Rating UserRating { get; set; } - + public List<Resource> Links { get; set; } - + public DateTime Created { get; set; } - + public DateTime Updated { get; set; } public class AniDB { public int ID { get; set; } - - public string Type { get; set; } - + + public SeriesType Type { get; set; } + public string Title { get; set; } - + public bool Restricted { get; set; } - + public DateTime? AirDate { get; set; } - + public DateTime? EndDate { get; set; } - + public List<Title> Titles { get; set; } - + public string Description { get; set; } - + public Image Poster { get; set; } - + public Rating Rating { get; set; } } - + public class TvDB { public int ID { get; set; } - + public DateTime? AirDate { get; set; } - + public DateTime? EndDate { get; set; } - + public string Title { get; set; } - + public string Description { get; set; } - + public int? Season { get; set; } - + public List<Image> Posters { get; set; } - + public List<Image> Fanarts { get; set; } - + public List<Image> Banners { get; set; } - + public Rating Rating { get; set; } } public class Resource { public string name { get; set; } - + public string url { get; set; } - + public Image image { get; set; } } } @@ -77,22 +77,54 @@ public class Resource public class SeriesIDs : IDs { public int AniDB { get; set; } - + public List<int> TvDB { get; set; } = new List<int>(); - + public List<int> MovieDB { get; set; } = new List<int>(); - + 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 SeriesSearchResult : Series { public string Match { get; set; } - + public double Distance { get; set; } } + + public enum SeriesType + { + /// <summary> + /// The series type is unknown. + /// </summary> + Unknown = 0, + /// <summary> + /// A catch-all type for future extensions when a provider can't use a current episode type, but knows what the future type should be. + /// </summary> + Other = 1, + /// <summary> + /// Standard TV series. + /// </summary> + TV = 2, + /// <summary> + /// TV special. + /// </summary> + TVSpecial = 3, + /// <summary> + /// Web series. + /// </summary> + Web = 4, + /// <summary> + /// All movies, regardless of source (e.g. web or theater) + /// </summary> + Movie = 5, + /// <summary> + /// Original Video Animations, AKA standalone releases that don't air on TV or the web. + /// </summary> + OVA = 6, + } } diff --git a/Shokofin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPI.cs index cac0eb42..279b5bb0 100644 --- a/Shokofin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPI.cs @@ -11,6 +11,9 @@ namespace Shokofin.API { + /// <summary> + /// All API calls to Shoko needs to go through this gateway. + /// </summary> internal class ShokoAPI { private static readonly HttpClient _httpClient; @@ -94,7 +97,7 @@ public static async Task<List<Episode>> GetEpisodeFromFile(string id) var responseStream = await CallApi($"/api/v3/Episode/{id}/AniDB"); return responseStream != null ? await JsonSerializer.DeserializeAsync<Episode.AniDB>(responseStream) : null; } - + public static async Task<IEnumerable<Episode.TvDB>> GetEpisodeTvDb(string id) { var responseStream = await CallApi($"/api/v3/Episode/{id}/TvDB"); @@ -106,13 +109,13 @@ public static async Task<File> GetFile(string id) var responseStream = await CallApi($"/api/v3/File/{id}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<File>(responseStream) : null; } - + public static async Task<IEnumerable<File.FileDetailed>> GetFileByPath(string filename) { var responseStream = await CallApi($"/api/v3/File/PathEndsWith/{Uri.EscapeDataString(filename)}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<File.FileDetailed>>(responseStream) : null; } - + public static async Task<Series> GetSeries(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}"); @@ -130,19 +133,19 @@ public static async Task<List<Series>> GetSeriesInGroup(string id) var responseStream = await CallApi($"/api/v3/Filter/0/Group/{id}/Series"); return responseStream != null ? await JsonSerializer.DeserializeAsync<List<Series>>(responseStream) : null; } - + public static async Task<Series.AniDB> GetSeriesAniDb(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/AniDB"); return responseStream != null ? await JsonSerializer.DeserializeAsync<Series.AniDB>(responseStream) : null; } - + public static async Task<IEnumerable<Role>> GetSeriesCast(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/Cast"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Role>>(responseStream) : null; } - + public static async Task<Images> GetSeriesImages(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/Images"); @@ -154,19 +157,19 @@ public static async Task<IEnumerable<Series>> GetSeriesPathEndsWith(string dirna var responseStream = await CallApi($"/api/v3/Series/PathEndsWith/{Uri.EscapeDataString(dirname)}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Series>>(responseStream) : null; } - + public static async Task<IEnumerable<Tag>> GetSeriesTags(string id, int filter = 0) { var responseStream = await CallApi($"/api/v3/Series/{id}/Tags/{filter}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Tag>>(responseStream) : null; } - + public static async Task<Group> GetGroup(string id) { var responseStream = await CallApi($"/api/v3/Group/{id}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<Group>(responseStream) : null; } - + public static async Task<Group> GetGroupFromSeries(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/Group"); diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 4d34602a..c9b4be21 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,7 +1,7 @@ using MediaBrowser.Model.Plugins; -using DisplayLanguageType = Shokofin.Utils.TextUtil.DisplayLanguageType; -using SeriesAndBoxSetGroupType = Shokofin.Utils.OrderingUtil.SeriesOrBoxSetGroupType; -using SeasonAndMovieOrderType = Shokofin.Utils.OrderingUtil.SeasonAndMovieOrderType; +using DisplayLanguageType = Shokofin.Utils.Text.DisplayLanguageType; +using SeriesAndBoxSetGroupType = Shokofin.Utils.Ordering.SeriesOrBoxSetGroupType; +using SeasonAndMovieOrderType = Shokofin.Utils.Ordering.SeasonAndMovieOrderType; namespace Shokofin.Configuration { @@ -77,4 +77,4 @@ public PluginConfiguration() SeperateLibraries = false; } } -} \ No newline at end of file +} diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index f4f8779d..8f3e517d 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -15,6 +15,7 @@ using IResolverIgnoreRule = MediaBrowser.Controller.Resolvers.IResolverIgnoreRule; using ILibraryManager = MediaBrowser.Controller.Library.ILibraryManager; +using SeriesType = Shokofin.API.Models.SeriesType; namespace Shokofin.Providers { @@ -42,7 +43,7 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat { default: return await GetDefaultMetadata(info, cancellationToken); - case OrderingUtil.SeriesOrBoxSetGroupType.ShokoGroup: + case Ordering.SeriesOrBoxSetGroupType.ShokoGroup: return await GetShokoGroupedMetadata(info, cancellationToken); } } @@ -56,7 +57,7 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<BoxSet>(); - var (id, series) = await DataUtil.GetSeriesInfoByPath(info.Path); + var series = await DataFetcher.GetSeriesInfoByPath(info.Path); if (series == null) { @@ -67,26 +68,26 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, Ca int aniDBId = series.AniDB.ID; var tvdbId = series?.TvDBID; - if (series.AniDB.Type != "Movie") + if (series.AniDB.Type != SeriesType.Movie) { - _logger.LogWarning($"Shoko Scanner... File found, but not a movie! Skipping."); + _logger.LogWarning($"Shoko Scanner... File found, but not a movie! Skipping path {info.Path}"); return result; } if (series.Shoko.Sizes.Total.Episodes <= 1) { - _logger.LogWarning("Shoko Scanner... series did not contain multiple movies! Skipping."); + _logger.LogWarning($"Shoko Scanner... series did not contain multiple movies! Skipping path {info.Path}"); return result; } - var ( displayTitle, alternateTitle ) = TextUtil.GetSeriesTitles(series.AniDB.Titles, series.AniDB.Title, info.MetadataLanguage); - var tags = await DataUtil.GetTags(series.ID); + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.AniDB.Title, info.MetadataLanguage); + var tags = await DataFetcher.GetTags(series.ID); result.Item = new BoxSet { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = TextUtil.SummarySanitizer(series.AniDB.Description), + Overview = Text.SummarySanitizer(series.AniDB.Description), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, @@ -102,30 +103,30 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, Ca private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<BoxSet>(); - var (id, group) = await DataUtil.GetGroupInfoByPath(info.Path, true); + var group = await DataFetcher.GetGroupInfoByPath(info.Path, true); if (group == null) { - _logger.LogWarning($"Shoko Scanner... Unable to find box-set info for path {id}"); + _logger.LogWarning($"Shoko Scanner... Unable to find box-set info for path {info.Path}"); return result; } var series = group.DefaultSeries; var tvdbId = series?.TvDBID; - if (series.AniDB.Type != "Movie") + if (series.AniDB.Type != API.Models.SeriesType.Movie) { _logger.LogWarning($"Shoko Scanner... File found, but not a movie! Skipping."); return result; } - var tags = await DataUtil.GetTags(series.ID); - var ( displayTitle, alternateTitle ) = TextUtil.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); + var tags = await DataFetcher.GetTags(series.ID); + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); result.Item = new BoxSet { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = TextUtil.SummarySanitizer(series.AniDB.Description), + Overview = Text.SummarySanitizer(series.AniDB.Description), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, @@ -140,13 +141,12 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in result.HasMetadata = true; result.ResetPeople(); - foreach (var person in await DataUtil.GetPeople(series.ID)) + foreach (var person in await DataFetcher.GetPeople(series.ID)) result.AddPerson(person); return result; } - public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) { _logger.LogInformation($"Searching BoxSet ({searchInfo.Name})"); @@ -189,15 +189,16 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base return false; } try { - var (id, series) = DataUtil.GetSeriesInfoByPathSync(fileInfo); + var path = System.IO.Path.Join(fileInfo.DirectoryName, fileInfo.FullName); + var series = DataFetcher.GetSeriesInfoByPathSync(path); if (series == null) { - _logger.LogWarning($"Shoko Scanner... Unable to find series info for path {id}"); + _logger.LogWarning($"Shoko Scanner... Unable to find series info for path {path}"); return false; } - _logger.LogInformation($"Shoko Filter... Found series info for path {id}"); + _logger.LogInformation($"Shoko Filter... Found series info for path {path}"); // Ignore series if we want to sperate our libraries - if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type != "Movie") + if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type != SeriesType.Movie) return true; return false; } diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index a0479c85..4fcfabac 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -9,10 +9,13 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; +using Shokofin.API; using Shokofin.Utils; +using Path = System.IO.Path; using IResolverIgnoreRule = MediaBrowser.Controller.Resolvers.IResolverIgnoreRule; using ILibraryManager = MediaBrowser.Controller.Library.ILibraryManager; +using EpisodeType = Shokofin.API.Models.EpisodeType; namespace Shokofin.Providers { @@ -39,31 +42,31 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell { var result = new MetadataResult<Episode>(); - var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == OrderingUtil.SeriesOrBoxSetGroupType.ShokoGroup; - var (id, file, episode, series, group) = await DataUtil.GetFileInfoByPath(info.Path, includeGroup); + var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == Ordering.SeriesOrBoxSetGroupType.ShokoGroup; + var (file, episode, series, group) = await DataFetcher.GetFileInfoByPath(info.Path, includeGroup); if (file == null) // if file is null then series and episode is also null. { - _logger.LogWarning($"Unable to find file info for path {id}"); + _logger.LogWarning($"Shoko Scanner... Unable to find file info for path {info.Path}"); return result; } - _logger.LogInformation($"Found file info for path {id}"); + _logger.LogInformation($"Shoko Scanner... Found file info for path {info.Path}"); - var ( displayTitle, alternateTitle ) = TextUtil.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, episode.Shoko.Name, info.MetadataLanguage); + var ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, episode.Shoko.Name, info.MetadataLanguage); int aniDBId = episode.AniDB.ID; int tvdbId = episode?.TvDB?.ID ?? 0; - if (group != null && episode.AniDB.Type != "Normal" && Plugin.Instance.Configuration.MarkSpecialsWhenGrouped) { + if (group != null && episode.AniDB.Type != EpisodeType.Normal && Plugin.Instance.Configuration.MarkSpecialsWhenGrouped) { displayTitle = $"SP {episode.AniDB.EpisodeNumber} {displayTitle}"; alternateTitle = $"SP {episode.AniDB.EpisodeNumber} {alternateTitle}"; } result.Item = new Episode { - IndexNumber = OrderingUtil.GetIndexNumber(series, episode), - ParentIndexNumber = OrderingUtil.GetSeasonNumber(group, series, episode), + IndexNumber = Ordering.GetIndexNumber(series, episode), + ParentIndexNumber = Ordering.GetSeasonNumber(group, series, episode), Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, - Overview = TextUtil.SummarySanitizer(episode.AniDB.Description), + Overview = Text.SummarySanitizer(episode.AniDB.Description), CommunityRating = (float) ((episode.AniDB.Rating.Value * 10) / episode.AniDB.Rating.MaxValue) }; result.Item.SetProviderId("Shoko Episode", episode.ID); @@ -105,15 +108,17 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base return false; } try { - var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == OrderingUtil.SeriesOrBoxSetGroupType.ShokoGroup; - var (id, file, episode, series, group) = DataUtil.GetFileInfoByPath(fileInfo, includeGroup); + var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == Ordering.SeriesOrBoxSetGroupType.ShokoGroup; + // TODO: Check if it can be written in a better way. Parent directory + File Name + var id = Path.Join(fileInfo.DirectoryName, fileInfo.FullName); + var (file, episode, series, group) = DataFetcher.GetFileInfoByPathSync(id, includeGroup); if (file == null) // if file is null then series and episode is also null. { _logger.LogWarning($"Shoko Filter... Unable to find file info for path {id}"); return true; } _logger.LogInformation($"Shoko Filter... Found file info for path {id}"); - var extraType = OrderingUtil.GetExtraType(episode.AniDB); + var extraType = Ordering.GetExtraType(episode.AniDB); if (extraType != null) { _logger.LogDebug($"Shoko Filter... Not a normal or special episode, skipping path {id}"); diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index f294dd06..dc59e92f 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -10,7 +10,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; -using Shokofin.Utils; +using Shokofin.API; namespace Shokofin.Providers { @@ -31,30 +31,30 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell var list = new List<RemoteImageInfo>(); try { - DataUtil.EpisodeInfo episode = null; - DataUtil.SeriesInfo series = null; + Shokofin.API.Info.EpisodeInfo episode = null; + Shokofin.API.Info.SeriesInfo series = null; if (item is Episode) { - episode = await DataUtil.GetEpisodeInfo(item.GetProviderId("Shoko Episode")); + episode = await DataFetcher.GetEpisodeInfo(item.GetProviderId("Shoko Episode")); } else if (item is Series) { var groupId = item.GetProviderId("Shoko Group"); if (string.IsNullOrEmpty(groupId)) { - series = await DataUtil.GetSeriesInfo(item.GetProviderId("Shoko Series")); + series = await DataFetcher.GetSeriesInfo(item.GetProviderId("Shoko Series")); } else { - series = (await DataUtil.GetGroupInfo(groupId))?.DefaultSeries; + series = (await DataFetcher.GetGroupInfo(groupId))?.DefaultSeries; } } else if (item is BoxSet || item is Movie) { - series = await DataUtil.GetSeriesInfo(item.GetProviderId("Shoko Series")); + series = await DataFetcher.GetSeriesInfo(item.GetProviderId("Shoko Series")); } else if (item is Season) { - series = await DataUtil.GetSeriesInfoFromGroup(item.GetParent()?.GetProviderId("Shoko Group"), item.IndexNumber ?? 1); + series = await DataFetcher.GetSeriesInfoFromGroup(item.GetParent()?.GetProviderId("Shoko Group"), item.IndexNumber ?? 1); } if (episode != null) { diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 4f682cc2..e6b15967 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -9,10 +9,12 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; +using Shokofin.API; using Shokofin.Utils; using IResolverIgnoreRule = MediaBrowser.Controller.Resolvers.IResolverIgnoreRule; using ILibraryManager = MediaBrowser.Controller.Library.ILibraryManager; +using SeriesType = Shokofin.API.Models.SeriesType; namespace Shokofin.Providers { @@ -37,8 +39,8 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio { var result = new MetadataResult<Movie>(); - var includeGroup = Plugin.Instance.Configuration.BoxSetGrouping == OrderingUtil.SeriesOrBoxSetGroupType.ShokoGroup; - var (id, file, episode, series, group) = await DataUtil.GetFileInfoByPath(info.Path, includeGroup, true); + var includeGroup = Plugin.Instance.Configuration.BoxSetGrouping == Ordering.SeriesOrBoxSetGroupType.ShokoGroup; + var (file, episode, series, group) = await DataFetcher.GetFileInfoByPath(info.Path, includeGroup, true); if (file == null) // if file is null then series and episode is also null. { @@ -50,24 +52,24 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio int aniDBId = isMultiEntry ? episode.AniDB.ID : series.AniDB.ID; var tvdbId = (isMultiEntry ? episode?.TvDB == null ? null : episode.TvDB.ID.ToString() : series?.TvDBID); - if (series.AniDB.Type != "Movie") + if (series.AniDB.Type != SeriesType.Movie) { - _logger.LogWarning($"File found, but not a movie! Skipping path {id}"); + _logger.LogWarning($"File found, but not a movie! Skipping path {info.Path}"); return result; } - var tags = await DataUtil.GetTags(series.ID); - var ( displayTitle, alternateTitle ) = TextUtil.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); + var tags = await DataFetcher.GetTags(series.ID); + var ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); var rating = isMultiEntry ? episode.AniDB.Rating.ToFloat(10) : series.AniDB.Rating.ToFloat(10); result.Item = new Movie { - IndexNumber = OrderingUtil.GetMovieIndexNumber(group, series, episode), + IndexNumber = Ordering.GetMovieIndexNumber(group, series, episode), Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, // Use the file description if collection contains more than one movie, otherwise use the collection description. - Overview = TextUtil.SummarySanitizer((isMultiEntry ? episode.AniDB.Description ?? series.AniDB.Description : series.AniDB.Description) ?? ""), + Overview = Text.SummarySanitizer((isMultiEntry ? episode.AniDB.Description ?? series.AniDB.Description : series.AniDB.Description) ?? ""), ProductionYear = episode.AniDB.AirDate?.Year, Tags = tags, CommunityRating = rating, @@ -82,7 +84,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio result.HasMetadata = true; result.ResetPeople(); - foreach (var person in await DataUtil.GetPeople(series.ID)) + foreach (var person in await DataFetcher.GetPeople(series.ID)) result.AddPerson(person); return result; @@ -116,19 +118,20 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base return false; } try { - var (id, file, episode, series, _group) = DataUtil.GetFileInfoByPath(fileInfo); + var path = System.IO.Path.Join(fileInfo.DirectoryName, fileInfo.FullName); + var (file, episode, series, _group) = DataFetcher.GetFileInfoByPathSync(path); if (file == null) { - _logger.LogWarning($"Shoko Scanner... Unable to find series info for path {id}"); + _logger.LogWarning($"Shoko Scanner... Unable to find series info for path {path}"); return false; } - _logger.LogInformation($"Shoko Filter... Found series info for path {id}"); - if (series.AniDB.Type != "Movie") { + _logger.LogInformation($"Shoko Filter... Found series info for path {path}"); + if (series.AniDB.Type != SeriesType.Movie) { return true; } - var extraType = OrderingUtil.GetExtraType(episode.AniDB); + var extraType = Ordering.GetExtraType(episode.AniDB); if (extraType != null) { - _logger.LogInformation($"Shoko Filter... File was not a 'normal' episode for path, skipping! {id}"); + _logger.LogInformation($"Shoko Filter... File was not a 'normal' episode for path, skipping! {path}"); return true; } // Ignore everything except movies diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 35f7ef2c..b3bb53aa 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -7,6 +7,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; +using Shokofin.API; using Shokofin.Utils; namespace Shokofin.Providers @@ -31,7 +32,7 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat { default: return GetDefaultMetadata(info, cancellationToken); - case OrderingUtil.SeriesOrBoxSetGroupType.ShokoGroup: + case Ordering.SeriesOrBoxSetGroupType.ShokoGroup: return await GetShokoGroupedMetadata(info, cancellationToken); } } @@ -55,10 +56,10 @@ private MetadataResult<Season> GetDefaultMetadata(SeasonInfo info, CancellationT ForcedSortName = seasonName }; result.HasMetadata = true; - + return result; } - + private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<Season>(); @@ -71,7 +72,7 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in var groupId = info.SeriesProviderIds["Shoko Group"]; var seasonNumber = info.IndexNumber ?? 1; - var series = await DataUtil.GetSeriesInfoFromGroup(groupId, seasonNumber); + var series = await DataFetcher.GetSeriesInfoFromGroup(groupId, seasonNumber); if (series == null) { _logger.LogWarning($"Shoko Scanner... Unable to find series info for G{groupId}:S{seasonNumber}"); @@ -79,15 +80,15 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in } _logger.LogInformation($"Shoko Scanner... Found series info for G{groupId}:S{seasonNumber}"); - var tags = await DataUtil.GetTags(series.ID); - var ( displayTitle, alternateTitle ) = TextUtil.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); + var tags = await DataFetcher.GetTags(series.ID); + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); result.Item = new Season { Name = displayTitle, OriginalTitle = alternateTitle, IndexNumber = seasonNumber, - Overview = TextUtil.SummarySanitizer(series.AniDB.Description), + Overview = Text.SummarySanitizer(series.AniDB.Description), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, @@ -98,7 +99,7 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in result.HasMetadata = true; result.ResetPeople(); - foreach (var person in await DataUtil.GetPeople(series.ID)) + foreach (var person in await DataFetcher.GetPeople(series.ID)) result.AddPerson(person); return result; @@ -109,7 +110,7 @@ public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchI // Isn't called from anywhere. If it is called, I don't know from where. throw new NotImplementedException(); } - + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); @@ -130,4 +131,4 @@ private string GetSeasonName(string season) } } } -} \ No newline at end of file +} diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index ae01f127..f6d84bd2 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -15,6 +15,7 @@ using IResolverIgnoreRule = MediaBrowser.Controller.Resolvers.IResolverIgnoreRule; using ILibraryManager = MediaBrowser.Controller.Library.ILibraryManager; +using SeriesType = Shokofin.API.Models.SeriesType; namespace Shokofin.Providers { @@ -45,7 +46,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat { default: return await GetDefaultMetadata(info, cancellationToken); - case OrderingUtil.SeriesOrBoxSetGroupType.ShokoGroup: + case Ordering.SeriesOrBoxSetGroupType.ShokoGroup: return await GetShokoGroupedMetadata(info, cancellationToken); } } @@ -59,20 +60,20 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<Series>(); - var (id, series) = await DataUtil.GetSeriesInfoByPath(info.Path); + var series = await DataFetcher.GetSeriesInfoByPath(info.Path); if (series == null) { - _logger.LogWarning($"Unable to find series info for path {id}"); + _logger.LogWarning($"Unable to find series info for path {info.Path}"); return result; } - _logger.LogInformation($"Found series info for path {id}"); + _logger.LogInformation($"Found series info for path {info.Path}"); - var tags = await DataUtil.GetTags(series.ID); - var ( displayTitle, alternateTitle ) = TextUtil.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); + var tags = await DataFetcher.GetTags(series.ID); + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); - if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type == "Movie") + if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type == SeriesType.Movie) { - _logger.LogWarning($"Shoko Scanner... Separate libraries are on, skipping {id}"); + _logger.LogWarning($"Separate libraries are on, skipping {info.Path}"); return result; } @@ -80,7 +81,7 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = TextUtil.SummarySanitizer(series.AniDB.Description), + Overview = Text.SummarySanitizer(series.AniDB.Description), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, @@ -95,7 +96,7 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C result.HasMetadata = true; result.ResetPeople(); - foreach (var person in await DataUtil.GetPeople(series.ID)) + foreach (var person in await DataFetcher.GetPeople(series.ID)) result.AddPerson(person); return result; @@ -104,29 +105,29 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<Series>(); - var (id, group) = await DataUtil.GetGroupInfoByPath(info.Path); + var group = await DataFetcher.GetGroupInfoByPath(info.Path); if (group == null) { - _logger.LogWarning($"Unable to find series info for path {id}"); + _logger.LogWarning($"Unable to find series info for path {info.Path}"); return result; } - _logger.LogInformation($"Found series info for path {id}"); + _logger.LogInformation($"Found series info for path {info.Path}"); var series = group.DefaultSeries; - if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type == "Movie") + if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type == SeriesType.Movie) { - _logger.LogWarning($"Shoko Scanner... Separate libraries are on, skipping {id}"); + _logger.LogWarning($"Separate libraries are on, skipping {info.Path}"); return result; } - var tags = await DataUtil.GetTags(series.ID); - var ( displayTitle, alternateTitle ) = TextUtil.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); + var tags = await DataFetcher.GetTags(series.ID); + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); result.Item = new Series { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = TextUtil.SummarySanitizer(series.AniDB.Description), + Overview = Text.SummarySanitizer(series.AniDB.Description), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, @@ -143,7 +144,7 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in result.HasMetadata = true; result.ResetPeople(); - foreach (var person in await DataUtil.GetPeople(series.ID)) + foreach (var person in await DataFetcher.GetPeople(series.ID)) result.AddPerson(person); return result; @@ -168,14 +169,13 @@ public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo s SearchProviderName = Name, ImageUrl = imageUrl }; - parsedSeries.SetProviderId("Shoko", series.IDs.ID.ToString()); + parsedSeries.SetProviderId("Shoko Series", series.IDs.ID.ToString()); results.Add(parsedSeries); } return results; } - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); @@ -191,15 +191,16 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base return false; } try { - var (id, series) = DataUtil.GetSeriesInfoByPathSync(fileInfo); + var path = System.IO.Path.Join(fileInfo.DirectoryName, fileInfo.FullName); + var series = DataFetcher.GetSeriesInfoByPathSync(path); if (series == null) { - _logger.LogWarning($"Shoko Scanner... Unable to find series info for path {id}"); + _logger.LogWarning($"Unable to find series info for path {path}"); return false; } - _logger.LogInformation($"Shoko Filter... Found series info for path {id}"); + _logger.LogInformation($"Shoko Filter... Found series info for path {path}"); // Ignore movies if we want to sperate our libraries - if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type == "Movie") + if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type == SeriesType.Movie) return true; return false; } diff --git a/Shokofin/Scrobbler.cs b/Shokofin/Scrobbler.cs index c90a8b46..2f786b2e 100644 --- a/Shokofin/Scrobbler.cs +++ b/Shokofin/Scrobbler.cs @@ -19,7 +19,7 @@ public Scrobbler(ISessionManager sessionManager, ILogger<Scrobbler> logger) _sessionManager = sessionManager; _logger = logger; } - + public Task RunAsync() { _sessionManager.PlaybackStopped += OnPlaybackStopped; @@ -29,7 +29,7 @@ public Task RunAsync() private async void OnPlaybackStopped(object sender, PlaybackStopEventArgs e) { if (!Plugin.Instance.Configuration.UpdateWatchedStatus) return; - + if (e.Item == null) { _logger.LogError("Event details incomplete. Cannot process current media"); @@ -45,7 +45,7 @@ private async void OnPlaybackStopped(object sender, PlaybackStopEventArgs e) if (e.Item is Episode episode && e.PlayedToCompletion) { var episodeId = episode.GetProviderId("Shoko Episode"); - + _logger.LogInformation("Item is played. Marking as watched on Shoko"); _logger.LogInformation($"{episode.SeriesName} S{episode.Season.IndexNumber}E{episode.IndexNumber} - {episode.Name} ({episodeId})"); @@ -56,10 +56,10 @@ private async void OnPlaybackStopped(object sender, PlaybackStopEventArgs e) _logger.LogError("Error marking episode as watched!"); } } - + public void Dispose() { _sessionManager.PlaybackStopped -= OnPlaybackStopped; } } -} \ No newline at end of file +} diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 194cdb1f..d180cf19 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -1,9 +1,11 @@ -using MediaBrowser.Model.Entities; using Shokofin.API.Models; +using Shokofin.API.Info; + +using ExtraType = MediaBrowser.Model.Entities.ExtraType; namespace Shokofin.Utils { - public class OrderingUtil + public class Ordering { /// <summary> @@ -52,7 +54,7 @@ public enum SeasonAndMovieOrderType /// Get index number for a movie in a box-set. /// </summary> /// <returns>Absoute index.</returns> - public static int GetMovieIndexNumber(DataUtil.GroupInfo group, DataUtil.SeriesInfo series, DataUtil.EpisodeInfo episode) + public static int GetMovieIndexNumber(GroupInfo group, SeriesInfo series, EpisodeInfo episode) { switch (Plugin.Instance.Configuration.BoxSetGrouping) { @@ -64,12 +66,12 @@ public static int GetMovieIndexNumber(DataUtil.GroupInfo group, DataUtil.SeriesI case SeriesOrBoxSetGroupType.ShokoGroup: { int offset = 0; - foreach (DataUtil.SeriesInfo s in group.SeriesList) + foreach (SeriesInfo s in group.SeriesList) { var sizes = s.Shoko.Sizes.Total; if (s != series) { - if (episode.AniDB.Type == "Special") + if (episode.AniDB.Type == EpisodeType.Special) { var index = series.FilteredSpecialEpisodesList.FindIndex(e => string.Equals(e.ID, episode.ID)); if (index == -1) @@ -78,21 +80,21 @@ public static int GetMovieIndexNumber(DataUtil.GroupInfo group, DataUtil.SeriesI } switch (episode.AniDB.Type) { - case "Normal": + case EpisodeType.Normal: // offset += 0; break; - case "Parody": + case EpisodeType.Parody: offset += sizes?.Episodes ?? 0; - goto case "Normal"; - case "Other": + goto case EpisodeType.Normal; + case EpisodeType.Other: offset += sizes?.Parodies ?? 0; - goto case "Parody"; + goto case EpisodeType.Parody; } return offset + episode.AniDB.EpisodeNumber; } else { - if (episode.AniDB.Type == "Special") { + if (episode.AniDB.Type == EpisodeType.Special) { offset -= series.FilteredSpecialEpisodesList.Count; } offset += (sizes?.Episodes ?? 0) + (sizes?.Parodies ?? 0) + (sizes?.Others ?? 0); @@ -108,7 +110,7 @@ public static int GetMovieIndexNumber(DataUtil.GroupInfo group, DataUtil.SeriesI /// Get index number for an episode in a series. /// </summary> /// <returns>Absolute index.</returns> - public static int GetIndexNumber(DataUtil.SeriesInfo series, DataUtil.EpisodeInfo episode) + public static int GetIndexNumber(SeriesInfo series, EpisodeInfo episode) { switch (Plugin.Instance.Configuration.SeriesGrouping) { @@ -124,7 +126,7 @@ public static int GetIndexNumber(DataUtil.SeriesInfo series, DataUtil.EpisodeInf } case SeriesOrBoxSetGroupType.ShokoGroup: { - if (episode.AniDB.Type == "Special") + if (episode.AniDB.Type == EpisodeType.Special) { var index = series.FilteredSpecialEpisodesList.FindIndex(e => string.Equals(e.ID, episode.ID)); if (index == -1) @@ -135,17 +137,15 @@ public static int GetIndexNumber(DataUtil.SeriesInfo series, DataUtil.EpisodeInf var sizes = series.Shoko.Sizes.Total; switch (episode.AniDB.Type) { - case "Normal": + case EpisodeType.Normal: + // offset += 0; break; - case "Special": + case EpisodeType.Parody: offset += sizes?.Episodes ?? 0; - break; // goto case "Normal"; - case "Other": - offset += sizes?.Specials ?? 0; - goto case "Special"; - case "Parody": - offset += sizes?.Others ?? 0; - goto case "Other"; + goto case EpisodeType.Normal; + case EpisodeType.Other: + offset += sizes?.Parodies ?? 0; + goto case EpisodeType.Parody; } return offset + episode.AniDB.EpisodeNumber; } @@ -159,7 +159,7 @@ public static int GetIndexNumber(DataUtil.SeriesInfo series, DataUtil.EpisodeInf /// <param name="series"></param> /// <param name="episode"></param> /// <returns></returns> - public static int GetSeasonNumber(DataUtil.GroupInfo group, DataUtil.SeriesInfo series, DataUtil.EpisodeInfo episode) + public static int GetSeasonNumber(GroupInfo group, SeriesInfo series, EpisodeInfo episode) { switch (Plugin.Instance.Configuration.SeriesGrouping) { @@ -167,9 +167,9 @@ public static int GetSeasonNumber(DataUtil.GroupInfo group, DataUtil.SeriesInfo case SeriesOrBoxSetGroupType.Default: switch (episode.AniDB.Type) { - case "Normal": + case EpisodeType.Normal: return 1; - case "Special": + case EpisodeType.Special: return 0; default: return 98; @@ -197,15 +197,17 @@ public static int GetSeasonNumber(DataUtil.GroupInfo group, DataUtil.SeriesInfo { switch (episode.Type) { - case "Normal": - case "Other": + case EpisodeType.Normal: + case EpisodeType.Other: return null; - case "ThemeSong": + case EpisodeType.ThemeSong: + case EpisodeType.OpeningSong: + case EpisodeType.EndingSong: return ExtraType.ThemeVideo; - case "Trailer": + case EpisodeType.Trailer: return ExtraType.Trailer; - case "Special": { - var title = TextUtil.GetTitleByLanguages(episode.Titles, "en") ?? ""; + case EpisodeType.Special: { + var title = Text.GetTitleByLanguages(episode.Titles, "en") ?? ""; // Interview if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) return ExtraType.Interview; diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index f5a599ed..5858a06a 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -6,7 +6,7 @@ namespace Shokofin.Utils { - public class TextUtil + public class Text { public enum DisplayLanguageType { Default = 1, @@ -173,4 +173,4 @@ private static string[] GuessOriginLanguage(IEnumerable<Title> titles) } } -} \ No newline at end of file +} From 47d91fff75ee69277eb04504239ddf5105e33e84 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 14 Mar 2021 02:32:12 +0100 Subject: [PATCH 0100/1103] Add missing annotations --- Shokofin/API/Models/Episode.cs | 2 ++ Shokofin/API/Models/Series.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index 99b47098..855fc56a 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text.Json.Serialization; namespace Shokofin.API.Models { @@ -13,6 +14,7 @@ public class AniDB { public int ID { get; set; } + [JsonConverter(typeof(JsonStringEnumConverter))] public EpisodeType Type { get; set; } public int EpisodeNumber { get; set; } diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index dded5db1..741fdf73 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text.Json.Serialization; namespace Shokofin.API.Models { @@ -21,6 +22,7 @@ public class AniDB { public int ID { get; set; } + [JsonConverter(typeof(JsonStringEnumConverter))] public SeriesType Type { get; set; } public string Title { get; set; } From 79d62d8ebf1f4621b9b973a6f46aee1999b52ebc Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 29 Aug 2021 20:39:47 +0200 Subject: [PATCH 0101/1103] Update the api manager and more So, did some coding over the weekend; - added a library "scanner", and removed all the other ignore rules present - laid the ground work for adding virtual missing episodes, theme videos, trailers, interviews to series/seasons (will be added in a later commit) - fixed the code style for _most_ of the code base to _my liking_. - fixed the "bug" resulting in single "episodes" of series with type "movie" only being named "Complete Movie" (note; it was not really a bug, but working as intended) - _a lot_ of refactor of the api manager. --- .vscode/settings.json | 6 + Shokofin/API/DataFetcher.cs | 375 ----------- Shokofin/API/Info/EpisodeInfo.cs | 7 +- Shokofin/API/Info/FileInfo.cs | 4 +- Shokofin/API/Info/GroupInfo.cs | 12 +- Shokofin/API/Info/SeriesInfo.cs | 9 +- Shokofin/API/Models/Series.cs | 15 +- Shokofin/API/ShokoAPIManager.cs | 637 ++++++++++++++++++ Shokofin/Configuration/PluginConfiguration.cs | 22 +- Shokofin/Configuration/configPage.html | 7 + Shokofin/LibraryScanner.cs | 130 ++++ Shokofin/Plugin.cs | 15 +- Shokofin/PluginServiceRegistrator.cs | 16 + Shokofin/Providers/BoxSetProvider.cs | 161 ++--- Shokofin/Providers/EpisodeProvider.cs | 115 ++-- Shokofin/Providers/ImageProvider.cs | 67 +- Shokofin/Providers/MovieProvider.cs | 113 +--- Shokofin/Providers/SeasonProvider.cs | 63 +- Shokofin/Providers/SeriesProvider.cs | 184 ++--- Shokofin/Scrobbler.cs | 24 +- Shokofin/Tasks/PostScanTask.cs | 28 + Shokofin/Utils/OrderingUtil.cs | 120 ++-- Shokofin/Utils/TextUtil.cs | 124 ++-- 23 files changed, 1266 insertions(+), 988 deletions(-) create mode 100644 .vscode/settings.json delete mode 100644 Shokofin/API/DataFetcher.cs create mode 100644 Shokofin/API/ShokoAPIManager.cs create mode 100644 Shokofin/LibraryScanner.cs create mode 100644 Shokofin/PluginServiceRegistrator.cs create mode 100644 Shokofin/Tasks/PostScanTask.cs diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..40d66586 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "editor.tabSize": 4, + "files.trimTrailingWhitespace": false, + "files.trimFinalNewlines": false, + "files.insertFinalNewline": false +} diff --git a/Shokofin/API/DataFetcher.cs b/Shokofin/API/DataFetcher.cs deleted file mode 100644 index 8bad7643..00000000 --- a/Shokofin/API/DataFetcher.cs +++ /dev/null @@ -1,375 +0,0 @@ -using MediaBrowser.Controller.Entities; -using MediaBrowser.Model.Entities; -using Microsoft.Extensions.Caching.Memory; -using Shokofin.API.Info; -using Shokofin.API.Models; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -using Path = System.IO.Path; - -namespace Shokofin.API -{ - public class DataFetcher - { - private static readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions() { - ExpirationScanFrequency = new System.TimeSpan(0, 3, 0), - }); - - private static readonly System.TimeSpan DefaultTimeSpan = new System.TimeSpan(0, 5, 0); - - #region People - - public static async Task<IEnumerable<PersonInfo>> GetPeople(string seriesId) - { - var list = new List<PersonInfo>(); - var roles = await ShokoAPI.GetSeriesCast(seriesId); - foreach (var role in roles) - { - list.Add(new PersonInfo - { - Type = PersonType.Actor, - Name = role.Staff.Name, - Role = role.Character.Name, - ImageUrl = role.Staff.Image?.ToURLString(), - }); - } - return list; - } - - #endregion - #region Tags - - public static async Task<string[]> GetTags(string seriesId) - { - return (await ShokoAPI.GetSeriesTags(seriesId, DataFetcher.GetTagFilter()))?.Select(tag => tag.Name).ToArray() ?? new string[0]; - } - - /// <summary> - /// Get the tag filter - /// </summary> - /// <returns></returns> - private static int GetTagFilter() - { - var config = Plugin.Instance.Configuration; - var filter = 0; - - if (config.HideAniDbTags) filter = 1; - if (config.HideArtStyleTags) filter |= (filter << 1); - if (config.HideSourceTags) filter |= (filter << 2); - if (config.HideMiscTags) filter |= (filter << 3); - if (config.HidePlotTags) filter |= (filter << 4); - - return filter; - } - - #endregion - #region File Info - - public static (FileInfo, EpisodeInfo, SeriesInfo, GroupInfo) GetFileInfoByPathSync(string path, bool includeGroup = true, bool onlyMovies = false) - { - return GetFileInfoByPath(path, includeGroup, onlyMovies).GetAwaiter().GetResult(); - } - - public static async Task<(FileInfo, EpisodeInfo, SeriesInfo, GroupInfo)> GetFileInfoByPath(string path, bool includeGroup = true, bool onlyMovies = false) - { - // TODO: Check if it can be written in a better way. Parent directory + File Name - var id = Path.Join( - Path.GetDirectoryName(path)?.Split(Path.DirectorySeparatorChar).LastOrDefault(), - Path.GetFileName(path)); - var result = await ShokoAPI.GetFileByPath(id); - - var file = result?.FirstOrDefault(); - if (file == null) - return (null, null, null, null); - - var series = file?.SeriesIDs.FirstOrDefault(); - var seriesId = series?.SeriesID.ID.ToString(); - var episodes = series?.EpisodeIDs?.FirstOrDefault(); - var episodeId = episodes?.ID.ToString(); - if (string.IsNullOrEmpty(seriesId) || string.IsNullOrEmpty(episodeId)) - return (null, null, null, null); - - GroupInfo groupInfo = null; - if (includeGroup) - { - groupInfo = await GetGroupInfoForSeries(seriesId, onlyMovies); - if (groupInfo == null) - return (null, null, null, null); - } - - var seriesInfo = await GetSeriesInfo(seriesId); - if (seriesInfo == null) - return (null, null, null, null); - - var episodeInfo = await GetEpisodeInfo(episodeId); - if (episodeInfo == null) - return (null, null, null, null); - - var fileInfo = CreateFileInfo(file, file.ID.ToString(), series?.EpisodeIDs?.Count ?? 0); - - return (fileInfo, episodeInfo, seriesInfo, groupInfo); - } - - public async Task<FileInfo> GetFileInfo(string fileId) - { - var file = await ShokoAPI.GetFile(fileId); - if (file == null) - return null; - return CreateFileInfo(file); - } - - private static FileInfo CreateFileInfo(File file, string fileId = null, int episodeCount = 0) - { - if (file == null) - return null; - if (string.IsNullOrEmpty(fileId)) - fileId = file.ID.ToString(); - var cacheKey = $"file:{fileId}:{episodeCount}"; - FileInfo info = null; - if (_cache.TryGetValue<FileInfo>(cacheKey, out info)) - return info; - info = new FileInfo - { - ID = fileId, - Shoko = file, - - }; - _cache.Set<FileInfo>(cacheKey, info, DefaultTimeSpan); - return info; - } - - #endregion - #region Episode Info - - public static async Task<EpisodeInfo> GetEpisodeInfo(string episodeId) - { - if (string.IsNullOrEmpty(episodeId)) - return null; - if (_cache.TryGetValue<EpisodeInfo>($"episode:{episodeId}", out var info)) - return info; - var episode = await ShokoAPI.GetEpisode(episodeId); - return await CreateEpisodeInfo(episode, episodeId); - } - - private static async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string episodeId = null) - { - if (episode == null) - return null; - if (string.IsNullOrEmpty(episodeId)) - episodeId = episode.IDs.ID.ToString(); - var cacheKey = $"episode:{episodeId}"; - EpisodeInfo info = null; - if (_cache.TryGetValue<EpisodeInfo>(cacheKey, out info)) - return info; - info = new EpisodeInfo - { - ID = episodeId, - Shoko = (await ShokoAPI.GetEpisode(episodeId)), - AniDB = (await ShokoAPI.GetEpisodeAniDb(episodeId)), - TvDB = ((await ShokoAPI.GetEpisodeTvDb(episodeId))?.FirstOrDefault()), - }; - _cache.Set<EpisodeInfo>(cacheKey, info, DefaultTimeSpan); - return info; - } - - #endregion - #region Series Info - public static SeriesInfo GetSeriesInfoByPathSync(string path) - { - return GetSeriesInfoByPath(path).GetAwaiter().GetResult(); - } - - public static async Task<SeriesInfo> GetSeriesInfoByPath(string path) - { - var id = Path.DirectorySeparatorChar + path.Split(Path.DirectorySeparatorChar).Last(); - var result = await ShokoAPI.GetSeriesPathEndsWith(id); - - var seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); - if (string.IsNullOrEmpty(seriesId)) - return null; - - return await GetSeriesInfo(seriesId); - } - - public static async Task<SeriesInfo> GetSeriesInfoFromGroup(string groupId, int seasonNumber) - { - var groupInfo = await GetGroupInfo(groupId, false); - if (groupInfo == null) - return null; - int seriesIndex = seasonNumber > 0 ? seasonNumber - 1 : seasonNumber; - var index = groupInfo.DefaultSeriesIndex + seriesIndex; - var seriesInfo = groupInfo.SeriesList[index]; - if (seriesInfo == null) - return null; - - return seriesInfo; - } - - public static async Task<SeriesInfo> GetSeriesInfo(string seriesId) - { - if (string.IsNullOrEmpty(seriesId)) - return null; - if (_cache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) - return info; - var series = await ShokoAPI.GetSeries(seriesId); - return await CreateSeriesInfo(series, seriesId); - } - - private static async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = null) - { - if (series == null) - return null; - - if (string.IsNullOrEmpty(seriesId)) - seriesId = series.IDs.ID.ToString(); - - SeriesInfo info = null; - var cacheKey = $"series:{seriesId}"; - if (_cache.TryGetValue<SeriesInfo>(cacheKey, out info)) - return info; - - var aniDb = await ShokoAPI.GetSeriesAniDb(seriesId); - var episodeList = await ShokoAPI.GetEpisodesFromSeries(seriesId) - .ContinueWith(async task => await Task.WhenAll(task.Result.Select(e => CreateEpisodeInfo(e)))).Unwrap() - .ContinueWith(l => l.Result.Where(s => s != null).ToList()); - var filteredSpecialEpisodesList = episodeList.Where(e => e.AniDB.Type == EpisodeType.Special && Shokofin.Utils.Ordering.GetExtraType(e.AniDB) != null).ToList(); - info = new SeriesInfo - { - ID = seriesId, - Shoko = series, - AniDB = aniDb, - TvDBID = series.IDs.TvDB.Count > 0 ? series.IDs.TvDB.FirstOrDefault().ToString() : null, - EpisodeList = episodeList, - FilteredSpecialEpisodesList = filteredSpecialEpisodesList, - }; - _cache.Set<SeriesInfo>(cacheKey, info, DefaultTimeSpan); - return info; - } - - #endregion - #region Group Info - - public static GroupInfo GetGroupInfoByPathSync(string path, bool onlyMovies = false) - { - return GetGroupInfoByPath(path, onlyMovies).GetAwaiter().GetResult(); - } - - public static async Task<GroupInfo> GetGroupInfoByPath(string path, bool onlyMovies = false) - { - var id = Path.DirectorySeparatorChar + path.Split(Path.DirectorySeparatorChar).Last(); - var result = await ShokoAPI.GetSeriesPathEndsWith(id); - - var seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); - if (string.IsNullOrEmpty(seriesId)) - return null; - - var groupInfo = await GetGroupInfoForSeries(seriesId, onlyMovies); - if (groupInfo == null) - return null; - - return groupInfo; - } - - public static async Task<GroupInfo> GetGroupInfo(string groupId, bool onlyMovies = false) - { - if (string.IsNullOrEmpty(groupId)) - return null; - if (_cache.TryGetValue<GroupInfo>($"group:{(onlyMovies ? "movies" : "all")}:{groupId}", out var info)) - return info; - var group = await ShokoAPI.GetGroup(groupId); - return await CreateGroupInfo(group, groupId, onlyMovies); - } - - public static async Task<GroupInfo> GetGroupInfoForSeries(string seriesId, bool onlyMovies = false) - { - // TODO: Find a way to remove the double requests for group info. - var group = await ShokoAPI.GetGroupFromSeries(seriesId); - if (group == null) - return null; - var groupId = group.IDs.ID.ToString(); - GroupInfo info = null; - var cacheKey = $"group-by-series:{(onlyMovies ? "movies" : "all")}:{seriesId}"; - if (_cache.TryGetValue<GroupInfo>(cacheKey, out info)) - return info; - info = await GetGroupInfo(groupId, onlyMovies); - _cache.Set<GroupInfo>(cacheKey, info, DefaultTimeSpan); - return info; - } - - private static async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, bool onlyMovies) - { - if (group == null) - return null; - - if (string.IsNullOrEmpty(groupId)) - groupId = group.IDs.ID.ToString(); - - var cacheKey = $"group:{(onlyMovies ? "movies" : "all")}:{groupId}"; - GroupInfo info = null; - if (_cache.TryGetValue<GroupInfo>(cacheKey, out info)) - return info; - - var seriesList = await ShokoAPI.GetSeriesInGroup(groupId) - .ContinueWith(async task => await Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s)))).Unwrap() - .ContinueWith(l => l.Result.Where(s => s != null).ToList()); - if (onlyMovies && seriesList != null && seriesList.Count > 0) - seriesList = seriesList.Where(s => s.AniDB.Type == SeriesType.Movie).ToList(); - if (seriesList == null || seriesList.Count == 0) - return null; - // Map - int foundIndex = -1; - int targetId = (group.IDs.DefaultSeries ?? 0); - // Sort list - var orderingType = onlyMovies ? Plugin.Instance.Configuration.MovieOrdering : Plugin.Instance.Configuration.SeasonOrdering; - switch (orderingType) - { - case Shokofin.Utils.Ordering.SeasonAndMovieOrderType.Default: - break; - case Shokofin.Utils.Ordering.SeasonAndMovieOrderType.ReleaseDate: - seriesList = seriesList.OrderBy(s => s?.AniDB?.AirDate ?? System.DateTime.MaxValue).ToList(); - break; - // Should not be selectable unless a user fidles with DevTools in the browser to select the option. - case Shokofin.Utils.Ordering.SeasonAndMovieOrderType.Chronological: - throw new System.Exception("Not implemented yet"); - } - // Select the targeted id if a group spesify a default series. - if (targetId != 0) - foundIndex = seriesList.FindIndex(s => s.Shoko.IDs.ID == targetId); - // Else select the default series as first-to-be-released. - else switch (orderingType) - { - // The list is already sorted by release date, so just return the first index. - case Shokofin.Utils.Ordering.SeasonAndMovieOrderType.ReleaseDate: - foundIndex = 0; - break; - // We don't know how Shoko may have sorted it, so just find the earliest series - case Shokofin.Utils.Ordering.SeasonAndMovieOrderType.Default: - // We can't be sure that the the series in the list was _released_ chronologically, so find the earliest series, and use that as a base. - case Shokofin.Utils.Ordering.SeasonAndMovieOrderType.Chronological: { - var earliestSeries = seriesList.Aggregate((cur, nxt) => (cur == null || (nxt?.AniDB.AirDate ?? System.DateTime.MaxValue) < (cur.AniDB.AirDate ?? System.DateTime.MaxValue)) ? nxt : cur); - foundIndex = seriesList.FindIndex(s => s == earliestSeries); - break; - } - } - - // Throw if we can't get a base point for seasons. - if (foundIndex == -1) - throw new System.Exception("Unable to get a base-point for seasions withing the group"); - - info = new GroupInfo - { - ID = groupId, - Shoko = group, - SeriesList = seriesList, - DefaultSeries = seriesList[foundIndex], - DefaultSeriesIndex = foundIndex, - }; - _cache.Set<GroupInfo>(cacheKey, info, DefaultTimeSpan); - return info; - } - - #endregion - } -} diff --git a/Shokofin/API/Info/EpisodeInfo.cs b/Shokofin/API/Info/EpisodeInfo.cs index f8b9cdf5..3c0d1dfe 100644 --- a/Shokofin/API/Info/EpisodeInfo.cs +++ b/Shokofin/API/Info/EpisodeInfo.cs @@ -4,9 +4,14 @@ namespace Shokofin.API.Info { public class EpisodeInfo { - public string ID; + public string Id; + + public MediaBrowser.Model.Entities.ExtraType? ExtraType; + public Episode Shoko; + public Episode.AniDB AniDB; + public Episode.TvDB TvDB; } } diff --git a/Shokofin/API/Info/FileInfo.cs b/Shokofin/API/Info/FileInfo.cs index ef9cf634..20d7576a 100644 --- a/Shokofin/API/Info/FileInfo.cs +++ b/Shokofin/API/Info/FileInfo.cs @@ -4,8 +4,10 @@ namespace Shokofin.API.Info { public class FileInfo { - public string ID; + public string Id; + public File Shoko; + public int EpisodesCount; } } diff --git a/Shokofin/API/Info/GroupInfo.cs b/Shokofin/API/Info/GroupInfo.cs index f4713785..f9cc2ac5 100644 --- a/Shokofin/API/Info/GroupInfo.cs +++ b/Shokofin/API/Info/GroupInfo.cs @@ -6,11 +6,19 @@ namespace Shokofin.API.Info { public class GroupInfo { - public string ID; + public string Id; + + /// <summary> + /// Shared Guid for series merging. + /// </summary> + public System.Guid Guid; + public Group Shoko; + public List<SeriesInfo> SeriesList; + public SeriesInfo DefaultSeries; + public int DefaultSeriesIndex; } - } diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index 920211c5..da6f38c7 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -5,14 +5,19 @@ namespace Shokofin.API.Info { public class SeriesInfo { - public string ID; + public string Id; + + public System.Guid Guid; + public Series Shoko; + public Series.AniDB AniDB; - public string TvDBID; + /// <summary> /// All episodes (of all type) that belong to this series. /// </summary> public List<EpisodeInfo> EpisodeList; + /// <summary> /// A pre-filtered list of special episodes without an ExtraType /// attached. diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index 741fdf73..dd5c1a8b 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -98,35 +98,36 @@ public class SeriesSearchResult : Series public double Distance { get; set; } } + [JsonConverter(typeof(JsonStringEnumConverter))] public enum SeriesType { /// <summary> /// The series type is unknown. /// </summary> - Unknown = 0, + Unknown, /// <summary> /// A catch-all type for future extensions when a provider can't use a current episode type, but knows what the future type should be. /// </summary> - Other = 1, + Other, /// <summary> /// Standard TV series. /// </summary> - TV = 2, + TV, /// <summary> /// TV special. /// </summary> - TVSpecial = 3, + TVSpecial, /// <summary> /// Web series. /// </summary> - Web = 4, + Web, /// <summary> /// All movies, regardless of source (e.g. web or theater) /// </summary> - Movie = 5, + Movie, /// <summary> /// Original Video Animations, AKA standalone releases that don't air on TV or the web. /// </summary> - OVA = 6, + OVA, } } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs new file mode 100644 index 00000000..a5cc0f0b --- /dev/null +++ b/Shokofin/API/ShokoAPIManager.cs @@ -0,0 +1,637 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Shokofin.API.Info; +using Shokofin.API.Models; +using Shokofin.Utils; + +using ILibraryManager = MediaBrowser.Controller.Library.ILibraryManager; + +namespace Shokofin.API +{ + public class ShokoAPIManager + { + private readonly ILogger<ShokoAPIManager> Logger; + + private readonly ILibraryManager LibraryManager; + + private static readonly List<Folder> RootFolderList = new List<Folder>(); + + private static readonly Dictionary<string, string> SeriesIdToPathDictionary = new Dictionary<string, string>(); + + private static readonly Dictionary<string, string> SeriesPathToIdDictionary = new Dictionary<string, string>(); + + /// <summary> + /// Episodes marked as ignored is skipped when adding missing episode metadata. + /// </summary> + private static readonly Dictionary<string, Dictionary<string, string>> SeriesIdToEpisodeIdIgnoreDictionery = new Dictionary<string, Dictionary<string, string>>(); + + /// <summary> + /// Episodes found while scanning the library for metadata. + /// </summary> + private static readonly Dictionary<string, HashSet<string>> SeriesIdToEpisodeIdDictionery = new Dictionary<string, HashSet<string>>(); + + private static readonly Dictionary<string, HashSet<string>> GroupIdToSeriesIdDictionery = new Dictionary<string, HashSet<string>>(); + + private static Dictionary<string, Guid> SeriesIdToGuidDictionary = new Dictionary<string, Guid>(); + + private static Dictionary<string, Guid> GroupIdToGuidDictionary = new Dictionary<string, Guid>(); + + private bool __isScanning = false; + + public bool IsScanning { get { return __isScanning; } } + + public void Scan() + { + if (!__isScanning) + __isScanning = true; + } + + public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ILibraryManager libraryManager) + { + Logger = logger; + LibraryManager = libraryManager; + } + + private static IMemoryCache DataCache = new MemoryCache(new MemoryCacheOptions() { + ExpirationScanFrequency = ExpirationScanFrequency, + }); + + private static readonly System.TimeSpan ExpirationScanFrequency = new System.TimeSpan(0, 25, 0); + + private static readonly System.TimeSpan DefaultTimeSpan = new System.TimeSpan(1, 0, 0); + + #region Ignore rule + + public Folder FindMediaFolder(string path, Folder parent, Folder root) + { + __isScanning = true; + var rootFolder = RootFolderList.Find((folder) => path.StartsWith(folder.Path)); + // Look for the root folder for the current item. + if (rootFolder != null) { + return rootFolder; + } + rootFolder = parent; + while (rootFolder.Parent != root) { + if (rootFolder.Parent == null) { + break; + } + rootFolder = rootFolder.Parent; + } + RootFolderList.Add(rootFolder); + return rootFolder; + } + + public string StripMediaFolder(string fullPath) + { + var mediaFolder = RootFolderList.Find((folder) => fullPath.StartsWith(folder.Path)); + // If no root folder was found, then we _most likely_ already stripped it out beforehand. + if (mediaFolder == null || string.IsNullOrEmpty(mediaFolder?.Path)) + return fullPath; + return fullPath.Substring(mediaFolder.Path.Length); + } + + #endregion + #region Clear + + public void Clear() + { + __isScanning = false; + DataCache.Dispose(); + RootFolderList.Clear(); + SeriesIdToPathDictionary.Clear(); + SeriesPathToIdDictionary.Clear(); + SeriesIdToEpisodeIdDictionery.Clear(); + SeriesIdToEpisodeIdIgnoreDictionery.Clear(); + SeriesIdToGroupIdDictionary.Clear(); + GroupIdToSeriesIdDictionery.Clear(); + DataCache = (new MemoryCache((new MemoryCacheOptions() { + ExpirationScanFrequency = ExpirationScanFrequency, + }))); + } + + #endregion + #region People + + public async Task<IEnumerable<PersonInfo>> GetPeople(string seriesId) + { + var list = new List<PersonInfo>(); + var roles = await ShokoAPI.GetSeriesCast(seriesId); + foreach (var role in roles) + { + list.Add(new PersonInfo + { + Type = PersonType.Actor, + Name = role.Staff.Name, + Role = role.Character.Name, + ImageUrl = role.Staff.Image?.ToURLString(), + }); + } + return list; + } + + #endregion + #region Tags + + public async Task<string[]> GetTags(string seriesId) + { + return (await ShokoAPI.GetSeriesTags(seriesId, GetTagFilter()))?.Select(tag => tag.Name).ToArray() ?? new string[0]; + } + + /// <summary> + /// Get the tag filter + /// </summary> + /// <returns></returns> + private int GetTagFilter() + { + var config = Plugin.Instance.Configuration; + var filter = 0; + + if (config.HideAniDbTags) filter = 1; + if (config.HideArtStyleTags) filter |= (filter << 1); + if (config.HideSourceTags) filter |= (filter << 2); + if (config.HideMiscTags) filter |= (filter << 3); + if (config.HidePlotTags) filter |= (filter << 4); + + return filter; + } + + #endregion + #region File Info + + public (FileInfo, EpisodeInfo, SeriesInfo, GroupInfo) GetFileInfoByPathSync(string path, Ordering.GroupFilterType? filterGroupByType) + { + return GetFileInfoByPath(path, filterGroupByType).GetAwaiter().GetResult(); + } + + public async Task<(FileInfo, EpisodeInfo, SeriesInfo, GroupInfo)> GetFileInfoByPath(string path, Ordering.GroupFilterType? filterGroupByType) + { + var partialPath = StripMediaFolder(path); + var result = await ShokoAPI.GetFileByPath(partialPath); + + var file = result?.FirstOrDefault(); + if (file == null) + return (null, null, null, null); + + var series = file?.SeriesIDs.FirstOrDefault(); + var seriesId = series?.SeriesID.ID.ToString(); + var episodes = series?.EpisodeIDs?.FirstOrDefault(); + var episodeId = episodes?.ID.ToString(); + if (string.IsNullOrEmpty(seriesId) || string.IsNullOrEmpty(episodeId)) + return (null, null, null, null); + + GroupInfo groupInfo = null; + if (filterGroupByType != null) { + groupInfo = await GetGroupInfoForSeries(seriesId, (Ordering.GroupFilterType)filterGroupByType); + if (groupInfo == null) + return (null, null, null, null); + } + + var seriesInfo = await GetSeriesInfo(seriesId); + if (seriesInfo == null) + return (null, null, null, null); + + var episodeInfo = await GetEpisodeInfo(episodeId); + if (episodeInfo == null) + return (null, null, null, null); + + var fileInfo = CreateFileInfo(file, file.ID.ToString(), series?.EpisodeIDs?.Count ?? 0); + + return (fileInfo, episodeInfo, seriesInfo, groupInfo); + } + + public async Task<FileInfo> GetFileInfo(string fileId) + { + var file = await ShokoAPI.GetFile(fileId); + if (file == null) + return null; + return CreateFileInfo(file); + } + + private FileInfo CreateFileInfo(File file, string fileId = null, int episodeCount = 0) + { + if (file == null) + return null; + if (string.IsNullOrEmpty(fileId)) + fileId = file.ID.ToString(); + var cacheKey = $"file:{fileId}:{episodeCount}"; + FileInfo info = null; + if (DataCache.TryGetValue<FileInfo>(cacheKey, out info)) + return info; + info = new FileInfo + { + Id = fileId, + Shoko = file, + + }; + DataCache.Set<FileInfo>(cacheKey, info, DefaultTimeSpan); + return info; + } + + #endregion + #region Episode Info + + public async Task<EpisodeInfo> GetEpisodeInfo(string episodeId) + { + if (string.IsNullOrEmpty(episodeId)) + return null; + if (DataCache.TryGetValue<EpisodeInfo>($"episode:{episodeId}", out var info)) + return info; + var episode = await ShokoAPI.GetEpisode(episodeId); + return await CreateEpisodeInfo(episode, episodeId); + } + + private async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string episodeId = null) + { + if (episode == null) + return null; + if (string.IsNullOrEmpty(episodeId)) + episodeId = episode.IDs.ID.ToString(); + var cacheKey = $"episode:{episodeId}"; + EpisodeInfo info = null; + if (DataCache.TryGetValue<EpisodeInfo>(cacheKey, out info)) + return info; + var aniDB = (await ShokoAPI.GetEpisodeAniDb(episodeId)); + info = new EpisodeInfo + { + Id = episodeId, + ExtraType = GetExtraType(aniDB), + Shoko = (await ShokoAPI.GetEpisode(episodeId)), + AniDB = aniDB, + TvDB = ((await ShokoAPI.GetEpisodeTvDb(episodeId))?.FirstOrDefault()), + }; + DataCache.Set<EpisodeInfo>(cacheKey, info, DefaultTimeSpan); + return info; + } + + public bool MarkEpisodeAsIgnored(string episodeId, string seriesId, string fullPath) + { + var dictionary = (SeriesIdToEpisodeIdIgnoreDictionery.ContainsKey(seriesId) ? SeriesIdToEpisodeIdIgnoreDictionery[seriesId] : (SeriesIdToEpisodeIdIgnoreDictionery[seriesId] = new Dictionary<string, string>())); + if (dictionary.ContainsKey(episodeId)) + return false; + + dictionary.Add(episodeId, fullPath); + return true; + } + + public bool MarkEpisodeAsFound(string episodeId, string seriesId) + { + return (SeriesIdToEpisodeIdDictionery.ContainsKey(seriesId) ? SeriesIdToEpisodeIdDictionery[seriesId] : (SeriesIdToEpisodeIdDictionery[seriesId] = new HashSet<string>())).Add(episodeId); + } + + private static ExtraType? GetExtraType(Episode.AniDB episode) + { + switch (episode.Type) + { + case EpisodeType.Normal: + case EpisodeType.Other: + return null; + case EpisodeType.ThemeSong: + case EpisodeType.OpeningSong: + case EpisodeType.EndingSong: + return ExtraType.ThemeVideo; + case EpisodeType.Trailer: + return ExtraType.Trailer; + case EpisodeType.Special: { + var title = Text.GetTitleByLanguages(episode.Titles, "en") ?? ""; + // Interview + if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) + return ExtraType.Interview; + // Cinema intro/outro + if (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) && + (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase))) + return ExtraType.Clip; + return null; + } + default: + return ExtraType.Unknown; + } + } + + #endregion + #region Series Info + + public SeriesInfo GetSeriesInfoByPathSync(string path) + { + if (SeriesPathToIdDictionary.ContainsKey(path)) + { + var seriesId = SeriesPathToIdDictionary[path]; + if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) + return info; + return GetSeriesInfo(seriesId).GetAwaiter().GetResult(); + } + return GetSeriesInfoByPath(path).GetAwaiter().GetResult(); + } + + public async Task<SeriesInfo> GetSeriesInfoByPath(string path) + { + var partialPath = StripMediaFolder(path); + string seriesId; + if (SeriesPathToIdDictionary.ContainsKey(path)) + { + seriesId = SeriesPathToIdDictionary[path]; + } + else + { + var result = await ShokoAPI.GetSeriesPathEndsWith(partialPath); + seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); + + SeriesPathToIdDictionary[path] = seriesId; + if (!string.IsNullOrEmpty(seriesId)) + SeriesIdToPathDictionary[seriesId] = path; + } + + if (string.IsNullOrEmpty(seriesId)) + return null; + + if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) + return info; + + var series = await ShokoAPI.GetSeries(seriesId); + return await CreateSeriesInfo(series, seriesId); + } + + public async Task<SeriesInfo> GetSeriesInfoFromGroup(string groupId, int seasonNumber, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + var groupInfo = await GetGroupInfo(groupId, filterByType); + if (groupInfo == null) + return null; + int seriesIndex = seasonNumber > 0 ? seasonNumber - 1 : seasonNumber; + var index = groupInfo.DefaultSeriesIndex + seriesIndex; + var seriesInfo = groupInfo.SeriesList[index]; + if (seriesInfo == null) + return null; + + return seriesInfo; + } + public SeriesInfo GetSeriesInfoSync(string seriesId) + { + if (string.IsNullOrEmpty(seriesId)) + return null; + if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) + return info; + var series = ShokoAPI.GetSeries(seriesId).GetAwaiter().GetResult(); + return CreateSeriesInfo(series, seriesId).GetAwaiter().GetResult(); + } + + public async Task<SeriesInfo> GetSeriesInfo(string seriesId) + { + if (string.IsNullOrEmpty(seriesId)) + return null; + if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) + return info; + var series = await ShokoAPI.GetSeries(seriesId); + return await CreateSeriesInfo(series, seriesId); + } + + private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = null) + { + if (series == null) + return null; + + if (string.IsNullOrEmpty(seriesId)) + seriesId = series.IDs.ID.ToString(); + + SeriesInfo info = null; + var cacheKey = $"series:{seriesId}"; + if (DataCache.TryGetValue<SeriesInfo>(cacheKey, out info)) + return info; + + var seriesGuid = GetSeriesGuid(seriesId); + var aniDb = await ShokoAPI.GetSeriesAniDb(seriesId); + var episodeList = await ShokoAPI.GetEpisodesFromSeries(seriesId) + .ContinueWith(async task => await Task.WhenAll(task.Result.Select(e => CreateEpisodeInfo(e)))).Unwrap() + .ContinueWith(l => l.Result.Where(s => s != null).ToList()); + var filteredSpecialEpisodesList = episodeList.Where(e => e.AniDB.Type == EpisodeType.Special && e.ExtraType == null).ToList(); + info = new SeriesInfo { + Id = seriesId, + Guid = seriesGuid, + Shoko = series, + AniDB = aniDb, + EpisodeList = episodeList, + FilteredSpecialEpisodesList = filteredSpecialEpisodesList, + }; + DataCache.Set<SeriesInfo>(cacheKey, info, DefaultTimeSpan); + return info; + } + + public bool MarkSeriesAsFound(string seriesId) + => MarkSeriesAsFound(seriesId, ""); + + public bool MarkSeriesAsFound(string seriesId, string groupId) + { + return (GroupIdToSeriesIdDictionery.ContainsKey(groupId) ? GroupIdToSeriesIdDictionery[groupId] : (GroupIdToSeriesIdDictionery[groupId] = new HashSet<string>())).Add(seriesId); + } + + private Guid GetSeriesGuid(string seriesId) + { + if (SeriesIdToGuidDictionary.ContainsKey(seriesId)) + return SeriesIdToGuidDictionary[seriesId]; + var itemType = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup ? nameof (MediaBrowser.Controller.Entities.TV.Season) : nameof (MediaBrowser.Controller.Entities.TV.Series); + var result = LibraryManager.GetItemsResult(new InternalItemsQuery { + HasAnyProviderId = { + ["Shoko Series"] = seriesId, + }, + IncludeItemTypes = new[] { itemType, nameof (MediaBrowser.Controller.Entities.Movies.BoxSet) }, + IsVirtualItem = false, + IsPlaceHolder = false, + }); + var seriesGuid = result?.Items.FirstOrDefault()?.Id ?? Guid.NewGuid(); + SeriesIdToGuidDictionary[seriesId] = seriesGuid; + return seriesGuid; + } + + public string GetPathForSeries(string seriesId) + { + return SeriesIdToPathDictionary.ContainsKey(seriesId) ? SeriesIdToPathDictionary[seriesId] : null; + } + + #endregion + #region Group Info + + public GroupInfo GetGroupInfoByPathSync(string path, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + return GetGroupInfoByPath(path, filterByType).GetAwaiter().GetResult(); + } + + public async Task<GroupInfo> GetGroupInfoByPath(string path, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + var partialPath = StripMediaFolder(path); + var result = await ShokoAPI.GetSeriesPathEndsWith(partialPath); + + var seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); + if (string.IsNullOrEmpty(seriesId)) + return null; + + var groupInfo = await GetGroupInfoForSeries(seriesId, filterByType); + if (groupInfo == null) + return null; + + return groupInfo; + } + + public async Task<GroupInfo> GetGroupInfo(string groupId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + if (string.IsNullOrEmpty(groupId)) + return null; + + if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) + return info; + + var group = await ShokoAPI.GetGroup(groupId); + return await CreateGroupInfo(group, groupId, filterByType); + } + + private static Dictionary<string, string> SeriesIdToGroupIdDictionary = new Dictionary<string, string>(); + + public GroupInfo GetGroupInfoForSeriesSync(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + if (SeriesIdToGroupIdDictionary.ContainsKey(seriesId)) { + var groupId = SeriesIdToGroupIdDictionary[seriesId]; + if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) + return info; + + return GetGroupInfo(groupId, filterByType).GetAwaiter().GetResult(); + } + + return GetGroupInfoForSeries(seriesId, filterByType).GetAwaiter().GetResult(); + } + + public async Task<GroupInfo> GetGroupInfoForSeries(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + string groupId; + if (SeriesIdToGroupIdDictionary.ContainsKey(seriesId)) { + groupId = SeriesIdToGroupIdDictionary[seriesId]; + } + else { + var group = await ShokoAPI.GetGroupFromSeries(seriesId); + if (group == null) + return null; + groupId = group.IDs.ID.ToString(); + } + + return await GetGroupInfo(groupId, filterByType); + } + + + private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Ordering.GroupFilterType filterByType) + { + if (group == null) + return null; + + if (string.IsNullOrEmpty(groupId)) + groupId = group.IDs.ID.ToString(); + + var cacheKey = $"group:{filterByType}:{groupId}"; + GroupInfo groupInfo = null; + if (DataCache.TryGetValue<GroupInfo>(cacheKey, out groupInfo)) + return groupInfo; + + var seriesList = await ShokoAPI.GetSeriesInGroup(groupId) + .ContinueWith(async task => await Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s)))).Unwrap() + .ContinueWith(l => l.Result.Where(s => s != null).ToList()); + if (seriesList != null && seriesList.Count > 0) switch (filterByType) { + default: + break; + case Ordering.GroupFilterType.Movies: + seriesList = seriesList.Where(s => s.AniDB.Type == SeriesType.Movie).ToList(); + break; + case Ordering.GroupFilterType.Others: + seriesList = seriesList.Where(s => s.AniDB.Type != SeriesType.Movie).ToList(); + break; + } + + // Return ealty if no series matched the filter or if the list was empty. + if (seriesList == null || seriesList.Count == 0) + return null; + + // Order series list + var orderingType = filterByType == Ordering.GroupFilterType.Movies ? Plugin.Instance.Configuration.MovieOrdering : Plugin.Instance.Configuration.SeasonOrdering; + switch (orderingType) { + case Ordering.OrderType.Default: + break; + case Ordering.OrderType.ReleaseDate: + seriesList = seriesList.OrderBy(s => s?.AniDB?.AirDate ?? System.DateTime.MaxValue).ToList(); + break; + // Should not be selectable unless a user fiddles with DevTools in the browser to select the option. + case Ordering.OrderType.Chronological: + throw new System.Exception("Not implemented yet"); + } + + // Select the targeted id if a group spesify a default series. + int foundIndex = -1; + int targetId = (group.IDs.DefaultSeries ?? 0); + if (targetId != 0) + foundIndex = seriesList.FindIndex(s => s.Shoko.IDs.ID == targetId); + // Else select the default series as first-to-be-released. + else switch (orderingType) { + // The list is already sorted by release date, so just return the first index. + case Ordering.OrderType.ReleaseDate: + foundIndex = 0; + break; + // We don't know how Shoko may have sorted it, so just find the earliest series + case Ordering.OrderType.Default: + // We can't be sure that the the series in the list was _released_ chronologically, so find the earliest series, and use that as a base. + case Ordering.OrderType.Chronological: { + var earliestSeries = seriesList.Aggregate((cur, nxt) => (cur == null || (nxt?.AniDB.AirDate ?? System.DateTime.MaxValue) < (cur.AniDB.AirDate ?? System.DateTime.MaxValue)) ? nxt : cur); + foundIndex = seriesList.FindIndex(s => s == earliestSeries); + break; + } + } + + // Throw if we can't get a base point for seasons. + if (foundIndex == -1) + throw new System.Exception("Unable to get a base-point for seasions withing the group"); + + var groupGuid = GetGroupGuid(groupId); + groupInfo = new GroupInfo { + Id = groupId, + Guid = groupGuid, + Shoko = group, + SeriesList = seriesList, + DefaultSeries = seriesList[foundIndex], + DefaultSeriesIndex = foundIndex, + }; + foreach (var series in seriesList) + SeriesIdToGroupIdDictionary[series.Id] = groupId; + DataCache.Set<GroupInfo>(cacheKey, groupInfo, DefaultTimeSpan); + return groupInfo; + } + + private Guid GetGroupGuid(string groupId) + { + if (GroupIdToGuidDictionary.ContainsKey(groupId)) + return GroupIdToGuidDictionary[groupId]; + + var result = LibraryManager.GetItemsResult(new InternalItemsQuery { + HasAnyProviderId = { + ["Shoko Group"] = groupId, + }, + IncludeItemTypes = new[] { nameof (MediaBrowser.Controller.Entities.TV.Series), nameof (MediaBrowser.Controller.Entities.Movies.BoxSet), }, + IsVirtualItem = false, + IsPlaceHolder = false, + }); + var groupGuid = result?.Items.FirstOrDefault()?.Id ?? Guid.NewGuid(); + GroupIdToGuidDictionary[groupId] = groupGuid; + return groupGuid; + } + + #endregion + #region Post Process Library Changes + + public Task PostProcess(IProgress<double> progress, CancellationToken token) + { + Logger.LogInformation("Hi"); + return Task.CompletedTask; + } + + #endregion + } +} diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index c9b4be21..1789ea1a 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,7 +1,7 @@ using MediaBrowser.Model.Plugins; using DisplayLanguageType = Shokofin.Utils.Text.DisplayLanguageType; -using SeriesAndBoxSetGroupType = Shokofin.Utils.Ordering.SeriesOrBoxSetGroupType; -using SeasonAndMovieOrderType = Shokofin.Utils.Ordering.SeasonAndMovieOrderType; +using SeriesAndBoxSetGroupType = Shokofin.Utils.Ordering.GroupType; +using OrderType = Shokofin.Utils.Ordering.OrderType; namespace Shokofin.Configuration { @@ -35,22 +35,26 @@ public class PluginConfiguration : BasePluginConfiguration public bool SynopsisCleanMultiEmptyLines { get; set; } + public bool AddAniDBId { get; set; } + public SeriesAndBoxSetGroupType SeriesGrouping { get; set; } - public SeasonAndMovieOrderType SeasonOrdering { get; set; } + public OrderType SeasonOrdering { get; set; } public bool MarkSpecialsWhenGrouped { get; set; } public SeriesAndBoxSetGroupType BoxSetGrouping { get; set; } - public SeasonAndMovieOrderType MovieOrdering { get; set; } + public OrderType MovieOrdering { get; set; } - public bool SeperateLibraries { get; set; } + public bool FilterOnLibraryTypes { get; set; } public DisplayLanguageType TitleMainType { get; set; } public DisplayLanguageType TitleAlternateType { get; set; } + public bool AddMissingEpisodeMetadata { get; set; } + public PluginConfiguration() { Host = "http://127.0.0.1:8111"; @@ -67,14 +71,16 @@ public PluginConfiguration() SynopsisCleanMiscLines = true; SynopsisRemoveSummary = true; SynopsisCleanMultiEmptyLines = true; + AddAniDBId = true; TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; SeriesGrouping = SeriesAndBoxSetGroupType.Default; - SeasonOrdering = SeasonAndMovieOrderType.Default; + SeasonOrdering = OrderType.Default; MarkSpecialsWhenGrouped = true; BoxSetGrouping = SeriesAndBoxSetGroupType.Default; - MovieOrdering = SeasonAndMovieOrderType.Default; - SeperateLibraries = false; + MovieOrdering = OrderType.Default; + AddMissingEpisodeMetadata = false; + FilterOnLibraryTypes = false; } } } diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index a031f2e6..10f0b6bc 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -112,6 +112,11 @@ <h3>Library Options</h3> <input is="emby-checkbox" type="checkbox" id="UpdateWatchedStatus" /> <span>Update watched status on Shoko (Scrobble)</span> </label> + <h3>Provider Options</h3> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="AddAniDBId" /> + <span>Add AniDB Id to all entries</span> + </label> <h3>Tag Options</h3> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="HideArtStyleTags" /> @@ -173,6 +178,7 @@ <h3>Tag Options</h3> document.querySelector('#MovieOrdering').value = config.MovieOrdering; document.querySelector('#MarkSpecialsWhenGrouped').checked = config.MarkSpecialsWhenGrouped; document.querySelector('#SeperateLibraries').checked = config.SeperateLibraries; + document.querySelector('#AddAniDBId').checked = config.AddAniDBId; if (config.SeriesGrouping === "ShokoGroup") { document.querySelector('#SG_ShokoGroup_Warning').removeAttribute("hidden"); document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); @@ -242,6 +248,7 @@ <h3>Tag Options</h3> config.MovieOrdering = document.querySelector('#MovieOrdering').value; config.MarkSpecialsWhenGrouped = document.querySelector('#MarkSpecialsWhenGrouped').checked; config.SeperateLibraries = document.querySelector('#SeperateLibraries').checked; + config.AddAniDBId = document.querySelector('#AddAniDBId').checked; ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); }); e.preventDefault(); diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs new file mode 100644 index 00000000..03b4b653 --- /dev/null +++ b/Shokofin/LibraryScanner.cs @@ -0,0 +1,130 @@ +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Resolvers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Models; +using Shokofin.Utils; +using System.Linq; + +namespace Shokofin +{ + public class LibraryScanner : IResolverIgnoreRule + { + private readonly ShokoAPIManager ApiManager; + + private readonly ILibraryManager LibraryManager; + + private readonly ILogger<LibraryScanner> Logger; + + public LibraryScanner(ShokoAPIManager apiManager, ILibraryManager libraryManager, ILogger<LibraryScanner> logger) + { + ApiManager = apiManager; + LibraryManager = libraryManager; + Logger = logger; + } + + /// <summary> + /// It's not really meant to be used this way, but this is our library + /// "scanner". It scans the files and folders, and conditionally filters + /// out _some_ of the files and/or folders. + /// </summary> + /// <param name="fileInfo"></param> + /// <param name="parent"></param> + /// <returns>True if the entry should be ignored.</returns> + public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, BaseItem parent) + { + // Everything in the root folder is ignored by us. + var root = LibraryManager.RootFolder; + if (fileInfo == null || parent == null || root == null || parent == root || !(parent is Folder) || fileInfo.FullName.StartsWith(root.Path)) + return false; + + try { + // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. + var libraryOptions = LibraryManager.GetLibraryOptions(parent); + if (!libraryOptions.TypeOptions.Any(o => o.MetadataFetchers.Contains("Shoko"))) + return false; + + var fullPath = fileInfo.FullName; + var rootFolder = ApiManager.FindMediaFolder(fullPath, parent as Folder, root); + var partialPath = fullPath.Substring(rootFolder.Path.Length); + if (fileInfo.IsDirectory) + return ScanDirectory(partialPath, fullPath, LibraryManager.GetInheritedContentType(parent)); + else + return ScanFile(partialPath, fullPath); + } + catch (System.Exception e) { + if (!(e is System.Net.Http.HttpRequestException && e.Message.Contains("Connection refused"))) + Logger.LogError(e, $"Threw unexpectedly - {e.Message}"); + return false; + } + } + + private bool ScanDirectory(string partialPath, string fullPath, string libraryType) + { + var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; + var series = ApiManager.GetSeriesInfoByPathSync(fullPath); + + // We warn here since we enabled the provider in our library, but we can't find a match for the given folder path. + if (series == null) { + Logger.LogWarning($"Skipped unknown folder at path \"{partialPath}\""); + return false; + } + Logger.LogInformation($"Found series with id \"{series.Id}\" at path \"{partialPath}\""); + + // Filter library if we enabled the option. + if (Plugin.Instance.Configuration.FilterOnLibraryTypes) switch (libraryType) { + default: + break; + case "tvshows": + if (series.AniDB.Type == SeriesType.Movie) { + Logger.LogInformation($"Library seperatation is enabled, ignoring series with id \"{series.Id}\" at path \"{partialPath}\""); + return true; + } + + // If we're using series grouping, pre-load the group now to help reduce load times later. + if (includeGroup) + ApiManager.GetGroupInfoForSeriesSync(series.Id, Ordering.GroupFilterType.Others); + break; + case "movies": + if (series.AniDB.Type != SeriesType.Movie) { + Logger.LogInformation($"Library seperatation is enabled, ignoring series with id \"{series.Id}\" at path \"{partialPath}\""); + return true; + } + + // If we're using series grouping, pre-load the group now to help reduce load times later. + if (includeGroup) + ApiManager.GetGroupInfoForSeriesSync(series.Id, Ordering.GroupFilterType.Movies); + break; + } + // If we're using series grouping, pre-load the group now to help reduce load times later. + else if (includeGroup) + ApiManager.GetGroupInfoForSeriesSync(series.Id); + + return false; + } + + private bool ScanFile(string partialPath, string fullPath) + { + var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; + var config = Plugin.Instance.Configuration; + var (file, episode, series, _group) = ApiManager.GetFileInfoByPathSync(fullPath, null); + + // We warn here since we enabled the provider in our library, but we can't find a match for the given file path. + if (file == null) { + Logger.LogWarning($"Skipped unknown file at path \"{partialPath}\""); + return false; + } + Logger.LogInformation($"Found file \"{file.Id}\" at path \"{partialPath}\""); + + // We're going to post process this file later, but we don't want to include it in our library for now. + if (episode.ExtraType != null) { + Logger.LogInformation($"File was assigned an extra type, so ignoring file with id \"{file.Id}\" at path \"{partialPath}\""); + ApiManager.MarkEpisodeAsIgnored(episode.Id, series.Id, fullPath); + return true; + } + + return false; + } + } +} \ No newline at end of file diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 64871715..4da5865d 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -10,25 +10,26 @@ namespace Shokofin { public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { - public override string Name => "Shokofin"; - + public override string Name => "Shoko"; + public override Guid Id => Guid.Parse("5216ccbf-d24a-4eb3-8a7e-7da4230b7052"); - + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) { Instance = this; } - + public static Plugin Instance { get; private set; } - + public IEnumerable<PluginPageInfo> GetPages() { + var name = GetType().Namespace; return new[] { new PluginPageInfo { - Name = this.Name, - EmbeddedResourcePath = string.Format("{0}.Configuration.configPage.html", GetType().Namespace) + Name = name, + EmbeddedResourcePath = $"{name}.configPage.html", } }; } diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs new file mode 100644 index 00000000..175a2af4 --- /dev/null +++ b/Shokofin/PluginServiceRegistrator.cs @@ -0,0 +1,16 @@ + +using MediaBrowser.Common.Plugins; +using Microsoft.Extensions.DependencyInjection; + +namespace Shokofin +{ + /// <inheritdoc /> + public class PluginServiceRegistrator : IPluginServiceRegistrator + { + /// <inheritdoc /> + public void RegisterServices(IServiceCollection serviceCollection) + { + serviceCollection.AddSingleton<Shokofin.API.ShokoAPIManager>(); + } + } +} \ No newline at end of file diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 8f3e517d..052048af 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -4,7 +4,6 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -13,43 +12,37 @@ using Shokofin.API; using Shokofin.Utils; -using IResolverIgnoreRule = MediaBrowser.Controller.Resolvers.IResolverIgnoreRule; -using ILibraryManager = MediaBrowser.Controller.Library.ILibraryManager; -using SeriesType = Shokofin.API.Models.SeriesType; - namespace Shokofin.Providers { - public class BoxSetProvider : IHasOrder, IRemoteMetadataProvider<BoxSet, BoxSetInfo>, IResolverIgnoreRule + public class BoxSetProvider : IRemoteMetadataProvider<BoxSet, BoxSetInfo> { public string Name => "Shoko"; - public int Order => 1; - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<BoxSetProvider> _logger; - private readonly ILibraryManager _library; + private readonly IHttpClientFactory HttpClientFactory; + + private readonly ILogger<BoxSetProvider> Logger; + + private readonly ShokoAPIManager ApiManager; - public BoxSetProvider(IHttpClientFactory httpClientFactory, ILogger<BoxSetProvider> logger, ILibraryManager library) + public BoxSetProvider(IHttpClientFactory httpClientFactory, ILogger<BoxSetProvider> logger, ShokoAPIManager apiManager) { - _logger = logger; - _httpClientFactory = httpClientFactory; - _library = library; + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiManager = apiManager; } public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) { - try - { - switch (Plugin.Instance.Configuration.BoxSetGrouping) - { + try { + switch (Plugin.Instance.Configuration.BoxSetGrouping) { default: return await GetDefaultMetadata(info, cancellationToken); - case Ordering.SeriesOrBoxSetGroupType.ShokoGroup: + case Ordering.GroupType.ShokoGroup: return await GetShokoGroupedMetadata(info, cancellationToken); } } - catch (Exception e) - { - _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); + catch (Exception e) { + Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); return new MetadataResult<BoxSet>(); } } @@ -57,44 +50,37 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<BoxSet>(); - var series = await DataFetcher.GetSeriesInfoByPath(info.Path); + var series = await ApiManager.GetSeriesInfoByPath(info.Path); - if (series == null) - { - _logger.LogWarning($"Shoko Scanner... Unable to find series info for path {info.Path}"); + if (series == null) { + Logger.LogWarning($"Unable to find series info for path {info.Path}"); return result; } int aniDBId = series.AniDB.ID; - var tvdbId = series?.TvDBID; - - if (series.AniDB.Type != SeriesType.Movie) - { - _logger.LogWarning($"Shoko Scanner... File found, but not a movie! Skipping path {info.Path}"); - return result; - } - if (series.Shoko.Sizes.Total.Episodes <= 1) - { - _logger.LogWarning($"Shoko Scanner... series did not contain multiple movies! Skipping path {info.Path}"); + if (series.Shoko.Sizes.Total.Episodes <= 1) { + Logger.LogWarning($"series did not contain multiple movies! Skipping path {info.Path}"); return result; } var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.AniDB.Title, info.MetadataLanguage); - var tags = await DataFetcher.GetTags(series.ID); + var tags = await ApiManager.GetTags(series.Id); - result.Item = new BoxSet - { + result.Item = new BoxSet { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.SummarySanitizer(series.AniDB.Description), + Overview = Text.SanitizeTextSummary(series.AniDB.Description), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, Tags = tags, CommunityRating = series.AniDB.Rating.ToFloat(10), }; - result.Item.SetProviderId("Shoko Series", series.ID); + result.Item.SetProviderId("Shoko Series", series.Id); + if (Plugin.Instance.Configuration.AddAniDBId) + result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); + result.HasMetadata = true; return result; @@ -103,111 +89,58 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, Ca private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<BoxSet>(); - var group = await DataFetcher.GetGroupInfoByPath(info.Path, true); - if (group == null) - { - _logger.LogWarning($"Shoko Scanner... Unable to find box-set info for path {info.Path}"); + var config = Plugin.Instance.Configuration; + Ordering.GroupFilterType filterByType = config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Movies : Ordering.GroupFilterType.Default; + var group = await ApiManager.GetGroupInfoByPath(info.Path, filterByType); + if (group == null) { + Logger.LogWarning($"Unable to find box-set info for path {info.Path}"); return result; } var series = group.DefaultSeries; - var tvdbId = series?.TvDBID; - - if (series.AniDB.Type != API.Models.SeriesType.Movie) - { - _logger.LogWarning($"Shoko Scanner... File found, but not a movie! Skipping."); + if (series.AniDB.Type != API.Models.SeriesType.Movie) { + Logger.LogWarning($"File found, but not a movie! Skipping."); return result; } - var tags = await DataFetcher.GetTags(series.ID); + var tags = await ApiManager.GetTags(series.Id); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); - result.Item = new BoxSet - { + result.Item = new BoxSet { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.SummarySanitizer(series.AniDB.Description), + Overview = Text.SanitizeTextSummary(series.AniDB.Description), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, Tags = tags, CommunityRating = (float)((series.AniDB.Rating.Value * 10) / series.AniDB.Rating.MaxValue) }; + result.Item.SetProviderId("Shoko Series", series.Id); + result.Item.SetProviderId("Shoko Group", group.Id); + if (config.AddAniDBId) + result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); - result.Item.SetProviderId("Shoko Series", series.ID); - result.Item.SetProviderId("Shoko Group", group.ID); - result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); - if (!string.IsNullOrEmpty(tvdbId)) result.Item.SetProviderId("Tvdb", tvdbId); result.HasMetadata = true; + ApiManager.MarkSeriesAsFound(series.Id, group.Id); result.ResetPeople(); - foreach (var person in await DataFetcher.GetPeople(series.ID)) + foreach (var person in await ApiManager.GetPeople(series.Id)) result.AddPerson(person); return result; } - public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) { - _logger.LogInformation($"Searching BoxSet ({searchInfo.Name})"); - var searchResults = await ShokoAPI.SeriesSearch(searchInfo.Name); - - if (searchResults.Count() == 0) searchResults = await ShokoAPI.SeriesStartsWith(searchInfo.Name); - - var results = new List<RemoteSearchResult>(); - - foreach (var series in searchResults) - { - var imageUrl = series.Images.Posters.FirstOrDefault()?.ToURLString(); - _logger.LogInformation(imageUrl); - var parsedBoxSet = new RemoteSearchResult - { - Name = series.Name, - SearchProviderName = Name, - ImageUrl = imageUrl - }; - parsedBoxSet.SetProviderId("Shoko", series.IDs.ID.ToString()); - results.Add(parsedBoxSet); - } - - return results; + // Isn't called from anywhere. If it is called, I don't know from where. + throw new NotImplementedException(); } public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { - return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); - } - - public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, BaseItem parent) - { - // Skip this handler if one of these requirements are met - if (fileInfo == null || parent == null || !fileInfo.IsDirectory || !fileInfo.Exists || !(parent is Folder)) - return false; - var libType = _library.GetInheritedContentType(parent); - if (libType != "movies") { - return false; - } - try { - var path = System.IO.Path.Join(fileInfo.DirectoryName, fileInfo.FullName); - var series = DataFetcher.GetSeriesInfoByPathSync(path); - if (series == null) - { - _logger.LogWarning($"Shoko Scanner... Unable to find series info for path {path}"); - return false; - } - _logger.LogInformation($"Shoko Filter... Found series info for path {path}"); - // Ignore series if we want to sperate our libraries - if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type != SeriesType.Movie) - return true; - return false; - } - catch (System.Exception e) - { - if (!(e is System.Net.Http.HttpRequestException && e.Message.Contains("Connection refused"))) - _logger.LogError(e, "Threw unexpectedly"); - return false; - } + return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } } diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 4fcfabac..673b2a3f 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -3,7 +3,6 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -12,77 +11,81 @@ using Shokofin.API; using Shokofin.Utils; -using Path = System.IO.Path; -using IResolverIgnoreRule = MediaBrowser.Controller.Resolvers.IResolverIgnoreRule; -using ILibraryManager = MediaBrowser.Controller.Library.ILibraryManager; using EpisodeType = Shokofin.API.Models.EpisodeType; namespace Shokofin.Providers { - public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo>, IResolverIgnoreRule + public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo> { public string Name => "Shoko"; - private readonly IHttpClientFactory _httpClientFactory; + private readonly IHttpClientFactory HttpClientFactory; - private readonly ILogger<EpisodeProvider> _logger; + private readonly ILogger<EpisodeProvider> Logger; - private readonly ILibraryManager _library; - public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProvider> logger, ILibraryManager library) + private readonly ShokoAPIManager ApiManager; + + public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProvider> logger, ShokoAPIManager apiManager) { - _httpClientFactory = httpClientFactory; - _logger = logger; - _library = library; + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiManager = apiManager; } public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) { - try - { + try { var result = new MetadataResult<Episode>(); + var config = Plugin.Instance.Configuration; + Ordering.GroupFilterType? filterByType = config.SeriesGrouping == Ordering.GroupType.ShokoGroup ? config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default : null; + var (file, episode, series, group) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); - var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == Ordering.SeriesOrBoxSetGroupType.ShokoGroup; - var (file, episode, series, group) = await DataFetcher.GetFileInfoByPath(info.Path, includeGroup); - - if (file == null) // if file is null then series and episode is also null. - { - _logger.LogWarning($"Shoko Scanner... Unable to find file info for path {info.Path}"); + // if file is null then series and episode is also null. + if (file == null) { + Logger.LogWarning($"Unable to find file info for path {info.Path}"); return result; } - _logger.LogInformation($"Shoko Scanner... Found file info for path {info.Path}"); + Logger.LogInformation($"Found file info for path {info.Path}"); + + string displayTitle, alternateTitle; + if (series.AniDB.Type == API.Models.SeriesType.Movie) + ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); + else + ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, episode.Shoko.Name, info.MetadataLanguage); - var ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, episode.Shoko.Name, info.MetadataLanguage); - int aniDBId = episode.AniDB.ID; - int tvdbId = episode?.TvDB?.ID ?? 0; - if (group != null && episode.AniDB.Type != EpisodeType.Normal && Plugin.Instance.Configuration.MarkSpecialsWhenGrouped) { + if (group != null && episode.AniDB.Type != EpisodeType.Normal && config.MarkSpecialsWhenGrouped) { displayTitle = $"SP {episode.AniDB.EpisodeNumber} {displayTitle}"; alternateTitle = $"SP {episode.AniDB.EpisodeNumber} {alternateTitle}"; } - result.Item = new Episode - { + + result.Item = new Episode { IndexNumber = Ordering.GetIndexNumber(series, episode), ParentIndexNumber = Ordering.GetSeasonNumber(group, series, episode), Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, - Overview = Text.SummarySanitizer(episode.AniDB.Description), + Overview = Text.SanitizeTextSummary(episode.AniDB.Description), CommunityRating = (float) ((episode.AniDB.Rating.Value * 10) / episode.AniDB.Rating.MaxValue) }; - result.Item.SetProviderId("Shoko Episode", episode.ID); - result.Item.SetProviderId("Shoko File", file.ID); - result.Item.SetProviderId("AniDB", aniDBId.ToString()); - if (tvdbId != 0) result.Item.SetProviderId("Tvdb", tvdbId.ToString()); + // NOTE: This next line will remain here till they fix the series merging for providers outside the MetadataProvider enum. + result.Item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{episode.Id}"); + result.Item.SetProviderId("Shoko Episode", episode.Id); + result.Item.SetProviderId("Shoko File", file.Id); + if (config.AddAniDBId) + result.Item.SetProviderId("AniDB", episode.AniDB.ID.ToString()); + result.HasMetadata = true; + ApiManager.MarkEpisodeAsFound(episode.Id, series.Id); - var episodeNumberEnd = episode.AniDB.EpisodeNumber + file.EpisodesCount - 1; - if (episode.AniDB.EpisodeNumber != episodeNumberEnd) result.Item.IndexNumberEnd = episodeNumberEnd; + var episodeNumberEnd = episode.AniDB.EpisodeNumber + file.EpisodesCount; + if (episode.AniDB.EpisodeNumber != episodeNumberEnd) + result.Item.IndexNumberEnd = episodeNumberEnd; return result; } - catch (Exception e) - { - _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); + catch (Exception e) { + Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); return new MetadataResult<Episode>(); } } @@ -95,43 +98,7 @@ public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo search public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { - return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); - } - - public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, BaseItem parent) - { - // Skip this handler if one of these requirements are met - if (fileInfo == null || parent == null || fileInfo.IsDirectory || !fileInfo.Exists || !(parent is Series || parent is Season)) - return false; - var libType = _library.GetInheritedContentType(parent); - if (libType != "tvshows") { - return false; - } - try { - var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == Ordering.SeriesOrBoxSetGroupType.ShokoGroup; - // TODO: Check if it can be written in a better way. Parent directory + File Name - var id = Path.Join(fileInfo.DirectoryName, fileInfo.FullName); - var (file, episode, series, group) = DataFetcher.GetFileInfoByPathSync(id, includeGroup); - if (file == null) // if file is null then series and episode is also null. - { - _logger.LogWarning($"Shoko Filter... Unable to find file info for path {id}"); - return true; - } - _logger.LogInformation($"Shoko Filter... Found file info for path {id}"); - var extraType = Ordering.GetExtraType(episode.AniDB); - if (extraType != null) - { - _logger.LogDebug($"Shoko Filter... Not a normal or special episode, skipping path {id}"); - return true; - } - return false; - } - catch (System.Exception e) - { - if (!(e is System.Net.Http.HttpRequestException && e.Message.Contains("Connection refused"))) - _logger.LogError(e, "Threw unexpectedly"); - return false; - } + return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } } diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index dc59e92f..2efc5a01 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -17,53 +17,48 @@ namespace Shokofin.Providers public class ImageProvider : IRemoteImageProvider { public string Name => "Shoko"; - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<ImageProvider> _logger; - public ImageProvider(IHttpClientFactory httpClientFactory, ILogger<ImageProvider> logger) + private readonly IHttpClientFactory HttpClientFactory; + + private readonly ILogger<ImageProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + public ImageProvider(IHttpClientFactory httpClientFactory, ILogger<ImageProvider> logger, ShokoAPIManager apiManager) { - _httpClientFactory = httpClientFactory; - _logger = logger; + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiManager = apiManager; } public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var list = new List<RemoteImageInfo>(); - try - { + try { Shokofin.API.Info.EpisodeInfo episode = null; Shokofin.API.Info.SeriesInfo series = null; - if (item is Episode) - { - episode = await DataFetcher.GetEpisodeInfo(item.GetProviderId("Shoko Episode")); + if (item is Episode) { + episode = await ApiManager.GetEpisodeInfo(item.GetProviderId("Shoko Episode")); } - else if (item is Series) - { + else if (item is Series) { var groupId = item.GetProviderId("Shoko Group"); if (string.IsNullOrEmpty(groupId)) - { - series = await DataFetcher.GetSeriesInfo(item.GetProviderId("Shoko Series")); - } - else { - series = (await DataFetcher.GetGroupInfo(groupId))?.DefaultSeries; - } + series = await ApiManager.GetSeriesInfo(item.GetProviderId("Shoko Series")); + else + series = (await ApiManager.GetGroupInfo(groupId))?.DefaultSeries; } - else if (item is BoxSet || item is Movie) - { - series = await DataFetcher.GetSeriesInfo(item.GetProviderId("Shoko Series")); + else if (item is BoxSet || item is Movie) { + series = await ApiManager.GetSeriesInfo(item.GetProviderId("Shoko Series")); } - else if (item is Season) - { - series = await DataFetcher.GetSeriesInfoFromGroup(item.GetParent()?.GetProviderId("Shoko Group"), item.IndexNumber ?? 1); + else if (item is Season) { + series = await ApiManager.GetSeriesInfoFromGroup(item.GetParent()?.GetProviderId("Shoko Group"), item.IndexNumber ?? 1); } - if (episode != null) - { - _logger.LogInformation($"Getting episode images ({episode.ID} - {item.Name})"); + if (episode != null) { + Logger.LogInformation($"Getting episode images ({episode.Id} - {item.Name})"); AddImage(ref list, ImageType.Primary, episode?.TvDB?.Thumbnail); } - if (series != null) - { - _logger.LogInformation($"Getting series images ({series.ID} - {item.Name})"); + if (series != null) { + Logger.LogInformation($"Getting series images ({series.Id} - {item.Name})"); var images = series.Shoko.Images; AddImage(ref list, ImageType.Primary, series.AniDB.Poster); foreach (var image in images?.Posters) @@ -74,12 +69,11 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell AddImage(ref list, ImageType.Banner, image); } - _logger.LogInformation($"List got {list.Count} item(s)."); + Logger.LogInformation($"List got {list.Count} item(s)."); return list; } - catch (Exception e) - { - _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); + catch (Exception e) { + Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); return list; } } @@ -96,8 +90,7 @@ private RemoteImageInfo GetImage(API.Models.Image image, ImageType imageType) var imageUrl = image?.ToURLString(); if (string.IsNullOrEmpty(imageUrl) || image.RelativeFilepath.Equals("/")) return null; - return new RemoteImageInfo - { + return new RemoteImageInfo { ProviderName = "Shoko", Type = imageType, Url = imageUrl @@ -116,7 +109,7 @@ public bool Supports(BaseItem item) public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { - return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); + return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } } diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index e6b15967..26a1d5ea 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -3,7 +3,6 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -12,86 +11,75 @@ using Shokofin.API; using Shokofin.Utils; -using IResolverIgnoreRule = MediaBrowser.Controller.Resolvers.IResolverIgnoreRule; -using ILibraryManager = MediaBrowser.Controller.Library.ILibraryManager; -using SeriesType = Shokofin.API.Models.SeriesType; - namespace Shokofin.Providers { - public class MovieProvider : IRemoteMetadataProvider<Movie, MovieInfo>, IResolverIgnoreRule + public class MovieProvider : IRemoteMetadataProvider<Movie, MovieInfo> { public string Name => "Shoko"; - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<MovieProvider> _logger; + + private readonly IHttpClientFactory HttpClientFactory; + + private readonly ILogger<MovieProvider> Logger; - private readonly ILibraryManager _library; + + private readonly ShokoAPIManager ApiManager; - public MovieProvider(IHttpClientFactory httpClientFactory, ILogger<MovieProvider> logger, ILibraryManager library) + public MovieProvider(IHttpClientFactory httpClientFactory, ILogger<MovieProvider> logger, ShokoAPIManager apiManager) { - _logger = logger; - _httpClientFactory = httpClientFactory; - _library = library; + Logger = logger; + HttpClientFactory = httpClientFactory; + ApiManager = apiManager; } public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken) { - try - { + try { var result = new MetadataResult<Movie>(); - var includeGroup = Plugin.Instance.Configuration.BoxSetGrouping == Ordering.SeriesOrBoxSetGroupType.ShokoGroup; - var (file, episode, series, group) = await DataFetcher.GetFileInfoByPath(info.Path, includeGroup, true); + var includeGroup = Plugin.Instance.Configuration.BoxSetGrouping == Ordering.GroupType.ShokoGroup; + var config = Plugin.Instance.Configuration; + Ordering.GroupFilterType? filterByType = config.SeriesGrouping == Ordering.GroupType.ShokoGroup ? config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Movies : Ordering.GroupFilterType.Default : null; + var (file, episode, series, group) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); - if (file == null) // if file is null then series and episode is also null. - { - _logger.LogWarning($"Unable to find file info for path {info.Path}"); + // if file is null then series and episode is also null. + if (file == null) { + Logger.LogWarning($"Unable to find file info for path {info.Path}"); return result; } bool isMultiEntry = series.Shoko.Sizes.Total.Episodes > 1; - int aniDBId = isMultiEntry ? episode.AniDB.ID : series.AniDB.ID; - var tvdbId = (isMultiEntry ? episode?.TvDB == null ? null : episode.TvDB.ID.ToString() : series?.TvDBID); - - if (series.AniDB.Type != SeriesType.Movie) - { - _logger.LogWarning($"File found, but not a movie! Skipping path {info.Path}"); - return result; - } - var tags = await DataFetcher.GetTags(series.ID); + var tags = await ApiManager.GetTags(series.Id); var ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); var rating = isMultiEntry ? episode.AniDB.Rating.ToFloat(10) : series.AniDB.Rating.ToFloat(10); - result.Item = new Movie - { + result.Item = new Movie { IndexNumber = Ordering.GetMovieIndexNumber(group, series, episode), Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, // Use the file description if collection contains more than one movie, otherwise use the collection description. - Overview = Text.SummarySanitizer((isMultiEntry ? episode.AniDB.Description ?? series.AniDB.Description : series.AniDB.Description) ?? ""), + Overview = Text.SanitizeTextSummary((isMultiEntry ? episode.AniDB.Description ?? series.AniDB.Description : series.AniDB.Description) ?? ""), ProductionYear = episode.AniDB.AirDate?.Year, Tags = tags, CommunityRating = rating, }; - result.Item.SetProviderId("Shoko File", file.ID); - result.Item.SetProviderId("Shoko Series", series.ID); - result.Item.SetProviderId("Shoko Episode", episode.ID); - if (aniDBId != 0) - result.Item.SetProviderId("AniDB", aniDBId.ToString()); - if (!string.IsNullOrEmpty(tvdbId)) - result.Item.SetProviderId("Tvdb", tvdbId); + result.Item.SetProviderId("Shoko File", file.Id); + result.Item.SetProviderId("Shoko Episode", episode.Id); + if (config.AddAniDBId) + result.Item.SetProviderId("AniDB", episode.AniDB.ID.ToString()); + result.HasMetadata = true; + ApiManager.MarkSeriesAsFound(series.Id, group.Id); result.ResetPeople(); - foreach (var person in await DataFetcher.GetPeople(series.ID)) + foreach (var person in await ApiManager.GetPeople(series.Id)) result.AddPerson(person); return result; } - catch (Exception e) - { - _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); + catch (Exception e) { + Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); return new MetadataResult<Movie>(); } } @@ -105,44 +93,7 @@ public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchIn public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { - return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); - } - - public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, BaseItem parent) - { - // Skip this handler if one of these requirements are met - if (fileInfo == null || parent == null || fileInfo.IsDirectory || !fileInfo.Exists) - return false; - var libType = _library.GetInheritedContentType(parent); - if (libType != "movies") { - return false; - } - try { - var path = System.IO.Path.Join(fileInfo.DirectoryName, fileInfo.FullName); - var (file, episode, series, _group) = DataFetcher.GetFileInfoByPathSync(path); - if (file == null) - { - _logger.LogWarning($"Shoko Scanner... Unable to find series info for path {path}"); - return false; - } - _logger.LogInformation($"Shoko Filter... Found series info for path {path}"); - if (series.AniDB.Type != SeriesType.Movie) { - return true; - } - var extraType = Ordering.GetExtraType(episode.AniDB); - if (extraType != null) { - _logger.LogInformation($"Shoko Filter... File was not a 'normal' episode for path, skipping! {path}"); - return true; - } - // Ignore everything except movies - return false; - } - catch (System.Exception e) - { - if (!(e is System.Net.Http.HttpRequestException && e.Message.Contains("Connection refused"))) - _logger.LogError(e, "Threw unexpectedly"); - return false; - } + return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } } diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index b3bb53aa..aee2a127 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -15,30 +15,30 @@ namespace Shokofin.Providers public class SeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo> { public string Name => "Shoko"; - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<SeasonProvider> _logger; + private readonly IHttpClientFactory HttpClientFactory; + private readonly ILogger<SeasonProvider> Logger; - public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvider> logger) + private readonly ShokoAPIManager ApiManager; + + public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvider> logger, ShokoAPIManager apiManager) { - _httpClientFactory = httpClientFactory; - _logger = logger; + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiManager = apiManager; } public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) { - try - { - switch (Plugin.Instance.Configuration.SeriesGrouping) - { + try { + switch (Plugin.Instance.Configuration.SeriesGrouping) { default: return GetDefaultMetadata(info, cancellationToken); - case Ordering.SeriesOrBoxSetGroupType.ShokoGroup: + case Ordering.GroupType.ShokoGroup: return await GetShokoGroupedMetadata(info, cancellationToken); } } - catch (Exception e) - { - _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); + catch (Exception e) { + Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); return new MetadataResult<Season>(); } } @@ -48,13 +48,13 @@ private MetadataResult<Season> GetDefaultMetadata(SeasonInfo info, CancellationT var result = new MetadataResult<Season>(); var seasonName = GetSeasonName(info.Name); - result.Item = new Season - { + result.Item = new Season { Name = seasonName, IndexNumber = info.IndexNumber, SortName = seasonName, ForcedSortName = seasonName }; + result.HasMetadata = true; return result; @@ -64,42 +64,44 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in { var result = new MetadataResult<Season>(); - if (!info.SeriesProviderIds.ContainsKey("Shoko Group")) - { - _logger.LogWarning($"Shoko Scanner... Shoko Group id not stored for series"); + if (!info.SeriesProviderIds.ContainsKey("Shoko Group")) { + Logger.LogWarning($"Shoko Group id not stored for series"); return result; } var groupId = info.SeriesProviderIds["Shoko Group"]; var seasonNumber = info.IndexNumber ?? 1; - var series = await DataFetcher.GetSeriesInfoFromGroup(groupId, seasonNumber); - if (series == null) - { - _logger.LogWarning($"Shoko Scanner... Unable to find series info for G{groupId}:S{seasonNumber}"); + var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes; + var series = await ApiManager.GetSeriesInfoFromGroup(groupId, seasonNumber, filterLibrary ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); + if (series == null) { + Logger.LogWarning($"Unable to find series info for G{groupId}:S{seasonNumber}"); return result; } - _logger.LogInformation($"Shoko Scanner... Found series info for G{groupId}:S{seasonNumber}"); + Logger.LogInformation($"Found series info for G{groupId}:S{seasonNumber}"); - var tags = await DataFetcher.GetTags(series.ID); + var tags = await ApiManager.GetTags(series.Id); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); - result.Item = new Season - { + result.Item = new Season { Name = displayTitle, OriginalTitle = alternateTitle, IndexNumber = seasonNumber, - Overview = Text.SummarySanitizer(series.AniDB.Description), + Overview = Text.SanitizeTextSummary(series.AniDB.Description), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, Tags = tags, CommunityRating = series.AniDB.Rating?.ToFloat(10), }; + result.Item.ProviderIds.Add("Shoko Series", series.Id); + if (Plugin.Instance.Configuration.AddAniDBId) + result.Item.ProviderIds.Add("AniDB", series.AniDB.ID.ToString()); result.HasMetadata = true; + ApiManager.MarkSeriesAsFound(series.Id, groupId); result.ResetPeople(); - foreach (var person in await DataFetcher.GetPeople(series.ID)) + foreach (var person in await ApiManager.GetPeople(series.Id)) result.AddPerson(person); return result; @@ -113,13 +115,12 @@ public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchI public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { - return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); + return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } private string GetSeasonName(string season) { - switch (season) - { + switch (season) { case "Season 100": return "Credits"; case "Season 99": diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index f6d84bd2..e1fdfd55 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -4,7 +4,6 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -13,46 +12,37 @@ using Shokofin.API; using Shokofin.Utils; -using IResolverIgnoreRule = MediaBrowser.Controller.Resolvers.IResolverIgnoreRule; -using ILibraryManager = MediaBrowser.Controller.Library.ILibraryManager; -using SeriesType = Shokofin.API.Models.SeriesType; - namespace Shokofin.Providers { - public class SeriesProvider : IHasOrder, IRemoteMetadataProvider<Series, SeriesInfo>, IResolverIgnoreRule + public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> { public string Name => "Shoko"; - public int Order => 1; - - private readonly IHttpClientFactory _httpClientFactory; + private readonly IHttpClientFactory HttpClientFactory; - private readonly ILogger<SeriesProvider> _logger; + private readonly ILogger<SeriesProvider> Logger; - private readonly ILibraryManager _library; + private readonly ShokoAPIManager ApiManager; - public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ILibraryManager library) + public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager) { - _logger = logger; - _httpClientFactory = httpClientFactory; - _library = library; + Logger = logger; + HttpClientFactory = httpClientFactory; + ApiManager = apiManager; } public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) { - try - { - switch (Plugin.Instance.Configuration.SeriesGrouping) - { + try { + switch (Plugin.Instance.Configuration.SeriesGrouping) { default: return await GetDefaultMetadata(info, cancellationToken); - case Ordering.SeriesOrBoxSetGroupType.ShokoGroup: + case Ordering.GroupType.ShokoGroup: return await GetShokoGroupedMetadata(info, cancellationToken); } } - catch (Exception e) - { - _logger.LogError($"{e.Message}{Environment.NewLine}{e.StackTrace}"); + catch (Exception e) { + Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); return new MetadataResult<Series>(); } } @@ -60,43 +50,36 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<Series>(); - var series = await DataFetcher.GetSeriesInfoByPath(info.Path); - if (series == null) - { - _logger.LogWarning($"Unable to find series info for path {info.Path}"); + var series = await ApiManager.GetSeriesInfoByPath(info.Path); + if (series == null) { + Logger.LogWarning($"Unable to find series info for path {info.Path}"); return result; } - _logger.LogInformation($"Found series info for path {info.Path}"); + Logger.LogInformation($"Found series info for path {info.Path}"); - var tags = await DataFetcher.GetTags(series.ID); + var tags = await ApiManager.GetTags(series.Id); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); - if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type == SeriesType.Movie) - { - _logger.LogWarning($"Separate libraries are on, skipping {info.Path}"); - return result; - } - - result.Item = new Series - { + result.Item = new Series { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.SummarySanitizer(series.AniDB.Description), + Overview = Text.SanitizeTextSummary(series.AniDB.Description), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, Status = series.AniDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, Tags = tags, - CommunityRating = series.AniDB.Rating.ToFloat(10) + CommunityRating = series.AniDB.Rating.ToFloat(10), }; + result.Item.SetProviderId("Shoko Series", series.Id); + if (Plugin.Instance.Configuration.AddAniDBId) + result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); - result.Item.SetProviderId("Shoko Series", series.ID); - result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); - if (!string.IsNullOrEmpty(series.TvDBID)) result.Item.SetProviderId("Tvdb", series.TvDBID); result.HasMetadata = true; + ApiManager.MarkSeriesAsFound(series.Id); result.ResetPeople(); - foreach (var person in await DataFetcher.GetPeople(series.ID)) + foreach (var person in await ApiManager.GetPeople(series.Id)) result.AddPerson(person); return result; @@ -105,29 +88,23 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<Series>(); - var group = await DataFetcher.GetGroupInfoByPath(info.Path); - if (group == null) - { - _logger.LogWarning($"Unable to find series info for path {info.Path}"); + var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes; + var group = await ApiManager.GetGroupInfoByPath(info.Path, filterLibrary ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); + if (group == null) { + Logger.LogWarning($"Unable to find series info for path {info.Path}"); return result; } - _logger.LogInformation($"Found series info for path {info.Path}"); + Logger.LogInformation($"Found series info for path {info.Path}"); var series = group.DefaultSeries; - if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type == SeriesType.Movie) - { - _logger.LogWarning($"Separate libraries are on, skipping {info.Path}"); - return result; - } - var tags = await DataFetcher.GetTags(series.ID); + var tags = await ApiManager.GetTags(series.Id); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); - result.Item = new Series - { + result.Item = new Series { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.SummarySanitizer(series.AniDB.Description), + Overview = Text.SanitizeTextSummary(series.AniDB.Description), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, @@ -135,81 +112,54 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in Tags = tags, CommunityRating = series.AniDB.Rating.ToFloat(10), }; + // NOTE: This next line will remain here till they fix the series merging for providers outside the MetadataProvider enum. + result.Item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{series.Id}"); + result.Item.SetProviderId("Shoko Series", series.Id); + result.Item.SetProviderId("Shoko Group", group.Id); + if (Plugin.Instance.Configuration.AddAniDBId) + result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); - result.Item.SetProviderId("Shoko Series", series.ID); - result.Item.SetProviderId("Shoko Group", group.ID); - result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); - var tvdbId = series?.TvDBID; - if (!string.IsNullOrEmpty(tvdbId)) result.Item.SetProviderId("Tvdb", tvdbId); result.HasMetadata = true; + ApiManager.MarkSeriesAsFound(series.Id, group.Id); result.ResetPeople(); - foreach (var person in await DataFetcher.GetPeople(series.ID)) + foreach (var person in await ApiManager.GetPeople(series.Id)) result.AddPerson(person); return result; } - public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo info, CancellationToken cancellationToken) { - _logger.LogInformation($"Searching Series ({searchInfo.Name})"); - var searchResults = await ShokoAPI.SeriesSearch(searchInfo.Name); - - if (searchResults.Count() == 0) searchResults = await ShokoAPI.SeriesStartsWith(searchInfo.Name); - - var results = new List<RemoteSearchResult>(); - - foreach (var series in searchResults) - { - var imageUrl = series.Images.Posters.FirstOrDefault()?.ToURLString(); - _logger.LogInformation(imageUrl); - var parsedSeries = new RemoteSearchResult - { - Name = series.Name, - SearchProviderName = Name, - ImageUrl = imageUrl - }; - parsedSeries.SetProviderId("Shoko Series", series.IDs.ID.ToString()); - results.Add(parsedSeries); - } + try { + var results = new List<RemoteSearchResult>(); + var searchResults = await ShokoAPI.SeriesSearch(info.Name).ContinueWith((e) => e.Result.ToList()); + Logger.LogInformation($"Series search returned {searchResults.Count} results."); + + foreach (var series in searchResults) { + var seriesId = series.IDs.ID.ToString(); + var seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); + var imageUrl = seriesInfo.AniDB.Poster?.ToURLString(); + var parsedSeries = new RemoteSearchResult { + Name = Text.GetSeriesTitle(seriesInfo.AniDB.Titles, seriesInfo.Shoko.Name, info.MetadataLanguage), + SearchProviderName = Name, + ImageUrl = imageUrl, + }; + parsedSeries.SetProviderId("Shoko Series", seriesId); + results.Add(parsedSeries); + } - return results; + return results; + } + catch (Exception e) { + Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); + return new List<RemoteSearchResult>(); + } } public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { - return _httpClientFactory.CreateClient().GetAsync(url, cancellationToken); - } - - public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, BaseItem parent) - { - // Skip this handler if one of these requirements are met - if (fileInfo == null || parent == null || !fileInfo.IsDirectory || !fileInfo.Exists || !(parent is Folder)) - return false; - var libType = _library.GetInheritedContentType(parent); - if (libType != "tvshows") { - return false; - } - try { - var path = System.IO.Path.Join(fileInfo.DirectoryName, fileInfo.FullName); - var series = DataFetcher.GetSeriesInfoByPathSync(path); - if (series == null) - { - _logger.LogWarning($"Unable to find series info for path {path}"); - return false; - } - _logger.LogInformation($"Shoko Filter... Found series info for path {path}"); - // Ignore movies if we want to sperate our libraries - if (Plugin.Instance.Configuration.SeperateLibraries && series.AniDB.Type == SeriesType.Movie) - return true; - return false; - } - catch (System.Exception e) - { - if (!(e is System.Net.Http.HttpRequestException && e.Message.Contains("Connection refused"))) - _logger.LogError(e, "Threw unexpectedly"); - return false; - } + return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } } diff --git a/Shokofin/Scrobbler.cs b/Shokofin/Scrobbler.cs index 2f786b2e..0cfcdb14 100644 --- a/Shokofin/Scrobbler.cs +++ b/Shokofin/Scrobbler.cs @@ -11,18 +11,18 @@ namespace Shokofin { public class Scrobbler : IServerEntryPoint { - private readonly ISessionManager _sessionManager; - private readonly ILogger<Scrobbler> _logger; + private readonly ISessionManager SessionManager; + private readonly ILogger<Scrobbler> Logger; public Scrobbler(ISessionManager sessionManager, ILogger<Scrobbler> logger) { - _sessionManager = sessionManager; - _logger = logger; + SessionManager = sessionManager; + Logger = logger; } public Task RunAsync() { - _sessionManager.PlaybackStopped += OnPlaybackStopped; + SessionManager.PlaybackStopped += OnPlaybackStopped; return Task.CompletedTask; } @@ -32,13 +32,13 @@ private async void OnPlaybackStopped(object sender, PlaybackStopEventArgs e) if (e.Item == null) { - _logger.LogError("Event details incomplete. Cannot process current media"); + Logger.LogError("Event details incomplete. Cannot process current media"); return; } if (!e.Item.HasProviderId("Shoko Episode")) { - _logger.LogError("Unrecognized file"); + Logger.LogError("Unrecognized file"); return; // Skip if file does exist in Shoko } @@ -46,20 +46,20 @@ private async void OnPlaybackStopped(object sender, PlaybackStopEventArgs e) { var episodeId = episode.GetProviderId("Shoko Episode"); - _logger.LogInformation("Item is played. Marking as watched on Shoko"); - _logger.LogInformation($"{episode.SeriesName} S{episode.Season.IndexNumber}E{episode.IndexNumber} - {episode.Name} ({episodeId})"); + Logger.LogInformation("Item is played. Marking as watched on Shoko"); + Logger.LogInformation($"{episode.SeriesName} S{episode.Season.IndexNumber}E{episode.IndexNumber} - {episode.Name} ({episodeId})"); var result = await ShokoAPI.MarkEpisodeWatched(episodeId); if (result) - _logger.LogInformation("Episode marked as watched!"); + Logger.LogInformation("Episode marked as watched!"); else - _logger.LogError("Error marking episode as watched!"); + Logger.LogError("Error marking episode as watched!"); } } public void Dispose() { - _sessionManager.PlaybackStopped -= OnPlaybackStopped; + SessionManager.PlaybackStopped -= OnPlaybackStopped; } } } diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs new file mode 100644 index 00000000..433e5b27 --- /dev/null +++ b/Shokofin/Tasks/PostScanTask.cs @@ -0,0 +1,28 @@ +using MediaBrowser.Controller.Library; +using Shokofin.API; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Shokofin.Tasks +{ + public class PostScanTask : ILibraryPostScanTask + { + private ShokoAPIManager ApiManager; + + public PostScanTask(ShokoAPIManager apiManager) + { + ApiManager = apiManager; + } + + public async Task Run(IProgress<double> progress, CancellationToken token) + { + try { + await ApiManager.PostProcess(progress, token); + } + finally { + ApiManager.Clear(); + } + } + } +} diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index d180cf19..0d49d190 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -1,30 +1,36 @@ -using Shokofin.API.Models; using Shokofin.API.Info; - -using ExtraType = MediaBrowser.Model.Entities.ExtraType; +using Shokofin.API.Models; namespace Shokofin.Utils { public class Ordering { + public enum GroupFilterType { + Default = 0, + Movies = 1, + Others = 2, + } /// <summary> /// Group series or movie box-sets /// </summary> - public enum SeriesOrBoxSetGroupType + public enum GroupType { /// <summary> /// No grouping. All series will have their own entry. /// </summary> Default = 0, + /// <summary> /// Don't group, but make series merge-friendly by using the season numbers from TvDB. /// </summary> MergeFriendly = 1, + /// <summary> /// Group seris based on Shoko's default group filter. /// </summary> ShokoGroup = 2, + /// <summary> /// Group movies based on Shoko's series. /// </summary> @@ -34,16 +40,18 @@ public enum SeriesOrBoxSetGroupType /// <summary> /// Season or movie ordering when grouping series/box-sets using Shoko's groups. /// </summary> - public enum SeasonAndMovieOrderType + public enum OrderType { /// <summary> /// Let Shoko decide the order. /// </summary> Default = 0, + /// <summary> /// Order seasons by release date. /// </summary> ReleaseDate = 1, + /// <summary> /// Order seasons based on the chronological order of relations. /// </summary> @@ -56,32 +64,26 @@ public enum SeasonAndMovieOrderType /// <returns>Absoute index.</returns> public static int GetMovieIndexNumber(GroupInfo group, SeriesInfo series, EpisodeInfo episode) { - switch (Plugin.Instance.Configuration.BoxSetGrouping) - { + switch (Plugin.Instance.Configuration.BoxSetGrouping) { default: - case SeriesOrBoxSetGroupType.Default: + case GroupType.Default: return 1; - case SeriesOrBoxSetGroupType.ShokoSeries: + case GroupType.ShokoSeries: return episode.AniDB.EpisodeNumber; - case SeriesOrBoxSetGroupType.ShokoGroup: - { + case GroupType.ShokoGroup: { int offset = 0; - foreach (SeriesInfo s in group.SeriesList) - { + foreach (SeriesInfo s in group.SeriesList) { var sizes = s.Shoko.Sizes.Total; - if (s != series) - { - if (episode.AniDB.Type == EpisodeType.Special) - { - var index = series.FilteredSpecialEpisodesList.FindIndex(e => string.Equals(e.ID, episode.ID)); + if (s != series) { + if (episode.AniDB.Type == EpisodeType.Special) { + var index = series.FilteredSpecialEpisodesList.FindIndex(e => string.Equals(e.Id, episode.Id)); if (index == -1) throw new System.IndexOutOfRangeException("Episode not in filtered list"); return offset - (index + 1); } - switch (episode.AniDB.Type) - { + switch (episode.AniDB.Type) { case EpisodeType.Normal: - // offset += 0; + // offset += 0; // it's not needed, so it's just here as a comment instead. break; case EpisodeType.Parody: offset += sizes?.Episodes ?? 0; @@ -92,8 +94,7 @@ public static int GetMovieIndexNumber(GroupInfo group, SeriesInfo series, Episod } return offset + episode.AniDB.EpisodeNumber; } - else - { + else { if (episode.AniDB.Type == EpisodeType.Special) { offset -= series.FilteredSpecialEpisodesList.Count; } @@ -115,30 +116,26 @@ public static int GetIndexNumber(SeriesInfo series, EpisodeInfo episode) switch (Plugin.Instance.Configuration.SeriesGrouping) { default: - case SeriesOrBoxSetGroupType.Default: + case GroupType.Default: return episode.AniDB.EpisodeNumber; - case SeriesOrBoxSetGroupType.MergeFriendly: - { + case GroupType.MergeFriendly: { var epNum = episode?.TvDB.Number ?? 0; if (epNum == 0) - goto case SeriesOrBoxSetGroupType.Default; + goto case GroupType.Default; return epNum; } - case SeriesOrBoxSetGroupType.ShokoGroup: - { - if (episode.AniDB.Type == EpisodeType.Special) - { - var index = series.FilteredSpecialEpisodesList.FindIndex(e => string.Equals(e.ID, episode.ID)); + case GroupType.ShokoGroup: { + if (episode.AniDB.Type == EpisodeType.Special) { + var index = series.FilteredSpecialEpisodesList.FindIndex(e => string.Equals(e.Id, episode.Id)); if (index == -1) throw new System.IndexOutOfRangeException("Episode not in filtered list"); return -(index + 1); } int offset = 0; var sizes = series.Shoko.Sizes.Total; - switch (episode.AniDB.Type) - { + switch (episode.AniDB.Type) { case EpisodeType.Normal: - // offset += 0; + // offset += 0; // it's not needed, so it's just here as a comment instead. break; case EpisodeType.Parody: offset += sizes?.Episodes ?? 0; @@ -161,65 +158,38 @@ public static int GetIndexNumber(SeriesInfo series, EpisodeInfo episode) /// <returns></returns> public static int GetSeasonNumber(GroupInfo group, SeriesInfo series, EpisodeInfo episode) { - switch (Plugin.Instance.Configuration.SeriesGrouping) - { + switch (Plugin.Instance.Configuration.SeriesGrouping) { default: - case SeriesOrBoxSetGroupType.Default: - switch (episode.AniDB.Type) - { + case GroupType.Default: + switch (episode.AniDB.Type) { case EpisodeType.Normal: return 1; case EpisodeType.Special: return 0; + case EpisodeType.Trailer: + return 99; + case EpisodeType.ThemeSong: + return 100; default: return 98; } - case SeriesOrBoxSetGroupType.MergeFriendly: { + case GroupType.MergeFriendly: { var seasonNumber = episode?.TvDB?.Season; if (seasonNumber == null) - goto case SeriesOrBoxSetGroupType.Default; + goto case GroupType.Default; return seasonNumber ?? 1; } - case SeriesOrBoxSetGroupType.ShokoGroup: { - var id = series.ID; + case GroupType.ShokoGroup: { + var id = series.Id; if (series == group.DefaultSeries) return 1; - var index = group.SeriesList.FindIndex(s => s.ID == id); + var index = group.SeriesList.FindIndex(s => s.Id == id); if (index == -1) - goto case SeriesOrBoxSetGroupType.Default; + goto case GroupType.Default; var value = index - group.DefaultSeriesIndex; return value < 0 ? value : value + 1; } } } - - public static ExtraType? GetExtraType(Episode.AniDB episode) - { - switch (episode.Type) - { - case EpisodeType.Normal: - case EpisodeType.Other: - return null; - case EpisodeType.ThemeSong: - case EpisodeType.OpeningSong: - case EpisodeType.EndingSong: - return ExtraType.ThemeVideo; - case EpisodeType.Trailer: - return ExtraType.Trailer; - case EpisodeType.Special: { - var title = Text.GetTitleByLanguages(episode.Titles, "en") ?? ""; - // Interview - if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) - return ExtraType.Interview; - // Cinema intro/outro - if (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) && - (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase))) - return ExtraType.Clip; - return null; - } - default: - return ExtraType.Unknown; - } - } } } diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index 5858a06a..26c50e30 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -1,27 +1,66 @@ +using Shokofin.API.Models; using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.RegularExpressions; -using Shokofin.API.Models; namespace Shokofin.Utils { public class Text { + /// <summary> + /// Determines the language to construct the title in. + /// </summary> public enum DisplayLanguageType { + /// <summary> + /// Let Shoko decide what to display. + /// </summary> Default = 1, - MetadataPreferred, - Origin, - Ignore + + /// <summary> + /// Prefer to use the selected metadata language for the library if + /// available, but fallback to the default view if it's not + /// available. + /// </summary> + MetadataPreferred = 2, + + /// <summary> + /// Use the origin language for the series. + /// </summary> + Origin = 3, + + /// <summary> + /// Don't display a title. + /// </summary> + Ignore = 4, } - public enum DisplyTitleType { + /// <summary> + /// Determines the type of title to construct. + /// </summary> + public enum DisplayTitleType { + /// <summary> + /// Only construct the main title. + /// </summary> MainTitle = 1, - SubTitle, - FullTitle, + + /// <summary> + /// Only construct the sub title. + /// </summary> + SubTitle = 2, + + /// <summary> + /// Construct a combined main and sub title. + /// </summary> + FullTitle = 3, } - public static string SummarySanitizer(string summary) // Based on ShokoMetadata which is based on HAMA's + /// <summary> + /// Based on ShokoMetadata's summary sanitizer which in turn is based on HAMA's summary sanitizer. + /// </summary> + /// <param name="summary">The raw AniDB summary</param> + /// <returns>The sanitized AniDB summary</returns> + public static string SanitizeTextSummary(string summary) { var config = Plugin.Instance.Configuration; @@ -41,18 +80,19 @@ public static string SummarySanitizer(string summary) // Based on ShokoMetadata } public static ( string, string ) GetEpisodeTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) - => GetTitles(seriesTitles, episodeTitles, null, episodeTitle, DisplyTitleType.SubTitle, metadataLanguage); + => GetTitles(seriesTitles, episodeTitles, null, episodeTitle, DisplayTitleType.SubTitle, metadataLanguage); public static ( string, string ) GetSeriesTitles(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) - => GetTitles(seriesTitles, null, seriesTitle, null, DisplyTitleType.MainTitle, metadataLanguage); + => GetTitles(seriesTitles, null, seriesTitle, null, DisplayTitleType.MainTitle, metadataLanguage); public static ( string, string ) GetMovieTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) - => GetTitles(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplyTitleType.FullTitle, metadataLanguage); + => GetTitles(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplayTitleType.FullTitle, metadataLanguage); - public static ( string, string ) GetTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplyTitleType outputType, string metadataLanguage) + public static ( string, string ) GetTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayTitleType outputType, string metadataLanguage) { // Don't process anything if the series titles are not provided. - if (seriesTitles == null) return ( null, null ); + if (seriesTitles == null) + return ( null, null ); var originLanguage = GuessOriginLanguage(seriesTitles); return ( GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage, originLanguage), @@ -61,18 +101,18 @@ public static ( string, string ) GetTitles(IEnumerable<Title> seriesTitles, IEnu } public static string GetEpisodeTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) - => GetTitle(seriesTitles, episodeTitles, null, episodeTitle, DisplyTitleType.SubTitle, metadataLanguage); + => GetTitle(seriesTitles, episodeTitles, null, episodeTitle, DisplayTitleType.SubTitle, metadataLanguage); public static string GetSeriesTitle(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) - => GetTitle(seriesTitles, null, seriesTitle, null, DisplyTitleType.MainTitle, metadataLanguage); + => GetTitle(seriesTitles, null, seriesTitle, null, DisplayTitleType.MainTitle, metadataLanguage); public static string GetMovieTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) - => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplyTitleType.FullTitle, metadataLanguage); + => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplayTitleType.FullTitle, metadataLanguage); - public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplyTitleType outputType, string metadataLanguage, params string[] originLanguages) + public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayTitleType outputType, string metadataLanguage, params string[] originLanguages) => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage, originLanguages); - public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayLanguageType languageType, DisplyTitleType outputType, string displayLanguage, params string[] originLanguages) + public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayLanguageType languageType, DisplayTitleType outputType, string displayLanguage, params string[] originLanguages) { // Don't process anything if the series titles are not provided. if (seriesTitles == null) @@ -80,8 +120,11 @@ public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title // Guess origin language if not provided. if (originLanguages.Length == 0) originLanguages = GuessOriginLanguage(seriesTitles); - switch (languageType) - { + switch (languageType) { + // 'Ignore' will always return null, and all other values will also return null. + default: + case DisplayLanguageType.Ignore: + return null; // Let Shoko decide the title. case DisplayLanguageType.Default: return __GetTitle(null, null, seriesTitle, episodeTitle, outputType); @@ -94,59 +137,54 @@ public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title // Display in origin language without fallback. case DisplayLanguageType.Origin: return __GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, outputType, originLanguages); - // 'Ignore' will always return null, and all other values will also return null. - case DisplayLanguageType.Ignore: - default: - return null; } } - private static string __GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplyTitleType outputType, params string[] languageCandidates) + private static string __GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayTitleType outputType, params string[] languageCandidates) { // Lazy init string builder when/if we need it. StringBuilder titleBuilder = null; - switch (outputType) - { - case DisplyTitleType.MainTitle: - case DisplyTitleType.FullTitle: { + switch (outputType) { + default: + return null; + case DisplayTitleType.MainTitle: + case DisplayTitleType.FullTitle: { string title = (GetTitleByTypeAndLanguage(seriesTitles, "official", languageCandidates) ?? seriesTitle)?.Trim(); // Return series title. - if (outputType == DisplyTitleType.MainTitle) + if (outputType == DisplayTitleType.MainTitle) return title; titleBuilder = new StringBuilder(title); - goto case DisplyTitleType.SubTitle; + goto case DisplayTitleType.SubTitle; } - case DisplyTitleType.SubTitle: { + case DisplayTitleType.SubTitle: { string title = (GetTitleByLanguages(episodeTitles, languageCandidates) ?? episodeTitle)?.Trim(); // Return episode title. - if (outputType == DisplyTitleType.SubTitle) + if (outputType == DisplayTitleType.SubTitle) return title; // Ignore sub-title of movie if it strictly equals the text below. if (title != "Complete Movie" && !string.IsNullOrEmpty(title?.Trim())) titleBuilder?.Append($": {title}"); return titleBuilder?.ToString() ?? ""; } - default: - return null; } } public static string GetTitleByTypeAndLanguage(IEnumerable<Title> titles, string type, params string[] langs) { - if (titles != null) foreach (string lang in langs) - { + if (titles != null) foreach (string lang in langs) { string title = titles.FirstOrDefault(s => s.Language == lang && s.Type == type)?.Name; - if (title != null) return title; + if (title != null) + return title; } return null; } public static string GetTitleByLanguages(IEnumerable<Title> titles, params string[] langs) { - if (titles != null) foreach (string lang in langs) - { + if (titles != null) foreach (string lang in langs) { string title = titles.FirstOrDefault(s => lang.Equals(s.Language, System.StringComparison.OrdinalIgnoreCase))?.Name; - if (title != null) return title; + if (title != null) + return title; } return null; } @@ -159,8 +197,7 @@ private static string[] GuessOriginLanguage(IEnumerable<Title> titles) { string langCode = titles.FirstOrDefault(t => t?.Type == "main")?.Language.ToLower(); // Guess the origin language based on the main title. - switch (langCode) - { + switch (langCode) { case null: // fallback case "x-other": case "x-jat": @@ -170,7 +207,6 @@ private static string[] GuessOriginLanguage(IEnumerable<Title> titles) default: return new string[] { langCode }; } - } } } From 08de4f5e59abd226e7e1700ef32e3d4a308e28a5 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 29 Aug 2021 20:04:35 +0000 Subject: [PATCH 0102/1103] Update unstable repo manifest --- manifest-unstable.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 134e954f..558183a9 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -12,8 +12,8 @@ "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.7.3/shokofin_1.4.7.3.zip", - "checksum": "d505259dbce9deebe9aeea1cf15ea661", - "timestamp": "2021-06-02T10:00:43Z" + "checksum": "11e33c3b755ef2c622134258abeca382", + "timestamp": "2021-08-29T20:04:33Z" } ] } From 79808b0440169d37c3a418e161556fd2ec0ea91e Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Mon, 30 Aug 2021 01:52:46 +0530 Subject: [PATCH 0103/1103] Fix manifest-unstable --- manifest-unstable.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 558183a9..29ef6587 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -12,9 +12,9 @@ "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.7.3/shokofin_1.4.7.3.zip", - "checksum": "11e33c3b755ef2c622134258abeca382", - "timestamp": "2021-08-29T20:04:33Z" + "checksum": "d505259dbce9deebe9aeea1cf15ea661", + "timestamp": "2021-06-02T10:00:43Z" } ] } -] \ No newline at end of file +] From b5f6631a694268c24647230f7106d6fcbd61ae7d Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Sun, 29 Aug 2021 20:23:24 +0000 Subject: [PATCH 0104/1103] Update unstable repo manifest --- manifest-unstable.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 29ef6587..c5989cce 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -12,9 +12,9 @@ "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.7.3/shokofin_1.4.7.3.zip", - "checksum": "d505259dbce9deebe9aeea1cf15ea661", - "timestamp": "2021-06-02T10:00:43Z" + "checksum": "f9436ec5df4c8d196c0d013e44c71860", + "timestamp": "2021-08-29T20:23:23Z" } ] } -] +] \ No newline at end of file From a554334abb3653d89fda3355f33f993dbe19615f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 29 Aug 2021 23:15:07 +0200 Subject: [PATCH 0105/1103] Fix Settings page not displaying And break it on my machine(tm) --- Shokofin/Plugin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 4da5865d..50e26d2a 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -29,7 +29,7 @@ public IEnumerable<PluginPageInfo> GetPages() new PluginPageInfo { Name = name, - EmbeddedResourcePath = $"{name}.configPage.html", + EmbeddedResourcePath = $"{name}.Configuration.configPage.html", } }; } From 9649ed1894a35a917f16fc444a1b686121be9639 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 29 Aug 2021 21:15:45 +0000 Subject: [PATCH 0106/1103] Update unstable repo manifest --- manifest-unstable.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index c5989cce..11ee8087 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -12,8 +12,8 @@ "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.7.3/shokofin_1.4.7.3.zip", - "checksum": "f9436ec5df4c8d196c0d013e44c71860", - "timestamp": "2021-08-29T20:23:23Z" + "checksum": "03832674c51bb60b01bf511c03eb9151", + "timestamp": "2021-08-29T21:15:44Z" } ] } From 4d1e1e10539cee6d6e452b826acb7c423ab69ced Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 30 Aug 2021 00:26:58 +0200 Subject: [PATCH 0107/1103] Add back TvDB ids --- Shokofin/API/Info/SeriesInfo.cs | 2 ++ Shokofin/API/ShokoAPIManager.cs | 2 ++ Shokofin/Configuration/PluginConfiguration.cs | 3 +++ Shokofin/Configuration/configPage.html | 6 ++++++ Shokofin/Providers/EpisodeProvider.cs | 2 ++ Shokofin/Providers/MovieProvider.cs | 2 ++ Shokofin/Providers/SeriesProvider.cs | 2 ++ 7 files changed, 19 insertions(+) diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index da6f38c7..b9dfe32a 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -13,6 +13,8 @@ public class SeriesInfo public Series.AniDB AniDB; + public string TvDBId; + /// <summary> /// All episodes (of all type) that belong to this series. /// </summary> diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index a5cc0f0b..62781458 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -406,6 +406,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = var seriesGuid = GetSeriesGuid(seriesId); var aniDb = await ShokoAPI.GetSeriesAniDb(seriesId); + var tvDbId = series.IDs.TvDB?.FirstOrDefault(); var episodeList = await ShokoAPI.GetEpisodesFromSeries(seriesId) .ContinueWith(async task => await Task.WhenAll(task.Result.Select(e => CreateEpisodeInfo(e)))).Unwrap() .ContinueWith(l => l.Result.Where(s => s != null).ToList()); @@ -415,6 +416,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = Guid = seriesGuid, Shoko = series, AniDB = aniDb, + TvDBId = tvDbId != 0 ? tvDbId.ToString() : null, EpisodeList = episodeList, FilteredSpecialEpisodesList = filteredSpecialEpisodesList, }; diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 1789ea1a..6d920a7c 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -37,6 +37,8 @@ public class PluginConfiguration : BasePluginConfiguration public bool AddAniDBId { get; set; } + public bool AddTvDBId { get; set; } + public SeriesAndBoxSetGroupType SeriesGrouping { get; set; } public OrderType SeasonOrdering { get; set; } @@ -72,6 +74,7 @@ public PluginConfiguration() SynopsisRemoveSummary = true; SynopsisCleanMultiEmptyLines = true; AddAniDBId = true; + AddTvDBId = true; TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; SeriesGrouping = SeriesAndBoxSetGroupType.Default; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 10f0b6bc..04a34b1b 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -117,6 +117,10 @@ <h3>Provider Options</h3> <input is="emby-checkbox" type="checkbox" id="AddAniDBId" /> <span>Add AniDB Id to all entries</span> </label> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="AddTvDBId" /> + <span>Add TvDB Id when present</span> + </label> <h3>Tag Options</h3> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="HideArtStyleTags" /> @@ -179,6 +183,7 @@ <h3>Tag Options</h3> document.querySelector('#MarkSpecialsWhenGrouped').checked = config.MarkSpecialsWhenGrouped; document.querySelector('#SeperateLibraries').checked = config.SeperateLibraries; document.querySelector('#AddAniDBId').checked = config.AddAniDBId; + document.querySelector('#AddTvDBId').checked = config.AddTvDBId; if (config.SeriesGrouping === "ShokoGroup") { document.querySelector('#SG_ShokoGroup_Warning').removeAttribute("hidden"); document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); @@ -249,6 +254,7 @@ <h3>Tag Options</h3> config.MarkSpecialsWhenGrouped = document.querySelector('#MarkSpecialsWhenGrouped').checked; config.SeperateLibraries = document.querySelector('#SeperateLibraries').checked; config.AddAniDBId = document.querySelector('#AddAniDBId').checked; + config.AddTvDBId = document.querySelector('#AddTvDBId').checked; ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); }); e.preventDefault(); diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 673b2a3f..6bbcea05 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -74,6 +74,8 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell result.Item.SetProviderId("Shoko File", file.Id); if (config.AddAniDBId) result.Item.SetProviderId("AniDB", episode.AniDB.ID.ToString()); + if (config.AddTvDBId && episode.TvDB != null && config.SeriesGrouping != Ordering.GroupType.ShokoGroup) + result.Item.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); result.HasMetadata = true; ApiManager.MarkEpisodeAsFound(episode.Id, series.Id); diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 26a1d5ea..ee9d2be2 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -68,6 +68,8 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio result.Item.SetProviderId("Shoko Episode", episode.Id); if (config.AddAniDBId) result.Item.SetProviderId("AniDB", episode.AniDB.ID.ToString()); + if (config.AddTvDBId && episode.TvDB != null && config.BoxSetGrouping != Ordering.GroupType.ShokoGroup) + result.Item.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); result.HasMetadata = true; ApiManager.MarkSeriesAsFound(series.Id, group.Id); diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index e1fdfd55..a4ccc79a 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -74,6 +74,8 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C result.Item.SetProviderId("Shoko Series", series.Id); if (Plugin.Instance.Configuration.AddAniDBId) result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); + if (Plugin.Instance.Configuration.AddTvDBId && !string.IsNullOrEmpty(series.TvDBId)) + result.Item.SetProviderId(MetadataProvider.Tvdb, series.TvDBId); result.HasMetadata = true; ApiManager.MarkSeriesAsFound(series.Id); From 126e79ba2fb1473b37d541063dcebaca0c79aef4 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 29 Aug 2021 22:27:41 +0000 Subject: [PATCH 0108/1103] Update unstable repo manifest --- manifest-unstable.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 11ee8087..624f6ad4 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -12,8 +12,8 @@ "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.7.3/shokofin_1.4.7.3.zip", - "checksum": "03832674c51bb60b01bf511c03eb9151", - "timestamp": "2021-08-29T21:15:44Z" + "checksum": "25f362f2f3782d13b7e5ad7670db3e3f", + "timestamp": "2021-08-29T22:27:40Z" } ] } From 6b36e84af1808a70a77ddb246b2dbe25ec9b66fa Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 30 Aug 2021 00:29:59 +0200 Subject: [PATCH 0109/1103] Only set Imdb id if series grouping is used. --- Shokofin/Providers/EpisodeProvider.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 6bbcea05..1f7848b5 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -69,7 +69,8 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell CommunityRating = (float) ((episode.AniDB.Rating.Value * 10) / episode.AniDB.Rating.MaxValue) }; // NOTE: This next line will remain here till they fix the series merging for providers outside the MetadataProvider enum. - result.Item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{episode.Id}"); + if (config.SeriesGrouping == Ordering.GroupType.ShokoGroup) + result.Item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{episode.Id}"); result.Item.SetProviderId("Shoko Episode", episode.Id); result.Item.SetProviderId("Shoko File", file.Id); if (config.AddAniDBId) From 5cf21db51ef0c9b91adbd8b65996a16c80df6296 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 29 Aug 2021 22:30:39 +0000 Subject: [PATCH 0110/1103] Update unstable repo manifest --- manifest-unstable.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 624f6ad4..f9048aad 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -12,8 +12,8 @@ "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.7.3/shokofin_1.4.7.3.zip", - "checksum": "25f362f2f3782d13b7e5ad7670db3e3f", - "timestamp": "2021-08-29T22:27:40Z" + "checksum": "38dcfce514523e7527f80d55e2e38704", + "timestamp": "2021-08-29T22:30:37Z" } ] } From ff781f94998dc282cc5b2a9f1bd32eb1eba9e4d3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 30 Aug 2021 01:48:03 +0200 Subject: [PATCH 0111/1103] Make TvDB ids opt-in instead of opt-out. --- 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 6d920a7c..1efaf253 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -74,7 +74,7 @@ public PluginConfiguration() SynopsisRemoveSummary = true; SynopsisCleanMultiEmptyLines = true; AddAniDBId = true; - AddTvDBId = true; + AddTvDBId = false; TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; SeriesGrouping = SeriesAndBoxSetGroupType.Default; From 5bb000877364f94fee4d23ad47948fb1f78a38e9 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 29 Aug 2021 23:48:36 +0000 Subject: [PATCH 0112/1103] Update unstable repo manifest --- manifest-unstable.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index f9048aad..1ffc43d1 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -12,8 +12,8 @@ "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.7.3/shokofin_1.4.7.3.zip", - "checksum": "38dcfce514523e7527f80d55e2e38704", - "timestamp": "2021-08-29T22:30:37Z" + "checksum": "20e8a7b5701f1f8eadb77652a6fd8b28", + "timestamp": "2021-08-29T23:48:35Z" } ] } From b420bb00db872274132b6b0b3fcb80c42b4737d1 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <revam@users.noreply.github.com> Date: Mon, 30 Aug 2021 02:11:37 +0200 Subject: [PATCH 0113/1103] Update README.md --- README.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c91089ff..cb352d58 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,15 @@ # Shokofin -**Warning**: This plugin currently requires the latest version of Jellyfin (`>10.7.0`) and daily version of Shoko (`>4.1.0`) to be installed to work. +**Warning**: This plugin requires a version of Jellyfin after 10.7 (`>=10.7.0`) and a stable version of Shoko after 4.1.1 (`>=4.1.1`) to be installed to work properly. A plugin to integrate your Shoko database with the Jellyfin media server. +## Breaking Changes + +### 1.5.0 + +If you're upgrading from an older version to version 1.5.0, then be sure to update the "Host" field in the plugin settings before you continue using the plugin. + ## Install There are multiple ways to install this plugin, but the recomended way is to use the official Jellyfin repository. @@ -12,16 +18,19 @@ There are multiple ways to install this plugin, but the recomended way is to use 1. Go to Dashboard -> Plugins -> Repositories 2. Add new repository with the following details - * Repository Name: `Shokofin` + * Repository Name: `Shokofin Stable` * Repository URL: `https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest.json` 3. Go to the catalog in the plugins page 4. Find and install Shokofin from the Metadata section +5. Restart your server to apply the changes. ### Github Releases 1. Download the `shokofin_*.zip` file from the latest release from GitHub [here](https://github.com/ShokoAnime/shokofin/releases/latest). -2. Extract the contained `Shokofin.dll` and `meta.json`, place both the files in a folder named `Shokofin` and copy this folder to the `plugins` folder under the Jellyfin program data directory or inside the portable install directory. +2. Extract the contained `Shokofin.dll` and `meta.json`, place both the files in a folder named `Shokofin` and copy this folder to the `plugins` folder under the Jellyfin program data directory or inside the portable install directory. Refer to the "Data Directory" section on [this page](https://jellyfin.org/docs/general/administration/configuration.html) for where to find your jellyfin install. + +3. Start or restart your server to apply the changes ### Build Process From c564b3f88bf89665cb0b7c8e55a274686c09fe9e Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 30 Aug 2021 00:12:15 +0000 Subject: [PATCH 0114/1103] Update unstable repo manifest --- manifest-unstable.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 1ffc43d1..804f4af6 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -12,8 +12,8 @@ "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.7.3/shokofin_1.4.7.3.zip", - "checksum": "20e8a7b5701f1f8eadb77652a6fd8b28", - "timestamp": "2021-08-29T23:48:35Z" + "checksum": "da198a7a6ef1b6c4cb4128fb705d9eed", + "timestamp": "2021-08-30T00:12:13Z" } ] } From 700dd82611adae42eaa0483e9f45fac504636197 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 30 Aug 2021 00:23:41 +0000 Subject: [PATCH 0115/1103] Update repo manifest --- manifest.json | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/manifest.json b/manifest.json index b0c5fda7..3717d9d1 100644 --- a/manifest.json +++ b/manifest.json @@ -8,7 +8,15 @@ "category": "Metadata", "versions": [ { - "version": "1.4.7", + "version": "1.5.0.0", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0/shokofin_1.5.0.0.zip", + "checksum": "1619ade0f980553dbc056fc414ad6243", + "timestamp": "2021-08-30T00:23:40Z" + }, + { + "version": "1.4.7.0", "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.7/shokofin_1.4.7.zip", @@ -16,7 +24,7 @@ "timestamp": "2021-06-01T17:14:41Z" }, { - "version": "1.4.6", + "version": "1.4.6.0", "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.6/shokofin_1.4.6.zip", @@ -32,7 +40,7 @@ "timestamp": "2021-03-26T06:05:25Z" }, { - "version": "1.4.5", + "version": "1.4.5.0", "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.5/shokofin_1.4.5.zip", @@ -40,7 +48,7 @@ "timestamp": "2021-03-25T13:10:36Z" }, { - "version": "1.4.4", + "version": "1.4.4.0", "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.4/shokofin_1.4.4.zip", @@ -48,7 +56,7 @@ "timestamp": "2021-03-24T09:41:27Z" }, { - "version": "1.4.3", + "version": "1.4.3.0", "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.3/shokofin_1.4.3.zip", @@ -56,7 +64,7 @@ "timestamp": "2021-03-18T17:38:49Z" }, { - "version": "1.4.2", + "version": "1.4.2.0", "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.2/shokofin_1.4.2.zip", @@ -64,7 +72,7 @@ "timestamp": "2021-03-17T07:31:27Z" }, { - "version": "1.4.1", + "version": "1.4.1.0", "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.1/shokofin_1.4.1.zip", @@ -72,7 +80,7 @@ "timestamp": "2021-03-16T15:01:11Z" }, { - "version": "1.4.0", + "version": "1.4.0.0", "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.0/shokofin_1.4.0.zip", @@ -80,7 +88,7 @@ "timestamp": "2021-03-03T20:39:56Z" }, { - "version": "1.3.1", + "version": "1.3.1.0", "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.3.1/shokofin_1.3.1.zip", @@ -88,7 +96,7 @@ "timestamp": "2020-10-12T14:11:59Z" }, { - "version": "1.3.0", + "version": "1.3.0.0", "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.3.0/shokofin_1.3.0.zip", @@ -96,7 +104,7 @@ "timestamp": "2020-09-30T20:54:31Z" }, { - "version": "1.2.0", + "version": "1.2.0.0", "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.2.0/shokojellyfin_1.2.0.zip", @@ -104,7 +112,7 @@ "timestamp": "2020-09-20T21:52:52Z" }, { - "version": "1.1.0", + "version": "1.1.0.0", "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.1.0/shokojellyfin_1.1.0.zip", @@ -112,7 +120,7 @@ "timestamp": "2020-09-08T22:17:26Z" }, { - "version": "1.0.0", + "version": "1.0.0.0", "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.0.0/shokojellyfin_1.0.0.zip", From 1d6a5d114b31ec0ae389f0159d4c885c2afeaa6a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 30 Aug 2021 17:45:13 +0200 Subject: [PATCH 0116/1103] Update the file scrobbler It will now also sync back the resume position when you pause an episode or a movie. Also, fixed it so it also works on movies. --- Shokofin/API/ShokoAPI.cs | 13 ++++++------ Shokofin/Scrobbler.cs | 44 ++++++++++++++++++---------------------- 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/Shokofin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPI.cs index 279b5bb0..a202c53e 100644 --- a/Shokofin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPI.cs @@ -33,6 +33,7 @@ private static async Task<Stream> CallApi(string url, string requestType = "GET" var apiBaseUrl = Plugin.Instance.Configuration.Host; switch (requestType) { + case "PATCH": case "POST": var response = await _httpClient.PostAsync($"{apiBaseUrl}{url}", new StringContent("")); return response.StatusCode == HttpStatusCode.OK ? response.Content.ReadAsStreamAsync().Result : null; @@ -110,6 +111,12 @@ public static async Task<File> GetFile(string id) return responseStream != null ? await JsonSerializer.DeserializeAsync<File>(responseStream) : null; } + public static async Task<bool> ScrobbleFile(string id, bool watched, long? progress) + { + var responseStream = await CallApi($"/api/v3/File/{id}/Scrobble?watched={watched}&resumePosition={progress ?? 0}", "PATCH"); + return responseStream != null; + } + public static async Task<IEnumerable<File.FileDetailed>> GetFileByPath(string filename) { var responseStream = await CallApi($"/api/v3/File/PathEndsWith/{Uri.EscapeDataString(filename)}"); @@ -176,12 +183,6 @@ public static async Task<Group> GetGroupFromSeries(string id) return responseStream != null ? await JsonSerializer.DeserializeAsync<Group>(responseStream) : null; } - public static async Task<bool> MarkEpisodeWatched(string id) - { - var responseStream = await CallApi($"/api/v3/Episode/{id}/watched/true", "POST"); - return responseStream != null; - } - public static async Task<IEnumerable<SeriesSearchResult>> SeriesSearch(string query) { var responseStream = await CallApi($"/api/v3/Series/Search/{Uri.EscapeDataString(query)}"); diff --git a/Shokofin/Scrobbler.cs b/Shokofin/Scrobbler.cs index 0cfcdb14..d015ef4e 100644 --- a/Shokofin/Scrobbler.cs +++ b/Shokofin/Scrobbler.cs @@ -1,5 +1,4 @@ using System.Threading.Tasks; -using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Session; @@ -12,11 +11,15 @@ namespace Shokofin public class Scrobbler : IServerEntryPoint { private readonly ISessionManager SessionManager; + + private readonly ILibraryManager LibraryManager; + private readonly ILogger<Scrobbler> Logger; - public Scrobbler(ISessionManager sessionManager, ILogger<Scrobbler> logger) + public Scrobbler(ISessionManager sessionManager, ILibraryManager libraryManager, ILogger<Scrobbler> logger) { SessionManager = sessionManager; + LibraryManager = libraryManager; Logger = logger; } @@ -28,33 +31,26 @@ public Task RunAsync() private async void OnPlaybackStopped(object sender, PlaybackStopEventArgs e) { - if (!Plugin.Instance.Configuration.UpdateWatchedStatus) return; - - if (e.Item == null) - { - Logger.LogError("Event details incomplete. Cannot process current media"); + // Only sync-back if we enabled the feature in the plugin settings and an item is present + if (!Plugin.Instance.Configuration.UpdateWatchedStatus || e.Item == null) return; - } - if (!e.Item.HasProviderId("Shoko Episode")) + // Only known episodes and movies have a file id, so if it doesn't have one then it's either urecognized or from another library. + if (!e.Item.HasProviderId("Shoko File")) { - Logger.LogError("Unrecognized file"); - return; // Skip if file does exist in Shoko + Logger.LogWarning("Unable to find a Shoko File Id for item {ItemName}", e.Item.Name); + return; } - if (e.Item is Episode episode && e.PlayedToCompletion) - { - var episodeId = episode.GetProviderId("Shoko Episode"); - - Logger.LogInformation("Item is played. Marking as watched on Shoko"); - Logger.LogInformation($"{episode.SeriesName} S{episode.Season.IndexNumber}E{episode.IndexNumber} - {episode.Name} ({episodeId})"); - - var result = await ShokoAPI.MarkEpisodeWatched(episodeId); - if (result) - Logger.LogInformation("Episode marked as watched!"); - else - Logger.LogError("Error marking episode as watched!"); - } + var fileId = e.Item.GetProviderId("Shoko File"); + var watched = e.PlayedToCompletion; + var resumePosition = e.PlaybackPositionTicks ?? 0; + Logger.LogInformation("Playback was stopped. Syncing watch state of file back to Shoko. (File={FileId},Watched={WatchState},ResumePosition={ResumePosition})", fileId, watched, resumePosition); + var result = await ShokoAPI.ScrobbleFile(fileId, watched, resumePosition); + if (result) + Logger.LogInformation("File marked as watched! (File={FileId})", fileId); + else + Logger.LogWarning("An error occured while syncing watch state of file back to Shoko! (File={FileId})", fileId); } public void Dispose() From d84b51ac01e478d46d36d34b86a31bc3b7161734 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 30 Aug 2021 15:45:54 +0000 Subject: [PATCH 0117/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 804f4af6..ed077a2d 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.1", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.1/shokofin_1.5.0.1.zip", + "checksum": "daba4219cddf344890b8e5c91cc9266f", + "timestamp": "2021-08-30T15:45:52Z" + }, { "version": "1.4.7.3", "changelog": "NA", From 3c4345e6c12aecf08b39f5e6794cd00507284992 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 30 Aug 2021 17:55:24 +0200 Subject: [PATCH 0118/1103] Cleanup; Remove unused reference to ILibraryManager Remove the unused reference to the ILibraryManager I forgot to remove in the previous commit. Welp. --- Shokofin/Scrobbler.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Shokofin/Scrobbler.cs b/Shokofin/Scrobbler.cs index d015ef4e..e1a61cf0 100644 --- a/Shokofin/Scrobbler.cs +++ b/Shokofin/Scrobbler.cs @@ -12,14 +12,11 @@ public class Scrobbler : IServerEntryPoint { private readonly ISessionManager SessionManager; - private readonly ILibraryManager LibraryManager; - private readonly ILogger<Scrobbler> Logger; - public Scrobbler(ISessionManager sessionManager, ILibraryManager libraryManager, ILogger<Scrobbler> logger) + public Scrobbler(ISessionManager sessionManager, ILogger<Scrobbler> logger) { SessionManager = sessionManager; - LibraryManager = libraryManager; Logger = logger; } From addbe237eb086b83162825035eda8aadcdedc5ae Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 30 Aug 2021 15:56:04 +0000 Subject: [PATCH 0119/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index ed077a2d..72454115 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.2", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.2/shokofin_1.5.0.2.zip", + "checksum": "b133d4bf937653e2d0158e9f5bb4a06c", + "timestamp": "2021-08-30T15:56:03Z" + }, { "version": "1.5.0.1", "changelog": "NA", From bd4a9964293d5e4fb3127c742fecdf05d584d449 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 30 Aug 2021 18:37:47 +0200 Subject: [PATCH 0120/1103] Use a constant to store the metadata provider name --- Shokofin/Plugin.cs | 2 ++ Shokofin/Providers/BoxSetProvider.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 +- 7 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 50e26d2a..d2bd62ee 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -10,6 +10,8 @@ namespace Shokofin { public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { + public static string MetadataProviderName = "Shoko"; + public override string Name => "Shoko"; public override Guid Id => Guid.Parse("5216ccbf-d24a-4eb3-8a7e-7da4230b7052"); diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 052048af..94d1eee7 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -16,7 +16,7 @@ namespace Shokofin.Providers { public class BoxSetProvider : IRemoteMetadataProvider<BoxSet, BoxSetInfo> { - public string Name => "Shoko"; + public string Name => Plugin.MetadataProviderName; private readonly IHttpClientFactory HttpClientFactory; diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 1f7848b5..26b05910 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -17,7 +17,7 @@ namespace Shokofin.Providers { public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo> { - public string Name => "Shoko"; + public string Name => Plugin.MetadataProviderName; private readonly IHttpClientFactory HttpClientFactory; diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 2efc5a01..d46007a8 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -16,7 +16,7 @@ namespace Shokofin.Providers { public class ImageProvider : IRemoteImageProvider { - public string Name => "Shoko"; + public string Name => Plugin.MetadataProviderName; private readonly IHttpClientFactory HttpClientFactory; diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index ee9d2be2..1ca81f56 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -15,7 +15,7 @@ namespace Shokofin.Providers { public class MovieProvider : IRemoteMetadataProvider<Movie, MovieInfo> { - public string Name => "Shoko"; + public string Name => Plugin.MetadataProviderName; private readonly IHttpClientFactory HttpClientFactory; diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index aee2a127..09bc5c66 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -14,7 +14,7 @@ namespace Shokofin.Providers { public class SeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo> { - public string Name => "Shoko"; + public string Name => Plugin.MetadataProviderName; private readonly IHttpClientFactory HttpClientFactory; private readonly ILogger<SeasonProvider> Logger; diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index a4ccc79a..1209ed59 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -16,7 +16,7 @@ namespace Shokofin.Providers { public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> { - public string Name => "Shoko"; + public string Name => Plugin.MetadataProviderName; private readonly IHttpClientFactory HttpClientFactory; From c757e81605fc140bf7853574688d05db3942801b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 30 Aug 2021 16:38:23 +0000 Subject: [PATCH 0121/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 72454115..8a4d9439 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.3", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.3/shokofin_1.5.0.3.zip", + "checksum": "7589e19680e18a285d7c8b74e5a1f4e6", + "timestamp": "2021-08-30T16:38:22Z" + }, { "version": "1.5.0.2", "changelog": "NA", From e439569e75e9f4024d90b24a9d772344df0fad91 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Mon, 30 Aug 2021 23:03:45 +0530 Subject: [PATCH 0122/1103] Do not update stable manifest for pre-releases --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2befd2fc..acd5ecc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: release: types: - - created + - released branches: master jobs: From 70436c9f6c1ca4053e1f4925c4df4fc2c146638c Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Mon, 30 Aug 2021 23:05:32 +0530 Subject: [PATCH 0123/1103] Update release-daily.yml --- .github/workflows/release-daily.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 782c8704..3d39fe9e 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -3,6 +3,10 @@ name: Daily Release on: push: branches: [ master ] + release: + types: + - prereleased + branches: master jobs: build: From 3a85df6e8732ce40e3854c7e44deb9cf6561c619 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 30 Aug 2021 23:27:33 +0200 Subject: [PATCH 0124/1103] Fix commuinity rating for episodes Use the helper method instead of directly computing the value. --- Shokofin/Providers/EpisodeProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 26b05910..ab7ea0ef 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -66,7 +66,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, Overview = Text.SanitizeTextSummary(episode.AniDB.Description), - CommunityRating = (float) ((episode.AniDB.Rating.Value * 10) / episode.AniDB.Rating.MaxValue) + CommunityRating = episode.AniDB.Rating.ToFloat(10), }; // NOTE: This next line will remain here till they fix the series merging for providers outside the MetadataProvider enum. if (config.SeriesGrouping == Ordering.GroupType.ShokoGroup) From 0662c62102f2ad4082d2f51b59eeebf8898bc584 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 30 Aug 2021 21:28:14 +0000 Subject: [PATCH 0125/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 8a4d9439..aea3885e 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.4", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.4/shokofin_1.5.0.4.zip", + "checksum": "91bad7c76687ef8ad60d40a5daad89d0", + "timestamp": "2021-08-30T21:28:12Z" + }, { "version": "1.5.0.3", "changelog": "NA", From b0e1cb98b276643ebb463f60dc089ac67aa22483 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 31 Aug 2021 14:40:53 +0200 Subject: [PATCH 0126/1103] Cleanup: Remove leftover debug code --- Shokofin/API/ShokoAPIManager.cs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 62781458..0c77372b 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -44,16 +44,6 @@ public class ShokoAPIManager private static Dictionary<string, Guid> GroupIdToGuidDictionary = new Dictionary<string, Guid>(); - private bool __isScanning = false; - - public bool IsScanning { get { return __isScanning; } } - - public void Scan() - { - if (!__isScanning) - __isScanning = true; - } - public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ILibraryManager libraryManager) { Logger = logger; @@ -72,7 +62,6 @@ public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ILibraryManager libraryM public Folder FindMediaFolder(string path, Folder parent, Folder root) { - __isScanning = true; var rootFolder = RootFolderList.Find((folder) => path.StartsWith(folder.Path)); // Look for the root folder for the current item. if (rootFolder != null) { @@ -103,7 +92,6 @@ public string StripMediaFolder(string fullPath) public void Clear() { - __isScanning = false; DataCache.Dispose(); RootFolderList.Clear(); SeriesIdToPathDictionary.Clear(); From 4609bd7157139e4a65f2112b5f197d2fe86df1ed Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 31 Aug 2021 12:41:34 +0000 Subject: [PATCH 0127/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index aea3885e..844845b6 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.5", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.5/shokofin_1.5.0.5.zip", + "checksum": "a6be64b2fbe544d54c9da6bb2fb89f02", + "timestamp": "2021-08-31T12:41:33Z" + }, { "version": "1.5.0.4", "changelog": "NA", From 8f3891a963e6ff39d85777a10b4d0b7b95044acb Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 31 Aug 2021 18:35:42 +0200 Subject: [PATCH 0128/1103] More cleanup of unused debug code --- Shokofin/API/Info/GroupInfo.cs | 5 ----- Shokofin/API/Info/SeriesInfo.cs | 2 -- Shokofin/API/ShokoAPIManager.cs | 40 --------------------------------- 3 files changed, 47 deletions(-) diff --git a/Shokofin/API/Info/GroupInfo.cs b/Shokofin/API/Info/GroupInfo.cs index f9cc2ac5..ccb75d10 100644 --- a/Shokofin/API/Info/GroupInfo.cs +++ b/Shokofin/API/Info/GroupInfo.cs @@ -8,11 +8,6 @@ public class GroupInfo { public string Id; - /// <summary> - /// Shared Guid for series merging. - /// </summary> - public System.Guid Guid; - public Group Shoko; public List<SeriesInfo> SeriesList; diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index b9dfe32a..1280c074 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -7,8 +7,6 @@ public class SeriesInfo { public string Id; - public System.Guid Guid; - public Series Shoko; public Series.AniDB AniDB; diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 0c77372b..7251f6f1 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -392,7 +392,6 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = if (DataCache.TryGetValue<SeriesInfo>(cacheKey, out info)) return info; - var seriesGuid = GetSeriesGuid(seriesId); var aniDb = await ShokoAPI.GetSeriesAniDb(seriesId); var tvDbId = series.IDs.TvDB?.FirstOrDefault(); var episodeList = await ShokoAPI.GetEpisodesFromSeries(seriesId) @@ -401,7 +400,6 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = var filteredSpecialEpisodesList = episodeList.Where(e => e.AniDB.Type == EpisodeType.Special && e.ExtraType == null).ToList(); info = new SeriesInfo { Id = seriesId, - Guid = seriesGuid, Shoko = series, AniDB = aniDb, TvDBId = tvDbId != 0 ? tvDbId.ToString() : null, @@ -420,23 +418,6 @@ public bool MarkSeriesAsFound(string seriesId, string groupId) return (GroupIdToSeriesIdDictionery.ContainsKey(groupId) ? GroupIdToSeriesIdDictionery[groupId] : (GroupIdToSeriesIdDictionery[groupId] = new HashSet<string>())).Add(seriesId); } - private Guid GetSeriesGuid(string seriesId) - { - if (SeriesIdToGuidDictionary.ContainsKey(seriesId)) - return SeriesIdToGuidDictionary[seriesId]; - var itemType = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup ? nameof (MediaBrowser.Controller.Entities.TV.Season) : nameof (MediaBrowser.Controller.Entities.TV.Series); - var result = LibraryManager.GetItemsResult(new InternalItemsQuery { - HasAnyProviderId = { - ["Shoko Series"] = seriesId, - }, - IncludeItemTypes = new[] { itemType, nameof (MediaBrowser.Controller.Entities.Movies.BoxSet) }, - IsVirtualItem = false, - IsPlaceHolder = false, - }); - var seriesGuid = result?.Items.FirstOrDefault()?.Id ?? Guid.NewGuid(); - SeriesIdToGuidDictionary[seriesId] = seriesGuid; - return seriesGuid; - } public string GetPathForSeries(string seriesId) { @@ -510,7 +491,6 @@ public async Task<GroupInfo> GetGroupInfoForSeries(string seriesId, Ordering.Gro return await GetGroupInfo(groupId, filterByType); } - private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Ordering.GroupFilterType filterByType) { if (group == null) @@ -580,10 +560,8 @@ private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Order if (foundIndex == -1) throw new System.Exception("Unable to get a base-point for seasions withing the group"); - var groupGuid = GetGroupGuid(groupId); groupInfo = new GroupInfo { Id = groupId, - Guid = groupGuid, Shoko = group, SeriesList = seriesList, DefaultSeries = seriesList[foundIndex], @@ -595,24 +573,6 @@ private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Order return groupInfo; } - private Guid GetGroupGuid(string groupId) - { - if (GroupIdToGuidDictionary.ContainsKey(groupId)) - return GroupIdToGuidDictionary[groupId]; - - var result = LibraryManager.GetItemsResult(new InternalItemsQuery { - HasAnyProviderId = { - ["Shoko Group"] = groupId, - }, - IncludeItemTypes = new[] { nameof (MediaBrowser.Controller.Entities.TV.Series), nameof (MediaBrowser.Controller.Entities.Movies.BoxSet), }, - IsVirtualItem = false, - IsPlaceHolder = false, - }); - var groupGuid = result?.Items.FirstOrDefault()?.Id ?? Guid.NewGuid(); - GroupIdToGuidDictionary[groupId] = groupGuid; - return groupGuid; - } - #endregion #region Post Process Library Changes From 3f3e132afa29a76f7836113a1361aea5539197cc Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 31 Aug 2021 19:01:28 +0200 Subject: [PATCH 0129/1103] Cleanup: cleanup library scanner Tweak log points and rename rootFolder to mediaFolder --- Shokofin/API/ShokoAPIManager.cs | 27 +++++++++++++++------------ Shokofin/LibraryScanner.cs | 30 +++++++++++++++++++----------- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 7251f6f1..da703ef2 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -22,7 +22,7 @@ public class ShokoAPIManager private readonly ILibraryManager LibraryManager; - private static readonly List<Folder> RootFolderList = new List<Folder>(); + private static readonly List<Folder> MediaFolderList = new List<Folder>(); private static readonly Dictionary<string, string> SeriesIdToPathDictionary = new Dictionary<string, string>(); @@ -62,25 +62,28 @@ public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ILibraryManager libraryM public Folder FindMediaFolder(string path, Folder parent, Folder root) { - var rootFolder = RootFolderList.Find((folder) => path.StartsWith(folder.Path)); + var mediaFolder = MediaFolderList.Find((folder) => path.StartsWith(folder.Path)); // Look for the root folder for the current item. - if (rootFolder != null) { - return rootFolder; + if (mediaFolder != null) { + return mediaFolder; } - rootFolder = parent; - while (rootFolder.Parent != root) { - if (rootFolder.Parent == null) { + mediaFolder = parent; + while (mediaFolder.ParentId.Equals(root.Id)) { + if (mediaFolder.Parent == null) { + if (mediaFolder.ParentId.Equals(Guid.Empty)) break; + mediaFolder = LibraryManager.GetItemById(mediaFolder.ParentId) as Folder; + continue; } - rootFolder = rootFolder.Parent; + mediaFolder = mediaFolder.Parent; } - RootFolderList.Add(rootFolder); - return rootFolder; + MediaFolderList.Add(mediaFolder); + return mediaFolder; } public string StripMediaFolder(string fullPath) { - var mediaFolder = RootFolderList.Find((folder) => fullPath.StartsWith(folder.Path)); + var mediaFolder = MediaFolderList.Find((folder) => fullPath.StartsWith(folder.Path)); // If no root folder was found, then we _most likely_ already stripped it out beforehand. if (mediaFolder == null || string.IsNullOrEmpty(mediaFolder?.Path)) return fullPath; @@ -93,7 +96,7 @@ public string StripMediaFolder(string fullPath) public void Clear() { DataCache.Dispose(); - RootFolderList.Clear(); + MediaFolderList.Clear(); SeriesIdToPathDictionary.Clear(); SeriesPathToIdDictionary.Clear(); SeriesIdToEpisodeIdDictionery.Clear(); diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index 03b4b653..28a78bbf 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -24,6 +24,15 @@ public LibraryScanner(ShokoAPIManager apiManager, ILibraryManager libraryManager Logger = logger; } + public bool IsEnabledForItem(BaseItem item) + { + if (item == null) + return false; + var libraryOptions = LibraryManager.GetLibraryOptions(item); + return libraryOptions != null && + libraryOptions.TypeOptions.Any(o => o.Type == nameof (Series) && o.MetadataFetchers.Contains(Plugin.MetadataProviderName)); + } + /// <summary> /// It's not really meant to be used this way, but this is our library /// "scanner". It scans the files and folders, and conditionally filters @@ -41,13 +50,12 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base try { // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. - var libraryOptions = LibraryManager.GetLibraryOptions(parent); - if (!libraryOptions.TypeOptions.Any(o => o.MetadataFetchers.Contains("Shoko"))) + if (IsEnabledForItem(parent)) return false; var fullPath = fileInfo.FullName; - var rootFolder = ApiManager.FindMediaFolder(fullPath, parent as Folder, root); - var partialPath = fullPath.Substring(rootFolder.Path.Length); + var mediaFolder = ApiManager.FindMediaFolder(fullPath, parent as Folder, root); + var partialPath = fullPath.Substring(mediaFolder.Path.Length); if (fileInfo.IsDirectory) return ScanDirectory(partialPath, fullPath, LibraryManager.GetInheritedContentType(parent)); else @@ -67,10 +75,10 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy // We warn here since we enabled the provider in our library, but we can't find a match for the given folder path. if (series == null) { - Logger.LogWarning($"Skipped unknown folder at path \"{partialPath}\""); + Logger.LogWarning("Skipped unknown folder at path {Path}", partialPath); return false; } - Logger.LogInformation($"Found series with id \"{series.Id}\" at path \"{partialPath}\""); + Logger.LogInformation("Found series {SeriesName} (Series={SeriesId})", series.AniDB.Title, series.Id); // Filter library if we enabled the option. if (Plugin.Instance.Configuration.FilterOnLibraryTypes) switch (libraryType) { @@ -78,7 +86,7 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy break; case "tvshows": if (series.AniDB.Type == SeriesType.Movie) { - Logger.LogInformation($"Library seperatation is enabled, ignoring series with id \"{series.Id}\" at path \"{partialPath}\""); + Logger.LogInformation("Library seperatation is enabled, ignoring series. (Series={SeriesId}", series.Id); return true; } @@ -88,7 +96,7 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy break; case "movies": if (series.AniDB.Type != SeriesType.Movie) { - Logger.LogInformation($"Library seperatation is enabled, ignoring series with id \"{series.Id}\" at path \"{partialPath}\""); + Logger.LogInformation("Library seperatation is enabled, ignoring series. (Series={SeriesId}", series.Id); return true; } @@ -112,14 +120,14 @@ private bool ScanFile(string partialPath, string fullPath) // We warn here since we enabled the provider in our library, but we can't find a match for the given file path. if (file == null) { - Logger.LogWarning($"Skipped unknown file at path \"{partialPath}\""); + Logger.LogWarning("Skipped unknown file at path {Path}", partialPath); return false; } - Logger.LogInformation($"Found file \"{file.Id}\" at path \"{partialPath}\""); + Logger.LogInformation("Found episode {EpisodeName} (Series={SeriesId},Episode={EpisodeId},File={FileId}})", series.AniDB.Title, series.Id, episode.Id, file.Id); // We're going to post process this file later, but we don't want to include it in our library for now. if (episode.ExtraType != null) { - Logger.LogInformation($"File was assigned an extra type, so ignoring file with id \"{file.Id}\" at path \"{partialPath}\""); + Logger.LogInformation("Episode was assigned an extra type, ignoring episode. (Series={SeriesId},Episode={EpisodeId},File={FileId}})", series.AniDB.Title, series.Id, episode.Id, file.Id); ApiManager.MarkEpisodeAsIgnored(episode.Id, series.Id, fullPath); return true; } From 7c3bb7ca027281ff33abc86f3d3f5df2dfe0576a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 31 Aug 2021 19:40:34 +0200 Subject: [PATCH 0130/1103] Fix specials for Shoko Groups --- Shokofin/API/Info/SeriesInfo.cs | 16 +++++- Shokofin/API/ShokoAPIManager.cs | 44 ++++++++++++-- Shokofin/Configuration/configPage.html | 2 +- Shokofin/Providers/EpisodeProvider.cs | 79 +++++++++++++++++++++----- Shokofin/Providers/SeasonProvider.cs | 11 ++-- Shokofin/Providers/SeriesProvider.cs | 13 ++--- Shokofin/Utils/OrderingUtil.cs | 30 +++++++--- 7 files changed, 152 insertions(+), 43 deletions(-) diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index 1280c074..b7e22022 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -15,13 +15,27 @@ public class SeriesInfo /// <summary> /// All episodes (of all type) that belong to this series. + /// + /// Ordered by AniDb air-date. /// </summary> public List<EpisodeInfo> EpisodeList; + /// <summary> + /// The number of normal episodes in this series. + /// </summary> + public int EpisodeCount; + + /// <summary> + /// A dictionary holding mappings for the previous normal episode for every special episode in a series. + /// </summary> + public Dictionary<string, string> SpesialsAnchors; + /// <summary> /// A pre-filtered list of special episodes without an ExtraType /// attached. + /// + /// Ordered by AniDb episode number. /// </summary> - public List<EpisodeInfo> FilteredSpecialEpisodesList; + public List<EpisodeInfo> SpecialsList; } } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index da703ef2..d64e2836 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -397,18 +397,52 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = var aniDb = await ShokoAPI.GetSeriesAniDb(seriesId); var tvDbId = series.IDs.TvDB?.FirstOrDefault(); - var episodeList = await ShokoAPI.GetEpisodesFromSeries(seriesId) - .ContinueWith(async task => await Task.WhenAll(task.Result.Select(e => CreateEpisodeInfo(e)))).Unwrap() - .ContinueWith(l => l.Result.Where(s => s != null).ToList()); - var filteredSpecialEpisodesList = episodeList.Where(e => e.AniDB.Type == EpisodeType.Special && e.ExtraType == null).ToList(); + var episodeCount = 0; + Dictionary<string, string> filteredSpecialsMapping = new Dictionary<string, string>(); + List<EpisodeInfo> filteredSpecialsList = new List<EpisodeInfo>(); + + // The episode list is ordered by air date + var episodeList = ShokoAPI.GetEpisodesFromSeries(seriesId) + .ContinueWith(task => Task.WhenAll(task.Result.Select(e => CreateEpisodeInfo(e)))) + .Unwrap() + .GetAwaiter() + .GetResult() + .Where(e => e != null && e.Shoko != null && e.AniDB != null) + .OrderBy(e => e.AniDB.AirDate) + .ToList(); + + // Iterate over the episodes once and store some values for later use. + for (var index = 0; index > episodeList.Count; index++) { + var episode = episodeList[index]; + EpisodeIdToSeriesIdDictionary[episode.Id] = seriesId; + if (episode.AniDB.Type == EpisodeType.Normal) + episodeCount++; + else if (episode.AniDB.Type == EpisodeType.Special && episode.ExtraType == null) { + filteredSpecialsList.Add(episode); + var previousEpisode = episodeList + .GetRange(0, index) + .LastOrDefault(e => e.AniDB.Type == EpisodeType.Normal); + if (previousEpisode != null) + filteredSpecialsMapping[episode.Id] = previousEpisode.Id; + } + } + + // While the filtered specials list is ordered by episode number + filteredSpecialsList = filteredSpecialsList + .OrderBy(e => e.AniDB.EpisodeNumber) + .ToList(); + info = new SeriesInfo { Id = seriesId, Shoko = series, AniDB = aniDb, TvDBId = tvDbId != 0 ? tvDbId.ToString() : null, EpisodeList = episodeList, - FilteredSpecialEpisodesList = filteredSpecialEpisodesList, + EpisodeCount = episodeCount, + SpesialsAnchors = filteredSpecialsMapping, + SpecialsList = filteredSpecialsList, }; + DataCache.Set<SeriesInfo>(cacheKey, info, DefaultTimeSpan); return info; } diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 04a34b1b..c4cc58b9 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -86,7 +86,7 @@ <h3>Library Options</h3> </div> <label id="MarkSpecialsWhenGroupedItem" class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> - <span>Mark special episodes with <stong>SP</stong> and the special number in the seasons they belong to</span> + <span>Mark all other epsiode types besides "Normal" with a letter and the episode number</span> </label> <div class="selectContainer"> <label class="selectLabel" for="BoxSetGrouping">Box-set grouping</label> diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index ab7ea0ef..8d0c1197 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -43,31 +44,79 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell // if file is null then series and episode is also null. if (file == null) { - Logger.LogWarning($"Unable to find file info for path {info.Path}"); + Logger.LogWarning("Unable to find episode info for path {Path}", info.Path); return result; } - Logger.LogInformation($"Found file info for path {info.Path}"); string displayTitle, alternateTitle; if (series.AniDB.Type == API.Models.SeriesType.Movie) ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); else ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, episode.Shoko.Name, info.MetadataLanguage); - - if (group != null && episode.AniDB.Type != EpisodeType.Normal && config.MarkSpecialsWhenGrouped) { - displayTitle = $"SP {episode.AniDB.EpisodeNumber} {displayTitle}"; - alternateTitle = $"SP {episode.AniDB.EpisodeNumber} {alternateTitle}"; + Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId})", displayTitle, file.Id, episode.Id); + + var episodeNumber = Ordering.GetEpisodeNumber(group, series, episode); + var seasonNumber = Ordering.GetSeasonNumber(group, series, episode); + + if (group != null && config.MarkSpecialsWhenGrouped && episode.AniDB.Type != EpisodeType.Normal) switch (episode.AniDB.Type) { + case EpisodeType.Special: + displayTitle = $"S{episodeNumber} {displayTitle}"; + alternateTitle = $"S{episodeNumber} {alternateTitle}"; + break; + case EpisodeType.ThemeSong: + case EpisodeType.EndingSong: + case EpisodeType.OpeningSong: + displayTitle = $"C{episodeNumber} {displayTitle}"; + alternateTitle = $"C{episodeNumber} {alternateTitle}"; + break; + case EpisodeType.Trailer: + displayTitle = $"T{episodeNumber} {displayTitle}"; + alternateTitle = $"T{episodeNumber} {alternateTitle}"; + break; + case EpisodeType.Parody: + displayTitle = $"P{episodeNumber} {displayTitle}"; + alternateTitle = $"P{episodeNumber} {alternateTitle}"; + break; + case EpisodeType.Other: + displayTitle = $"O{episodeNumber} {displayTitle}"; + alternateTitle = $"O{episodeNumber} {alternateTitle}"; + break; + default: + displayTitle = $"U{episodeNumber} {displayTitle}"; + alternateTitle = $"U{episodeNumber} {alternateTitle}"; + break; } - result.Item = new Episode { - IndexNumber = Ordering.GetIndexNumber(series, episode), - ParentIndexNumber = Ordering.GetSeasonNumber(group, series, episode), - Name = displayTitle, - OriginalTitle = alternateTitle, - PremiereDate = episode.AniDB.AirDate, - Overview = Text.SanitizeTextSummary(episode.AniDB.Description), - CommunityRating = episode.AniDB.Rating.ToFloat(10), - }; + if (group != null && episode.AniDB.Type == EpisodeType.Special) { + int previousEpisodeNumber; + if (!series.SpesialsAnchors.TryGetValue(episode.Id, out var previousEpisode)) + previousEpisodeNumber = Ordering.GetEpisodeNumber(group, series, episode); + else + previousEpisodeNumber = series.EpisodeCount; + result.Item = new Episode { + IndexNumber = episodeNumber, + ParentIndexNumber = 0, + AirsAfterSeasonNumber = seasonNumber, + AirsBeforeEpisodeNumber = previousEpisodeNumber + 1, + AirsBeforeSeasonNumber = seasonNumber + 1, + Name = displayTitle, + OriginalTitle = alternateTitle, + PremiereDate = episode.AniDB.AirDate, + Overview = Text.SanitizeTextSummary(episode.AniDB.Description), + CommunityRating = episode.AniDB.Rating.ToFloat(10), + }; + } + else { + result.Item = new Episode { + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, + Name = displayTitle, + OriginalTitle = alternateTitle, + PremiereDate = episode.AniDB.AirDate, + Overview = Text.SanitizeTextSummary(episode.AniDB.Description), + CommunityRating = episode.AniDB.Rating.ToFloat(10), + }; + } // NOTE: This next line will remain here till they fix the series merging for providers outside the MetadataProvider enum. if (config.SeriesGrouping == Ordering.GroupType.ShokoGroup) result.Item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{episode.Id}"); diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 09bc5c66..45bb792a 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -34,6 +34,8 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat default: return GetDefaultMetadata(info, cancellationToken); case Ordering.GroupType.ShokoGroup: + if (info.IndexNumber.HasValue && info.IndexNumber.Value == 0) + goto default; return await GetShokoGroupedMetadata(info, cancellationToken); } } @@ -64,20 +66,19 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in { var result = new MetadataResult<Season>(); - if (!info.SeriesProviderIds.ContainsKey("Shoko Group")) { - Logger.LogWarning($"Shoko Group id not stored for series"); + if (!info.SeriesProviderIds.TryGetValue("Shoko Group", out var groupId)) { + Logger.LogWarning($"Unable refresh item, Shoko Group Id was not stored for Series."); return result; } - var groupId = info.SeriesProviderIds["Shoko Group"]; var seasonNumber = info.IndexNumber ?? 1; var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes; var series = await ApiManager.GetSeriesInfoFromGroup(groupId, seasonNumber, filterLibrary ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); if (series == null) { - Logger.LogWarning($"Unable to find series info for G{groupId}:S{seasonNumber}"); + Logger.LogWarning("Unable to find selected series in Group (Group={GroupId})", groupId); return result; } - Logger.LogInformation($"Found series info for G{groupId}:S{seasonNumber}"); + Logger.LogInformation("Found Series {SeriesName} (Group={GroupId},Series={SeriesId})", series.Shoko.Name, groupId, series.Id); var tags = await ApiManager.GetTags(series.Id); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 1209ed59..a5189b49 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -52,13 +52,13 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C var result = new MetadataResult<Series>(); var series = await ApiManager.GetSeriesInfoByPath(info.Path); if (series == null) { - Logger.LogWarning($"Unable to find series info for path {info.Path}"); + Logger.LogWarning("Unable to find group info for path {Path}", info.Path); return result; } - Logger.LogInformation($"Found series info for path {info.Path}"); var tags = await ApiManager.GetTags(series.Id); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); + Logger.LogInformation("Found series {SeriesName} (Series={SeriesId})", displayTitle, series.Id); result.Item = new Series { Name = displayTitle, @@ -93,16 +93,15 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes; var group = await ApiManager.GetGroupInfoByPath(info.Path, filterLibrary ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); if (group == null) { - Logger.LogWarning($"Unable to find series info for path {info.Path}"); + Logger.LogWarning("Unable to find group info for path {Path}", info.Path); return result; } - Logger.LogInformation($"Found series info for path {info.Path}"); var series = group.DefaultSeries; + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, group.Shoko.Name, info.MetadataLanguage); + Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, series.Id, group.Id); var tags = await ApiManager.GetTags(series.Id); - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); - result.Item = new Series { Name = displayTitle, OriginalTitle = alternateTitle, @@ -114,7 +113,7 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in Tags = tags, CommunityRating = series.AniDB.Rating.ToFloat(10), }; - // NOTE: This next line will remain here till they fix the series merging for providers outside the MetadataProvider enum. + // NOTE: This next line will remain here till they fix the series merging for providers outside the MetadataProvider enum. result.Item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{series.Id}"); result.Item.SetProviderId("Shoko Series", series.Id); result.Item.SetProviderId("Shoko Group", group.Id); diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 0d49d190..11e2a488 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -1,3 +1,4 @@ +using System.Linq; using Shokofin.API.Info; using Shokofin.API.Models; @@ -76,7 +77,7 @@ public static int GetMovieIndexNumber(GroupInfo group, SeriesInfo series, Episod var sizes = s.Shoko.Sizes.Total; if (s != series) { if (episode.AniDB.Type == EpisodeType.Special) { - var index = series.FilteredSpecialEpisodesList.FindIndex(e => string.Equals(e.Id, episode.Id)); + var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); if (index == -1) throw new System.IndexOutOfRangeException("Episode not in filtered list"); return offset - (index + 1); @@ -96,7 +97,7 @@ public static int GetMovieIndexNumber(GroupInfo group, SeriesInfo series, Episod } else { if (episode.AniDB.Type == EpisodeType.Special) { - offset -= series.FilteredSpecialEpisodesList.Count; + offset -= series.SpecialsList.Count; } offset += (sizes?.Episodes ?? 0) + (sizes?.Parodies ?? 0) + (sizes?.Others ?? 0); } @@ -111,7 +112,7 @@ public static int GetMovieIndexNumber(GroupInfo group, SeriesInfo series, Episod /// Get index number for an episode in a series. /// </summary> /// <returns>Absolute index.</returns> - public static int GetIndexNumber(SeriesInfo series, EpisodeInfo episode) + public static int GetEpisodeNumber(GroupInfo group, SeriesInfo series, EpisodeInfo episode) { switch (Plugin.Instance.Configuration.SeriesGrouping) { @@ -119,19 +120,23 @@ public static int GetIndexNumber(SeriesInfo series, EpisodeInfo episode) case GroupType.Default: return episode.AniDB.EpisodeNumber; case GroupType.MergeFriendly: { - var epNum = episode?.TvDB.Number ?? 0; - if (epNum == 0) + var episodeNumber = episode?.TvDB?.Number ?? 0; + if (episodeNumber == 0) goto case GroupType.Default; - return epNum; + return episodeNumber; } case GroupType.ShokoGroup: { + int offset = 0; if (episode.AniDB.Type == EpisodeType.Special) { - var index = series.FilteredSpecialEpisodesList.FindIndex(e => string.Equals(e.Id, episode.Id)); + var seriesIndex = group.SeriesList.FindIndex(s => string.Equals(s.Id, series.Id)); + if (seriesIndex == -1) + throw new System.IndexOutOfRangeException("Series is not part of the provided group"); + var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); if (index == -1) throw new System.IndexOutOfRangeException("Episode not in filtered list"); - return -(index + 1); + offset = group.SeriesList.GetRange(0, seriesIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); + return offset + (index + 1); } - int offset = 0; var sizes = series.Shoko.Sizes.Total; switch (episode.AniDB.Type) { case EpisodeType.Normal: @@ -143,6 +148,13 @@ public static int GetIndexNumber(SeriesInfo series, EpisodeInfo episode) case EpisodeType.Other: offset += sizes?.Parodies ?? 0; goto case EpisodeType.Parody; + // Add them to the bottom of the list if we didn't filter them out properly. + case EpisodeType.OpeningSong: + offset += sizes?.Others ?? 0; + goto case EpisodeType.Other; + case EpisodeType.Trailer: + offset += sizes?.Credits ?? 0; + goto case EpisodeType.OpeningSong; } return offset + episode.AniDB.EpisodeNumber; } From 1ec7038368437d32dd34784cd8d960b9123416c9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 31 Aug 2021 19:45:24 +0200 Subject: [PATCH 0131/1103] Add future methods and fix build because i'm stupid and work on multiple featues at the same time. --- Shokofin/API/ShokoAPIManager.cs | 53 ++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index d64e2836..e4cfdea1 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -71,7 +71,7 @@ public Folder FindMediaFolder(string path, Folder parent, Folder root) while (mediaFolder.ParentId.Equals(root.Id)) { if (mediaFolder.Parent == null) { if (mediaFolder.ParentId.Equals(Guid.Empty)) - break; + break; mediaFolder = LibraryManager.GetItemById(mediaFolder.ParentId) as Folder; continue; } @@ -97,6 +97,7 @@ public void Clear() { DataCache.Dispose(); MediaFolderList.Clear(); + EpisodeIdToSeriesIdDictionary.Clear(); SeriesIdToPathDictionary.Clear(); SeriesPathToIdDictionary.Clear(); SeriesIdToEpisodeIdDictionery.Clear(); @@ -229,6 +230,15 @@ private FileInfo CreateFileInfo(File file, string fileId = null, int episodeCoun #endregion #region Episode Info + public EpisodeInfo GetEpisodeInfoSync(string episodeId) + { + if (string.IsNullOrEmpty(episodeId)) + return null; + if (DataCache.TryGetValue<EpisodeInfo>($"episode:{episodeId}", out var info)) + return info; + return GetEpisodeInfo(episodeId).GetAwaiter().GetResult(); + } + public async Task<EpisodeInfo> GetEpisodeInfo(string episodeId) { if (string.IsNullOrEmpty(episodeId)) @@ -277,6 +287,11 @@ public bool MarkEpisodeAsFound(string episodeId, string seriesId) return (SeriesIdToEpisodeIdDictionery.ContainsKey(seriesId) ? SeriesIdToEpisodeIdDictionery[seriesId] : (SeriesIdToEpisodeIdDictionery[seriesId] = new HashSet<string>())).Add(episodeId); } + public bool IsEpisodeOnDisk(EpisodeInfo episode, SeriesInfo series) + { + return SeriesIdToEpisodeIdDictionery.ContainsKey(series.Id) && SeriesIdToEpisodeIdDictionery[series.Id].Contains(episode.Id); + } + private static ExtraType? GetExtraType(Episode.AniDB episode) { switch (episode.Type) @@ -382,6 +397,37 @@ public async Task<SeriesInfo> GetSeriesInfo(string seriesId) return await CreateSeriesInfo(series, seriesId); } + private static Dictionary<string, string> EpisodeIdToSeriesIdDictionary = new Dictionary<string, string>(); + + public SeriesInfo GetSeriesInfoForEpisodeSync(string episodeId) + { + if (EpisodeIdToSeriesIdDictionary.ContainsKey(episodeId)) { + var seriesId = EpisodeIdToSeriesIdDictionary[episodeId]; + if (DataCache.TryGetValue<SeriesInfo>($"series:{seriesId}", out var info)) + return info; + + return GetSeriesInfo(seriesId).GetAwaiter().GetResult(); + } + + return GetSeriesInfoForEpisode(episodeId).GetAwaiter().GetResult(); + } + + public async Task<SeriesInfo> GetSeriesInfoForEpisode(string episodeId) + { + string seriesId; + if (EpisodeIdToSeriesIdDictionary.ContainsKey(episodeId)) { + seriesId = EpisodeIdToSeriesIdDictionary[episodeId]; + } + else { + var group = await ShokoAPI.GetGroupFromSeries(episodeId); + if (group == null) + return null; + seriesId = group.IDs.ID.ToString(); + } + + return await GetSeriesInfo(seriesId); + } + private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = null) { if (series == null) @@ -455,6 +501,11 @@ public bool MarkSeriesAsFound(string seriesId, string groupId) return (GroupIdToSeriesIdDictionery.ContainsKey(groupId) ? GroupIdToSeriesIdDictionery[groupId] : (GroupIdToSeriesIdDictionery[groupId] = new HashSet<string>())).Add(seriesId); } + public bool IsSeriesOnDisk(SeriesInfo series, GroupInfo group) + { + var groupId = group?.Id ?? ""; + return GroupIdToSeriesIdDictionery.ContainsKey(groupId) && GroupIdToSeriesIdDictionery[groupId].Contains(series.Id); + } public string GetPathForSeries(string seriesId) { From f9aa58cff021f4843705b870a2a3a3984f0f42de Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 31 Aug 2021 17:46:05 +0000 Subject: [PATCH 0132/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 844845b6..b2ecfff1 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.6", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.6/shokofin_1.5.0.6.zip", + "checksum": "bec863c6fe9d16ee44452021dc20cfdc", + "timestamp": "2021-08-31T17:46:04Z" + }, { "version": "1.5.0.5", "changelog": "NA", From 8b93b3b07ce9303e218275453098b46e3e7b9f0f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 Sep 2021 18:04:52 +0200 Subject: [PATCH 0133/1103] Fix: forgot to change the id used for library seperation --- 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 c4cc58b9..f60a4908 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -105,7 +105,7 @@ <h3>Library Options</h3> </select> </div> <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="SeperateLibraries" /> + <input is="emby-checkbox" type="checkbox" id="FilterOnLibraryTypes" /> <span>Disallow overlap of Movies and Series library types</span> </label> <label class="checkboxContainer"> @@ -181,7 +181,7 @@ <h3>Tag Options</h3> document.querySelector('#BoxSetGrouping').value = config.BoxSetGrouping; document.querySelector('#MovieOrdering').value = config.MovieOrdering; document.querySelector('#MarkSpecialsWhenGrouped').checked = config.MarkSpecialsWhenGrouped; - document.querySelector('#SeperateLibraries').checked = config.SeperateLibraries; + document.querySelector('#FilterOnLibraryTypes').checked = config.FilterOnLibraryTypes; document.querySelector('#AddAniDBId').checked = config.AddAniDBId; document.querySelector('#AddTvDBId').checked = config.AddTvDBId; if (config.SeriesGrouping === "ShokoGroup") { @@ -252,7 +252,7 @@ <h3>Tag Options</h3> config.BoxSetGrouping = document.querySelector('#BoxSetGrouping').value; config.MovieOrdering = document.querySelector('#MovieOrdering').value; config.MarkSpecialsWhenGrouped = document.querySelector('#MarkSpecialsWhenGrouped').checked; - config.SeperateLibraries = document.querySelector('#SeperateLibraries').checked; + config.FilterOnLibraryTypes = document.querySelector('#FilterOnLibraryTypes').checked; config.AddAniDBId = document.querySelector('#AddAniDBId').checked; config.AddTvDBId = document.querySelector('#AddTvDBId').checked; ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); From 1c62c53edb78c86f24c8ec4c29f70c7bbb3cec72 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 Sep 2021 18:05:19 +0200 Subject: [PATCH 0134/1103] Cleanup: Series id is not used for movies anymore --- Shokofin/ExternalIds.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/ExternalIds.cs b/Shokofin/ExternalIds.cs index d87e38b2..b908838a 100644 --- a/Shokofin/ExternalIds.cs +++ b/Shokofin/ExternalIds.cs @@ -27,7 +27,7 @@ public string UrlFormatString public class ShokoSeriesExternalId : IExternalId { public bool Supports(IHasProviderIds item) - => item is Series || item is Movie || item is BoxSet; + => item is Series || item is BoxSet; public string ProviderName => "Shoko Series"; From 0a23ecc5e5cdade58299e6fa70982e302ebce4f5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 Sep 2021 19:05:30 +0200 Subject: [PATCH 0135/1103] Feature: Allow using TvDb as a source for descriptions --- Shokofin/API/Info/SeriesInfo.cs | 2 + Shokofin/API/ShokoAPI.cs | 8 ++- Shokofin/API/ShokoAPIManager.cs | 3 +- Shokofin/Configuration/PluginConfiguration.cs | 4 ++ Shokofin/Configuration/configPage.html | 10 +++ Shokofin/Providers/BoxSetProvider.cs | 4 +- Shokofin/Providers/EpisodeProvider.cs | 5 +- Shokofin/Providers/MovieProvider.cs | 2 +- Shokofin/Providers/SeasonProvider.cs | 2 +- Shokofin/Providers/SeriesProvider.cs | 4 +- Shokofin/Utils/TextUtil.cs | 63 ++++++++++++++++++- 11 files changed, 96 insertions(+), 11 deletions(-) diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index b7e22022..8e468401 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -13,6 +13,8 @@ public class SeriesInfo public string TvDBId; + public Series.TvDB TvDB; + /// <summary> /// All episodes (of all type) that belong to this series. /// diff --git a/Shokofin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPI.cs index a202c53e..d0c9ed53 100644 --- a/Shokofin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPI.cs @@ -141,12 +141,18 @@ public static async Task<List<Series>> GetSeriesInGroup(string id) return responseStream != null ? await JsonSerializer.DeserializeAsync<List<Series>>(responseStream) : null; } - public static async Task<Series.AniDB> GetSeriesAniDb(string id) + public static async Task<Series.AniDB> GetSeriesAniDB(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/AniDB"); return responseStream != null ? await JsonSerializer.DeserializeAsync<Series.AniDB>(responseStream) : null; } + public static async Task<IEnumerable<Series.TvDB>> GetSeriesTvDB(string id) + { + var responseStream = await CallApi($"/api/v3/Series/{id}/TvDB"); + return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Series.TvDB>>(responseStream) : null; + } + public static async Task<IEnumerable<Role>> GetSeriesCast(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/Cast"); diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index e4cfdea1..651013b8 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -441,7 +441,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = if (DataCache.TryGetValue<SeriesInfo>(cacheKey, out info)) return info; - var aniDb = await ShokoAPI.GetSeriesAniDb(seriesId); + var aniDb = await ShokoAPI.GetSeriesAniDB(seriesId); var tvDbId = series.IDs.TvDB?.FirstOrDefault(); var episodeCount = 0; Dictionary<string, string> filteredSpecialsMapping = new Dictionary<string, string>(); @@ -483,6 +483,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = Shoko = series, AniDB = aniDb, TvDBId = tvDbId != 0 ? tvDbId.ToString() : null, + TvDB = tvDbId != 0 ? (await ShokoAPI.GetSeriesTvDB(seriesId)).FirstOrDefault() : null, EpisodeList = episodeList, EpisodeCount = episodeCount, SpesialsAnchors = filteredSpecialsMapping, diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 1efaf253..b50370c9 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,4 +1,5 @@ using MediaBrowser.Model.Plugins; +using TextSourceType = Shokofin.Utils.Text.TextSourceType; using DisplayLanguageType = Shokofin.Utils.Text.DisplayLanguageType; using SeriesAndBoxSetGroupType = Shokofin.Utils.Ordering.GroupType; using OrderType = Shokofin.Utils.Ordering.OrderType; @@ -39,6 +40,8 @@ public class PluginConfiguration : BasePluginConfiguration public bool AddTvDBId { get; set; } + public TextSourceType DescriptionSource { get; set; } + public SeriesAndBoxSetGroupType SeriesGrouping { get; set; } public OrderType SeasonOrdering { get; set; } @@ -77,6 +80,7 @@ public PluginConfiguration() AddTvDBId = false; TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; + DescriptionSource = TextSourceType.Default; SeriesGrouping = SeriesAndBoxSetGroupType.Default; SeasonOrdering = OrderType.Default; MarkSpecialsWhenGrouped = true; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index f60a4908..79ce40d6 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -50,6 +50,14 @@ <h3>Title Options</h3> <div class="fieldDescription">Titles will fallback to Default if not found for the target language.</div> </div> <h3>Synopsis Options</h3> + <div class="selectContainer"> + <label class="selectLabel" for="DescriptionSource">Synopsis source</label> + <select is="emby-select" id="DescriptionSource" name="DescriptionSource" class="emby-select-withcolor emby-select"> + <option value="Default">Use default source</option> + <option value="AllowOthers">Prefer TvDB/TMDB if available, otherwise use AniDb</option> + <option value="OnlyAniDb">Only use AniDb</option> + </select> + </div> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="SynopsisCleanLinks" /> <span>Remove links</span> @@ -176,6 +184,7 @@ <h3>Tag Options</h3> document.querySelector('#SynopsisCleanMultiEmptyLines').checked = config.SynopsisCleanMultiEmptyLines; document.querySelector('#TitleMainType').value = config.TitleMainType; document.querySelector('#TitleAlternateType').value = config.TitleAlternateType; + document.querySelector('#DescriptionSource').value = config.DescriptionSource; document.querySelector('#SeriesGrouping').value = config.SeriesGrouping; document.querySelector('#SeasonOrdering').value = config.SeasonOrdering; document.querySelector('#BoxSetGrouping').value = config.BoxSetGrouping; @@ -247,6 +256,7 @@ <h3>Tag Options</h3> config.SynopsisCleanMultiEmptyLines = document.querySelector('#SynopsisCleanMultiEmptyLines').checked; config.TitleMainType = document.querySelector('#TitleMainType').value; config.TitleAlternateType = document.querySelector('#TitleAlternateType').value; + config.DescriptionSource = document.querySelector('#DescriptionSource').value; config.SeriesGrouping = document.querySelector('#SeriesGrouping').value; config.SeasonOrdering = document.querySelector('#SeasonOrdering').value; config.BoxSetGrouping = document.querySelector('#BoxSetGrouping').value; diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 94d1eee7..b018fec9 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -70,7 +70,7 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, Ca result.Item = new BoxSet { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.SanitizeTextSummary(series.AniDB.Description), + Overview = Text.GetDescription(series), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, @@ -109,7 +109,7 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in result.Item = new BoxSet { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.SanitizeTextSummary(series.AniDB.Description), + Overview = Text.GetDescription(series), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 8d0c1197..c566d8a2 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -57,6 +57,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell var episodeNumber = Ordering.GetEpisodeNumber(group, series, episode); var seasonNumber = Ordering.GetSeasonNumber(group, series, episode); + var description = Text.GetDescription(episode); if (group != null && config.MarkSpecialsWhenGrouped && episode.AniDB.Type != EpisodeType.Normal) switch (episode.AniDB.Type) { case EpisodeType.Special: @@ -102,7 +103,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, - Overview = Text.SanitizeTextSummary(episode.AniDB.Description), + Overview = description, CommunityRating = episode.AniDB.Rating.ToFloat(10), }; } @@ -113,7 +114,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, - Overview = Text.SanitizeTextSummary(episode.AniDB.Description), + Overview = description, CommunityRating = episode.AniDB.Rating.ToFloat(10), }; } diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 1ca81f56..0a2ab462 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -59,7 +59,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, // Use the file description if collection contains more than one movie, otherwise use the collection description. - Overview = Text.SanitizeTextSummary((isMultiEntry ? episode.AniDB.Description ?? series.AniDB.Description : series.AniDB.Description) ?? ""), + Overview = (isMultiEntry ? Text.GetDescription(episode) : Text.GetDescription(series)), ProductionYear = episode.AniDB.AirDate?.Year, Tags = tags, CommunityRating = rating, diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 45bb792a..d91fe1b6 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -87,7 +87,7 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in Name = displayTitle, OriginalTitle = alternateTitle, IndexNumber = seasonNumber, - Overview = Text.SanitizeTextSummary(series.AniDB.Description), + Overview = Text.GetDescription(series), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index a5189b49..0412d843 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -63,7 +63,7 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C result.Item = new Series { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.SanitizeTextSummary(series.AniDB.Description), + Overview = Text.GetDescription(series), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, @@ -105,7 +105,7 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in result.Item = new Series { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.SanitizeTextSummary(series.AniDB.Description), + Overview = Text.GetDescription(series), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index 26c50e30..7587b6a9 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -1,3 +1,4 @@ +using Shokofin.API.Info; using Shokofin.API.Models; using System.Collections.Generic; using System.Linq; @@ -8,6 +9,26 @@ namespace Shokofin.Utils { public class Text { + /// <summary> + /// Where to get text the text from. + /// </summary> + public enum TextSourceType { + /// <summary> + /// Use the default. + /// </summary> + Default = 1, + + /// <summary> + /// Only use AniDb. + /// </summary> + OnlyAniDb = 2, + + /// <summary> + /// Allow other providers, like TvDB/TMDB. + /// </summary> + AllowOthers = 3, + } + /// <summary> /// Determines the language to construct the title in. /// </summary> @@ -55,12 +76,52 @@ public enum DisplayTitleType { FullTitle = 3, } + public static string GetDescription(EpisodeInfo episode) + { + string overview; + switch (Plugin.Instance.Configuration.DescriptionSource) { + default: + case Text.TextSourceType.Default: + case Text.TextSourceType.AllowOthers: + if (episode.TvDB != null) + goto case Text.TextSourceType.OnlyAniDb; + overview = episode.TvDB.Description; + if (string.IsNullOrEmpty(overview)) + goto case Text.TextSourceType.OnlyAniDb; + break; + case Text.TextSourceType.OnlyAniDb: + overview = Text.SanitizeTextSummary(episode.AniDB.Description); + break; + } + return overview; + } + + public static string GetDescription(SeriesInfo series) + { + string overview; + switch (Plugin.Instance.Configuration.DescriptionSource) { + default: + case Text.TextSourceType.Default: + case Text.TextSourceType.AllowOthers: + if (series.TvDB != null) + goto case Text.TextSourceType.OnlyAniDb; + overview = series.TvDB.Description; + if (string.IsNullOrEmpty(overview)) + goto case Text.TextSourceType.OnlyAniDb; + break; + case Text.TextSourceType.OnlyAniDb: + overview = Text.SanitizeTextSummary(series.AniDB.Description); + break; + } + return overview; + } + /// <summary> /// Based on ShokoMetadata's summary sanitizer which in turn is based on HAMA's summary sanitizer. /// </summary> /// <param name="summary">The raw AniDB summary</param> /// <returns>The sanitized AniDB summary</returns> - public static string SanitizeTextSummary(string summary) + private static string SanitizeTextSummary(string summary) { var config = Plugin.Instance.Configuration; From 9793f7d1b5c84a54884d1b85a17c2a78d21ad670 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 Sep 2021 19:12:33 +0200 Subject: [PATCH 0136/1103] Feature: add a toggle to preferring AniDB posters --- Shokofin/Configuration/PluginConfiguration.cs | 3 +++ Shokofin/Configuration/configPage.html | 7 +++++++ Shokofin/Providers/ImageProvider.cs | 5 ++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index b50370c9..c18cfe05 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -40,6 +40,8 @@ public class PluginConfiguration : BasePluginConfiguration public bool AddTvDBId { get; set; } + public bool PreferAniDbPoster { get; set; } + public TextSourceType DescriptionSource { get; set; } public SeriesAndBoxSetGroupType SeriesGrouping { get; set; } @@ -78,6 +80,7 @@ public PluginConfiguration() SynopsisCleanMultiEmptyLines = true; AddAniDBId = true; AddTvDBId = false; + PreferAniDbPoster = true; TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; DescriptionSource = TextSourceType.Default; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 79ce40d6..fb9ebc83 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -74,6 +74,11 @@ <h3>Synopsis Options</h3> <input is="emby-checkbox" type="checkbox" id="SynopsisCleanMultiEmptyLines" /> <span>Collapse excessive empty lines</span> </label> + <h3>Image Options</h3> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="PreferAniDbPoster" /> + <span>Prefer poster image from AniDb for Series/Seasons</span> + </label> <h3>Library Options</h3> <div class="selectContainer"> <label class="selectLabel" for="SeriesGrouping">Series grouping</label> @@ -193,6 +198,7 @@ <h3>Tag Options</h3> document.querySelector('#FilterOnLibraryTypes').checked = config.FilterOnLibraryTypes; document.querySelector('#AddAniDBId').checked = config.AddAniDBId; document.querySelector('#AddTvDBId').checked = config.AddTvDBId; + document.querySelector('#PreferAniDbPoster').checked = config.PreferAniDbPoster; if (config.SeriesGrouping === "ShokoGroup") { document.querySelector('#SG_ShokoGroup_Warning').removeAttribute("hidden"); document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); @@ -265,6 +271,7 @@ <h3>Tag Options</h3> config.FilterOnLibraryTypes = document.querySelector('#FilterOnLibraryTypes').checked; config.AddAniDBId = document.querySelector('#AddAniDBId').checked; config.AddTvDBId = document.querySelector('#AddTvDBId').checked; + config.PreferAniDbPoster = document.querySelector('#PreferAniDbPoster').checked; ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); }); e.preventDefault(); diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index d46007a8..50fd2593 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -60,9 +60,12 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (series != null) { Logger.LogInformation($"Getting series images ({series.Id} - {item.Name})"); var images = series.Shoko.Images; - AddImage(ref list, ImageType.Primary, series.AniDB.Poster); + if (Plugin.Instance.Configuration.PreferAniDbPoster) + AddImage(ref list, ImageType.Primary, series.AniDB.Poster); foreach (var image in images?.Posters) AddImage(ref list, ImageType.Primary, image); + if (!Plugin.Instance.Configuration.PreferAniDbPoster) + AddImage(ref list, ImageType.Primary, series.AniDB.Poster); foreach (var image in images?.Fanarts) AddImage(ref list, ImageType.Backdrop, image); foreach (var image in images?.Banners) From ff6e061490f439f4a66d7e5c11276bddc61be1ab Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 Sep 2021 19:13:45 +0200 Subject: [PATCH 0137/1103] Cleanup: Update log messages --- Shokofin/LibraryScanner.cs | 4 ++-- Shokofin/Providers/EpisodeProvider.cs | 2 +- Shokofin/Providers/MovieProvider.cs | 7 ++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index 28a78bbf..33ac9b34 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -78,7 +78,7 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy Logger.LogWarning("Skipped unknown folder at path {Path}", partialPath); return false; } - Logger.LogInformation("Found series {SeriesName} (Series={SeriesId})", series.AniDB.Title, series.Id); + Logger.LogInformation("Found series {SeriesName} (Series={SeriesId})", series.Shoko.Name, series.Id); // Filter library if we enabled the option. if (Plugin.Instance.Configuration.FilterOnLibraryTypes) switch (libraryType) { @@ -123,7 +123,7 @@ private bool ScanFile(string partialPath, string fullPath) Logger.LogWarning("Skipped unknown file at path {Path}", partialPath); return false; } - Logger.LogInformation("Found episode {EpisodeName} (Series={SeriesId},Episode={EpisodeId},File={FileId}})", series.AniDB.Title, series.Id, episode.Id, file.Id); + Logger.LogInformation("Found episode {EpisodeName} (Series={SeriesId},Episode={EpisodeId},File={FileId}})", series.Shoko.Name, series.Id, episode.Id, file.Id); // We're going to post process this file later, but we don't want to include it in our library for now. if (episode.ExtraType != null) { diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index c566d8a2..c3747b83 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -53,7 +53,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); else ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, episode.Shoko.Name, info.MetadataLanguage); - Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId})", displayTitle, file.Id, episode.Id); + Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", displayTitle, file.Id, episode.Id, series.Id); var episodeNumber = Ordering.GetEpisodeNumber(group, series, episode); var seasonNumber = Ordering.GetSeasonNumber(group, series, episode); diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 0a2ab462..77a792ad 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -43,14 +43,15 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio // if file is null then series and episode is also null. if (file == null) { - Logger.LogWarning($"Unable to find file info for path {info.Path}"); + Logger.LogWarning("Unable to find movie info for path {Path}", info.Path); return result; } - bool isMultiEntry = series.Shoko.Sizes.Total.Episodes > 1; + var ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); + Logger.LogInformation("Found movie {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", displayTitle, file.Id, episode.Id, series.Id); var tags = await ApiManager.GetTags(series.Id); - var ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); + bool isMultiEntry = series.Shoko.Sizes.Total.Episodes > 1; var rating = isMultiEntry ? episode.AniDB.Rating.ToFloat(10) : series.AniDB.Rating.ToFloat(10); result.Item = new Movie { From 627c3b716c84d433aaed6d2932761946b35cf57c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 Sep 2021 19:14:22 +0200 Subject: [PATCH 0138/1103] Fix: fix season sort order for grouped series or at least attempt to. --- Shokofin/Providers/SeasonProvider.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index d91fe1b6..0d71d91d 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -82,11 +82,14 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in var tags = await ApiManager.GetTags(series.Id); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); + var sortTitle = $"S{seasonNumber} - {series.Shoko.Name}"; result.Item = new Season { Name = displayTitle, OriginalTitle = alternateTitle, IndexNumber = seasonNumber, + SortName = sortTitle, + ForcedSortName = sortTitle, Overview = Text.GetDescription(series), PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, From 25e2bc950cfdbcbeec5f436876c7b535fd0ff7d1 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 1 Sep 2021 17:15:06 +0000 Subject: [PATCH 0139/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index b2ecfff1..54d699b4 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.7", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.7/shokofin_1.5.0.7.zip", + "checksum": "8e976e353733943b5b2fcc7ae9d1a4ea", + "timestamp": "2021-09-01T17:15:05Z" + }, { "version": "1.5.0.6", "changelog": "NA", From 888479e70ae066ef16cff3b35d5ce9987793c677 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 Sep 2021 20:29:37 +0200 Subject: [PATCH 0140/1103] Fix: fix the description getter --- Shokofin/Utils/TextUtil.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index 7587b6a9..680673ed 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -83,9 +83,7 @@ public static string GetDescription(EpisodeInfo episode) default: case Text.TextSourceType.Default: case Text.TextSourceType.AllowOthers: - if (episode.TvDB != null) - goto case Text.TextSourceType.OnlyAniDb; - overview = episode.TvDB.Description; + overview = episode.TvDB?.Description ?? ""; if (string.IsNullOrEmpty(overview)) goto case Text.TextSourceType.OnlyAniDb; break; @@ -103,9 +101,7 @@ public static string GetDescription(SeriesInfo series) default: case Text.TextSourceType.Default: case Text.TextSourceType.AllowOthers: - if (series.TvDB != null) - goto case Text.TextSourceType.OnlyAniDb; - overview = series.TvDB.Description; + overview = series.TvDB?.Description ?? ""; if (string.IsNullOrEmpty(overview)) goto case Text.TextSourceType.OnlyAniDb; break; From 6c7cfc4b4c50d3c35c79f7a25009e85ad8ed3343 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 Sep 2021 20:31:10 +0200 Subject: [PATCH 0141/1103] Fix and update episode type indicators --- Shokofin/Configuration/configPage.html | 2 +- Shokofin/Providers/EpisodeProvider.cs | 10 +++++----- Shokofin/Providers/SeasonProvider.cs | 2 ++ Shokofin/Utils/OrderingUtil.cs | 17 +++++++++++++---- 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index fb9ebc83..2a6bd311 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -99,7 +99,7 @@ <h3>Library Options</h3> </div> <label id="MarkSpecialsWhenGroupedItem" class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> - <span>Mark all other epsiode types besides "Normal" with a letter and the episode number</span> + <span>Excluding normal episodes, add type and number to title (e.g. "S1 title", "C1 title", "O1 title")</span> </label> <div class="selectContainer"> <label class="selectLabel" for="BoxSetGrouping">Box-set grouping</label> diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index c3747b83..b5fd7cd3 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -78,14 +78,14 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell displayTitle = $"P{episodeNumber} {displayTitle}"; alternateTitle = $"P{episodeNumber} {alternateTitle}"; break; - case EpisodeType.Other: - displayTitle = $"O{episodeNumber} {displayTitle}"; - alternateTitle = $"O{episodeNumber} {alternateTitle}"; - break; - default: + case EpisodeType.Unknown: displayTitle = $"U{episodeNumber} {displayTitle}"; alternateTitle = $"U{episodeNumber} {alternateTitle}"; break; + default: + displayTitle = $"O{episodeNumber} {displayTitle}"; + alternateTitle = $"O{episodeNumber} {alternateTitle}"; + break; } if (group != null && episode.AniDB.Type == EpisodeType.Special) { diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 0d71d91d..e73368e0 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -130,6 +130,8 @@ private string GetSeasonName(string season) case "Season 99": return "Trailers"; case "Season 98": + return "Others"; + case "Season 97": return "Misc."; default: return season; diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 11e2a488..8fed647b 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -89,9 +89,16 @@ public static int GetMovieIndexNumber(GroupInfo group, SeriesInfo series, Episod case EpisodeType.Parody: offset += sizes?.Episodes ?? 0; goto case EpisodeType.Normal; - case EpisodeType.Other: + case EpisodeType.Unknown: offset += sizes?.Parodies ?? 0; goto case EpisodeType.Parody; + // Add them to the bottom of the list if we didn't filter them out properly. + case EpisodeType.OpeningSong: + offset += sizes?.Others ?? 0; + goto case EpisodeType.Unknown; + case EpisodeType.Trailer: + offset += sizes?.Credits ?? 0; + goto case EpisodeType.OpeningSong; } return offset + episode.AniDB.EpisodeNumber; } @@ -145,13 +152,13 @@ public static int GetEpisodeNumber(GroupInfo group, SeriesInfo series, EpisodeIn case EpisodeType.Parody: offset += sizes?.Episodes ?? 0; goto case EpisodeType.Normal; - case EpisodeType.Other: + case EpisodeType.Unknown: offset += sizes?.Parodies ?? 0; goto case EpisodeType.Parody; // Add them to the bottom of the list if we didn't filter them out properly. case EpisodeType.OpeningSong: offset += sizes?.Others ?? 0; - goto case EpisodeType.Other; + goto case EpisodeType.Unknown; case EpisodeType.Trailer: offset += sizes?.Credits ?? 0; goto case EpisodeType.OpeningSong; @@ -178,12 +185,14 @@ public static int GetSeasonNumber(GroupInfo group, SeriesInfo series, EpisodeInf return 1; case EpisodeType.Special: return 0; + case EpisodeType.Unknown: + return 98; case EpisodeType.Trailer: return 99; case EpisodeType.ThemeSong: return 100; default: - return 98; + return 97; } case GroupType.MergeFriendly: { var seasonNumber = episode?.TvDB?.Season; From ca8763264a5120e481301d37ee35483ddd07a5ea Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 1 Sep 2021 18:31:57 +0000 Subject: [PATCH 0142/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 54d699b4..5ed3db7f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.8", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.8/shokofin_1.5.0.8.zip", + "checksum": "ab88a37687332b28c7f5f817aeb40ffb", + "timestamp": "2021-09-01T18:31:55Z" + }, { "version": "1.5.0.7", "changelog": "NA", From 19a32e26cb726afb473e56a61e3705f3ab387327 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 3 Sep 2021 01:16:20 +0200 Subject: [PATCH 0143/1103] Use negative indexes for misc. seasons when using the default series grouping, and select season name based on index and not name. --- Shokofin/Providers/SeasonProvider.cs | 16 ++++++++-------- Shokofin/Utils/OrderingUtil.cs | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index e73368e0..3ab4d097 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -49,7 +49,7 @@ private MetadataResult<Season> GetDefaultMetadata(SeasonInfo info, CancellationT { var result = new MetadataResult<Season>(); - var seasonName = GetSeasonName(info.Name); + var seasonName = GetSeasonName(info.IndexNumber, info.Name); result.Item = new Season { Name = seasonName, IndexNumber = info.IndexNumber, @@ -122,19 +122,19 @@ public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } - private string GetSeasonName(string season) + private string GetSeasonName(int? seasonNumber, string seasonName) { - switch (season) { - case "Season 100": + switch (seasonNumber ?? 1) { + case -127: return "Credits"; - case "Season 99": + case -126: return "Trailers"; - case "Season 98": + case -125: return "Others"; - case "Season 97": + case -124: return "Misc."; default: - return season; + return seasonName; } } } diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 8fed647b..f0f720c5 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -186,13 +186,13 @@ public static int GetSeasonNumber(GroupInfo group, SeriesInfo series, EpisodeInf case EpisodeType.Special: return 0; case EpisodeType.Unknown: - return 98; + return -125; case EpisodeType.Trailer: - return 99; + return -126; case EpisodeType.ThemeSong: - return 100; + return -127; default: - return 97; + return -124; } case GroupType.MergeFriendly: { var seasonNumber = episode?.TvDB?.Season; From 2690d64a969544f9455d07d985377e623101a24c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 3 Sep 2021 01:17:49 +0200 Subject: [PATCH 0144/1103] Add debug statements to manager --- Shokofin/API/ShokoAPIManager.cs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 651013b8..baab948b 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -95,6 +95,7 @@ public string StripMediaFolder(string fullPath) public void Clear() { + Logger.LogDebug("Clearing data."); DataCache.Dispose(); MediaFolderList.Clear(); EpisodeIdToSeriesIdDictionary.Clear(); @@ -166,6 +167,7 @@ private int GetTagFilter() public async Task<(FileInfo, EpisodeInfo, SeriesInfo, GroupInfo)> GetFileInfoByPath(string path, Ordering.GroupFilterType? filterGroupByType) { var partialPath = StripMediaFolder(path); + Logger.LogDebug("Looking for file matching {Path}", partialPath); var result = await ShokoAPI.GetFileByPath(partialPath); var file = result?.FirstOrDefault(); @@ -217,6 +219,7 @@ private FileInfo CreateFileInfo(File file, string fileId = null, int episodeCoun FileInfo info = null; if (DataCache.TryGetValue<FileInfo>(cacheKey, out info)) return info; + Logger.LogDebug("Creating info object for file. (File={FileId})", fileId); info = new FileInfo { Id = fileId, @@ -259,6 +262,7 @@ private async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string episod EpisodeInfo info = null; if (DataCache.TryGetValue<EpisodeInfo>(cacheKey, out info)) return info; + Logger.LogDebug("Creating info object for episode {EpisodeName}. (Episode={EpisodeId})", episode.Name, episodeId); var aniDB = (await ShokoAPI.GetEpisodeAniDb(episodeId)); info = new EpisodeInfo { @@ -339,19 +343,20 @@ public SeriesInfo GetSeriesInfoByPathSync(string path) public async Task<SeriesInfo> GetSeriesInfoByPath(string path) { var partialPath = StripMediaFolder(path); + Logger.LogDebug("Looking for series matching {Path}", partialPath); string seriesId; - if (SeriesPathToIdDictionary.ContainsKey(path)) + if (SeriesPathToIdDictionary.ContainsKey(partialPath)) { - seriesId = SeriesPathToIdDictionary[path]; + seriesId = SeriesPathToIdDictionary[partialPath]; } else { var result = await ShokoAPI.GetSeriesPathEndsWith(partialPath); seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); - SeriesPathToIdDictionary[path] = seriesId; + SeriesPathToIdDictionary[partialPath] = seriesId; if (!string.IsNullOrEmpty(seriesId)) - SeriesIdToPathDictionary[seriesId] = path; + SeriesIdToPathDictionary[seriesId] = partialPath; } if (string.IsNullOrEmpty(seriesId)) @@ -440,6 +445,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = var cacheKey = $"series:{seriesId}"; if (DataCache.TryGetValue<SeriesInfo>(cacheKey, out info)) return info; + Logger.LogDebug("Creating info object for series {SeriesName}. (Series={SeriesId})", series.Name, seriesId); var aniDb = await ShokoAPI.GetSeriesAniDB(seriesId); var tvDbId = series.IDs.TvDB?.FirstOrDefault(); @@ -524,6 +530,7 @@ public GroupInfo GetGroupInfoByPathSync(string path, Ordering.GroupFilterType fi public async Task<GroupInfo> GetGroupInfoByPath(string path, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) { var partialPath = StripMediaFolder(path); + Logger.LogDebug("Looking for group matching {Path}", partialPath); var result = await ShokoAPI.GetSeriesPathEndsWith(partialPath); var seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); @@ -592,6 +599,7 @@ private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Order GroupInfo groupInfo = null; if (DataCache.TryGetValue<GroupInfo>(cacheKey, out groupInfo)) return groupInfo; + Logger.LogDebug("Creating info object for group {GroupName}. (Group={GroupId})", group.Name, groupId); var seriesList = await ShokoAPI.GetSeriesInGroup(groupId) .ContinueWith(async task => await Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s)))).Unwrap() From d9c0e215cb22f1a3ed47083f82d8dcbab3824944 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 2 Sep 2021 23:18:49 +0000 Subject: [PATCH 0145/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 5ed3db7f..9fe9fae3 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.9", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.9/shokofin_1.5.0.9.zip", + "checksum": "782e0312a2953b61e817ca020ecf58a8", + "timestamp": "2021-09-02T23:18:47Z" + }, { "version": "1.5.0.8", "changelog": "NA", From 64d1526169f2172f2f13ba95d597a051b57f2fe4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 5 Sep 2021 16:57:07 +0200 Subject: [PATCH 0146/1103] Better align season numbers with SM Better align season number with Shoko Metadata for Default/Merge Friendly series sorting. --- Shokofin/Providers/SeasonProvider.cs | 8 ++++---- Shokofin/Utils/OrderingUtil.cs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 3ab4d097..8e6d1aac 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -125,13 +125,13 @@ public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken private string GetSeasonName(int? seasonNumber, string seasonName) { switch (seasonNumber ?? 1) { - case -127: + case -1: return "Credits"; - case -126: + case -2: return "Trailers"; - case -125: + case -3: return "Others"; - case -124: + case -4: return "Misc."; default: return seasonName; diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index f0f720c5..24b6ed0f 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -186,13 +186,13 @@ public static int GetSeasonNumber(GroupInfo group, SeriesInfo series, EpisodeInf case EpisodeType.Special: return 0; case EpisodeType.Unknown: - return -125; + return -3; case EpisodeType.Trailer: - return -126; + return -2; case EpisodeType.ThemeSong: - return -127; + return -1; default: - return -124; + return -4; } case GroupType.MergeFriendly: { var seasonNumber = episode?.TvDB?.Season; From 5158df71b5c934f9886cf9a3911396c2c400dd4c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 5 Sep 2021 17:03:50 +0200 Subject: [PATCH 0147/1103] Adda catch-all for unsupported episode types + fix messages Adda a catch-all for unsupported episode types in the ordering utility and tweak the error messages. --- Shokofin/Utils/OrderingUtil.cs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 24b6ed0f..839aee62 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -79,7 +79,7 @@ public static int GetMovieIndexNumber(GroupInfo group, SeriesInfo series, Episod if (episode.AniDB.Type == EpisodeType.Special) { var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); if (index == -1) - throw new System.IndexOutOfRangeException("Episode not in filtered list"); + throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); return offset - (index + 1); } switch (episode.AniDB.Type) { @@ -99,6 +99,9 @@ public static int GetMovieIndexNumber(GroupInfo group, SeriesInfo series, Episod case EpisodeType.Trailer: offset += sizes?.Credits ?? 0; goto case EpisodeType.OpeningSong; + default: + offset += sizes?.Trailers ?? 0; + goto case EpisodeType.Trailer; } return offset + episode.AniDB.EpisodeNumber; } @@ -137,10 +140,10 @@ public static int GetEpisodeNumber(GroupInfo group, SeriesInfo series, EpisodeIn if (episode.AniDB.Type == EpisodeType.Special) { var seriesIndex = group.SeriesList.FindIndex(s => string.Equals(s.Id, series.Id)); if (seriesIndex == -1) - throw new System.IndexOutOfRangeException("Series is not part of the provided group"); + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); if (index == -1) - throw new System.IndexOutOfRangeException("Episode not in filtered list"); + throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); offset = group.SeriesList.GetRange(0, seriesIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); return offset + (index + 1); } @@ -162,6 +165,9 @@ public static int GetEpisodeNumber(GroupInfo group, SeriesInfo series, EpisodeIn case EpisodeType.Trailer: offset += sizes?.Credits ?? 0; goto case EpisodeType.OpeningSong; + default: + offset += sizes?.Trailers ?? 0; + goto case EpisodeType.Trailer; } return offset + episode.AniDB.EpisodeNumber; } @@ -206,7 +212,7 @@ public static int GetSeasonNumber(GroupInfo group, SeriesInfo series, EpisodeInf return 1; var index = group.SeriesList.FindIndex(s => s.Id == id); if (index == -1) - goto case GroupType.Default; + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={id})"); var value = index - group.DefaultSeriesIndex; return value < 0 ? value : value + 1; } From d4cf69f1221c358b659f55c8b0f7136d3fc37a53 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 5 Sep 2021 17:13:56 +0200 Subject: [PATCH 0148/1103] Tweak the conditions to create movie titles for episodes Make sure the episode is either a "normal" type or a "special" type before chosing to create a movie title. This will prevent OPs/EDs/Extras/etc. to get the full movie title. --- Shokofin/Providers/EpisodeProvider.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index b5fd7cd3..f194cc4d 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -12,6 +12,8 @@ using Shokofin.API; using Shokofin.Utils; +using Info = Shokofin.API.Info; +using SeriesType = Shokofin.API.Models.SeriesType; using EpisodeType = Shokofin.API.Models.EpisodeType; namespace Shokofin.Providers @@ -24,7 +26,6 @@ public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo> private readonly ILogger<EpisodeProvider> Logger; - private readonly ShokoAPIManager ApiManager; public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProvider> logger, ShokoAPIManager apiManager) @@ -49,7 +50,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell } string displayTitle, alternateTitle; - if (series.AniDB.Type == API.Models.SeriesType.Movie) + if (series.AniDB.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); else ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, episode.Shoko.Name, info.MetadataLanguage); From fa7731808c75afd8b7c813a6324f2a938aad174e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 5 Sep 2021 17:24:02 +0200 Subject: [PATCH 0149/1103] Feature: Add a missing metadata provider --- Shokofin/API/ShokoAPIManager.cs | 78 +--- Shokofin/Configuration/PluginConfiguration.cs | 4 +- Shokofin/Configuration/configPage.html | 6 + Shokofin/LibraryScanner.cs | 6 +- Shokofin/Plugin.cs | 5 +- Shokofin/Providers/BoxSetProvider.cs | 1 - Shokofin/Providers/EpisodeProvider.cs | 191 +++++---- Shokofin/Providers/MissingMetadataProvider.cs | 401 ++++++++++++++++++ Shokofin/Providers/MovieProvider.cs | 1 - Shokofin/Providers/SeasonProvider.cs | 1 - Shokofin/Providers/SeriesProvider.cs | 2 - 11 files changed, 544 insertions(+), 152 deletions(-) create mode 100644 Shokofin/Providers/MissingMetadataProvider.cs diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index baab948b..b6af8a96 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -3,11 +3,11 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; - using Shokofin.API.Info; using Shokofin.API.Models; using Shokofin.Utils; @@ -24,25 +24,16 @@ public class ShokoAPIManager private static readonly List<Folder> MediaFolderList = new List<Folder>(); - private static readonly Dictionary<string, string> SeriesIdToPathDictionary = new Dictionary<string, string>(); + private static readonly ConcurrentDictionary<string, string> SeriesPathToIdDictionary = new ConcurrentDictionary<string, string>(); - private static readonly Dictionary<string, string> SeriesPathToIdDictionary = new Dictionary<string, string>(); + private static ConcurrentDictionary<string, string> SeriesIdToGroupIdDictionary = new ConcurrentDictionary<string, string>(); - /// <summary> - /// Episodes marked as ignored is skipped when adding missing episode metadata. - /// </summary> - private static readonly Dictionary<string, Dictionary<string, string>> SeriesIdToEpisodeIdIgnoreDictionery = new Dictionary<string, Dictionary<string, string>>(); + private static ConcurrentDictionary<string, string> EpisodeIdToSeriesIdDictionary = new ConcurrentDictionary<string, string>(); /// <summary> - /// Episodes found while scanning the library for metadata. + /// Episodes marked as ignored is skipped when adding missing episode metadata. /// </summary> - private static readonly Dictionary<string, HashSet<string>> SeriesIdToEpisodeIdDictionery = new Dictionary<string, HashSet<string>>(); - - private static readonly Dictionary<string, HashSet<string>> GroupIdToSeriesIdDictionery = new Dictionary<string, HashSet<string>>(); - - private static Dictionary<string, Guid> SeriesIdToGuidDictionary = new Dictionary<string, Guid>(); - - private static Dictionary<string, Guid> GroupIdToGuidDictionary = new Dictionary<string, Guid>(); + private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, string>> SeriesIdToEpisodeIdIgnoreDictionery = new ConcurrentDictionary<string, ConcurrentDictionary<string, string>>(); public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ILibraryManager libraryManager) { @@ -68,7 +59,7 @@ public Folder FindMediaFolder(string path, Folder parent, Folder root) return mediaFolder; } mediaFolder = parent; - while (mediaFolder.ParentId.Equals(root.Id)) { + while (!mediaFolder.ParentId.Equals(root.Id)) { if (mediaFolder.Parent == null) { if (mediaFolder.ParentId.Equals(Guid.Empty)) break; @@ -99,12 +90,9 @@ public void Clear() DataCache.Dispose(); MediaFolderList.Clear(); EpisodeIdToSeriesIdDictionary.Clear(); - SeriesIdToPathDictionary.Clear(); SeriesPathToIdDictionary.Clear(); - SeriesIdToEpisodeIdDictionery.Clear(); SeriesIdToEpisodeIdIgnoreDictionery.Clear(); SeriesIdToGroupIdDictionary.Clear(); - GroupIdToSeriesIdDictionery.Clear(); DataCache = (new MemoryCache((new MemoryCacheOptions() { ExpirationScanFrequency = ExpirationScanFrequency, }))); @@ -278,22 +266,9 @@ private async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string episod public bool MarkEpisodeAsIgnored(string episodeId, string seriesId, string fullPath) { - var dictionary = (SeriesIdToEpisodeIdIgnoreDictionery.ContainsKey(seriesId) ? SeriesIdToEpisodeIdIgnoreDictionery[seriesId] : (SeriesIdToEpisodeIdIgnoreDictionery[seriesId] = new Dictionary<string, string>())); - if (dictionary.ContainsKey(episodeId)) + if (!(SeriesIdToEpisodeIdIgnoreDictionery.TryGetValue(seriesId, out var dictionary) || SeriesIdToEpisodeIdIgnoreDictionery.TryAdd(seriesId, dictionary = new ConcurrentDictionary<string, string>()))) return false; - - dictionary.Add(episodeId, fullPath); - return true; - } - - public bool MarkEpisodeAsFound(string episodeId, string seriesId) - { - return (SeriesIdToEpisodeIdDictionery.ContainsKey(seriesId) ? SeriesIdToEpisodeIdDictionery[seriesId] : (SeriesIdToEpisodeIdDictionery[seriesId] = new HashSet<string>())).Add(episodeId); - } - - public bool IsEpisodeOnDisk(EpisodeInfo episode, SeriesInfo series) - { - return SeriesIdToEpisodeIdDictionery.ContainsKey(series.Id) && SeriesIdToEpisodeIdDictionery[series.Id].Contains(episode.Id); + return dictionary.TryAdd(episodeId, fullPath); } private static ExtraType? GetExtraType(Episode.AniDB episode) @@ -355,8 +330,6 @@ public async Task<SeriesInfo> GetSeriesInfoByPath(string path) seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); SeriesPathToIdDictionary[partialPath] = seriesId; - if (!string.IsNullOrEmpty(seriesId)) - SeriesIdToPathDictionary[seriesId] = partialPath; } if (string.IsNullOrEmpty(seriesId)) @@ -402,8 +375,6 @@ public async Task<SeriesInfo> GetSeriesInfo(string seriesId) return await CreateSeriesInfo(series, seriesId); } - private static Dictionary<string, string> EpisodeIdToSeriesIdDictionary = new Dictionary<string, string>(); - public SeriesInfo GetSeriesInfoForEpisodeSync(string episodeId) { if (EpisodeIdToSeriesIdDictionary.ContainsKey(episodeId)) { @@ -464,7 +435,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = .ToList(); // Iterate over the episodes once and store some values for later use. - for (var index = 0; index > episodeList.Count; index++) { + for (var index = 0; index < episodeList.Count; index++) { var episode = episodeList[index]; EpisodeIdToSeriesIdDictionary[episode.Id] = seriesId; if (episode.AniDB.Type == EpisodeType.Normal) @@ -500,25 +471,6 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = return info; } - public bool MarkSeriesAsFound(string seriesId) - => MarkSeriesAsFound(seriesId, ""); - - public bool MarkSeriesAsFound(string seriesId, string groupId) - { - return (GroupIdToSeriesIdDictionery.ContainsKey(groupId) ? GroupIdToSeriesIdDictionery[groupId] : (GroupIdToSeriesIdDictionery[groupId] = new HashSet<string>())).Add(seriesId); - } - - public bool IsSeriesOnDisk(SeriesInfo series, GroupInfo group) - { - var groupId = group?.Id ?? ""; - return GroupIdToSeriesIdDictionery.ContainsKey(groupId) && GroupIdToSeriesIdDictionery[groupId].Contains(series.Id); - } - - public string GetPathForSeries(string seriesId) - { - return SeriesIdToPathDictionary.ContainsKey(seriesId) ? SeriesIdToPathDictionary[seriesId] : null; - } - #endregion #region Group Info @@ -556,8 +508,6 @@ public async Task<GroupInfo> GetGroupInfo(string groupId, Ordering.GroupFilterTy return await CreateGroupInfo(group, groupId, filterByType); } - private static Dictionary<string, string> SeriesIdToGroupIdDictionary = new Dictionary<string, string>(); - public GroupInfo GetGroupInfoForSeriesSync(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) { if (SeriesIdToGroupIdDictionary.ContainsKey(seriesId)) { @@ -601,9 +551,11 @@ private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Order return groupInfo; Logger.LogDebug("Creating info object for group {GroupName}. (Group={GroupId})", group.Name, groupId); - var seriesList = await ShokoAPI.GetSeriesInGroup(groupId) - .ContinueWith(async task => await Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s)))).Unwrap() - .ContinueWith(l => l.Result.Where(s => s != null).ToList()); + var seriesList = (await ShokoAPI.GetSeriesInGroup(groupId) + .ContinueWith(task => Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s)))) + .Unwrap()) + .Where(s => s != null) + .ToList(); if (seriesList != null && seriesList.Count > 0) switch (filterByType) { default: break; diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index c18cfe05..26ce707e 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -60,7 +60,7 @@ public class PluginConfiguration : BasePluginConfiguration public DisplayLanguageType TitleAlternateType { get; set; } - public bool AddMissingEpisodeMetadata { get; set; } + public bool AddMissingMetadata { get; set; } public PluginConfiguration() { @@ -89,7 +89,7 @@ public PluginConfiguration() MarkSpecialsWhenGrouped = true; BoxSetGrouping = SeriesAndBoxSetGroupType.Default; MovieOrdering = OrderType.Default; - AddMissingEpisodeMetadata = false; + AddMissingMetadata = false; FilterOnLibraryTypes = false; } } diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 2a6bd311..7fef9780 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -101,6 +101,10 @@ <h3>Library Options</h3> <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> <span>Excluding normal episodes, add type and number to title (e.g. "S1 title", "C1 title", "O1 title")</span> </label> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="AddMissingMetadata" /> + <span>Add metadata for missing seasons/episodes</span> + </label> <div class="selectContainer"> <label class="selectLabel" for="BoxSetGrouping">Box-set grouping</label> <select is="emby-select" id="BoxSetGrouping" name="BoxSetGrouping" class="emby-select-withcolor emby-select"> @@ -195,6 +199,7 @@ <h3>Tag Options</h3> document.querySelector('#BoxSetGrouping').value = config.BoxSetGrouping; document.querySelector('#MovieOrdering').value = config.MovieOrdering; document.querySelector('#MarkSpecialsWhenGrouped').checked = config.MarkSpecialsWhenGrouped; + document.querySelector('#AddMissingMetadata').checked = config.AddMissingMetadata; document.querySelector('#FilterOnLibraryTypes').checked = config.FilterOnLibraryTypes; document.querySelector('#AddAniDBId').checked = config.AddAniDBId; document.querySelector('#AddTvDBId').checked = config.AddTvDBId; @@ -268,6 +273,7 @@ <h3>Tag Options</h3> config.BoxSetGrouping = document.querySelector('#BoxSetGrouping').value; config.MovieOrdering = document.querySelector('#MovieOrdering').value; config.MarkSpecialsWhenGrouped = document.querySelector('#MarkSpecialsWhenGrouped').checked; + config.AddMissingMetadata = document.querySelector('#AddMissingMetadata').checked; config.FilterOnLibraryTypes = document.querySelector('#FilterOnLibraryTypes').checked; config.AddAniDBId = document.querySelector('#AddAniDBId').checked; config.AddTvDBId = document.querySelector('#AddTvDBId').checked; diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index 33ac9b34..b36fc411 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -50,7 +50,7 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base try { // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. - if (IsEnabledForItem(parent)) + if (!IsEnabledForItem(parent)) return false; var fullPath = fileInfo.FullName; @@ -123,11 +123,11 @@ private bool ScanFile(string partialPath, string fullPath) Logger.LogWarning("Skipped unknown file at path {Path}", partialPath); return false; } - Logger.LogInformation("Found episode {EpisodeName} (Series={SeriesId},Episode={EpisodeId},File={FileId}})", series.Shoko.Name, series.Id, episode.Id, file.Id); + Logger.LogInformation("Found episode for {SeriesName} (Series={SeriesId},Episode={EpisodeId},File={FileId})", series.Shoko.Name, series.Id, episode.Id, file.Id); // We're going to post process this file later, but we don't want to include it in our library for now. if (episode.ExtraType != null) { - Logger.LogInformation("Episode was assigned an extra type, ignoring episode. (Series={SeriesId},Episode={EpisodeId},File={FileId}})", series.AniDB.Title, series.Id, episode.Id, file.Id); + Logger.LogInformation("Episode was assigned an extra type, ignoring episode. (Series={SeriesId},Episode={EpisodeId},File={FileId})", series.Id, episode.Id, file.Id); ApiManager.MarkEpisodeAsIgnored(episode.Id, series.Id, fullPath); return true; } diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index d2bd62ee..faa1d140 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -25,13 +25,12 @@ public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) public IEnumerable<PluginPageInfo> GetPages() { - var name = GetType().Namespace; return new[] { new PluginPageInfo { - Name = name, - EmbeddedResourcePath = $"{name}.Configuration.configPage.html", + Name = Name, + EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configPage.html", } }; } diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index b018fec9..8c4126e9 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -122,7 +122,6 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); result.HasMetadata = true; - ApiManager.MarkSeriesAsFound(series.Id, group.Id); result.ResetPeople(); foreach (var person in await ApiManager.GetPeople(series.Id)) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index f194cc4d..00f85e41 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -49,69 +49,107 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell return result; } - string displayTitle, alternateTitle; - if (series.AniDB.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) - ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); - else - ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, episode.Shoko.Name, info.MetadataLanguage); - Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", displayTitle, file.Id, episode.Id, series.Id); - - var episodeNumber = Ordering.GetEpisodeNumber(group, series, episode); - var seasonNumber = Ordering.GetSeasonNumber(group, series, episode); - var description = Text.GetDescription(episode); - - if (group != null && config.MarkSpecialsWhenGrouped && episode.AniDB.Type != EpisodeType.Normal) switch (episode.AniDB.Type) { - case EpisodeType.Special: - displayTitle = $"S{episodeNumber} {displayTitle}"; - alternateTitle = $"S{episodeNumber} {alternateTitle}"; - break; - case EpisodeType.ThemeSong: - case EpisodeType.EndingSong: - case EpisodeType.OpeningSong: - displayTitle = $"C{episodeNumber} {displayTitle}"; - alternateTitle = $"C{episodeNumber} {alternateTitle}"; - break; - case EpisodeType.Trailer: - displayTitle = $"T{episodeNumber} {displayTitle}"; - alternateTitle = $"T{episodeNumber} {alternateTitle}"; - break; - case EpisodeType.Parody: - displayTitle = $"P{episodeNumber} {displayTitle}"; - alternateTitle = $"P{episodeNumber} {alternateTitle}"; - break; - case EpisodeType.Unknown: - displayTitle = $"U{episodeNumber} {displayTitle}"; - alternateTitle = $"U{episodeNumber} {alternateTitle}"; - break; - default: - displayTitle = $"O{episodeNumber} {displayTitle}"; - alternateTitle = $"O{episodeNumber} {alternateTitle}"; - break; - } + result.Item = CreateMetadata(group, series, episode, file.Id, info.MetadataLanguage); + Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", result.Item.Name, file.Id, episode.Id, series.Id); - if (group != null && episode.AniDB.Type == EpisodeType.Special) { - int previousEpisodeNumber; - if (!series.SpesialsAnchors.TryGetValue(episode.Id, out var previousEpisode)) - previousEpisodeNumber = Ordering.GetEpisodeNumber(group, series, episode); - else - previousEpisodeNumber = series.EpisodeCount; - result.Item = new Episode { - IndexNumber = episodeNumber, - ParentIndexNumber = 0, - AirsAfterSeasonNumber = seasonNumber, - AirsBeforeEpisodeNumber = previousEpisodeNumber + 1, - AirsBeforeSeasonNumber = seasonNumber + 1, + result.HasMetadata = true; + + var episodeNumberEnd = episode.AniDB.EpisodeNumber + file.EpisodesCount; + if (episode.AniDB.EpisodeNumber != episodeNumberEnd) + result.Item.IndexNumberEnd = episodeNumberEnd; + + return result; + } + catch (Exception e) { + Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); + return new MetadataResult<Episode>(); + } + } + + public static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo series, Info.EpisodeInfo episode, Season season, System.Guid episodeId) + => CreateMetadata(group, series, episode, null, null, season, episodeId); + + public static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo series, Info.EpisodeInfo episode, string fileId, string metadataLanguage) + => CreateMetadata(group, series, episode, metadataLanguage, fileId, null, Guid.Empty); + + private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo series, Info.EpisodeInfo episode, string fileId, string metadataLanguage, Season season, System.Guid episodeId) + { + if (string.IsNullOrEmpty(metadataLanguage) && season != null) + metadataLanguage = season.GetPreferredMetadataLanguage(); + var config = Plugin.Instance.Configuration; + string displayTitle, alternateTitle; + if (series.AniDB.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) + ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, metadataLanguage); + else + ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, episode.Shoko.Name, metadataLanguage); + + var episodeNumber = Ordering.GetEpisodeNumber(group, series, episode); + var seasonNumber = Ordering.GetSeasonNumber(group, series, episode); + var description = Text.GetDescription(episode); + + if (group != null && config.MarkSpecialsWhenGrouped && episode.AniDB.Type != EpisodeType.Normal) switch (episode.AniDB.Type) { + case EpisodeType.Special: + displayTitle = $"S{episodeNumber} {displayTitle}"; + alternateTitle = $"S{episodeNumber} {alternateTitle}"; + break; + case EpisodeType.ThemeSong: + case EpisodeType.EndingSong: + case EpisodeType.OpeningSong: + displayTitle = $"C{episodeNumber} {displayTitle}"; + alternateTitle = $"C{episodeNumber} {alternateTitle}"; + break; + case EpisodeType.Trailer: + displayTitle = $"T{episodeNumber} {displayTitle}"; + alternateTitle = $"T{episodeNumber} {alternateTitle}"; + break; + case EpisodeType.Parody: + displayTitle = $"P{episodeNumber} {displayTitle}"; + alternateTitle = $"P{episodeNumber} {alternateTitle}"; + break; + case EpisodeType.Unknown: + displayTitle = $"U{episodeNumber} {displayTitle}"; + alternateTitle = $"U{episodeNumber} {alternateTitle}"; + break; + default: + displayTitle = $"O{episodeNumber} {displayTitle}"; + alternateTitle = $"O{episodeNumber} {alternateTitle}"; + break; + } + + Episode result; + if (group != null && episode.AniDB.Type == EpisodeType.Special) { + int previousEpisodeNumber; + if (!series.SpesialsAnchors.TryGetValue(episode.Id, out var previousEpisode)) + previousEpisodeNumber = Ordering.GetEpisodeNumber(group, series, episode); + else + previousEpisodeNumber = series.EpisodeCount; + if (season != null) { + result = new Episode { Name = displayTitle, OriginalTitle = alternateTitle, + IndexNumber = Ordering.GetEpisodeNumber(group, series, episode), + ParentIndexNumber = Ordering.GetSeasonNumber(group, series, episode), + Id = episodeId, + IsVirtualItem = true, + SeasonId = season.Id, + SeriesId = season.Series.Id, + Overview = Text.GetDescription(episode), + CommunityRating = episode.AniDB.Rating.ToFloat(), PremiereDate = episode.AniDB.AirDate, - Overview = description, - CommunityRating = episode.AniDB.Rating.ToFloat(10), + SeriesName = season.Series.Name, + SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, + SeasonName = season.Name, + DateLastSaved = DateTime.UtcNow, }; + result.PresentationUniqueKey = result.GetPresentationUniqueKey(); } else { - result.Item = new Episode { + result = new Episode { IndexNumber = episodeNumber, - ParentIndexNumber = seasonNumber, + ParentIndexNumber = 0, + AirsAfterSeasonNumber = seasonNumber, + AirsBeforeEpisodeNumber = previousEpisodeNumber + 1, + AirsBeforeSeasonNumber = seasonNumber + 1, Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, @@ -119,29 +157,30 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell CommunityRating = episode.AniDB.Rating.ToFloat(10), }; } - // NOTE: This next line will remain here till they fix the series merging for providers outside the MetadataProvider enum. - if (config.SeriesGrouping == Ordering.GroupType.ShokoGroup) - result.Item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{episode.Id}"); - result.Item.SetProviderId("Shoko Episode", episode.Id); - result.Item.SetProviderId("Shoko File", file.Id); - if (config.AddAniDBId) - result.Item.SetProviderId("AniDB", episode.AniDB.ID.ToString()); - if (config.AddTvDBId && episode.TvDB != null && config.SeriesGrouping != Ordering.GroupType.ShokoGroup) - result.Item.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); - - result.HasMetadata = true; - ApiManager.MarkEpisodeAsFound(episode.Id, series.Id); - - var episodeNumberEnd = episode.AniDB.EpisodeNumber + file.EpisodesCount; - if (episode.AniDB.EpisodeNumber != episodeNumberEnd) - result.Item.IndexNumberEnd = episodeNumberEnd; - - return result; } - catch (Exception e) { - Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); - return new MetadataResult<Episode>(); + else { + result = new Episode { + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, + Name = displayTitle, + OriginalTitle = alternateTitle, + PremiereDate = episode.AniDB.AirDate, + Overview = description, + CommunityRating = episode.AniDB.Rating.ToFloat(10), + }; } + // NOTE: This next line will remain here till they fix the series merging for providers outside the MetadataProvider enum. + if (config.SeriesGrouping == Ordering.GroupType.ShokoGroup) + result.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{episode.Id}"); + result.SetProviderId("Shoko Episode", episode.Id); + if (!string.IsNullOrEmpty(fileId)) + result.SetProviderId("Shoko File", fileId); + if (config.AddAniDBId) + result.SetProviderId("AniDB", episode.AniDB.ID.ToString()); + if (config.AddTvDBId && episode.TvDB != null && config.SeriesGrouping != Ordering.GroupType.ShokoGroup) + result.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); + + return result; } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) diff --git a/Shokofin/Providers/MissingMetadataProvider.cs b/Shokofin/Providers/MissingMetadataProvider.cs new file mode 100644 index 00000000..59b76885 --- /dev/null +++ b/Shokofin/Providers/MissingMetadataProvider.cs @@ -0,0 +1,401 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Events; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Globalization; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.Utils; + +using Info = Shokofin.API.Info; + +namespace Shokofin.Providers +{ + public class MissingMetadataProvider : IServerEntryPoint + { + private readonly ShokoAPIManager ApiManager; + + private readonly ILibraryManager LibraryManager; + + private readonly IProviderManager ProviderManager; + + private readonly ILocalizationManager LocalizationManager; + + private readonly ILogger<MissingMetadataProvider> Logger; + + public MissingMetadataProvider(ShokoAPIManager apiManager, ILibraryManager libraryManager, IProviderManager providerManager, ILocalizationManager localizationManager, ILogger<MissingMetadataProvider> logger) + { + ApiManager = apiManager; + LibraryManager = libraryManager; + ProviderManager = providerManager; + LocalizationManager = localizationManager; + Logger = logger; + } + + public Task RunAsync() + { + LibraryManager.ItemUpdated += OnLibraryManagerItemUpdated; + LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; + ProviderManager.RefreshCompleted += OnProviderManagerRefreshComplete; + + return Task.CompletedTask; + } + + public void Dispose() + { + LibraryManager.ItemUpdated -= OnLibraryManagerItemUpdated; + LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; + ProviderManager.RefreshCompleted -= OnProviderManagerRefreshComplete; + } + + public bool IsEnabledForItem(BaseItem item) + { + if (item == null) + return false; + + BaseItem seriesOrItem = item switch + { + Episode e => e.Series, + Series s => s, + Season s => s.Series, + _ => item, + }; + + if (seriesOrItem == null) + return false; + + var libraryOptions = LibraryManager.GetLibraryOptions(seriesOrItem); + return libraryOptions != null && libraryOptions.TypeOptions.Any(o => o.Type == nameof (Series) && o.MetadataFetchers.Contains(Plugin.MetadataProviderName)); + } + + private void OnProviderManagerRefreshComplete(object sender, GenericEventArgs<BaseItem> genericEventArgs) + { + // No action needed if either 1) the setting is turned of, 2) the provider is not enabled for the item + if (!Plugin.Instance.Configuration.AddMissingMetadata || !IsEnabledForItem(genericEventArgs.Argument)) + return; + + switch (genericEventArgs.Argument) { + case Series series: + HandleSeries(series); + break; + case Season season: + HandleSeason(season); + break; + } + } + + // NOTE: Always delete stall metadata, even if we disabled the feature in the settings page. + private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs itemChangeEventArgs) + { + // Only interested in non-virtual Seasons and Episodes + if (itemChangeEventArgs.Item.IsVirtualItem + || !(itemChangeEventArgs.Item is Season || itemChangeEventArgs.Item is Episode)) + return; + + if (!IsEnabledForItem(itemChangeEventArgs.Item)) + return; + + // Abort if we're unable to get the shoko episode id + if (!itemChangeEventArgs.Item.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) || string.IsNullOrEmpty(episodeId)) + return; + + var indexNumber = itemChangeEventArgs.Item.IndexNumber; + + // If the item is an Episode, filter on ParentIndexNumber as well (season number) + int? parentIndexNumber = null; + if (itemChangeEventArgs.Item is Episode) + parentIndexNumber = itemChangeEventArgs.Item.ParentIndexNumber; + + var query = new InternalItemsQuery { + IsVirtualItem = true, + IndexNumber = indexNumber, + ParentIndexNumber = parentIndexNumber, + IncludeItemTypes = new [] { itemChangeEventArgs.Item.GetType().Name }, + Parent = itemChangeEventArgs.Parent, + GroupByPresentationUniqueKey = false, + DtoOptions = new DtoOptions(true), + }; + + var existingVirtualItems = LibraryManager.GetItemList(query); + + var deleteOptions = new DeleteOptions { + DeleteFileLocation = true, + }; + + // Remove the virtual season/episode that matches the newly updated item + foreach (var item in existingVirtualItems) + LibraryManager.DeleteItem(item, deleteOptions); + } + + private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs itemChangeEventArgs) + { + // No action needed if either 1) the setting is turned of, 2) the item is virtual, 3) the provider is not enabled for the item + if (!Plugin.Instance.Configuration.AddMissingMetadata || itemChangeEventArgs.Item.IsVirtualItem || !IsEnabledForItem(itemChangeEventArgs.Item)) + return; + + switch (itemChangeEventArgs.Item) { + // Create a new virtual season if the real one was deleted. + case Season season: + HandleSeason(season, true); + break; + // Similarly, create a new virtual episode if the real one was deleted. + case Episode episode: + HandleEpisode(episode); + break; + } + } + + private void HandleSeries(Series series) + { + // Abort if we're unable to get the series id + if (!series.ProviderIds.TryGetValue("Shoko Series", out var seriesId) || string.IsNullOrEmpty(seriesId)) + return; + + var seasons = new Dictionary<int, Season>(); + var existingEpisodes = new HashSet<string>(); + foreach (var child in series.GetRecursiveChildren()) switch (child) { + case Season season: + if (season.IndexNumber.HasValue) + seasons.TryAdd(season.IndexNumber.Value, season); + break; + case Episode episode: + if (!episode.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) || string.IsNullOrEmpty(episodeId)) + continue; + existingEpisodes.Add(episodeId); + break; + } + + // Provider metadata for a series using Shoko's Group feature + if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { + var groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); + + // Add missing seasons + foreach (var pair in AddMissingSeasons(groupInfo, series, seasons)) { + seasons.Add(pair.Key, pair.Value); + } + + // Add missing episodes + foreach (var (seriesInfo, index) in groupInfo.SeriesList.Select((s, i) => (s, i))) { + var value = index - groupInfo.DefaultSeriesIndex; + var seasonNumber = value < 0 ? value : value + 1; + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; + + foreach (var episodeInfo in seriesInfo.EpisodeList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + } + } + } + // Provide metadata for other series + else { + var seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); + + // Compute the season numbers for each episode in the series in advance, since we need to filter out the missing seasons + var episodeInfoToSeasonNumberDirectory = seriesInfo.EpisodeList.ToDictionary(e => e, e => Ordering.GetSeasonNumber(null, seriesInfo, e)); + + // Add missing seasons + var allKnownSeasonNumbers = episodeInfoToSeasonNumberDirectory.Values.Distinct().ToList(); + foreach (var pair in AddMissingSeasons(series, seasons, allKnownSeasonNumbers)) { + seasons.Add(pair.Key, pair.Value); + } + + // Add missing episodes + foreach (var episodeInfo in seriesInfo.EpisodeList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + var seasonNumber = episodeInfoToSeasonNumberDirectory[episodeInfo]; + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; + + AddVirtualEpisode(null, seriesInfo, episodeInfo, season); + } + } + } + + private void HandleSeason(Season season, bool deleted = false) + { + if (!(season.ProviderIds.TryGetValue("Shoko Series", out var seriesId) || season.Series.ProviderIds.TryGetValue("Shoko Series", out seriesId)) || string.IsNullOrEmpty(seriesId)) + return; + + var seasonNumber = season.IndexNumber!.Value; + var existingEpisodes = season.Children.OfType<Episode>() + .Where(ep => ep.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) && !string.IsNullOrEmpty(episodeId)) + .Select(ep => ep.ProviderIds["Shoko Episode"]) + .ToHashSet(); + var series = season.Series; + Info.GroupInfo groupInfo = null; + Info.SeriesInfo seriesInfo = null; + + if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { + var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; + groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, filterLibrary); + if (groupInfo == null) { + Logger.LogWarning("Unable to find group info for series. (Series={SeriesId})", seriesId); + return; + } + + int seriesIndex = seasonNumber > 0 ? seasonNumber - 1 : seasonNumber; + var index = groupInfo.DefaultSeriesIndex + seriesIndex; + seriesInfo = groupInfo.SeriesList[index]; + if (seriesInfo == null) { + Logger.LogWarning("Unable to find series info for {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, groupInfo.Id); + return; + } + + if (deleted) + season = AddVirtualSeason(seriesInfo, seasonNumber, series); + } + else { + seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); + if (seriesInfo == null) { + Logger.LogWarning("Unable to find series info. (Series={SeriesId})", seriesId); + return; + } + + if (deleted) + season = AddVirtualSeason(seasonNumber, series); + } + + foreach (var episodeInfo in seriesInfo.EpisodeList) { + var episodeParentIndex = Ordering.GetSeasonNumber(groupInfo, seriesInfo, episodeInfo); + if (episodeParentIndex != seasonNumber) + continue; + + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + } + } + + private void HandleEpisode(Episode episode) + { + // Abort if we're unable to get the shoko episode id + if (!episode.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) || string.IsNullOrEmpty(episodeId)) + return; + + Info.GroupInfo groupInfo = null; + Info.SeriesInfo seriesInfo = ApiManager.GetSeriesInfoForEpisodeSync(episodeId); + Info.EpisodeInfo episodeInfo = seriesInfo.EpisodeList.Find(e => e.Id == episodeId); + if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) + groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesInfo.Id, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); + + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, episode.Season); + } + + private IEnumerable<KeyValuePair<int, Season>> AddMissingSeasons(Series series, Dictionary<int, Season> existingSeasons, List<int> allSeasonNumbers) + { + var missingSeasonNumbers = allSeasonNumbers.Except(existingSeasons.Keys).ToList(); + foreach (var seasonNumber in missingSeasonNumbers) { + yield return KeyValuePair.Create(seasonNumber, AddVirtualSeason(seasonNumber, series)); + } + } + + private IEnumerable<KeyValuePair<int, Season>> AddMissingSeasons(Info.GroupInfo groupInfo, Series series, Dictionary<int, Season> seasons) + { + bool hasSpecials = false; + foreach (var (s, index) in groupInfo.SeriesList.Select((a, b) => (a, b))) { + var value = index - groupInfo.DefaultSeriesIndex; + var seasonNumber = value < 0 ? value : value + 1; + if (seasons.ContainsKey(seasonNumber)) + continue; + if (s.SpecialsList.Count > 0) + hasSpecials = true; + var season = AddVirtualSeason(s, seasonNumber, series); + yield return KeyValuePair.Create(seasonNumber, season); + } + if (hasSpecials) + yield return KeyValuePair.Create(0, AddVirtualSeason(0, series)); + } + + private Season AddVirtualSeason(int seasonNumber, Series series) + { + string seasonName; + if (seasonNumber == 0) + seasonName = LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName; + else + seasonName = string.Format( + LocalizationManager.GetLocalizedString("NameSeasonNumber"), + seasonNumber.ToString(CultureInfo.InvariantCulture)); + + Logger.LogInformation("Creating virtual season {SeasonName} entry for {SeriesName}", seasonName, series.Name); + + var result = new Season { + Name = seasonName, + IndexNumber = seasonNumber, + SortName = seasonName, + ForcedSortName = seasonName, + Id = LibraryManager.GetNewItemId( + series.Id + seasonNumber.ToString(CultureInfo.InvariantCulture) + seasonName, + typeof(Season)), + IsVirtualItem = true, + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey() + }; + + series.AddChild(result, CancellationToken.None); + + return result; + } + + private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, int seasonNumber, Series series) + { + var tags = ApiManager.GetTags(seriesInfo.Id).GetAwaiter().GetResult(); + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(seriesInfo.AniDB.Titles, seriesInfo.Shoko.Name, series.GetPreferredMetadataLanguage()); + var sortTitle = $"S{seasonNumber} - {seriesInfo.Shoko.Name}"; + + Logger.LogInformation("Adding virtual season {SeasonName} entry for {SeriesName}", displayTitle, series.Name); + var result = new Season { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = seasonNumber, + SortName = sortTitle, + ForcedSortName = sortTitle, + Id = LibraryManager.GetNewItemId( + series.Id + "Season " + seriesInfo.Id.ToString(CultureInfo.InvariantCulture), + typeof(Season)), + IsVirtualItem = true, + Overview = Text.GetDescription(seriesInfo), + PremiereDate = seriesInfo.AniDB.AirDate, + EndDate = seriesInfo.AniDB.EndDate, + ProductionYear = seriesInfo.AniDB.AirDate?.Year, + Tags = tags, + CommunityRating = seriesInfo.AniDB.Rating?.ToFloat(10), + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), + }; + result.ProviderIds.Add("Shoko Series", seriesInfo.Id); + if (Plugin.Instance.Configuration.AddAniDBId) + result.ProviderIds.Add("AniDB", seriesInfo.AniDB.ID.ToString()); + + series.AddChild(result, CancellationToken.None); + + return result; + } + + private void AddVirtualEpisode(Info.GroupInfo groupInfo, Info.SeriesInfo seriesInfo, Info.EpisodeInfo episodeInfo, MediaBrowser.Controller.Entities.TV.Season season) + { + var episodeId = LibraryManager.GetNewItemId(season.Series.Id + "Season " + seriesInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); + var result = EpisodeProvider.CreateMetadata(groupInfo, seriesInfo, episodeInfo, season, episodeId); + + Logger.LogInformation("Creating virtual episode {EpisodeName} S{SeasonNumber}:E{EpisodeNumber}", season.Series.Name, season.IndexNumber, result.IndexNumber); + + season.AddChild(result, CancellationToken.None); + } + } +} \ No newline at end of file diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 77a792ad..1a9d8d62 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -73,7 +73,6 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio result.Item.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); result.HasMetadata = true; - ApiManager.MarkSeriesAsFound(series.Id, group.Id); result.ResetPeople(); foreach (var person in await ApiManager.GetPeople(series.Id)) diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 8e6d1aac..39a0c2bc 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -102,7 +102,6 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in result.Item.ProviderIds.Add("AniDB", series.AniDB.ID.ToString()); result.HasMetadata = true; - ApiManager.MarkSeriesAsFound(series.Id, groupId); result.ResetPeople(); foreach (var person in await ApiManager.GetPeople(series.Id)) diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 0412d843..f1e5fb3b 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -78,7 +78,6 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C result.Item.SetProviderId(MetadataProvider.Tvdb, series.TvDBId); result.HasMetadata = true; - ApiManager.MarkSeriesAsFound(series.Id); result.ResetPeople(); foreach (var person in await ApiManager.GetPeople(series.Id)) @@ -121,7 +120,6 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); result.HasMetadata = true; - ApiManager.MarkSeriesAsFound(series.Id, group.Id); result.ResetPeople(); foreach (var person in await ApiManager.GetPeople(series.Id)) From cf9c665e2e3392996ce30e3cb288542f3f9edbb6 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 5 Sep 2021 22:06:48 +0000 Subject: [PATCH 0150/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 9fe9fae3..c10f9b60 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.10", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.10/shokofin_1.5.0.10.zip", + "checksum": "ff9d75f2beba5953a8b9bcf294e684c2", + "timestamp": "2021-09-05T22:06:46Z" + }, { "version": "1.5.0.9", "changelog": "NA", From 399b737066dfa82ceb9b6ac3375b3cd42036678e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 6 Sep 2021 22:14:28 +0200 Subject: [PATCH 0151/1103] Fetch missing episode data if the setting to show missing data is toggled --- Shokofin/API/ShokoAPI.cs | 4 ++-- Shokofin/API/ShokoAPIManager.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPI.cs index d0c9ed53..bef5efb1 100644 --- a/Shokofin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPI.cs @@ -81,9 +81,9 @@ public static async Task<Episode> GetEpisode(string id) return responseStream != null ? await JsonSerializer.DeserializeAsync<Episode>(responseStream) : null; } - public static async Task<List<Episode>> GetEpisodesFromSeries(string seriesId) + public static async Task<List<Episode>> GetEpisodesFromSeries(string seriesId, bool includeMissing) { - var responseStream = await CallApi($"/api/v3/Series/{seriesId}/Episode"); + var responseStream = await CallApi($"/api/v3/Series/{seriesId}/Episode?includeMissing={includeMissing}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<List<Episode>>(responseStream) : null; } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index b6af8a96..a7b8f2ed 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -425,7 +425,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = List<EpisodeInfo> filteredSpecialsList = new List<EpisodeInfo>(); // The episode list is ordered by air date - var episodeList = ShokoAPI.GetEpisodesFromSeries(seriesId) + var episodeList = ShokoAPI.GetEpisodesFromSeries(seriesId, Plugin.Instance.Configuration.AddMissingMetadata) .ContinueWith(task => Task.WhenAll(task.Result.Select(e => CreateEpisodeInfo(e)))) .Unwrap() .GetAwaiter() From 63bff8ce34c0573b594c21113796d60e17116392 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 6 Sep 2021 23:22:43 +0200 Subject: [PATCH 0152/1103] Only set TvDB ids if merge friendly series grouping is enabled Also, remove the settings to enable it manually. --- Shokofin/Configuration/PluginConfiguration.cs | 3 --- Shokofin/Configuration/configPage.html | 6 ------ Shokofin/Providers/EpisodeProvider.cs | 4 ++-- Shokofin/Providers/MovieProvider.cs | 2 +- Shokofin/Providers/SeriesProvider.cs | 2 +- 5 files changed, 4 insertions(+), 13 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 26ce707e..3e9d8d30 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -38,8 +38,6 @@ public class PluginConfiguration : BasePluginConfiguration public bool AddAniDBId { get; set; } - public bool AddTvDBId { get; set; } - public bool PreferAniDbPoster { get; set; } public TextSourceType DescriptionSource { get; set; } @@ -79,7 +77,6 @@ public PluginConfiguration() SynopsisRemoveSummary = true; SynopsisCleanMultiEmptyLines = true; AddAniDBId = true; - AddTvDBId = false; PreferAniDbPoster = true; TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 7fef9780..0e68c509 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -134,10 +134,6 @@ <h3>Provider Options</h3> <input is="emby-checkbox" type="checkbox" id="AddAniDBId" /> <span>Add AniDB Id to all entries</span> </label> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="AddTvDBId" /> - <span>Add TvDB Id when present</span> - </label> <h3>Tag Options</h3> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="HideArtStyleTags" /> @@ -202,7 +198,6 @@ <h3>Tag Options</h3> document.querySelector('#AddMissingMetadata').checked = config.AddMissingMetadata; document.querySelector('#FilterOnLibraryTypes').checked = config.FilterOnLibraryTypes; document.querySelector('#AddAniDBId').checked = config.AddAniDBId; - document.querySelector('#AddTvDBId').checked = config.AddTvDBId; document.querySelector('#PreferAniDbPoster').checked = config.PreferAniDbPoster; if (config.SeriesGrouping === "ShokoGroup") { document.querySelector('#SG_ShokoGroup_Warning').removeAttribute("hidden"); @@ -276,7 +271,6 @@ <h3>Tag Options</h3> config.AddMissingMetadata = document.querySelector('#AddMissingMetadata').checked; config.FilterOnLibraryTypes = document.querySelector('#FilterOnLibraryTypes').checked; config.AddAniDBId = document.querySelector('#AddAniDBId').checked; - config.AddTvDBId = document.querySelector('#AddTvDBId').checked; config.PreferAniDbPoster = document.querySelector('#PreferAniDbPoster').checked; ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); }); diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 00f85e41..c53ef2c8 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -172,13 +172,13 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri // NOTE: This next line will remain here till they fix the series merging for providers outside the MetadataProvider enum. if (config.SeriesGrouping == Ordering.GroupType.ShokoGroup) result.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{episode.Id}"); + else if (config.SeriesGrouping == Ordering.GroupType.MergeFriendly && episode.TvDB != null && config.SeriesGrouping != Ordering.GroupType.ShokoGroup) + result.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); result.SetProviderId("Shoko Episode", episode.Id); if (!string.IsNullOrEmpty(fileId)) result.SetProviderId("Shoko File", fileId); if (config.AddAniDBId) result.SetProviderId("AniDB", episode.AniDB.ID.ToString()); - if (config.AddTvDBId && episode.TvDB != null && config.SeriesGrouping != Ordering.GroupType.ShokoGroup) - result.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); return result; } diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 1a9d8d62..d850fda5 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -69,7 +69,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio result.Item.SetProviderId("Shoko Episode", episode.Id); if (config.AddAniDBId) result.Item.SetProviderId("AniDB", episode.AniDB.ID.ToString()); - if (config.AddTvDBId && episode.TvDB != null && config.BoxSetGrouping != Ordering.GroupType.ShokoGroup) + if (config.BoxSetGrouping == Ordering.GroupType.MergeFriendly && episode.TvDB != null && config.BoxSetGrouping != Ordering.GroupType.ShokoGroup) result.Item.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); result.HasMetadata = true; diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index f1e5fb3b..f729a34c 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -74,7 +74,7 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C result.Item.SetProviderId("Shoko Series", series.Id); if (Plugin.Instance.Configuration.AddAniDBId) result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); - if (Plugin.Instance.Configuration.AddTvDBId && !string.IsNullOrEmpty(series.TvDBId)) + if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && !string.IsNullOrEmpty(series.TvDBId)) result.Item.SetProviderId(MetadataProvider.Tvdb, series.TvDBId); result.HasMetadata = true; From 671aedda662bb3ac12813c8888d2478eebc55360 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 6 Sep 2021 23:24:00 +0200 Subject: [PATCH 0153/1103] Fix the missing-metadata/episode/season providers --- Shokofin/API/Info/SeriesInfo.cs | 12 ++-- Shokofin/API/ShokoAPIManager.cs | 31 ++++----- Shokofin/Providers/EpisodeProvider.cs | 68 +++++++++++++------ Shokofin/Providers/MissingMetadataProvider.cs | 51 +++++++++++--- Shokofin/Providers/SeasonProvider.cs | 15 ++-- 5 files changed, 120 insertions(+), 57 deletions(-) diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index 8e468401..2cae857d 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -18,19 +18,21 @@ public class SeriesInfo /// <summary> /// All episodes (of all type) that belong to this series. /// - /// Ordered by AniDb air-date. + /// Unordered. /// </summary> - public List<EpisodeInfo> EpisodeList; + public List<EpisodeInfo> RawEpisodeList; /// <summary> - /// The number of normal episodes in this series. + /// A pre-filtered list of normal episodes that belong to this series. + /// + /// Ordered by AniDb air-date. /// </summary> - public int EpisodeCount; + public List<EpisodeInfo> EpisodeList; /// <summary> /// A dictionary holding mappings for the previous normal episode for every special episode in a series. /// </summary> - public Dictionary<string, string> SpesialsAnchors; + public Dictionary<string, EpisodeInfo> SpesialsAnchors; /// <summary> /// A pre-filtered list of special episodes without an ExtraType diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index a7b8f2ed..4d276423 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -420,38 +420,37 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = var aniDb = await ShokoAPI.GetSeriesAniDB(seriesId); var tvDbId = series.IDs.TvDB?.FirstOrDefault(); - var episodeCount = 0; - Dictionary<string, string> filteredSpecialsMapping = new Dictionary<string, string>(); - List<EpisodeInfo> filteredSpecialsList = new List<EpisodeInfo>(); + Dictionary<string, EpisodeInfo> specialsAnchorDictionary = new Dictionary<string, EpisodeInfo>(); + var specialsList = new List<EpisodeInfo>(); + var episodesList = new List<EpisodeInfo>(); // The episode list is ordered by air date - var episodeList = ShokoAPI.GetEpisodesFromSeries(seriesId, Plugin.Instance.Configuration.AddMissingMetadata) + var allEpisodesList = ShokoAPI.GetEpisodesFromSeries(seriesId, Plugin.Instance.Configuration.AddMissingMetadata) .ContinueWith(task => Task.WhenAll(task.Result.Select(e => CreateEpisodeInfo(e)))) .Unwrap() .GetAwaiter() .GetResult() .Where(e => e != null && e.Shoko != null && e.AniDB != null) - .OrderBy(e => e.AniDB.AirDate) .ToList(); // Iterate over the episodes once and store some values for later use. - for (var index = 0; index < episodeList.Count; index++) { - var episode = episodeList[index]; + for (var index = 0; index < allEpisodesList.Count; index++) { + var episode = allEpisodesList[index]; EpisodeIdToSeriesIdDictionary[episode.Id] = seriesId; if (episode.AniDB.Type == EpisodeType.Normal) - episodeCount++; + episodesList.Add(episode); else if (episode.AniDB.Type == EpisodeType.Special && episode.ExtraType == null) { - filteredSpecialsList.Add(episode); - var previousEpisode = episodeList + specialsList.Add(episode); + var previousEpisode = allEpisodesList .GetRange(0, index) .LastOrDefault(e => e.AniDB.Type == EpisodeType.Normal); if (previousEpisode != null) - filteredSpecialsMapping[episode.Id] = previousEpisode.Id; + specialsAnchorDictionary[episode.Id] = previousEpisode; } } // While the filtered specials list is ordered by episode number - filteredSpecialsList = filteredSpecialsList + specialsList = specialsList .OrderBy(e => e.AniDB.EpisodeNumber) .ToList(); @@ -461,10 +460,10 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = AniDB = aniDb, TvDBId = tvDbId != 0 ? tvDbId.ToString() : null, TvDB = tvDbId != 0 ? (await ShokoAPI.GetSeriesTvDB(seriesId)).FirstOrDefault() : null, - EpisodeList = episodeList, - EpisodeCount = episodeCount, - SpesialsAnchors = filteredSpecialsMapping, - SpecialsList = filteredSpecialsList, + RawEpisodeList = allEpisodesList, + EpisodeList = episodesList, + SpesialsAnchors = specialsAnchorDictionary, + SpecialsList = specialsList, }; DataCache.Set<SeriesInfo>(cacheKey, info, DefaultTimeSpan); diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index c53ef2c8..354661c4 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -39,6 +39,10 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell { try { var result = new MetadataResult<Episode>(); + + // Don't provide metadata for missing episodes... for now. + if (info.IsMissingEpisode || info.Path == null) return result; + var config = Plugin.Instance.Configuration; Ordering.GroupFilterType? filterByType = config.SeriesGrouping == Ordering.GroupType.ShokoGroup ? config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default : null; var (file, episode, series, group) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); @@ -70,7 +74,7 @@ public static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo serie => CreateMetadata(group, series, episode, null, null, season, episodeId); public static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo series, Info.EpisodeInfo episode, string fileId, string metadataLanguage) - => CreateMetadata(group, series, episode, metadataLanguage, fileId, null, Guid.Empty); + => CreateMetadata(group, series, episode, fileId, metadataLanguage, null, Guid.Empty); private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo series, Info.EpisodeInfo episode, string fileId, string metadataLanguage, Season season, System.Guid episodeId) { @@ -118,23 +122,25 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri Episode result; if (group != null && episode.AniDB.Type == EpisodeType.Special) { - int previousEpisodeNumber; - if (!series.SpesialsAnchors.TryGetValue(episode.Id, out var previousEpisode)) - previousEpisodeNumber = Ordering.GetEpisodeNumber(group, series, episode); - else - previousEpisodeNumber = series.EpisodeCount; + int? previousEpisodeNumber = null; + if (series.SpesialsAnchors.TryGetValue(episode.Id, out var previousEpisode)) + previousEpisodeNumber = Ordering.GetEpisodeNumber(group, series, previousEpisode); + int? nextEpisodeNumber = previousEpisodeNumber.HasValue && previousEpisodeNumber.Value < series.EpisodeList.Count ? previousEpisodeNumber.Value + 1 : null; if (season != null) { result = new Episode { Name = displayTitle, OriginalTitle = alternateTitle, - IndexNumber = Ordering.GetEpisodeNumber(group, series, episode), - ParentIndexNumber = Ordering.GetSeasonNumber(group, series, episode), + IndexNumber = episodeNumber, + ParentIndexNumber = 0, + AirsAfterSeasonNumber = seasonNumber, + AirsBeforeEpisodeNumber = nextEpisodeNumber, + AirsBeforeSeasonNumber = seasonNumber + 1, Id = episodeId, IsVirtualItem = true, SeasonId = season.Id, SeriesId = season.Series.Id, - Overview = Text.GetDescription(episode), - CommunityRating = episode.AniDB.Rating.ToFloat(), + Overview = description, + CommunityRating = episode.AniDB.Rating.ToFloat(10), PremiereDate = episode.AniDB.AirDate, SeriesName = season.Series.Name, SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, @@ -148,7 +154,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri IndexNumber = episodeNumber, ParentIndexNumber = 0, AirsAfterSeasonNumber = seasonNumber, - AirsBeforeEpisodeNumber = previousEpisodeNumber + 1, + AirsBeforeEpisodeNumber = nextEpisodeNumber, AirsBeforeSeasonNumber = seasonNumber + 1, Name = displayTitle, OriginalTitle = alternateTitle, @@ -159,15 +165,37 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri } } else { - result = new Episode { - IndexNumber = episodeNumber, - ParentIndexNumber = seasonNumber, - Name = displayTitle, - OriginalTitle = alternateTitle, - PremiereDate = episode.AniDB.AirDate, - Overview = description, - CommunityRating = episode.AniDB.Rating.ToFloat(10), - }; + if (season != null) { + result = new Episode { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, + Id = episodeId, + IsVirtualItem = true, + SeasonId = season.Id, + SeriesId = season.Series.Id, + Overview = description, + CommunityRating = episode.AniDB.Rating.ToFloat(10), + PremiereDate = episode.AniDB.AirDate, + SeriesName = season.Series.Name, + SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, + SeasonName = season.Name, + DateLastSaved = DateTime.UtcNow, + }; + result.PresentationUniqueKey = result.GetPresentationUniqueKey(); + } + else { + result = new Episode { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, + PremiereDate = episode.AniDB.AirDate, + Overview = description, + CommunityRating = episode.AniDB.Rating.ToFloat(10), + }; + } } // NOTE: This next line will remain here till they fix the series merging for providers outside the MetadataProvider enum. if (config.SeriesGrouping == Ordering.GroupType.ShokoGroup) diff --git a/Shokofin/Providers/MissingMetadataProvider.cs b/Shokofin/Providers/MissingMetadataProvider.cs index 59b76885..a3e6571a 100644 --- a/Shokofin/Providers/MissingMetadataProvider.cs +++ b/Shokofin/Providers/MissingMetadataProvider.cs @@ -162,15 +162,14 @@ private void HandleSeries(Series series) var seasons = new Dictionary<int, Season>(); var existingEpisodes = new HashSet<string>(); - foreach (var child in series.GetRecursiveChildren()) switch (child) { + foreach (var item in series.GetRecursiveChildren()) switch (item) { case Season season: if (season.IndexNumber.HasValue) seasons.TryAdd(season.IndexNumber.Value, season); break; case Episode episode: - if (!episode.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) || string.IsNullOrEmpty(episodeId)) - continue; - existingEpisodes.Add(episodeId); + if (episode.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) && !string.IsNullOrEmpty(episodeId)) + existingEpisodes.Add(episodeId); break; } @@ -183,6 +182,20 @@ private void HandleSeries(Series series) seasons.Add(pair.Key, pair.Value); } + // Handle specials when grouped. + if (seasons.TryGetValue(0, out var zeroSeason)) { + foreach (var sI in groupInfo.SeriesList) { + foreach (var episodeInfo in sI.SpecialsList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + AddVirtualEpisode(groupInfo, sI, episodeInfo, zeroSeason); + } + } + + return; + } + // Add missing episodes foreach (var (seriesInfo, index) in groupInfo.SeriesList.Select((s, i) => (s, i))) { var value = index - groupInfo.DefaultSeriesIndex; @@ -227,14 +240,15 @@ private void HandleSeries(Series series) private void HandleSeason(Season season, bool deleted = false) { - if (!(season.ProviderIds.TryGetValue("Shoko Series", out var seriesId) || season.Series.ProviderIds.TryGetValue("Shoko Series", out seriesId)) || string.IsNullOrEmpty(seriesId)) + if (!season.IndexNumber.HasValue || !(season.ProviderIds.TryGetValue("Shoko Series", out var seriesId) || season.Series.ProviderIds.TryGetValue("Shoko Series", out seriesId)) || string.IsNullOrEmpty(seriesId)) return; var seasonNumber = season.IndexNumber!.Value; - var existingEpisodes = season.Children.OfType<Episode>() - .Where(ep => ep.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) && !string.IsNullOrEmpty(episodeId)) - .Select(ep => ep.ProviderIds["Shoko Episode"]) - .ToHashSet(); + var existingEpisodes = new HashSet<string>(); + foreach (var item in season.Children.OfType<Episode>()) { + if (item.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) && !string.IsNullOrEmpty(episodeId)) + existingEpisodes.Add(episodeId); + } var series = season.Series; Info.GroupInfo groupInfo = null; Info.SeriesInfo seriesInfo = null; @@ -247,6 +261,23 @@ private void HandleSeason(Season season, bool deleted = false) return; } + // Handle specials when grouped. + if (seasonNumber == 0) { + if (deleted) + season = AddVirtualSeason(0, series); + + foreach (var sI in groupInfo.SeriesList) { + foreach (var episodeInfo in sI.SpecialsList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + } + } + + return; + } + int seriesIndex = seasonNumber > 0 ? seasonNumber - 1 : seasonNumber; var index = groupInfo.DefaultSeriesIndex + seriesIndex; seriesInfo = groupInfo.SeriesList[index]; @@ -393,7 +424,7 @@ private void AddVirtualEpisode(Info.GroupInfo groupInfo, Info.SeriesInfo seriesI var episodeId = LibraryManager.GetNewItemId(season.Series.Id + "Season " + seriesInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); var result = EpisodeProvider.CreateMetadata(groupInfo, seriesInfo, episodeInfo, season, episodeId); - Logger.LogInformation("Creating virtual episode {EpisodeName} S{SeasonNumber}:E{EpisodeNumber}", season.Series.Name, season.IndexNumber, result.IndexNumber); + Logger.LogInformation("Creating virtual episode for {SeriesName} S{SeasonNumber}:E{EpisodeNumber} (Group={GroupId},Series={SeriesId},Episode={EpisodeId}),", groupInfo?.Shoko.Name ?? seriesInfo.Shoko.Name, season.IndexNumber, result.IndexNumber, groupInfo?.Id ?? null, seriesInfo.Id, episodeInfo.Id); season.AddChild(result, CancellationToken.None); } diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 39a0c2bc..4cfad647 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -29,12 +29,15 @@ public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvid public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) { + if (!info.IndexNumber.HasValue) { + return new MetadataResult<Season>(); + } try { switch (Plugin.Instance.Configuration.SeriesGrouping) { default: return GetDefaultMetadata(info, cancellationToken); case Ordering.GroupType.ShokoGroup: - if (info.IndexNumber.HasValue && info.IndexNumber.Value == 0) + if (info.IndexNumber.Value == 0) goto default; return await GetShokoGroupedMetadata(info, cancellationToken); } @@ -49,7 +52,7 @@ private MetadataResult<Season> GetDefaultMetadata(SeasonInfo info, CancellationT { var result = new MetadataResult<Season>(); - var seasonName = GetSeasonName(info.IndexNumber, info.Name); + var seasonName = GetSeasonName(info.IndexNumber.Value, info.Name); result.Item = new Season { Name = seasonName, IndexNumber = info.IndexNumber, @@ -71,11 +74,11 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in return result; } - var seasonNumber = info.IndexNumber ?? 1; + var seasonNumber = info.IndexNumber.Value; var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes; var series = await ApiManager.GetSeriesInfoFromGroup(groupId, seasonNumber, filterLibrary ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); if (series == null) { - Logger.LogWarning("Unable to find selected series in Group (Group={GroupId})", groupId); + Logger.LogWarning("Unable to find series for season {SeasonNumber} in Group. (Group={GroupId})", seasonNumber, groupId); return result; } Logger.LogInformation("Found Series {SeriesName} (Group={GroupId},Series={SeriesId})", series.Shoko.Name, groupId, series.Id); @@ -121,9 +124,9 @@ public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } - private string GetSeasonName(int? seasonNumber, string seasonName) + private string GetSeasonName(int seasonNumber, string seasonName) { - switch (seasonNumber ?? 1) { + switch (seasonNumber) { case -1: return "Credits"; case -2: From b00b51a12ebed1cfbe48ebc551360cdf0154c632 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 6 Sep 2021 21:28:41 +0000 Subject: [PATCH 0154/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index c10f9b60..f6102c13 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.11", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.11/shokofin_1.5.0.11.zip", + "checksum": "50a625c223c2b875bdf20e4b213d9f8c", + "timestamp": "2021-09-06T21:28:40Z" + }, { "version": "1.5.0.10", "changelog": "NA", From 164a667afec300b6b80aca367d5f6189b5578752 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 6 Sep 2021 23:37:56 +0200 Subject: [PATCH 0155/1103] Delete virtual items and force a re-fetch of real children --- Shokofin/Providers/MissingMetadataProvider.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Shokofin/Providers/MissingMetadataProvider.cs b/Shokofin/Providers/MissingMetadataProvider.cs index a3e6571a..4aeca609 100644 --- a/Shokofin/Providers/MissingMetadataProvider.cs +++ b/Shokofin/Providers/MissingMetadataProvider.cs @@ -160,6 +160,13 @@ private void HandleSeries(Series series) if (!series.ProviderIds.TryGetValue("Shoko Series", out var seriesId) || string.IsNullOrEmpty(seriesId)) return; + var deleteOptions = new DeleteOptions { + DeleteFileLocation = true, + }; + foreach (var item in series.GetRecursiveChildren().Where(item => item.IsVirtualItem)) { + LibraryManager.DeleteItem(item, deleteOptions, true); + } + var seasons = new Dictionary<int, Season>(); var existingEpisodes = new HashSet<string>(); foreach (var item in series.GetRecursiveChildren()) switch (item) { @@ -243,9 +250,16 @@ private void HandleSeason(Season season, bool deleted = false) if (!season.IndexNumber.HasValue || !(season.ProviderIds.TryGetValue("Shoko Series", out var seriesId) || season.Series.ProviderIds.TryGetValue("Shoko Series", out seriesId)) || string.IsNullOrEmpty(seriesId)) return; + var deleteOptions = new DeleteOptions { + DeleteFileLocation = true, + }; + foreach (var item in season.Children.Where(item => item.IsVirtualItem)) { + LibraryManager.DeleteItem(item, deleteOptions, true); + } + var seasonNumber = season.IndexNumber!.Value; var existingEpisodes = new HashSet<string>(); - foreach (var item in season.Children.OfType<Episode>()) { + foreach (var item in season.GetEpisodes()) { if (item.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) && !string.IsNullOrEmpty(episodeId)) existingEpisodes.Add(episodeId); } From a1a5c3a71568beb836281f531426f7a811e660cf Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 6 Sep 2021 21:38:41 +0000 Subject: [PATCH 0156/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index f6102c13..7fa21a8d 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.12", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.12/shokofin_1.5.0.12.zip", + "checksum": "c549d18c15a71ddd808494e079d2b8ab", + "timestamp": "2021-09-06T21:38:39Z" + }, { "version": "1.5.0.11", "changelog": "NA", From bad9b559c92ae65acc9bb78bad4bd7054837de1e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 8 Sep 2021 21:55:57 +0200 Subject: [PATCH 0157/1103] Revert "Cleanup: Series id is not used for movies anymore" This reverts commit 1c62c53edb78c86f24c8ec4c29f70c7bbb3cec72. --- Shokofin/ExternalIds.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/ExternalIds.cs b/Shokofin/ExternalIds.cs index b908838a..d87e38b2 100644 --- a/Shokofin/ExternalIds.cs +++ b/Shokofin/ExternalIds.cs @@ -27,7 +27,7 @@ public string UrlFormatString public class ShokoSeriesExternalId : IExternalId { public bool Supports(IHasProviderIds item) - => item is Series || item is BoxSet; + => item is Series || item is Movie || item is BoxSet; public string ProviderName => "Shoko Series"; From 53aa6fac782d6463524dc0499dae612213b623ce Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 8 Sep 2021 21:58:18 +0200 Subject: [PATCH 0158/1103] Update image provider --- Shokofin/API/Info/GroupInfo.cs | 13 +++++ Shokofin/API/ShokoAPIManager.cs | 8 +-- Shokofin/Providers/ImageProvider.cs | 79 +++++++++++++++++++---------- Shokofin/Providers/MovieProvider.cs | 1 + 4 files changed, 68 insertions(+), 33 deletions(-) diff --git a/Shokofin/API/Info/GroupInfo.cs b/Shokofin/API/Info/GroupInfo.cs index ccb75d10..ca2bfa8b 100644 --- a/Shokofin/API/Info/GroupInfo.cs +++ b/Shokofin/API/Info/GroupInfo.cs @@ -10,6 +10,19 @@ public class GroupInfo public Group Shoko; + public SeriesInfo GetSeriesInfoBySeasonNumber(int seasonNumber) { + if (seasonNumber == 0) + return null; + + int seriesIndex = seasonNumber > 0 ? seasonNumber - 1 : seasonNumber; + var index = DefaultSeriesIndex + seriesIndex; + var seriesInfo = SeriesList[index]; + if (seriesInfo == null) + return null; + + return seriesInfo; + } + public List<SeriesInfo> SeriesList; public SeriesInfo DefaultSeries; diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 4d276423..c0727ecb 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -347,13 +347,7 @@ public async Task<SeriesInfo> GetSeriesInfoFromGroup(string groupId, int seasonN var groupInfo = await GetGroupInfo(groupId, filterByType); if (groupInfo == null) return null; - int seriesIndex = seasonNumber > 0 ? seasonNumber - 1 : seasonNumber; - var index = groupInfo.DefaultSeriesIndex + seriesIndex; - var seriesInfo = groupInfo.SeriesList[index]; - if (seriesInfo == null) - return null; - - return seriesInfo; + return groupInfo.GetSeriesInfoBySeasonNumber(seasonNumber); } public SeriesInfo GetSeriesInfoSync(string seriesId) { diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 50fd2593..356b2a27 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -35,44 +35,71 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell { var list = new List<RemoteImageInfo>(); try { - Shokofin.API.Info.EpisodeInfo episode = null; - Shokofin.API.Info.SeriesInfo series = null; - if (item is Episode) { - episode = await ApiManager.GetEpisodeInfo(item.GetProviderId("Shoko Episode")); - } - else if (item is Series) { - var groupId = item.GetProviderId("Shoko Group"); - if (string.IsNullOrEmpty(groupId)) - series = await ApiManager.GetSeriesInfo(item.GetProviderId("Shoko Series")); - else - series = (await ApiManager.GetGroupInfo(groupId))?.DefaultSeries; - } - else if (item is BoxSet || item is Movie) { - series = await ApiManager.GetSeriesInfo(item.GetProviderId("Shoko Series")); - } - else if (item is Season) { - series = await ApiManager.GetSeriesInfoFromGroup(item.GetParent()?.GetProviderId("Shoko Group"), item.IndexNumber ?? 1); + Shokofin.API.Info.EpisodeInfo episodeInfo = null; + Shokofin.API.Info.SeriesInfo seriesInfo = null; + + var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Utils.Ordering.GroupFilterType.Others : Utils.Ordering.GroupFilterType.Default; + switch (item) { + case Episode: { + if (item.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) && !string.IsNullOrEmpty(episodeId)) { + episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); + if (episodeInfo != null) + Logger.LogInformation("Getting images for episode {EpisodeName} (Episode={EpisodeId})", episodeInfo.Shoko.Name, episodeId); + } + break; + } + case Series: { + if (item.ProviderIds.TryGetValue("Shoko Group", out var groupId) && !string.IsNullOrEmpty(groupId)) { + var groupInfo = await ApiManager.GetGroupInfo(groupId, filterLibrary); + seriesInfo = groupInfo?.DefaultSeries; + if (seriesInfo != null) + Logger.LogInformation("Getting images for series {SeriesName} (Series={SeriesId},Group={GroupId})", groupInfo.Shoko.Name, seriesInfo.Id, groupId); + } + else if (item.ProviderIds.TryGetValue("Shoko Series", out var seriesId) && !string.IsNullOrEmpty(seriesId)) { + seriesInfo = await ApiManager.GetSeriesInfo(seriesId); + if (seriesInfo != null) + Logger.LogInformation("Getting images for series {SeriesName} (Series={SeriesId})", seriesInfo.Shoko.Name, seriesId); + } + break; + } + case Season season: { + if (season.IndexNumber.HasValue && season.Series.ProviderIds.TryGetValue("Shoko Group", out var groupId) && !string.IsNullOrEmpty(groupId)) { + var groupInfo = await ApiManager.GetGroupInfo(groupId, filterLibrary); + seriesInfo = groupInfo?.GetSeriesInfoBySeasonNumber(season.IndexNumber.Value); + if (seriesInfo != null) + Logger.LogInformation("Getting images for season {SeasonNumber} in {SeriesName} (Series={SeriesId},Group={GroupId})", season.IndexNumber.Value, groupInfo.Shoko.Name, seriesInfo.Id, groupId); + } + break; + } + case Movie: + case BoxSet: { + if (item.ProviderIds.TryGetValue("Shoko Series", out var seriesId) && !string.IsNullOrEmpty(seriesId)) { + seriesInfo = await ApiManager.GetSeriesInfo(seriesId); + if (seriesInfo != null) + Logger.LogInformation("Getting images for movie or box-set {MovieName} (Series={SeriesId})", seriesInfo.Shoko.Name, seriesId); + } + break; + } } - if (episode != null) { - Logger.LogInformation($"Getting episode images ({episode.Id} - {item.Name})"); - AddImage(ref list, ImageType.Primary, episode?.TvDB?.Thumbnail); + + if (episodeInfo != null) { + AddImage(ref list, ImageType.Primary, episodeInfo?.TvDB?.Thumbnail); } - if (series != null) { - Logger.LogInformation($"Getting series images ({series.Id} - {item.Name})"); - var images = series.Shoko.Images; + if (seriesInfo != null) { + var images = seriesInfo.Shoko.Images; if (Plugin.Instance.Configuration.PreferAniDbPoster) - AddImage(ref list, ImageType.Primary, series.AniDB.Poster); + AddImage(ref list, ImageType.Primary, seriesInfo.AniDB.Poster); foreach (var image in images?.Posters) AddImage(ref list, ImageType.Primary, image); if (!Plugin.Instance.Configuration.PreferAniDbPoster) - AddImage(ref list, ImageType.Primary, series.AniDB.Poster); + AddImage(ref list, ImageType.Primary, seriesInfo.AniDB.Poster); foreach (var image in images?.Fanarts) AddImage(ref list, ImageType.Backdrop, image); foreach (var image in images?.Banners) AddImage(ref list, ImageType.Banner, image); } - Logger.LogInformation($"List got {list.Count} item(s)."); + Logger.LogInformation("List got {Count} item(s). (Series={SeriesId},Episode={EpisodeId})", list.Count, seriesInfo?.Id ?? null, episodeInfo?.Id ?? null); return list; } catch (Exception e) { diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index d850fda5..ce4200af 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -67,6 +67,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio }; result.Item.SetProviderId("Shoko File", file.Id); result.Item.SetProviderId("Shoko Episode", episode.Id); + result.Item.SetProviderId("Shoko Series", series.Id); if (config.AddAniDBId) result.Item.SetProviderId("AniDB", episode.AniDB.ID.ToString()); if (config.BoxSetGrouping == Ordering.GroupType.MergeFriendly && episode.TvDB != null && config.BoxSetGrouping != Ordering.GroupType.ShokoGroup) From 2b5f8f5e8ccd01fb5b9795ba8b713d94073b1599 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 9 Sep 2021 00:14:43 +0200 Subject: [PATCH 0159/1103] Provide metadata for virtual episodes --- Shokofin/API/Info/FileInfo.cs | 2 +- Shokofin/API/ShokoAPIManager.cs | 2 +- Shokofin/Providers/EpisodeProvider.cs | 47 ++++++++++++++++++++------- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/Shokofin/API/Info/FileInfo.cs b/Shokofin/API/Info/FileInfo.cs index 20d7576a..9360011c 100644 --- a/Shokofin/API/Info/FileInfo.cs +++ b/Shokofin/API/Info/FileInfo.cs @@ -8,6 +8,6 @@ public class FileInfo public File Shoko; - public int EpisodesCount; + public int ExtraEpisodesCount; } } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index c0727ecb..39c37856 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -212,7 +212,7 @@ private FileInfo CreateFileInfo(File file, string fileId = null, int episodeCoun { Id = fileId, Shoko = file, - + ExtraEpisodesCount = episodeCount - 1, }; DataCache.Set<FileInfo>(cacheKey, info, DefaultTimeSpan); return info; diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 354661c4..f3c48e74 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -39,28 +39,51 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell { try { var result = new MetadataResult<Episode>(); - - // Don't provide metadata for missing episodes... for now. - if (info.IsMissingEpisode || info.Path == null) return result; - var config = Plugin.Instance.Configuration; Ordering.GroupFilterType? filterByType = config.SeriesGrouping == Ordering.GroupType.ShokoGroup ? config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default : null; - var (file, episode, series, group) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); - // if file is null then series and episode is also null. - if (file == null) { + // Fetch the episode, series and group info (and file info, but that's not really used (yet)) + Info.FileInfo fileInfo = null; + Info.EpisodeInfo episodeInfo = null; + Info.SeriesInfo seriesInfo = null; + Info.GroupInfo groupInfo = null; + if (info.IsMissingEpisode || info.Path == null) { + // We're unable to fetch the latest metadata for the virtual episode. + if (!info.ProviderIds.TryGetValue("Shoko Episode", out var episodeId)) + return result; + + episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); + if (episodeInfo == null) + return result; + + seriesInfo = await ApiManager.GetSeriesInfoForEpisode(episodeId); + if (seriesInfo == null) + return result; + + groupInfo = filterByType.HasValue ? (await ApiManager.GetGroupInfoForSeries(seriesInfo.Id, filterByType.Value)) : null; + } + else { + (fileInfo, episodeInfo, seriesInfo, groupInfo) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); + } + + // if the episode info is null then the series info and conditionally the group info is also null. + if (episodeInfo == null) { Logger.LogWarning("Unable to find episode info for path {Path}", info.Path); return result; } - result.Item = CreateMetadata(group, series, episode, file.Id, info.MetadataLanguage); - Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", result.Item.Name, file.Id, episode.Id, series.Id); + var fileId = fileInfo?.Id ?? null; + result.Item = CreateMetadata(groupInfo, seriesInfo, episodeInfo, fileId, info.MetadataLanguage); + Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", result.Item.Name, fileId, episodeInfo.Id, seriesInfo.Id); result.HasMetadata = true; - var episodeNumberEnd = episode.AniDB.EpisodeNumber + file.EpisodesCount; - if (episode.AniDB.EpisodeNumber != episodeNumberEnd) - result.Item.IndexNumberEnd = episodeNumberEnd; + if (fileInfo != null) { + var episodeNumberEnd = episodeInfo.AniDB.EpisodeNumber + fileInfo.ExtraEpisodesCount; + if (episodeInfo.AniDB.EpisodeNumber != episodeNumberEnd) + result.Item.IndexNumberEnd = episodeNumberEnd; + } + return result; } From a616658bd9cbc48aff8ceb27c18c6af18a10b8ef Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 9 Sep 2021 00:28:21 +0200 Subject: [PATCH 0160/1103] Update season provider --- Shokofin/Providers/SeasonProvider.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 4cfad647..1fbf355e 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -75,17 +75,18 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in } var seasonNumber = info.IndexNumber.Value; - var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes; - var series = await ApiManager.GetSeriesInfoFromGroup(groupId, seasonNumber, filterLibrary ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); - if (series == null) { - Logger.LogWarning("Unable to find series for season {SeasonNumber} in Group. (Group={GroupId})", seasonNumber, groupId); + var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; + var group = await ApiManager.GetGroupInfo(groupId, filterLibrary); + var series = group?.GetSeriesInfoBySeasonNumber(seasonNumber); + if (group == null || series == null) { + Logger.LogWarning("Unable to find info for Season {SeasonNumber} in Series {SeriesName}. (Group={GroupId})", seasonNumber, group.Shoko.Name, groupId); return result; } - Logger.LogInformation("Found Series {SeriesName} (Group={GroupId},Series={SeriesId})", series.Shoko.Name, groupId, series.Id); + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Group={GroupId},Series={SeriesId})", seasonNumber, group.Shoko.Name, groupId, series.Id); var tags = await ApiManager.GetTags(series.Id); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); - var sortTitle = $"S{seasonNumber} - {series.Shoko.Name}"; + var sortTitle = $"I{seasonNumber} - {series.Shoko.Name}"; result.Item = new Season { Name = displayTitle, From 1fd60352b58f04071f471423adcc49174a138690 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 9 Sep 2021 00:28:45 +0200 Subject: [PATCH 0161/1103] Update box-set provider --- Shokofin/Providers/BoxSetProvider.cs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 8c4126e9..7d699afc 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -53,14 +53,12 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, Ca var series = await ApiManager.GetSeriesInfoByPath(info.Path); if (series == null) { - Logger.LogWarning($"Unable to find series info for path {info.Path}"); + Logger.LogWarning("Unable to find movie box-set info for path {Path}", info.Path); return result; } - int aniDBId = series.AniDB.ID; - - if (series.Shoko.Sizes.Total.Episodes <= 1) { - Logger.LogWarning($"series did not contain multiple movies! Skipping path {info.Path}"); + if (series.EpisodeList.Count <= 1) { + Logger.LogWarning("Series did not contain multiple movies! Skipping path {Path} (Series={SeriesId})", info.Path, series.Id); return result; } @@ -93,16 +91,16 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in Ordering.GroupFilterType filterByType = config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Movies : Ordering.GroupFilterType.Default; var group = await ApiManager.GetGroupInfoByPath(info.Path, filterByType); if (group == null) { - Logger.LogWarning($"Unable to find box-set info for path {info.Path}"); + Logger.LogWarning("Unable to find movie box-set info for path {Path}", info.Path); return result; } var series = group.DefaultSeries; - if (series.AniDB.Type != API.Models.SeriesType.Movie) { - Logger.LogWarning($"File found, but not a movie! Skipping."); + + if (group.SeriesList.Count <= 1 && series.EpisodeList.Count <= 1) { + Logger.LogWarning("Group did not contain multiple movies! Skipping path {Path} (Series={SeriesId},Group={GroupId})", info.Path, group.Id, series.Id); return result; } - var tags = await ApiManager.GetTags(series.Id); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); From 13bfd44cb326fb279ba28d12643b5aa25d5dcd89 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 9 Sep 2021 00:30:15 +0200 Subject: [PATCH 0162/1103] Hopefully fix the missing metadata provider --- Shokofin/API/ShokoAPIManager.cs | 32 +++++++++++++ Shokofin/LibraryScanner.cs | 5 ++- Shokofin/Providers/MissingMetadataProvider.cs | 45 +++++++++---------- 3 files changed, 56 insertions(+), 26 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 39c37856..449a2196 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -28,6 +28,8 @@ public class ShokoAPIManager private static ConcurrentDictionary<string, string> SeriesIdToGroupIdDictionary = new ConcurrentDictionary<string, string>(); + private static ConcurrentDictionary<string, string> EpisodePathToEpisodeIdDirectory = new ConcurrentDictionary<string, string>(); + private static ConcurrentDictionary<string, string> EpisodeIdToSeriesIdDictionary = new ConcurrentDictionary<string, string>(); /// <summary> @@ -35,6 +37,11 @@ public class ShokoAPIManager /// </summary> private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, string>> SeriesIdToEpisodeIdIgnoreDictionery = new ConcurrentDictionary<string, ConcurrentDictionary<string, string>>(); + /// <summary> + /// Episodes found while scanning the library for metadata. + /// </summary> + private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, string>> SeriesIdToEpisodeIdDictionery = new ConcurrentDictionary<string, ConcurrentDictionary<string, string>>(); + public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ILibraryManager libraryManager) { Logger = logger; @@ -90,7 +97,9 @@ public void Clear() DataCache.Dispose(); MediaFolderList.Clear(); EpisodeIdToSeriesIdDictionary.Clear(); + EpisodePathToEpisodeIdDirectory.Clear(); SeriesPathToIdDictionary.Clear(); + SeriesIdToEpisodeIdDictionery.Clear(); SeriesIdToEpisodeIdIgnoreDictionery.Clear(); SeriesIdToGroupIdDictionary.Clear(); DataCache = (new MemoryCache((new MemoryCacheOptions() { @@ -266,11 +275,34 @@ private async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string episod public bool MarkEpisodeAsIgnored(string episodeId, string seriesId, string fullPath) { + EpisodePathToEpisodeIdDirectory.TryAdd(fullPath, episodeId); + if (!(SeriesIdToEpisodeIdIgnoreDictionery.TryGetValue(seriesId, out var dictionary) || SeriesIdToEpisodeIdIgnoreDictionery.TryAdd(seriesId, dictionary = new ConcurrentDictionary<string, string>()))) + return false; + return dictionary.TryAdd(episodeId, fullPath); + } + + public bool MarkEpisodeAsFound(string episodeId, string seriesId, string fullPath) + { + EpisodePathToEpisodeIdDirectory.TryAdd(fullPath, episodeId); if (!(SeriesIdToEpisodeIdIgnoreDictionery.TryGetValue(seriesId, out var dictionary) || SeriesIdToEpisodeIdIgnoreDictionery.TryAdd(seriesId, dictionary = new ConcurrentDictionary<string, string>()))) return false; return dictionary.TryAdd(episodeId, fullPath); } + public bool TryGetEpisodeIdForPath(string fullPath, out string episodeId) + { + if (string.IsNullOrEmpty(fullPath)) { + episodeId = null; + return false; + } + return EpisodePathToEpisodeIdDirectory.TryGetValue(fullPath, out episodeId); + } + + public bool IsEpisodeOnDisk(EpisodeInfo episodeInfo, SeriesInfo seriesInfo) + { + return SeriesIdToEpisodeIdDictionery.TryGetValue(seriesInfo.Id, out var dictionary) && dictionary.ContainsKey(episodeInfo.Id); + } + private static ExtraType? GetExtraType(Episode.AniDB episode) { switch (episode.Type) diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index b36fc411..5860bfda 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -86,7 +86,7 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy break; case "tvshows": if (series.AniDB.Type == SeriesType.Movie) { - Logger.LogInformation("Library seperatation is enabled, ignoring series. (Series={SeriesId}", series.Id); + Logger.LogInformation("Library seperatation is enabled, ignoring series. (Series={SeriesId})", series.Id); return true; } @@ -96,7 +96,7 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy break; case "movies": if (series.AniDB.Type != SeriesType.Movie) { - Logger.LogInformation("Library seperatation is enabled, ignoring series. (Series={SeriesId}", series.Id); + Logger.LogInformation("Library seperatation is enabled, ignoring series. (Series={SeriesId})", series.Id); return true; } @@ -132,6 +132,7 @@ private bool ScanFile(string partialPath, string fullPath) return true; } + ApiManager.MarkEpisodeAsFound(episode.Id, series.Id, fullPath); return false; } } diff --git a/Shokofin/Providers/MissingMetadataProvider.cs b/Shokofin/Providers/MissingMetadataProvider.cs index 4aeca609..2dc10f54 100644 --- a/Shokofin/Providers/MissingMetadataProvider.cs +++ b/Shokofin/Providers/MissingMetadataProvider.cs @@ -160,13 +160,6 @@ private void HandleSeries(Series series) if (!series.ProviderIds.TryGetValue("Shoko Series", out var seriesId) || string.IsNullOrEmpty(seriesId)) return; - var deleteOptions = new DeleteOptions { - DeleteFileLocation = true, - }; - foreach (var item in series.GetRecursiveChildren().Where(item => item.IsVirtualItem)) { - LibraryManager.DeleteItem(item, deleteOptions, true); - } - var seasons = new Dictionary<int, Season>(); var existingEpisodes = new HashSet<string>(); foreach (var item in series.GetRecursiveChildren()) switch (item) { @@ -175,12 +168,18 @@ private void HandleSeries(Series series) seasons.TryAdd(season.IndexNumber.Value, season); break; case Episode episode: - if (episode.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) && !string.IsNullOrEmpty(episodeId)) + // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. + if (( + // This will account for virtual episodes and existing episodes + episode.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) || + // This will account for new episodes that haven't received their first metadata update yet + ApiManager.TryGetEpisodeIdForPath(episode.Path, out episodeId) + ) && !string.IsNullOrEmpty(episodeId)) existingEpisodes.Add(episodeId); break; } - // Provider metadata for a series using Shoko's Group feature + // Provide metadata for a series using Shoko's Group feature if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { var groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); @@ -250,26 +249,25 @@ private void HandleSeason(Season season, bool deleted = false) if (!season.IndexNumber.HasValue || !(season.ProviderIds.TryGetValue("Shoko Series", out var seriesId) || season.Series.ProviderIds.TryGetValue("Shoko Series", out seriesId)) || string.IsNullOrEmpty(seriesId)) return; - var deleteOptions = new DeleteOptions { - DeleteFileLocation = true, - }; - foreach (var item in season.Children.Where(item => item.IsVirtualItem)) { - LibraryManager.DeleteItem(item, deleteOptions, true); - } - var seasonNumber = season.IndexNumber!.Value; var existingEpisodes = new HashSet<string>(); - foreach (var item in season.GetEpisodes()) { - if (item.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) && !string.IsNullOrEmpty(episodeId)) + foreach (var episode in season.Children.OfType<Episode>()) { + // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. + if (( + // This will account for virtual episodes and existing episodes + episode.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) || + // This will account for new episodes that haven't received their first metadata update yet + ApiManager.TryGetEpisodeIdForPath(episode.Path, out episodeId) + ) && !string.IsNullOrEmpty(episodeId)) existingEpisodes.Add(episodeId); } + var series = season.Series; Info.GroupInfo groupInfo = null; Info.SeriesInfo seriesInfo = null; - + // Provide metadata for a season using Shoko's Group feature if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { - var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, filterLibrary); + groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); if (groupInfo == null) { Logger.LogWarning("Unable to find group info for series. (Series={SeriesId})", seriesId); return; @@ -292,9 +290,7 @@ private void HandleSeason(Season season, bool deleted = false) return; } - int seriesIndex = seasonNumber > 0 ? seasonNumber - 1 : seasonNumber; - var index = groupInfo.DefaultSeriesIndex + seriesIndex; - seriesInfo = groupInfo.SeriesList[index]; + seriesInfo = groupInfo.GetSeriesInfoBySeasonNumber(seasonNumber); if (seriesInfo == null) { Logger.LogWarning("Unable to find series info for {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, groupInfo.Id); return; @@ -303,6 +299,7 @@ private void HandleSeason(Season season, bool deleted = false) if (deleted) season = AddVirtualSeason(seriesInfo, seasonNumber, series); } + // Provide metadata for other seasons else { seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); if (seriesInfo == null) { From 394ca775455b780085c61319a50518f8e3dfedbe Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 8 Sep 2021 22:30:55 +0000 Subject: [PATCH 0163/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 7fa21a8d..444b0730 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.13", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.13/shokofin_1.5.0.13.zip", + "checksum": "4c4360c6139dccf1bb42a324273f5cd4", + "timestamp": "2021-09-08T22:30:53Z" + }, { "version": "1.5.0.12", "changelog": "NA", From f2d46255ab3a4f77deff596dc319b362dae1cdc9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 10 Sep 2021 22:49:24 +0200 Subject: [PATCH 0164/1103] Display special markings relative to season --- Shokofin/Providers/EpisodeProvider.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index f3c48e74..c0352c11 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -115,10 +115,13 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri var description = Text.GetDescription(episode); if (group != null && config.MarkSpecialsWhenGrouped && episode.AniDB.Type != EpisodeType.Normal) switch (episode.AniDB.Type) { - case EpisodeType.Special: - displayTitle = $"S{episodeNumber} {displayTitle}"; - alternateTitle = $"S{episodeNumber} {alternateTitle}"; + case EpisodeType.Special: { + // We're guaranteed to find the index, because otherwise it would've thrown when getting the episode number. + var index = series.SpecialsList.FindIndex(ep => ep == episode); + displayTitle = $"S{index + 1} {displayTitle}"; + alternateTitle = $"S{index + 1} {alternateTitle}"; break; + } case EpisodeType.ThemeSong: case EpisodeType.EndingSong: case EpisodeType.OpeningSong: From 449f2d22642679ac4891073b47c87691fcb2a142 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 10 Sep 2021 22:49:35 +0200 Subject: [PATCH 0165/1103] Log the group in the episode provider --- Shokofin/Providers/EpisodeProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index c0352c11..89dc9711 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -74,7 +74,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell var fileId = fileInfo?.Id ?? null; result.Item = CreateMetadata(groupInfo, seriesInfo, episodeInfo, fileId, info.MetadataLanguage); - Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", result.Item.Name, fileId, episodeInfo.Id, seriesInfo.Id); + Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},Group={GroupId})", result.Item.Name, fileId, episodeInfo.Id, seriesInfo.Id, groupInfo?.Id ?? null); result.HasMetadata = true; From c29a2cc7a04ab734146149d8274dac583776fd02 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 11 Sep 2021 00:27:33 +0200 Subject: [PATCH 0166/1103] Show Shoko Series Id for seasons --- Shokofin/ExternalIds.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/ExternalIds.cs b/Shokofin/ExternalIds.cs index d87e38b2..732963ea 100644 --- a/Shokofin/ExternalIds.cs +++ b/Shokofin/ExternalIds.cs @@ -27,7 +27,7 @@ public string UrlFormatString public class ShokoSeriesExternalId : IExternalId { public bool Supports(IHasProviderIds item) - => item is Series || item is Movie || item is BoxSet; + => item is Series || item is Season || item is Movie || item is BoxSet; public string ProviderName => "Shoko Series"; From 65abae9640dff786f2b83788b4048652e22db5e8 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 11 Sep 2021 00:28:31 +0200 Subject: [PATCH 0167/1103] Try to fix the metadata provider, but still fail miserably at it. Though it's still better off --- Shokofin/API/ShokoAPIManager.cs | 21 +- Shokofin/Providers/MissingMetadataProvider.cs | 230 +++++++++++------- 2 files changed, 159 insertions(+), 92 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 449a2196..f2749716 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -289,13 +289,13 @@ public bool MarkEpisodeAsFound(string episodeId, string seriesId, string fullPat return dictionary.TryAdd(episodeId, fullPath); } - public bool TryGetEpisodeIdForPath(string fullPath, out string episodeId) + public bool TryGetEpisodeIdForPath(string path, out string episodeId) { - if (string.IsNullOrEmpty(fullPath)) { + if (string.IsNullOrEmpty(path)) { episodeId = null; return false; } - return EpisodePathToEpisodeIdDirectory.TryGetValue(fullPath, out episodeId); + return EpisodePathToEpisodeIdDirectory.TryGetValue(path, out episodeId); } public bool IsEpisodeOnDisk(EpisodeInfo episodeInfo, SeriesInfo seriesInfo) @@ -352,16 +352,16 @@ public async Task<SeriesInfo> GetSeriesInfoByPath(string path) var partialPath = StripMediaFolder(path); Logger.LogDebug("Looking for series matching {Path}", partialPath); string seriesId; - if (SeriesPathToIdDictionary.ContainsKey(partialPath)) + if (SeriesPathToIdDictionary.ContainsKey(path)) { - seriesId = SeriesPathToIdDictionary[partialPath]; + seriesId = SeriesPathToIdDictionary[path]; } else { var result = await ShokoAPI.GetSeriesPathEndsWith(partialPath); seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); - SeriesPathToIdDictionary[partialPath] = seriesId; + SeriesPathToIdDictionary[path] = seriesId; } if (string.IsNullOrEmpty(seriesId)) @@ -430,6 +430,15 @@ public async Task<SeriesInfo> GetSeriesInfoForEpisode(string episodeId) return await GetSeriesInfo(seriesId); } + public bool TryGetSeriesIdForPath(string path, out string seriesId) + { + if (string.IsNullOrEmpty(path)) { + seriesId = null; + return false; + } + return SeriesPathToIdDictionary.TryGetValue(path, out seriesId); + } + private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = null) { if (series == null) diff --git a/Shokofin/Providers/MissingMetadataProvider.cs b/Shokofin/Providers/MissingMetadataProvider.cs index 2dc10f54..90720518 100644 --- a/Shokofin/Providers/MissingMetadataProvider.cs +++ b/Shokofin/Providers/MissingMetadataProvider.cs @@ -88,7 +88,7 @@ private void OnProviderManagerRefreshComplete(object sender, GenericEventArgs<Ba HandleSeries(series); break; case Season season: - HandleSeason(season); + HandleSeason(season, season.Series); break; } } @@ -96,44 +96,56 @@ private void OnProviderManagerRefreshComplete(object sender, GenericEventArgs<Ba // NOTE: Always delete stall metadata, even if we disabled the feature in the settings page. private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs itemChangeEventArgs) { - // Only interested in non-virtual Seasons and Episodes - if (itemChangeEventArgs.Item.IsVirtualItem - || !(itemChangeEventArgs.Item is Season || itemChangeEventArgs.Item is Episode)) - return; if (!IsEnabledForItem(itemChangeEventArgs.Item)) return; - // Abort if we're unable to get the shoko episode id - if (!itemChangeEventArgs.Item.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) || string.IsNullOrEmpty(episodeId)) - return; - - var indexNumber = itemChangeEventArgs.Item.IndexNumber; // If the item is an Episode, filter on ParentIndexNumber as well (season number) - int? parentIndexNumber = null; - if (itemChangeEventArgs.Item is Episode) - parentIndexNumber = itemChangeEventArgs.Item.ParentIndexNumber; - - var query = new InternalItemsQuery { - IsVirtualItem = true, - IndexNumber = indexNumber, - ParentIndexNumber = parentIndexNumber, - IncludeItemTypes = new [] { itemChangeEventArgs.Item.GetType().Name }, - Parent = itemChangeEventArgs.Parent, - GroupByPresentationUniqueKey = false, - DtoOptions = new DtoOptions(true), - }; - - var existingVirtualItems = LibraryManager.GetItemList(query); - - var deleteOptions = new DeleteOptions { - DeleteFileLocation = true, - }; + switch (itemChangeEventArgs.Item) { + // case Season season: { + // // Abort if we're unable to get the shoko episode id + // if (!IsEnabledForSeason(season, out var seriesId)) + // return; + // // Only interested in non-virtual Seasons and Episodes + // if (!season.IndexNumber.HasValue) + // return; + // + // + // break; + // } + case Episode episode: { + // Abort if we're unable to get the shoko episode id + if (!IsEnabledForEpisode(episode, out var episodeId)) + return; + + // Only interested in non-virtual Seasons and Episodes + if (episode.IsVirtualItem) + return; + + var query = new InternalItemsQuery { + IsVirtualItem = true, + IndexNumber = episode.IndexNumber, + ParentIndexNumber = episode.ParentIndexNumber, + IncludeItemTypes = new [] { episode.GetType().Name }, + Parent = itemChangeEventArgs.Parent, + GroupByPresentationUniqueKey = false, + DtoOptions = new DtoOptions(true), + }; + + var existingVirtualItems = LibraryManager.GetItemList(query); + + var deleteOptions = new DeleteOptions { + DeleteFileLocation = true, + }; + + // Remove the virtual season/episode that matches the newly updated item + foreach (var item in existingVirtualItems) + LibraryManager.DeleteItem(item, deleteOptions); + break; + } + } - // Remove the virtual season/episode that matches the newly updated item - foreach (var item in existingVirtualItems) - LibraryManager.DeleteItem(item, deleteOptions); } private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs itemChangeEventArgs) @@ -145,7 +157,7 @@ private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs item switch (itemChangeEventArgs.Item) { // Create a new virtual season if the real one was deleted. case Season season: - HandleSeason(season, true); + HandleSeason(season, itemChangeEventArgs.Parent as Series, true); break; // Similarly, create a new virtual episode if the real one was deleted. case Episode episode: @@ -154,52 +166,49 @@ private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs item } } + private bool IsEnabledForSeries(Series series, out string seriesId) + { + return (series.ProviderIds.TryGetValue("Shoko Series", out seriesId) || ApiManager.TryGetSeriesIdForPath(series.Path, out seriesId)) && !string.IsNullOrEmpty(seriesId); + } + private void HandleSeries(Series series) { // Abort if we're unable to get the series id - if (!series.ProviderIds.TryGetValue("Shoko Series", out var seriesId) || string.IsNullOrEmpty(seriesId)) + if (!IsEnabledForSeries(series, out var seriesId)) return; - var seasons = new Dictionary<int, Season>(); - var existingEpisodes = new HashSet<string>(); - foreach (var item in series.GetRecursiveChildren()) switch (item) { - case Season season: - if (season.IndexNumber.HasValue) - seasons.TryAdd(season.IndexNumber.Value, season); - break; - case Episode episode: - // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - if (( - // This will account for virtual episodes and existing episodes - episode.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) || - // This will account for new episodes that haven't received their first metadata update yet - ApiManager.TryGetEpisodeIdForPath(episode.Path, out episodeId) - ) && !string.IsNullOrEmpty(episodeId)) - existingEpisodes.Add(episodeId); - break; - } - // Provide metadata for a series using Shoko's Group feature if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { var groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); + if (groupInfo == null) { + Logger.LogWarning("Unable to find group info for series. (Series={SeriesID})", seriesId); + return; + } + + // If the series id did not match, then it was too early to try matching it. + if (groupInfo.DefaultSeries.Id != seriesId) { + Logger.LogInformation("Selected series is not the same as the of the default series in the group. Ignoring series. (Series={SeriesId},Group={GroupId})", seriesId, groupInfo.Id); + return; + } + + // Get the existing seasons and episode ids + var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); // Add missing seasons - foreach (var pair in AddMissingSeasons(groupInfo, series, seasons)) { - seasons.Add(pair.Key, pair.Value); + foreach (var (seasonNumber, season) in CreateMissingSeasons(groupInfo, series, seasons)) { + seasons.Add(seasonNumber, season); } // Handle specials when grouped. if (seasons.TryGetValue(0, out var zeroSeason)) { - foreach (var sI in groupInfo.SeriesList) { - foreach (var episodeInfo in sI.SpecialsList) { - if (existingEpisodes.Contains(episodeInfo.Id)) + foreach (var seriesInfo in groupInfo.SeriesList) { + foreach (var episodeInfo in seriesInfo.SpecialsList) { + if (episodeIds.Contains(episodeInfo.Id)) continue; - AddVirtualEpisode(groupInfo, sI, episodeInfo, zeroSeason); + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, zeroSeason); } } - - return; } // Add missing episodes @@ -210,7 +219,7 @@ private void HandleSeries(Series series) continue; foreach (var episodeInfo in seriesInfo.EpisodeList) { - if (existingEpisodes.Contains(episodeInfo.Id)) + if (episodeIds.Contains(episodeInfo.Id)) continue; AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); @@ -220,19 +229,26 @@ private void HandleSeries(Series series) // Provide metadata for other series else { var seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); + if (seriesInfo == null) { + Logger.LogWarning("Unable to find series info. (Series={SeriesID})", seriesId); + return; + } + + // Get the existing seasons and episode ids + var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); // Compute the season numbers for each episode in the series in advance, since we need to filter out the missing seasons var episodeInfoToSeasonNumberDirectory = seriesInfo.EpisodeList.ToDictionary(e => e, e => Ordering.GetSeasonNumber(null, seriesInfo, e)); // Add missing seasons var allKnownSeasonNumbers = episodeInfoToSeasonNumberDirectory.Values.Distinct().ToList(); - foreach (var pair in AddMissingSeasons(series, seasons, allKnownSeasonNumbers)) { - seasons.Add(pair.Key, pair.Value); + foreach (var (seasonNumber, season) in CreateMissingSeasons(series, seasons, allKnownSeasonNumbers)) { + seasons.Add(seasonNumber, season); } // Add missing episodes foreach (var episodeInfo in seriesInfo.EpisodeList) { - if (existingEpisodes.Contains(episodeInfo.Id)) + if (episodeIds.Contains(episodeInfo.Id)) continue; var seasonNumber = episodeInfoToSeasonNumberDirectory[episodeInfo]; @@ -244,25 +260,27 @@ private void HandleSeries(Series series) } } - private void HandleSeason(Season season, bool deleted = false) + private bool IsEnabledForSeason(Season season, out string seriesId) + { + if (!season.IndexNumber.HasValue) { + seriesId = null; + return false; + } + return IsEnabledForSeries(season.Series, out seriesId); + } + + private void HandleSeason(Season season, Series series, bool deleted = false) { - if (!season.IndexNumber.HasValue || !(season.ProviderIds.TryGetValue("Shoko Series", out var seriesId) || season.Series.ProviderIds.TryGetValue("Shoko Series", out seriesId)) || string.IsNullOrEmpty(seriesId)) + if (!IsEnabledForSeason(season, out var seriesId)) return; var seasonNumber = season.IndexNumber!.Value; var existingEpisodes = new HashSet<string>(); - foreach (var episode in season.Children.OfType<Episode>()) { - // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - if (( - // This will account for virtual episodes and existing episodes - episode.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) || - // This will account for new episodes that haven't received their first metadata update yet - ApiManager.TryGetEpisodeIdForPath(episode.Path, out episodeId) - ) && !string.IsNullOrEmpty(episodeId)) + // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. + foreach (var episode in season.Children.OfType<Episode>()) + if (IsEnabledForEpisode(episode, out var episodeId)) existingEpisodes.Add(episodeId); - } - var series = season.Series; Info.GroupInfo groupInfo = null; Info.SeriesInfo seriesInfo = null; // Provide metadata for a season using Shoko's Group feature @@ -277,7 +295,7 @@ private void HandleSeason(Season season, bool deleted = false) if (seasonNumber == 0) { if (deleted) season = AddVirtualSeason(0, series); - + foreach (var sI in groupInfo.SeriesList) { foreach (var episodeInfo in sI.SpecialsList) { if (existingEpisodes.Contains(episodeInfo.Id)) @@ -323,10 +341,20 @@ private void HandleSeason(Season season, bool deleted = false) } } + private bool IsEnabledForEpisode(Episode episode, out string episodeId) + { + return ( + // This will account for virtual episodes and existing episodes + episode.ProviderIds.TryGetValue("Shoko Episode", out episodeId) || + // This will account for new episodes that haven't received their first metadata update yet + ApiManager.TryGetEpisodeIdForPath(episode.Path, out episodeId) + ) && !string.IsNullOrEmpty(episodeId); + } + private void HandleEpisode(Episode episode) { // Abort if we're unable to get the shoko episode id - if (!episode.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) || string.IsNullOrEmpty(episodeId)) + if (!IsEnabledForEpisode(episode, out var episodeId)) return; Info.GroupInfo groupInfo = null; @@ -338,15 +366,33 @@ private void HandleEpisode(Episode episode) AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, episode.Season); } - private IEnumerable<KeyValuePair<int, Season>> AddMissingSeasons(Series series, Dictionary<int, Season> existingSeasons, List<int> allSeasonNumbers) + private (Dictionary<int, Season>, HashSet<string>) GetExistingSeasonsAndEpisodeIds(Series series) + { + var seasons = new Dictionary<int, Season>(); + var episodes = new HashSet<string>(); + foreach (var item in series.GetRecursiveChildren()) switch (item) { + case Season season: + if (season.IndexNumber.HasValue) + seasons.TryAdd(season.IndexNumber.Value, season); + break; + case Episode episode: + // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. + if (IsEnabledForEpisode(episode, out var episodeId)) + episodes.Add(episodeId); + break; + } + return (seasons, episodes); + } + + private IEnumerable<(int, Season)> CreateMissingSeasons(Series series, Dictionary<int, Season> existingSeasons, List<int> allSeasonNumbers) { var missingSeasonNumbers = allSeasonNumbers.Except(existingSeasons.Keys).ToList(); foreach (var seasonNumber in missingSeasonNumbers) { - yield return KeyValuePair.Create(seasonNumber, AddVirtualSeason(seasonNumber, series)); + yield return (seasonNumber, AddVirtualSeason(seasonNumber, series)); } } - private IEnumerable<KeyValuePair<int, Season>> AddMissingSeasons(Info.GroupInfo groupInfo, Series series, Dictionary<int, Season> seasons) + private IEnumerable<(int, Season)> CreateMissingSeasons(Info.GroupInfo groupInfo, Series series, Dictionary<int, Season> seasons) { bool hasSpecials = false; foreach (var (s, index) in groupInfo.SeriesList.Select((a, b) => (a, b))) { @@ -357,10 +403,10 @@ private IEnumerable<KeyValuePair<int, Season>> AddMissingSeasons(Info.GroupInfo if (s.SpecialsList.Count > 0) hasSpecials = true; var season = AddVirtualSeason(s, seasonNumber, series); - yield return KeyValuePair.Create(seasonNumber, season); + yield return (seasonNumber, season); } if (hasSpecials) - yield return KeyValuePair.Create(0, AddVirtualSeason(0, series)); + yield return (0, AddVirtualSeason(0, series)); } private Season AddVirtualSeason(int seasonNumber, Series series) @@ -386,7 +432,9 @@ private Season AddVirtualSeason(int seasonNumber, Series series) IsVirtualItem = true, SeriesId = series.Id, SeriesName = series.Name, - SeriesPresentationUniqueKey = series.GetPresentationUniqueKey() + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), + DateModified = DateTime.UtcNow, + DateLastSaved = DateTime.UtcNow, }; series.AddChild(result, CancellationToken.None); @@ -420,6 +468,8 @@ private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, int seasonNumber, Se SeriesId = series.Id, SeriesName = series.Name, SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), + DateModified = DateTime.UtcNow, + DateLastSaved = DateTime.UtcNow, }; result.ProviderIds.Add("Shoko Series", seriesInfo.Id); if (Plugin.Instance.Configuration.AddAniDBId) @@ -432,10 +482,18 @@ private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, int seasonNumber, Se private void AddVirtualEpisode(Info.GroupInfo groupInfo, Info.SeriesInfo seriesInfo, Info.EpisodeInfo episodeInfo, MediaBrowser.Controller.Entities.TV.Season season) { + var groupId = groupInfo?.Id ?? null; + var results = LibraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new [] { nameof (Episode) }, HasAnyProviderId = { ["Shoko Episode"] = episodeInfo.Id }, DtoOptions = new DtoOptions(true) }, true); + + if (results.Count > 0) { + Logger.LogWarning("A virtual or physical episode entry already exists. Ignoreing. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episodeInfo.Id, seriesInfo.Id, groupId); + return; + } + var episodeId = LibraryManager.GetNewItemId(season.Series.Id + "Season " + seriesInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); var result = EpisodeProvider.CreateMetadata(groupInfo, seriesInfo, episodeInfo, season, episodeId); - Logger.LogInformation("Creating virtual episode for {SeriesName} S{SeasonNumber}:E{EpisodeNumber} (Group={GroupId},Series={SeriesId},Episode={EpisodeId}),", groupInfo?.Shoko.Name ?? seriesInfo.Shoko.Name, season.IndexNumber, result.IndexNumber, groupInfo?.Id ?? null, seriesInfo.Id, episodeInfo.Id); + Logger.LogInformation("Creating virtual episode for {SeriesName} S{SeasonNumber}:E{EpisodeNumber} (Episode={EpisodeId},Series={SeriesId},Group={GroupId}),", groupInfo?.Shoko.Name ?? seriesInfo.Shoko.Name, season.IndexNumber, result.IndexNumber, episodeInfo.Id, seriesInfo.Id, groupId); season.AddChild(result, CancellationToken.None); } From 5b90a9e8938b27d864bf117164b4e5ce709fb1b5 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 10 Sep 2021 22:33:23 +0000 Subject: [PATCH 0168/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 444b0730..a6e8911f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.14", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.14/shokofin_1.5.0.14.zip", + "checksum": "3cc15d702cafe7b32e1cfb349c591768", + "timestamp": "2021-09-10T22:33:21Z" + }, { "version": "1.5.0.13", "changelog": "NA", From e24101081d71154e2cef7aca8533587bcdc83aae Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 11 Sep 2021 20:08:23 +0200 Subject: [PATCH 0169/1103] Don't add a special season if one already exists --- Shokofin/Providers/MissingMetadataProvider.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Providers/MissingMetadataProvider.cs b/Shokofin/Providers/MissingMetadataProvider.cs index 90720518..58b0d836 100644 --- a/Shokofin/Providers/MissingMetadataProvider.cs +++ b/Shokofin/Providers/MissingMetadataProvider.cs @@ -196,7 +196,7 @@ private void HandleSeries(Series series) // Add missing seasons foreach (var (seasonNumber, season) in CreateMissingSeasons(groupInfo, series, seasons)) { - seasons.Add(seasonNumber, season); + seasons.TryAdd(seasonNumber, season); } // Handle specials when grouped. @@ -405,7 +405,7 @@ private void HandleEpisode(Episode episode) var season = AddVirtualSeason(s, seasonNumber, series); yield return (seasonNumber, season); } - if (hasSpecials) + if (hasSpecials && !seasons.ContainsKey(0)) yield return (0, AddVirtualSeason(0, series)); } From bf2d8bb7441570a8473dff10d216c70e3f36c615 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 11 Sep 2021 18:08:58 +0000 Subject: [PATCH 0170/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index a6e8911f..b37e4a1f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.15", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.15/shokofin_1.5.0.15.zip", + "checksum": "14325846ab086fbd39b6a9ec772a07ec", + "timestamp": "2021-09-11T18:08:57Z" + }, { "version": "1.5.0.14", "changelog": "NA", From cf4cb69bdd590d04c932d662553f71db8441654c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 Sep 2021 20:25:13 +0200 Subject: [PATCH 0171/1103] Cleanup api manager and add new sync method to get the group info --- Shokofin/API/ShokoAPIManager.cs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index f2749716..a344376c 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -530,6 +530,14 @@ public async Task<GroupInfo> GetGroupInfoByPath(string path, Ordering.GroupFilte return groupInfo; } + public GroupInfo GetGroupInfoSync(string groupId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + if (!string.IsNullOrEmpty(groupId) && DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) + return info; + + return GetGroupInfo(groupId, filterByType).GetAwaiter().GetResult(); + } + public async Task<GroupInfo> GetGroupInfo(string groupId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) { if (string.IsNullOrEmpty(groupId)) @@ -544,12 +552,11 @@ public async Task<GroupInfo> GetGroupInfo(string groupId, Ordering.GroupFilterTy public GroupInfo GetGroupInfoForSeriesSync(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) { - if (SeriesIdToGroupIdDictionary.ContainsKey(seriesId)) { - var groupId = SeriesIdToGroupIdDictionary[seriesId]; + if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) return info; - return GetGroupInfo(groupId, filterByType).GetAwaiter().GetResult(); + return GetGroupInfoSync(groupId, filterByType); } return GetGroupInfoForSeries(seriesId, filterByType).GetAwaiter().GetResult(); @@ -557,14 +564,11 @@ public GroupInfo GetGroupInfoForSeriesSync(string seriesId, Ordering.GroupFilter public async Task<GroupInfo> GetGroupInfoForSeries(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) { - string groupId; - if (SeriesIdToGroupIdDictionary.ContainsKey(seriesId)) { - groupId = SeriesIdToGroupIdDictionary[seriesId]; - } - else { + if (!SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { var group = await ShokoAPI.GetGroupFromSeries(seriesId); if (group == null) return null; + groupId = group.IDs.ID.ToString(); } From 435ddbab9b61c7b9997163e825eb1e596da88710 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 Sep 2021 20:25:59 +0200 Subject: [PATCH 0172/1103] Hopefully fix the metadata provider this time --- Shokofin/API/ShokoAPIManager.cs | 36 +- Shokofin/Providers/MissingMetadataProvider.cs | 463 ++++++++++++------ Shokofin/Providers/SeriesProvider.cs | 28 +- 3 files changed, 360 insertions(+), 167 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index a344376c..8752d504 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -26,11 +26,11 @@ public class ShokoAPIManager private static readonly ConcurrentDictionary<string, string> SeriesPathToIdDictionary = new ConcurrentDictionary<string, string>(); - private static ConcurrentDictionary<string, string> SeriesIdToGroupIdDictionary = new ConcurrentDictionary<string, string>(); + private static readonly ConcurrentDictionary<string, string> SeriesIdToGroupIdDictionary = new ConcurrentDictionary<string, string>(); - private static ConcurrentDictionary<string, string> EpisodePathToEpisodeIdDirectory = new ConcurrentDictionary<string, string>(); + private static readonly ConcurrentDictionary<string, string> EpisodePathToEpisodeIdDirectory = new ConcurrentDictionary<string, string>(); - private static ConcurrentDictionary<string, string> EpisodeIdToSeriesIdDictionary = new ConcurrentDictionary<string, string>(); + private static readonly ConcurrentDictionary<string, string> EpisodeIdToSeriesIdDictionary = new ConcurrentDictionary<string, string>(); /// <summary> /// Episodes marked as ignored is skipped when adding missing episode metadata. @@ -42,6 +42,8 @@ public class ShokoAPIManager /// </summary> private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, string>> SeriesIdToEpisodeIdDictionery = new ConcurrentDictionary<string, ConcurrentDictionary<string, string>>(); + public static readonly ConcurrentDictionary<string, HashSet<string>> LockedIdDictionary = new ConcurrentDictionary<string, HashSet<string>>(); + public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ILibraryManager libraryManager) { Logger = logger; @@ -88,6 +90,28 @@ public string StripMediaFolder(string fullPath) return fullPath.Substring(mediaFolder.Path.Length); } + #endregion + #region Update locks + + public bool TryLockActionForIdOFType(string type, string id, string action = "default") + { + var key = $"{type}:{id}"; + if (!LockedIdDictionary.TryGetValue(key, out var hashSet)) { + LockedIdDictionary.TryAdd(key, new HashSet<string>()); + if (!LockedIdDictionary.TryGetValue(key, out hashSet)) + throw new Exception("Unable to set hash set"); + } + return hashSet.Add(action); + } + + public bool TryUnlockActionForIdOFType(string type, string id, string action = "default") + { + var key = $"{type}:{id}"; + if (!LockedIdDictionary.TryGetValue(key, out var hashSet)) + return false; + return hashSet.Remove(action); + } + #endregion #region Clear @@ -95,6 +119,7 @@ public void Clear() { Logger.LogDebug("Clearing data."); DataCache.Dispose(); + LockedIdDictionary.Clear(); MediaFolderList.Clear(); EpisodeIdToSeriesIdDictionary.Clear(); EpisodePathToEpisodeIdDirectory.Clear(); @@ -439,6 +464,11 @@ public bool TryGetSeriesIdForPath(string path, out string seriesId) return SeriesPathToIdDictionary.TryGetValue(path, out seriesId); } + public bool TryGetGroupIdForSeriesId(string seriesId, out string groupId) + { + return SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out groupId); + } + private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = null) { if (series == null) diff --git a/Shokofin/Providers/MissingMetadataProvider.cs b/Shokofin/Providers/MissingMetadataProvider.cs index 58b0d836..76d09288 100644 --- a/Shokofin/Providers/MissingMetadataProvider.cs +++ b/Shokofin/Providers/MissingMetadataProvider.cs @@ -43,6 +43,7 @@ public MissingMetadataProvider(ShokoAPIManager apiManager, ILibraryManager libra public Task RunAsync() { + LibraryManager.ItemAdded += OnLibraryManagerItemAdded; LibraryManager.ItemUpdated += OnLibraryManagerItemUpdated; LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; ProviderManager.RefreshCompleted += OnProviderManagerRefreshComplete; @@ -52,6 +53,7 @@ public Task RunAsync() public void Dispose() { + LibraryManager.ItemAdded -= OnLibraryManagerItemAdded; LibraryManager.ItemUpdated -= OnLibraryManagerItemUpdated; LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; ProviderManager.RefreshCompleted -= OnProviderManagerRefreshComplete; @@ -93,42 +95,101 @@ private void OnProviderManagerRefreshComplete(object sender, GenericEventArgs<Ba } } + private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs itemChangeEventArgs) + { + if (!Plugin.Instance.Configuration.AddMissingMetadata || !IsEnabledForItem(itemChangeEventArgs.Item)) + return; + + switch (itemChangeEventArgs.Item) { + case Episode episode: { + // Abort if we're unable to get the shoko episode id + if (!IsEnabledForEpisode(episode, out var episodeId)) + return; + + var query = new InternalItemsQuery { + IsVirtualItem = true, + HasAnyProviderId = { ["Shoko Episode"] = episodeId }, + IncludeItemTypes = new [] { nameof (Episode) }, + GroupByPresentationUniqueKey = false, + DtoOptions = new DtoOptions(true), + }; + + var existingVirtualItems = LibraryManager.GetItemList(query); + var deleteOptions = new DeleteOptions { + DeleteFileLocation = true, + }; + + // Remove the old virtual episode that matches the newly created item + foreach (var item in existingVirtualItems) { + if (episode.IsVirtualItem && System.Guid.Equals(item.Id, episode.Id)) + continue; + + LibraryManager.DeleteItem(item, deleteOptions); + } + break; + } + } + } + // NOTE: Always delete stall metadata, even if we disabled the feature in the settings page. private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs itemChangeEventArgs) { - if (!IsEnabledForItem(itemChangeEventArgs.Item)) return; - - // If the item is an Episode, filter on ParentIndexNumber as well (season number) switch (itemChangeEventArgs.Item) { - // case Season season: { - // // Abort if we're unable to get the shoko episode id - // if (!IsEnabledForSeason(season, out var seriesId)) - // return; - // // Only interested in non-virtual Seasons and Episodes - // if (!season.IndexNumber.HasValue) - // return; - // - // - // break; - // } - case Episode episode: { + case Series series: { // Abort if we're unable to get the shoko episode id - if (!IsEnabledForEpisode(episode, out var episodeId)) + if (!IsEnabledForSeries(series, out var seriesId)) return; - // Only interested in non-virtual Seasons and Episodes - if (episode.IsVirtualItem) + if (!ApiManager.TryLockActionForIdOFType("series", seriesId, "remove")) + return; + + try { + foreach (var season in series.GetSeasons(null, new DtoOptions(true))) { + OnLibraryManagerItemUpdated(this, new ItemChangeEventArgs { Item = season, Parent = series, UpdateReason = ItemUpdateType.None }); + } + } + finally { + ApiManager.TryUnlockActionForIdOFType("series", seriesId, "remove"); + } + + return; + } + case Season season: { + // We're not interested in the dummy season. + if (!season.IndexNumber.HasValue) + return; + + // Abort if we're unable to get the shoko episode id + if (!IsEnabledForSeason(season, out var seriesId)) + return; + + var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; + if (!ApiManager.TryLockActionForIdOFType("season", seasonId, "remove")) + return; + + try { + foreach (var episode in season.GetEpisodes(null, new DtoOptions(true)).Where(ep => !ep.IsVirtualItem)) { + OnLibraryManagerItemUpdated(this, new ItemChangeEventArgs { Item = episode, Parent = season, UpdateReason = ItemUpdateType.None }); + } + } + finally { + ApiManager.TryUnlockActionForIdOFType("season", seasonId, "remove"); + } + + return; + } + case Episode episode: { + // Abort if we're unable to get the shoko episode id + if (!IsEnabledForEpisode(episode, out var episodeId)) return; var query = new InternalItemsQuery { IsVirtualItem = true, - IndexNumber = episode.IndexNumber, - ParentIndexNumber = episode.ParentIndexNumber, - IncludeItemTypes = new [] { episode.GetType().Name }, - Parent = itemChangeEventArgs.Parent, + HasAnyProviderId = { ["Shoko Episode"] = episodeId }, + IncludeItemTypes = new [] { nameof (Episode) }, GroupByPresentationUniqueKey = false, DtoOptions = new DtoOptions(true), }; @@ -139,13 +200,21 @@ private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs item DeleteFileLocation = true, }; + var count = existingVirtualItems.Count; // Remove the virtual season/episode that matches the newly updated item - foreach (var item in existingVirtualItems) + foreach (var item in existingVirtualItems) { + if (episode.IsVirtualItem && System.Guid.Equals(item.Id, episode.Id)) { + count--; + continue; + } + LibraryManager.DeleteItem(item, deleteOptions); - break; + } + Logger.LogInformation("Removed {Count} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", count, episode.Name, episodeId); + + return; } } - } private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs itemChangeEventArgs) @@ -168,7 +237,29 @@ private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs item private bool IsEnabledForSeries(Series series, out string seriesId) { - return (series.ProviderIds.TryGetValue("Shoko Series", out seriesId) || ApiManager.TryGetSeriesIdForPath(series.Path, out seriesId)) && !string.IsNullOrEmpty(seriesId); + if (series.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { + return true; + } + + if (ApiManager.TryGetSeriesIdForPath(series.Path, out seriesId)) { + // Set the "Shoko Group" and "Shoko Series" provider ids for the series, since it haven't been set again. It doesn't matter if it's not saved to the database, since we only need it while running the following code. + if (ApiManager.TryGetGroupIdForSeriesId(seriesId, out var groupId)) { + var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; + var groupInfo = ApiManager.GetGroupInfoSync(groupId, filterByType); + seriesId = groupInfo.DefaultSeries.Id; + + SeriesProvider.AddProviderIds(series, seriesId, groupInfo.Id); + } + // Same as above, but only set the "Shoko Series" id. + else { + SeriesProvider.AddProviderIds(series, seriesId); + } + // Make sure the presentation unique is not cached, so we won't reuse the cache key. + series.PresentationUniqueKey = null; + return true; + } + + return false; } private void HandleSeries(Series series) @@ -177,87 +268,108 @@ private void HandleSeries(Series series) if (!IsEnabledForSeries(series, out var seriesId)) return; - // Provide metadata for a series using Shoko's Group feature - if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { - var groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); - if (groupInfo == null) { - Logger.LogWarning("Unable to find group info for series. (Series={SeriesID})", seriesId); - return; - } - - // If the series id did not match, then it was too early to try matching it. - if (groupInfo.DefaultSeries.Id != seriesId) { - Logger.LogInformation("Selected series is not the same as the of the default series in the group. Ignoring series. (Series={SeriesId},Group={GroupId})", seriesId, groupInfo.Id); - return; - } - - // Get the existing seasons and episode ids - var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); + if (!ApiManager.TryLockActionForIdOFType("series", seriesId)) + return; - // Add missing seasons - foreach (var (seasonNumber, season) in CreateMissingSeasons(groupInfo, series, seasons)) { - seasons.TryAdd(seasonNumber, season); - } + try { - // Handle specials when grouped. - if (seasons.TryGetValue(0, out var zeroSeason)) { - foreach (var seriesInfo in groupInfo.SeriesList) { - foreach (var episodeInfo in seriesInfo.SpecialsList) { - if (episodeIds.Contains(episodeInfo.Id)) - continue; + // Provide metadata for a series using Shoko's Group feature + if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { + var groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); + if (groupInfo == null) { + Logger.LogWarning("Unable to find group info for series. (Series={SeriesID})", seriesId); + return; + } - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, zeroSeason); + // Get the existing seasons and episode ids + var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); + + // Add missing seasons + foreach (var (seasonNumber, season) in CreateMissingSeasons(groupInfo, series, seasons)) + seasons.TryAdd(seasonNumber, season); + + // Handle specials when grouped. + if (seasons.TryGetValue(0, out var zeroSeason)) { + var seasonId = $"{seriesId}:0"; + if (ApiManager.TryLockActionForIdOFType("season", seasonId)) { + try { + foreach (var seriesInfo in groupInfo.SeriesList) { + foreach (var episodeInfo in seriesInfo.SpecialsList) { + if (episodeIds.Contains(episodeInfo.Id)) + continue; + + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, zeroSeason); + } + } + } + finally { + ApiManager.TryUnlockActionForIdOFType("season", seasonId); + } } - } - } - // Add missing episodes - foreach (var (seriesInfo, index) in groupInfo.SeriesList.Select((s, i) => (s, i))) { - var value = index - groupInfo.DefaultSeriesIndex; - var seasonNumber = value < 0 ? value : value + 1; - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; + } - foreach (var episodeInfo in seriesInfo.EpisodeList) { - if (episodeIds.Contains(episodeInfo.Id)) + // Add missing episodes + foreach (var (seriesInfo, index) in groupInfo.SeriesList.Select((s, i) => (s, i))) { + var value = index - groupInfo.DefaultSeriesIndex; + var seasonNumber = value < 0 ? value : value + 1; + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) continue; - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + var seasonId = $"{seriesId}:{seasonNumber}"; + if (ApiManager.TryLockActionForIdOFType("season", seasonId)) { + try { + foreach (var episodeInfo in seriesInfo.EpisodeList) { + if (episodeIds.Contains(episodeInfo.Id)) + continue; + + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + } + } + finally { + ApiManager.TryUnlockActionForIdOFType("season", seasonId); + } + } } } - } - // Provide metadata for other series - else { - var seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); - if (seriesInfo == null) { - Logger.LogWarning("Unable to find series info. (Series={SeriesID})", seriesId); - return; - } + // Provide metadata for other series + else { + var seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); + if (seriesInfo == null) { + Logger.LogWarning("Unable to find series info. (Series={SeriesID})", seriesId); + return; + } - // Get the existing seasons and episode ids - var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); + // Get the existing seasons and episode ids + var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); - // Compute the season numbers for each episode in the series in advance, since we need to filter out the missing seasons - var episodeInfoToSeasonNumberDirectory = seriesInfo.EpisodeList.ToDictionary(e => e, e => Ordering.GetSeasonNumber(null, seriesInfo, e)); + // Compute the season numbers for each episode in the series in advance, since we need to filter out the missing seasons + var episodeInfoToSeasonNumberDirectory = seriesInfo.RawEpisodeList.ToDictionary(e => e, e => Ordering.GetSeasonNumber(null, seriesInfo, e)); - // Add missing seasons - var allKnownSeasonNumbers = episodeInfoToSeasonNumberDirectory.Values.Distinct().ToList(); - foreach (var (seasonNumber, season) in CreateMissingSeasons(series, seasons, allKnownSeasonNumbers)) { - seasons.Add(seasonNumber, season); - } + // Add missing seasons + var allKnownSeasonNumbers = episodeInfoToSeasonNumberDirectory.Values.Distinct().ToList(); + foreach (var (seasonNumber, season) in CreateMissingSeasons(series, seasons, allKnownSeasonNumbers)) + seasons.Add(seasonNumber, season); - // Add missing episodes - foreach (var episodeInfo in seriesInfo.EpisodeList) { - if (episodeIds.Contains(episodeInfo.Id)) - continue; + // Add missing episodes + foreach (var episodeInfo in seriesInfo.RawEpisodeList) { + if (episodeInfo.ExtraType != null) + continue; - var seasonNumber = episodeInfoToSeasonNumberDirectory[episodeInfo]; - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; + if (episodeIds.Contains(episodeInfo.Id)) + continue; + + var seasonNumber = episodeInfoToSeasonNumberDirectory[episodeInfo]; + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; - AddVirtualEpisode(null, seriesInfo, episodeInfo, season); + AddVirtualEpisode(null, seriesInfo, episodeInfo, season); + } } } + finally { + ApiManager.TryUnlockActionForIdOFType("series", seriesId); + } } private bool IsEnabledForSeason(Season season, out string seriesId) @@ -274,71 +386,81 @@ private void HandleSeason(Season season, Series series, bool deleted = false) if (!IsEnabledForSeason(season, out var seriesId)) return; - var seasonNumber = season.IndexNumber!.Value; - var existingEpisodes = new HashSet<string>(); - // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - foreach (var episode in season.Children.OfType<Episode>()) - if (IsEnabledForEpisode(episode, out var episodeId)) - existingEpisodes.Add(episodeId); - - Info.GroupInfo groupInfo = null; - Info.SeriesInfo seriesInfo = null; - // Provide metadata for a season using Shoko's Group feature - if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { - groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); - if (groupInfo == null) { - Logger.LogWarning("Unable to find group info for series. (Series={SeriesId})", seriesId); + var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; + try { + if (!ApiManager.TryLockActionForIdOFType("season", seasonId)) return; - } - // Handle specials when grouped. - if (seasonNumber == 0) { - if (deleted) - season = AddVirtualSeason(0, series); + var seasonNumber = season.IndexNumber!.Value; + var existingEpisodes = new HashSet<string>(); + // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. + foreach (var episode in season.Children.OfType<Episode>()) + if (IsEnabledForEpisode(episode, out var episodeId)) + existingEpisodes.Add(episodeId); + + Info.GroupInfo groupInfo = null; + Info.SeriesInfo seriesInfo = null; + // Provide metadata for a season using Shoko's Group feature + if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { + groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); + if (groupInfo == null) { + Logger.LogWarning("Unable to find group info for series. (Series={SeriesId})", seriesId); + return; + } + + // Handle specials when grouped. + if (seasonNumber == 0) { + if (deleted) + season = AddVirtualSeason(0, series); - foreach (var sI in groupInfo.SeriesList) { - foreach (var episodeInfo in sI.SpecialsList) { - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; + foreach (var sI in groupInfo.SeriesList) { + foreach (var episodeInfo in sI.SpecialsList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + } } + + return; } - return; - } + seriesInfo = groupInfo.GetSeriesInfoBySeasonNumber(seasonNumber); + if (seriesInfo == null) { + Logger.LogWarning("Unable to find series info for {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, groupInfo.Id); + return; + } - seriesInfo = groupInfo.GetSeriesInfoBySeasonNumber(seasonNumber); - if (seriesInfo == null) { - Logger.LogWarning("Unable to find series info for {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, groupInfo.Id); - return; + if (deleted) + season = AddVirtualSeason(seriesInfo, seasonNumber, series); } + // Provide metadata for other seasons + else { + seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); + if (seriesInfo == null) { + Logger.LogWarning("Unable to find series info. (Series={SeriesId})", seriesId); + return; + } - if (deleted) - season = AddVirtualSeason(seriesInfo, seasonNumber, series); - } - // Provide metadata for other seasons - else { - seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); - if (seriesInfo == null) { - Logger.LogWarning("Unable to find series info. (Series={SeriesId})", seriesId); - return; + if (deleted) + season = AddVirtualSeason(seasonNumber, series); } - if (deleted) - season = AddVirtualSeason(seasonNumber, series); - } - - foreach (var episodeInfo in seriesInfo.EpisodeList) { - var episodeParentIndex = Ordering.GetSeasonNumber(groupInfo, seriesInfo, episodeInfo); - if (episodeParentIndex != seasonNumber) - continue; + foreach (var episodeInfo in seriesInfo.EpisodeList) { + var episodeParentIndex = Ordering.GetSeasonNumber(groupInfo, seriesInfo, episodeInfo); + if (episodeParentIndex != seasonNumber) + continue; - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + } } + finally { + ApiManager.TryUnlockActionForIdOFType("season", seasonId); + } + } private bool IsEnabledForEpisode(Episode episode, out string episodeId) @@ -388,7 +510,10 @@ private void HandleEpisode(Episode episode) { var missingSeasonNumbers = allSeasonNumbers.Except(existingSeasons.Keys).ToList(); foreach (var seasonNumber in missingSeasonNumbers) { - yield return (seasonNumber, AddVirtualSeason(seasonNumber, series)); + var season = AddVirtualSeason(seasonNumber, series); + if (season == null) + continue; + yield return (seasonNumber, season); } } @@ -403,6 +528,8 @@ private void HandleEpisode(Episode episode) if (s.SpecialsList.Count > 0) hasSpecials = true; var season = AddVirtualSeason(s, seasonNumber, series); + if (season == null) + continue; yield return (seasonNumber, season); } if (hasSpecials && !seasons.ContainsKey(0)) @@ -411,6 +538,19 @@ private void HandleEpisode(Episode episode) private Season AddVirtualSeason(int seasonNumber, Series series) { + var seriesPresentationUniqueKey = series.GetPresentationUniqueKey(); + var searchList = LibraryManager.GetItemList(new InternalItemsQuery { + IncludeItemTypes = new [] { nameof (Season) }, + IndexNumber = seasonNumber, + SeriesPresentationUniqueKey = seriesPresentationUniqueKey, + DtoOptions = new DtoOptions(true), + }, true); + + if (searchList.Count > 0) { + Logger.LogDebug("Season {SeasonName} for Series {SeriesName} was created in another concurrent thread, skipping.", searchList[0].Name, series.Name); + return null; + } + string seasonName; if (seasonNumber == 0) seasonName = LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName; @@ -421,7 +561,7 @@ private Season AddVirtualSeason(int seasonNumber, Series series) Logger.LogInformation("Creating virtual season {SeasonName} entry for {SeriesName}", seasonName, series.Name); - var result = new Season { + var season = new Season { Name = seasonName, IndexNumber = seasonNumber, SortName = seasonName, @@ -437,19 +577,32 @@ private Season AddVirtualSeason(int seasonNumber, Series series) DateLastSaved = DateTime.UtcNow, }; - series.AddChild(result, CancellationToken.None); + series.AddChild(season, CancellationToken.None); - return result; + return season; } private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, int seasonNumber, Series series) { + var seriesPresentationUniqueKey = series.GetPresentationUniqueKey(); + var searchList = LibraryManager.GetItemList(new InternalItemsQuery { + IncludeItemTypes = new [] { nameof (Season) }, + HasAnyProviderId = { ["Shoko Series"] = seriesInfo.Id }, + SeriesPresentationUniqueKey = seriesPresentationUniqueKey, + DtoOptions = new DtoOptions(true), + }, true); + + if (searchList.Count > 0) { + Logger.LogDebug("Season {SeasonName} for Series {SeriesName} was created in another concurrent thread, skipping.", searchList[0].Name, series.Name); + return null; + } + var tags = ApiManager.GetTags(seriesInfo.Id).GetAwaiter().GetResult(); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(seriesInfo.AniDB.Titles, seriesInfo.Shoko.Name, series.GetPreferredMetadataLanguage()); var sortTitle = $"S{seasonNumber} - {seriesInfo.Shoko.Name}"; Logger.LogInformation("Adding virtual season {SeasonName} entry for {SeriesName}", displayTitle, series.Name); - var result = new Season { + var season = new Season { Name = displayTitle, OriginalTitle = alternateTitle, IndexNumber = seasonNumber, @@ -467,26 +620,30 @@ private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, int seasonNumber, Se CommunityRating = seriesInfo.AniDB.Rating?.ToFloat(10), SeriesId = series.Id, SeriesName = series.Name, - SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), + SeriesPresentationUniqueKey = seriesPresentationUniqueKey, DateModified = DateTime.UtcNow, DateLastSaved = DateTime.UtcNow, }; - result.ProviderIds.Add("Shoko Series", seriesInfo.Id); + season.ProviderIds.Add("Shoko Series", seriesInfo.Id); if (Plugin.Instance.Configuration.AddAniDBId) - result.ProviderIds.Add("AniDB", seriesInfo.AniDB.ID.ToString()); + season.ProviderIds.Add("AniDB", seriesInfo.AniDB.ID.ToString()); - series.AddChild(result, CancellationToken.None); + series.AddChild(season, CancellationToken.None); - return result; + return season; } private void AddVirtualEpisode(Info.GroupInfo groupInfo, Info.SeriesInfo seriesInfo, Info.EpisodeInfo episodeInfo, MediaBrowser.Controller.Entities.TV.Season season) { var groupId = groupInfo?.Id ?? null; - var results = LibraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new [] { nameof (Episode) }, HasAnyProviderId = { ["Shoko Episode"] = episodeInfo.Id }, DtoOptions = new DtoOptions(true) }, true); - - if (results.Count > 0) { - Logger.LogWarning("A virtual or physical episode entry already exists. Ignoreing. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episodeInfo.Id, seriesInfo.Id, groupId); + var searchList = LibraryManager.GetItemList(new InternalItemsQuery { + IncludeItemTypes = new [] { nameof (Episode) }, + HasAnyProviderId = { ["Shoko Episode"] = episodeInfo.Id }, + DtoOptions = new DtoOptions(true) + }, true); + + if (searchList.Count > 0) { + Logger.LogDebug("A virtual or physical episode entry already exists. Ignoreing. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episodeInfo.Id, seriesInfo.Id, groupId); return; } diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index f729a34c..a530d831 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -71,11 +71,7 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C Tags = tags, CommunityRating = series.AniDB.Rating.ToFloat(10), }; - result.Item.SetProviderId("Shoko Series", series.Id); - if (Plugin.Instance.Configuration.AddAniDBId) - result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); - if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && !string.IsNullOrEmpty(series.TvDBId)) - result.Item.SetProviderId(MetadataProvider.Tvdb, series.TvDBId); + AddProviderIds(result.Item, series.Id, null, series.AniDB.ID.ToString(), Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly ? series.TvDBId : null); result.HasMetadata = true; @@ -112,12 +108,7 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in Tags = tags, CommunityRating = series.AniDB.Rating.ToFloat(10), }; - // NOTE: This next line will remain here till they fix the series merging for providers outside the MetadataProvider enum. - result.Item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{series.Id}"); - result.Item.SetProviderId("Shoko Series", series.Id); - result.Item.SetProviderId("Shoko Group", group.Id); - if (Plugin.Instance.Configuration.AddAniDBId) - result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); + AddProviderIds(result.Item, series.Id, group.Id, series.AniDB.ID.ToString(), null); result.HasMetadata = true; @@ -128,6 +119,21 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in return result; } + public static void AddProviderIds(Series series, string seriesId, string groupId = null, string aniDbId = null, string tvDbId = null) + { + // NOTE: These next two lines will remain here till _someone_ fix the series merging for providers other then TvDB and ImDB in Jellyfin. + if (string.IsNullOrEmpty(tvDbId)) + series.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{seriesId}"); + + series.SetProviderId("Shoko Series", seriesId); + if (!string.IsNullOrEmpty(groupId)) + series.SetProviderId("Shoko Group", groupId); + if (Plugin.Instance.Configuration.AddAniDBId && !string.IsNullOrEmpty(aniDbId)) + series.SetProviderId("AniDB", aniDbId); + if (!string.IsNullOrEmpty(tvDbId)) + series.SetProviderId(MetadataProvider.Tvdb, tvDbId); + } + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo info, CancellationToken cancellationToken) { try { From 5623902b68d30bc096ea8187b9594256309f6154 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 12 Sep 2021 18:26:35 +0000 Subject: [PATCH 0173/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index b37e4a1f..398050a2 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.16", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.16/shokofin_1.5.0.16.zip", + "checksum": "c44459c10a42cea673d1cce21fc83313", + "timestamp": "2021-09-12T18:26:34Z" + }, { "version": "1.5.0.15", "changelog": "NA", From f5d846fe40d6e283d86f71b648496f6477332bd1 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 Sep 2021 20:38:17 +0200 Subject: [PATCH 0174/1103] Add support for dual-level libraries --- Shokofin/Providers/SeriesProvider.cs | 36 ++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index a530d831..162cf66e 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -7,6 +7,7 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; @@ -24,11 +25,14 @@ public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> private readonly ShokoAPIManager ApiManager; - public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager) + private readonly IFileSystem FileSystem; + + public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager, IFileSystem fileSystem) { Logger = logger; HttpClientFactory = httpClientFactory; ApiManager = apiManager; + FileSystem = fileSystem; } public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) @@ -52,8 +56,17 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C var result = new MetadataResult<Series>(); var series = await ApiManager.GetSeriesInfoByPath(info.Path); if (series == null) { - Logger.LogWarning("Unable to find group info for path {Path}", info.Path); - return result; + // Look for the "season" directories to probe for the series information + var entries = FileSystem.GetDirectories(info.Path, false); + foreach (var entry in entries) { + series = await ApiManager.GetSeriesInfoByPath(entry.FullName); + if (series != null) + break; + } + if (series == null) { + Logger.LogWarning("Unable to find series info for path {Path}", info.Path); + return result; + } } var tags = await ApiManager.GetTags(series.Id); @@ -85,11 +98,20 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<Series>(); - var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes; - var group = await ApiManager.GetGroupInfoByPath(info.Path, filterLibrary ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); + var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; + var group = await ApiManager.GetGroupInfoByPath(info.Path, filterLibrary); if (group == null) { - Logger.LogWarning("Unable to find group info for path {Path}", info.Path); - return result; + // Look for the "season" directories to probe for the group information + var entries = FileSystem.GetDirectories(info.Path, false); + foreach (var entry in entries) { + group = await ApiManager.GetGroupInfoByPath(entry.FullName, filterLibrary); + if (group != null) + break; + } + if (group == null) { + Logger.LogWarning("Unable to find group info for path {Path}", info.Path); + return result; + } } var series = group.DefaultSeries; From 7bebc2b59c3b3db0d30b24c1e0521c4ff439df4e Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 12 Sep 2021 18:39:37 +0000 Subject: [PATCH 0175/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 398050a2..0ef8527d 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.17", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.17/shokofin_1.5.0.17.zip", + "checksum": "15b37a792d79baf15ddad4890d0d896a", + "timestamp": "2021-09-12T18:39:36Z" + }, { "version": "1.5.0.16", "changelog": "NA", From 14433f909ff5af3fb421cf5d0c2678088d67e332 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 Sep 2021 20:56:31 +0200 Subject: [PATCH 0176/1103] Explicitly tell which action to perform when locking --- Shokofin/API/ShokoAPIManager.cs | 4 ++-- Shokofin/Providers/MissingMetadataProvider.cs | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 8752d504..3bfef4e6 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -93,7 +93,7 @@ public string StripMediaFolder(string fullPath) #endregion #region Update locks - public bool TryLockActionForIdOFType(string type, string id, string action = "default") + public bool TryLockActionForIdOFType(string type, string id, string action) { var key = $"{type}:{id}"; if (!LockedIdDictionary.TryGetValue(key, out var hashSet)) { @@ -104,7 +104,7 @@ public bool TryLockActionForIdOFType(string type, string id, string action = "de return hashSet.Add(action); } - public bool TryUnlockActionForIdOFType(string type, string id, string action = "default") + public bool TryUnlockActionForIdOFType(string type, string id, string action) { var key = $"{type}:{id}"; if (!LockedIdDictionary.TryGetValue(key, out var hashSet)) diff --git a/Shokofin/Providers/MissingMetadataProvider.cs b/Shokofin/Providers/MissingMetadataProvider.cs index 76d09288..b4b658b7 100644 --- a/Shokofin/Providers/MissingMetadataProvider.cs +++ b/Shokofin/Providers/MissingMetadataProvider.cs @@ -268,7 +268,7 @@ private void HandleSeries(Series series) if (!IsEnabledForSeries(series, out var seriesId)) return; - if (!ApiManager.TryLockActionForIdOFType("series", seriesId)) + if (!ApiManager.TryLockActionForIdOFType("series", seriesId, "update")) return; try { @@ -291,7 +291,7 @@ private void HandleSeries(Series series) // Handle specials when grouped. if (seasons.TryGetValue(0, out var zeroSeason)) { var seasonId = $"{seriesId}:0"; - if (ApiManager.TryLockActionForIdOFType("season", seasonId)) { + if (ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) { try { foreach (var seriesInfo in groupInfo.SeriesList) { foreach (var episodeInfo in seriesInfo.SpecialsList) { @@ -303,7 +303,7 @@ private void HandleSeries(Series series) } } finally { - ApiManager.TryUnlockActionForIdOFType("season", seasonId); + ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); } } @@ -317,7 +317,7 @@ private void HandleSeries(Series series) continue; var seasonId = $"{seriesId}:{seasonNumber}"; - if (ApiManager.TryLockActionForIdOFType("season", seasonId)) { + if (ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) { try { foreach (var episodeInfo in seriesInfo.EpisodeList) { if (episodeIds.Contains(episodeInfo.Id)) @@ -327,7 +327,7 @@ private void HandleSeries(Series series) } } finally { - ApiManager.TryUnlockActionForIdOFType("season", seasonId); + ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); } } } @@ -368,7 +368,7 @@ private void HandleSeries(Series series) } } finally { - ApiManager.TryUnlockActionForIdOFType("series", seriesId); + ApiManager.TryUnlockActionForIdOFType("series", seriesId, "update"); } } @@ -388,7 +388,7 @@ private void HandleSeason(Season season, Series series, bool deleted = false) var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; try { - if (!ApiManager.TryLockActionForIdOFType("season", seasonId)) + if (!ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) return; var seasonNumber = season.IndexNumber!.Value; @@ -458,7 +458,7 @@ private void HandleSeason(Season season, Series series, bool deleted = false) } } finally { - ApiManager.TryUnlockActionForIdOFType("season", seasonId); + ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); } } From dfe3e338ba68a717d6f8c53ce6c3ca52f3103a16 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 Sep 2021 20:56:55 +0200 Subject: [PATCH 0177/1103] Do nothing if the feature is not enabled --- Shokofin/Providers/MissingMetadataProvider.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Shokofin/Providers/MissingMetadataProvider.cs b/Shokofin/Providers/MissingMetadataProvider.cs index b4b658b7..7ee5350c 100644 --- a/Shokofin/Providers/MissingMetadataProvider.cs +++ b/Shokofin/Providers/MissingMetadataProvider.cs @@ -131,10 +131,9 @@ private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs itemCh } } - // NOTE: Always delete stall metadata, even if we disabled the feature in the settings page. private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs itemChangeEventArgs) { - if (!IsEnabledForItem(itemChangeEventArgs.Item)) + if (!Plugin.Instance.Configuration.AddMissingMetadata || !IsEnabledForItem(itemChangeEventArgs.Item)) return; switch (itemChangeEventArgs.Item) { From ee92253844fc5f134fb034c80127e7449982d7e1 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 13 Sep 2021 00:01:55 +0200 Subject: [PATCH 0178/1103] Cleanup left-over debug code --- Shokofin/API/ShokoAPIManager.cs | 44 ++++++++------------------------- Shokofin/LibraryScanner.cs | 5 ++-- 2 files changed, 12 insertions(+), 37 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 3bfef4e6..0deb2339 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -28,19 +28,11 @@ public class ShokoAPIManager private static readonly ConcurrentDictionary<string, string> SeriesIdToGroupIdDictionary = new ConcurrentDictionary<string, string>(); - private static readonly ConcurrentDictionary<string, string> EpisodePathToEpisodeIdDirectory = new ConcurrentDictionary<string, string>(); + private static readonly ConcurrentDictionary<string, string> EpisodePathToEpisodeIdDictionary = new ConcurrentDictionary<string, string>(); - private static readonly ConcurrentDictionary<string, string> EpisodeIdToSeriesIdDictionary = new ConcurrentDictionary<string, string>(); - - /// <summary> - /// Episodes marked as ignored is skipped when adding missing episode metadata. - /// </summary> - private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, string>> SeriesIdToEpisodeIdIgnoreDictionery = new ConcurrentDictionary<string, ConcurrentDictionary<string, string>>(); + private static readonly ConcurrentDictionary<string, string> EpisodeIdToEpisodePathDictionary = new ConcurrentDictionary<string, string>(); - /// <summary> - /// Episodes found while scanning the library for metadata. - /// </summary> - private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, string>> SeriesIdToEpisodeIdDictionery = new ConcurrentDictionary<string, ConcurrentDictionary<string, string>>(); + private static readonly ConcurrentDictionary<string, string> EpisodeIdToSeriesIdDictionary = new ConcurrentDictionary<string, string>(); public static readonly ConcurrentDictionary<string, HashSet<string>> LockedIdDictionary = new ConcurrentDictionary<string, HashSet<string>>(); @@ -122,10 +114,9 @@ public void Clear() LockedIdDictionary.Clear(); MediaFolderList.Clear(); EpisodeIdToSeriesIdDictionary.Clear(); - EpisodePathToEpisodeIdDirectory.Clear(); + EpisodePathToEpisodeIdDictionary.Clear(); + EpisodeIdToEpisodePathDictionary.Clear(); SeriesPathToIdDictionary.Clear(); - SeriesIdToEpisodeIdDictionery.Clear(); - SeriesIdToEpisodeIdIgnoreDictionery.Clear(); SeriesIdToGroupIdDictionary.Clear(); DataCache = (new MemoryCache((new MemoryCacheOptions() { ExpirationScanFrequency = ExpirationScanFrequency, @@ -298,20 +289,10 @@ private async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string episod return info; } - public bool MarkEpisodeAsIgnored(string episodeId, string seriesId, string fullPath) + public void MarkEpisodeAsFound(string episodeId, string fullPath) { - EpisodePathToEpisodeIdDirectory.TryAdd(fullPath, episodeId); - if (!(SeriesIdToEpisodeIdIgnoreDictionery.TryGetValue(seriesId, out var dictionary) || SeriesIdToEpisodeIdIgnoreDictionery.TryAdd(seriesId, dictionary = new ConcurrentDictionary<string, string>()))) - return false; - return dictionary.TryAdd(episodeId, fullPath); - } - - public bool MarkEpisodeAsFound(string episodeId, string seriesId, string fullPath) - { - EpisodePathToEpisodeIdDirectory.TryAdd(fullPath, episodeId); - if (!(SeriesIdToEpisodeIdIgnoreDictionery.TryGetValue(seriesId, out var dictionary) || SeriesIdToEpisodeIdIgnoreDictionery.TryAdd(seriesId, dictionary = new ConcurrentDictionary<string, string>()))) - return false; - return dictionary.TryAdd(episodeId, fullPath); + EpisodePathToEpisodeIdDictionary.TryAdd(fullPath, episodeId); + EpisodeIdToEpisodePathDictionary.TryAdd(episodeId, fullPath); } public bool TryGetEpisodeIdForPath(string path, out string episodeId) @@ -320,12 +301,7 @@ public bool TryGetEpisodeIdForPath(string path, out string episodeId) episodeId = null; return false; } - return EpisodePathToEpisodeIdDirectory.TryGetValue(path, out episodeId); - } - - public bool IsEpisodeOnDisk(EpisodeInfo episodeInfo, SeriesInfo seriesInfo) - { - return SeriesIdToEpisodeIdDictionery.TryGetValue(seriesInfo.Id, out var dictionary) && dictionary.ContainsKey(episodeInfo.Id); + return EpisodePathToEpisodeIdDictionary.TryGetValue(path, out episodeId); } private static ExtraType? GetExtraType(Episode.AniDB episode) @@ -695,7 +671,7 @@ private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Order public Task PostProcess(IProgress<double> progress, CancellationToken token) { - Logger.LogInformation("Hi"); + Clear(); return Task.CompletedTask; } diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index 5860bfda..56303ab3 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -123,16 +123,15 @@ private bool ScanFile(string partialPath, string fullPath) Logger.LogWarning("Skipped unknown file at path {Path}", partialPath); return false; } + + ApiManager.MarkEpisodeAsFound(episode.Id, fullPath); Logger.LogInformation("Found episode for {SeriesName} (Series={SeriesId},Episode={EpisodeId},File={FileId})", series.Shoko.Name, series.Id, episode.Id, file.Id); // We're going to post process this file later, but we don't want to include it in our library for now. if (episode.ExtraType != null) { Logger.LogInformation("Episode was assigned an extra type, ignoring episode. (Series={SeriesId},Episode={EpisodeId},File={FileId})", series.Id, episode.Id, file.Id); - ApiManager.MarkEpisodeAsIgnored(episode.Id, series.Id, fullPath); return true; } - - ApiManager.MarkEpisodeAsFound(episode.Id, series.Id, fullPath); return false; } } From 826255f2b3e4852247201992970c413cfbd1406b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 13 Sep 2021 00:02:56 +0200 Subject: [PATCH 0179/1103] Fix specials for seasons --- Shokofin/Configuration/PluginConfiguration.cs | 3 +++ Shokofin/Configuration/configPage.html | 10 +++++++++ Shokofin/Providers/EpisodeProvider.cs | 21 ++++++++++++------- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 3e9d8d30..6d785a5c 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -48,6 +48,8 @@ public class PluginConfiguration : BasePluginConfiguration public bool MarkSpecialsWhenGrouped { get; set; } + public bool DisplaySpecialsInSeason { get; set; } + public SeriesAndBoxSetGroupType BoxSetGrouping { get; set; } public OrderType MovieOrdering { get; set; } @@ -84,6 +86,7 @@ public PluginConfiguration() SeriesGrouping = SeriesAndBoxSetGroupType.Default; SeasonOrdering = OrderType.Default; MarkSpecialsWhenGrouped = true; + DisplaySpecialsInSeason = false; BoxSetGrouping = SeriesAndBoxSetGroupType.Default; MovieOrdering = OrderType.Default; AddMissingMetadata = false; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 0e68c509..2c8601bc 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -101,6 +101,10 @@ <h3>Library Options</h3> <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> <span>Excluding normal episodes, add type and number to title (e.g. "S1 title", "C1 title", "O1 title")</span> </label> + <label id="DisplaySpecialsInSeasonItem" class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="DisplaySpecialsInSeason" /> + <span>Display specials in-between normal episodes</span> + </label> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="AddMissingMetadata" /> <span>Add metadata for missing seasons/episodes</span> @@ -195,6 +199,7 @@ <h3>Tag Options</h3> document.querySelector('#BoxSetGrouping').value = config.BoxSetGrouping; document.querySelector('#MovieOrdering').value = config.MovieOrdering; document.querySelector('#MarkSpecialsWhenGrouped').checked = config.MarkSpecialsWhenGrouped; + document.querySelector('#DisplaySpecialsInSeason').checked = config.DisplaySpecialsInSeason; document.querySelector('#AddMissingMetadata').checked = config.AddMissingMetadata; document.querySelector('#FilterOnLibraryTypes').checked = config.FilterOnLibraryTypes; document.querySelector('#AddAniDBId').checked = config.AddAniDBId; @@ -203,11 +208,13 @@ <h3>Tag Options</h3> document.querySelector('#SG_ShokoGroup_Warning').removeAttribute("hidden"); document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); document.querySelector('#MarkSpecialsWhenGroupedItem').removeAttribute("hidden"); + document.querySelector('#DisplaySpecialsInSeasonItem').removeAttribute("hidden"); } else { document.querySelector('#SG_ShokoGroup_Warning').setAttribute("hidden", ""); document.querySelector('#SeasonOrderingItem').setAttribute("hidden", ""); document.querySelector('#MarkSpecialsWhenGroupedItem').setAttribute("hidden", ""); + document.querySelector('#DisplaySpecialsInSeasonItem').setAttribute("hidden", ""); } if (config.BoxSetGrouping === "ShokoGroup") { document.querySelector('#MovieOrderingItem').removeAttribute("hidden"); @@ -224,11 +231,13 @@ <h3>Tag Options</h3> document.querySelector('#SG_ShokoGroup_Warning').removeAttribute("hidden"); document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); document.querySelector('#MarkSpecialsWhenGroupedItem').removeAttribute("hidden"); + document.querySelector('#DisplaySpecialsInSeasonItem').removeAttribute("hidden"); } else { document.querySelector('#SG_ShokoGroup_Warning').setAttribute("hidden", ""); document.querySelector('#SeasonOrderingItem').setAttribute("hidden", ""); document.querySelector('#MarkSpecialsWhenGroupedItem').setAttribute("hidden", ""); + document.querySelector('#DisplaySpecialsInSeasonItem').setAttribute("hidden", ""); } }); document.querySelector('#BoxSetGrouping') @@ -268,6 +277,7 @@ <h3>Tag Options</h3> config.BoxSetGrouping = document.querySelector('#BoxSetGrouping').value; config.MovieOrdering = document.querySelector('#MovieOrdering').value; config.MarkSpecialsWhenGrouped = document.querySelector('#MarkSpecialsWhenGrouped').checked; + config.DisplaySpecialsInSeason = document.querySelector('#DisplaySpecialsInSeason').checked; config.AddMissingMetadata = document.querySelector('#AddMissingMetadata').checked; config.FilterOnLibraryTypes = document.querySelector('#FilterOnLibraryTypes').checked; config.AddAniDBId = document.querySelector('#AddAniDBId').checked; diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 89dc9711..3b2da011 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -148,19 +148,24 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri Episode result; if (group != null && episode.AniDB.Type == EpisodeType.Special) { - int? previousEpisodeNumber = null; - if (series.SpesialsAnchors.TryGetValue(episode.Id, out var previousEpisode)) - previousEpisodeNumber = Ordering.GetEpisodeNumber(group, series, previousEpisode); - int? nextEpisodeNumber = previousEpisodeNumber.HasValue && previousEpisodeNumber.Value < series.EpisodeList.Count ? previousEpisodeNumber.Value + 1 : null; + var displayInBetween = Plugin.Instance.Configuration.DisplaySpecialsInSeason; + int? nextEpisodeNumber = null; + if (displayInBetween) { + int? previousEpisodeNumber = null; + if (series.SpesialsAnchors.TryGetValue(episode.Id, out var previousEpisode)) + previousEpisodeNumber = Ordering.GetEpisodeNumber(group, series, previousEpisode); + nextEpisodeNumber = previousEpisodeNumber.HasValue && previousEpisodeNumber.Value < series.EpisodeList.Count ? previousEpisodeNumber.Value + 1 : series.EpisodeList.Count; + } + if (season != null) { result = new Episode { Name = displayTitle, OriginalTitle = alternateTitle, IndexNumber = episodeNumber, ParentIndexNumber = 0, - AirsAfterSeasonNumber = seasonNumber, + AirsAfterSeasonNumber = displayInBetween ? null : seasonNumber, AirsBeforeEpisodeNumber = nextEpisodeNumber, - AirsBeforeSeasonNumber = seasonNumber + 1, + AirsBeforeSeasonNumber = displayInBetween ? seasonNumber : null, Id = episodeId, IsVirtualItem = true, SeasonId = season.Id, @@ -179,9 +184,9 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri result = new Episode { IndexNumber = episodeNumber, ParentIndexNumber = 0, - AirsAfterSeasonNumber = seasonNumber, + AirsAfterSeasonNumber = displayInBetween ? null : seasonNumber, AirsBeforeEpisodeNumber = nextEpisodeNumber, - AirsBeforeSeasonNumber = seasonNumber + 1, + AirsBeforeSeasonNumber = displayInBetween ? seasonNumber : null, Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, From 5bbfbd2f66136837fc6fd72ffe6cccb70e9593b0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 13 Sep 2021 00:04:24 +0200 Subject: [PATCH 0180/1103] Filter extras into their own list --- Shokofin/API/Info/SeriesInfo.cs | 7 +++++++ Shokofin/API/ShokoAPIManager.cs | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index 2cae857d..46066d75 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -29,6 +29,13 @@ public class SeriesInfo /// </summary> public List<EpisodeInfo> EpisodeList; + /// <summary> + /// A pre-filtered list of "extra" videos that belong to this series. + /// + /// Ordered by AniDb air-date. + /// </summary> + public List<EpisodeInfo> ExtrasList; + /// <summary> /// A dictionary holding mappings for the previous normal episode for every special episode in a series. /// </summary> diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 0deb2339..7402d189 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -464,6 +464,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = Dictionary<string, EpisodeInfo> specialsAnchorDictionary = new Dictionary<string, EpisodeInfo>(); var specialsList = new List<EpisodeInfo>(); var episodesList = new List<EpisodeInfo>(); + var extrasList = new List<EpisodeInfo>(); // The episode list is ordered by air date var allEpisodesList = ShokoAPI.GetEpisodesFromSeries(seriesId, Plugin.Instance.Configuration.AddMissingMetadata) @@ -480,7 +481,9 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = EpisodeIdToSeriesIdDictionary[episode.Id] = seriesId; if (episode.AniDB.Type == EpisodeType.Normal) episodesList.Add(episode); - else if (episode.AniDB.Type == EpisodeType.Special && episode.ExtraType == null) { + else if (episode.ExtraType != null) + extrasList.Add(episode); + else if (episode.AniDB.Type == EpisodeType.Special) { specialsList.Add(episode); var previousEpisode = allEpisodesList .GetRange(0, index) @@ -503,6 +506,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = TvDB = tvDbId != 0 ? (await ShokoAPI.GetSeriesTvDB(seriesId)).FirstOrDefault() : null, RawEpisodeList = allEpisodesList, EpisodeList = episodesList, + ExtrasList = extrasList, SpesialsAnchors = specialsAnchorDictionary, SpecialsList = specialsList, }; From 21c53c492eb8ef6d3b4fe8b32e10e584cb93720f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 13 Sep 2021 00:19:24 +0200 Subject: [PATCH 0181/1103] Stub implmenetation for extras --- Shokofin/API/ShokoAPIManager.cs | 9 + Shokofin/Configuration/PluginConfiguration.cs | 3 + Shokofin/Providers/MissingMetadataProvider.cs | 220 +++++++++++------- 3 files changed, 145 insertions(+), 87 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 7402d189..0ad78c9c 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -304,6 +304,15 @@ public bool TryGetEpisodeIdForPath(string path, out string episodeId) return EpisodePathToEpisodeIdDictionary.TryGetValue(path, out episodeId); } + public bool TryGetEpisodePathForId(string episodeId, out string path) + { + if (string.IsNullOrEmpty(episodeId)) { + path = null; + return false; + } + return EpisodeIdToEpisodePathDictionary.TryGetValue(episodeId, out path); + } + private static ExtraType? GetExtraType(Episode.AniDB episode) { switch (episode.Type) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 6d785a5c..d70f9f55 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -62,6 +62,8 @@ public class PluginConfiguration : BasePluginConfiguration public bool AddMissingMetadata { get; set; } + public bool AddExtraVideos { get; set; } + public PluginConfiguration() { Host = "http://127.0.0.1:8111"; @@ -90,6 +92,7 @@ public PluginConfiguration() BoxSetGrouping = SeriesAndBoxSetGroupType.Default; MovieOrdering = OrderType.Default; AddMissingMetadata = false; + AddExtraVideos = false; FilterOnLibraryTypes = false; } } diff --git a/Shokofin/Providers/MissingMetadataProvider.cs b/Shokofin/Providers/MissingMetadataProvider.cs index 7ee5350c..b6ac0b09 100644 --- a/Shokofin/Providers/MissingMetadataProvider.cs +++ b/Shokofin/Providers/MissingMetadataProvider.cs @@ -271,7 +271,6 @@ private void HandleSeries(Series series) return; try { - // Provide metadata for a series using Shoko's Group feature if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { var groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); @@ -279,55 +278,67 @@ private void HandleSeries(Series series) Logger.LogWarning("Unable to find group info for series. (Series={SeriesID})", seriesId); return; } - // Get the existing seasons and episode ids var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); - // Add missing seasons - foreach (var (seasonNumber, season) in CreateMissingSeasons(groupInfo, series, seasons)) - seasons.TryAdd(seasonNumber, season); + if (Plugin.Instance.Configuration.AddMissingMetadata) { + // Add missing seasons + foreach (var (seasonNumber, season) in CreateMissingSeasons(groupInfo, series, seasons)) + seasons.TryAdd(seasonNumber, season); + + // Handle specials when grouped. + if (seasons.TryGetValue(0, out var zeroSeason)) { + var seasonId = $"{seriesId}:0"; + if (ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) { + try { + foreach (var seriesInfo in groupInfo.SeriesList) { + foreach (var episodeInfo in seriesInfo.SpecialsList) { + if (episodeIds.Contains(episodeInfo.Id)) + continue; + + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, zeroSeason); + } + } + } + finally { + ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); + } + } + } - // Handle specials when grouped. - if (seasons.TryGetValue(0, out var zeroSeason)) { - var seasonId = $"{seriesId}:0"; - if (ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) { - try { - foreach (var seriesInfo in groupInfo.SeriesList) { - foreach (var episodeInfo in seriesInfo.SpecialsList) { + // Add missing episodes + foreach (var (seriesInfo, index) in groupInfo.SeriesList.Select((s, i) => (s, i))) { + var value = index - groupInfo.DefaultSeriesIndex; + var seasonNumber = value < 0 ? value : value + 1; + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; + + var seasonId = $"{seriesId}:{seasonNumber}"; + if (ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) { + try { + foreach (var episodeInfo in seriesInfo.EpisodeList) { if (episodeIds.Contains(episodeInfo.Id)) continue; - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, zeroSeason); + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); } } - } - finally { - ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); + finally { + ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); + } } } - } - // Add missing episodes - foreach (var (seriesInfo, index) in groupInfo.SeriesList.Select((s, i) => (s, i))) { - var value = index - groupInfo.DefaultSeriesIndex; - var seasonNumber = value < 0 ? value : value + 1; - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; - - var seasonId = $"{seriesId}:{seasonNumber}"; - if (ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) { - try { - foreach (var episodeInfo in seriesInfo.EpisodeList) { - if (episodeIds.Contains(episodeInfo.Id)) - continue; + // We add the extras to the season if we're using Shoko Groups. + if (Plugin.Instance.Configuration.AddExtraVideos) { + foreach (var (seriesInfo, index) in groupInfo.SeriesList.Select((s, i) => (s, i))) { + var value = index - groupInfo.DefaultSeriesIndex; + var seasonNumber = value < 0 ? value : value + 1; + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); - } - } - finally { - ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); - } + AddExtras(season, seriesInfo); } } } @@ -339,30 +350,37 @@ private void HandleSeries(Series series) return; } - // Get the existing seasons and episode ids - var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); + if (Plugin.Instance.Configuration.AddMissingMetadata) { + // Get the existing seasons and episode ids + var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); - // Compute the season numbers for each episode in the series in advance, since we need to filter out the missing seasons - var episodeInfoToSeasonNumberDirectory = seriesInfo.RawEpisodeList.ToDictionary(e => e, e => Ordering.GetSeasonNumber(null, seriesInfo, e)); + // Compute the season numbers for each episode in the series in advance, since we need to filter out the missing seasons + var episodeInfoToSeasonNumberDirectory = seriesInfo.RawEpisodeList.ToDictionary(e => e, e => Ordering.GetSeasonNumber(null, seriesInfo, e)); - // Add missing seasons - var allKnownSeasonNumbers = episodeInfoToSeasonNumberDirectory.Values.Distinct().ToList(); - foreach (var (seasonNumber, season) in CreateMissingSeasons(series, seasons, allKnownSeasonNumbers)) - seasons.Add(seasonNumber, season); + // Add missing seasons + var allKnownSeasonNumbers = episodeInfoToSeasonNumberDirectory.Values.Distinct().ToList(); + foreach (var (seasonNumber, season) in CreateMissingSeasons(series, seasons, allKnownSeasonNumbers)) + seasons.Add(seasonNumber, season); - // Add missing episodes - foreach (var episodeInfo in seriesInfo.RawEpisodeList) { - if (episodeInfo.ExtraType != null) - continue; + // Add missing episodes + foreach (var episodeInfo in seriesInfo.RawEpisodeList) { + if (episodeInfo.ExtraType != null) + continue; - if (episodeIds.Contains(episodeInfo.Id)) - continue; + if (episodeIds.Contains(episodeInfo.Id)) + continue; - var seasonNumber = episodeInfoToSeasonNumberDirectory[episodeInfo]; - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; + var seasonNumber = episodeInfoToSeasonNumberDirectory[episodeInfo]; + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; - AddVirtualEpisode(null, seriesInfo, episodeInfo, season); + AddVirtualEpisode(null, seriesInfo, episodeInfo, season); + } + } + + // We add the extras to the series if not. + if (Plugin.Instance.Configuration.AddExtraVideos) { + AddExtras(series, seriesInfo); } } } @@ -391,38 +409,18 @@ private void HandleSeason(Season season, Series series, bool deleted = false) return; var seasonNumber = season.IndexNumber!.Value; - var existingEpisodes = new HashSet<string>(); - // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - foreach (var episode in season.Children.OfType<Episode>()) - if (IsEnabledForEpisode(episode, out var episodeId)) - existingEpisodes.Add(episodeId); - + var addMissing = Plugin.Instance.Configuration.AddMissingMetadata; + var seriesGrouping = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; Info.GroupInfo groupInfo = null; Info.SeriesInfo seriesInfo = null; // Provide metadata for a season using Shoko's Group feature - if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { + if (seriesGrouping) { groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); if (groupInfo == null) { Logger.LogWarning("Unable to find group info for series. (Series={SeriesId})", seriesId); return; } - // Handle specials when grouped. - if (seasonNumber == 0) { - if (deleted) - season = AddVirtualSeason(0, series); - - foreach (var sI in groupInfo.SeriesList) { - foreach (var episodeInfo in sI.SpecialsList) { - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; - - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); - } - } - - return; - } seriesInfo = groupInfo.GetSeriesInfoBySeasonNumber(seasonNumber); if (seriesInfo == null) { @@ -430,8 +428,8 @@ private void HandleSeason(Season season, Series series, bool deleted = false) return; } - if (deleted) - season = AddVirtualSeason(seriesInfo, seasonNumber, series); + if (addMissing && deleted) + season = seasonNumber == 0 ? AddVirtualSeason(0, series) : AddVirtualSeason(seriesInfo, seasonNumber, series); } // Provide metadata for other seasons else { @@ -441,19 +439,56 @@ private void HandleSeason(Season season, Series series, bool deleted = false) return; } - if (deleted) + if (addMissing && deleted) season = AddVirtualSeason(seasonNumber, series); } - foreach (var episodeInfo in seriesInfo.EpisodeList) { - var episodeParentIndex = Ordering.GetSeasonNumber(groupInfo, seriesInfo, episodeInfo); - if (episodeParentIndex != seasonNumber) - continue; + if (addMissing) { + // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. + var existingEpisodes = new HashSet<string>(); + foreach (var episode in season.Children.OfType<Episode>()) + if (IsEnabledForEpisode(episode, out var episodeId)) + existingEpisodes.Add(episodeId); + + // Handle specials when grouped. + if (seasonNumber == 0) { + if (seriesGrouping) { + foreach (var sI in groupInfo.SeriesList) { + foreach (var episodeInfo in sI.SpecialsList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + } + } + } + else { + foreach (var episodeInfo in seriesInfo.SpecialsList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + } + } + + return; + } + + foreach (var episodeInfo in seriesInfo.EpisodeList) { + var episodeParentIndex = Ordering.GetSeasonNumber(groupInfo, seriesInfo, episodeInfo); + if (episodeParentIndex != seasonNumber) + continue; + + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + } + } + + // We add the extras to the season if we're using Shoko Groups. + if (Plugin.Instance.Configuration.AddExtraVideos && seriesGrouping) { + AddExtras(season, seriesInfo); } } finally { @@ -653,5 +688,16 @@ private void AddVirtualEpisode(Info.GroupInfo groupInfo, Info.SeriesInfo seriesI season.AddChild(result, CancellationToken.None); } + + private void AddExtras(BaseItem item, Info.SeriesInfo seriesInfo) + { + foreach (var episodeInfo in seriesInfo.ExtrasList) { + if (!ApiManager.TryGetEpisodePathForId(episodeInfo.Id, out var episodePath)) + continue; + + Logger.LogInformation("TODO: Add {ExtraType} to {ItemName}", episodeInfo.ExtraType, item.Name); + // The extra video is available locally. + } + } } } \ No newline at end of file From dc721c3c7f83aed5d606581df35cac7d441a34a1 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 13 Sep 2021 00:21:05 +0200 Subject: [PATCH 0182/1103] Rename missing metadata provider to extra metadata provider, since it can provide extra metadata such as trailers, theme videos and missing episodes. --- ...{MissingMetadataProvider.cs => ExtraMetadataProvider.cs} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename Shokofin/Providers/{MissingMetadataProvider.cs => ExtraMetadataProvider.cs} (98%) diff --git a/Shokofin/Providers/MissingMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs similarity index 98% rename from Shokofin/Providers/MissingMetadataProvider.cs rename to Shokofin/Providers/ExtraMetadataProvider.cs index b6ac0b09..c671ed20 100644 --- a/Shokofin/Providers/MissingMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -20,7 +20,7 @@ namespace Shokofin.Providers { - public class MissingMetadataProvider : IServerEntryPoint + public class ExtraMetadataProvider : IServerEntryPoint { private readonly ShokoAPIManager ApiManager; @@ -30,9 +30,9 @@ public class MissingMetadataProvider : IServerEntryPoint private readonly ILocalizationManager LocalizationManager; - private readonly ILogger<MissingMetadataProvider> Logger; + private readonly ILogger<ExtraMetadataProvider> Logger; - public MissingMetadataProvider(ShokoAPIManager apiManager, ILibraryManager libraryManager, IProviderManager providerManager, ILocalizationManager localizationManager, ILogger<MissingMetadataProvider> logger) + public ExtraMetadataProvider(ShokoAPIManager apiManager, ILibraryManager libraryManager, IProviderManager providerManager, ILocalizationManager localizationManager, ILogger<ExtraMetadataProvider> logger) { ApiManager = apiManager; LibraryManager = libraryManager; From ef27ff9fea8972910b4928528274c4b94d604bfc Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 12 Sep 2021 22:21:57 +0000 Subject: [PATCH 0183/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 0ef8527d..6fa9f7fa 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.18", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.18/shokofin_1.5.0.18.zip", + "checksum": "66130e1d8508a5316e0db88dc24000f5", + "timestamp": "2021-09-12T22:21:56Z" + }, { "version": "1.5.0.17", "changelog": "NA", From 9417f7da502eae21043afaa3ee6438e95d78baac Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 13 Sep 2021 17:48:53 +0200 Subject: [PATCH 0184/1103] Add another edge case for speical episodes --- Shokofin/Providers/EpisodeProvider.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 3b2da011..90481fd0 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -154,7 +154,11 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri int? previousEpisodeNumber = null; if (series.SpesialsAnchors.TryGetValue(episode.Id, out var previousEpisode)) previousEpisodeNumber = Ordering.GetEpisodeNumber(group, series, previousEpisode); - nextEpisodeNumber = previousEpisodeNumber.HasValue && previousEpisodeNumber.Value < series.EpisodeList.Count ? previousEpisodeNumber.Value + 1 : series.EpisodeList.Count; + nextEpisodeNumber = previousEpisodeNumber.HasValue && previousEpisodeNumber.Value < series.EpisodeList.Count ? previousEpisodeNumber.Value + 1 : null; + + // If the next episode was not found, then append it at the end of the season instead. + if (!nextEpisodeNumber.HasValue) + displayInBetween = false; } if (season != null) { From 7bae4c7474398449a62921040c41f50d6263e986 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 13 Sep 2021 19:29:14 +0200 Subject: [PATCH 0185/1103] Add back some more episode details for merge-friendly grouping --- Shokofin/Providers/EpisodeProvider.cs | 53 +++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 90481fd0..f6d9e5ce 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -104,11 +104,17 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri if (string.IsNullOrEmpty(metadataLanguage) && season != null) metadataLanguage = season.GetPreferredMetadataLanguage(); var config = Plugin.Instance.Configuration; + var mergeFriendly = config.SeriesGrouping == Ordering.GroupType.MergeFriendly && series.TvDB != null && episode.TvDB != null; + string displayTitle, alternateTitle; - if (series.AniDB.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) - ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, metadataLanguage); - else - ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, episode.Shoko.Name, metadataLanguage); + string defaultEpisodeTitle = mergeFriendly ? episode.TvDB.Title : episode.Shoko.Name; + if (series.AniDB.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) { + string defaultSeriesTitle = mergeFriendly ? series.TvDB.Title : series.Shoko.Name; + ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); + } + else { + ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultEpisodeTitle, metadataLanguage); + } var episodeNumber = Ordering.GetEpisodeNumber(group, series, episode); var seasonNumber = Ordering.GetSeasonNumber(group, series, episode); @@ -199,6 +205,45 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri }; } } + else if (mergeFriendly) { + if (season != null) { + result = new Episode { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, + AirsAfterSeasonNumber = episode.TvDB.AirsAfterSeason, + AirsBeforeEpisodeNumber = episode.TvDB.AirsBeforeEpisode, + AirsBeforeSeasonNumber = episode.TvDB.AirsBeforeSeason, + Id = episodeId, + IsVirtualItem = true, + SeasonId = season.Id, + SeriesId = season.Series.Id, + Overview = description, + CommunityRating = episode.TvDB.Rating?.ToFloat(10), + PremiereDate = episode.TvDB.AirDate, + SeriesName = season.Series.Name, + SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, + SeasonName = season.Name, + DateLastSaved = DateTime.UtcNow, + }; + result.PresentationUniqueKey = result.GetPresentationUniqueKey(); + } + else { + result = new Episode { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, + AirsAfterSeasonNumber = episode.TvDB.AirsAfterSeason, + AirsBeforeEpisodeNumber = episode.TvDB.AirsBeforeEpisode, + AirsBeforeSeasonNumber = episode.TvDB.AirsBeforeSeason, + CommunityRating = episode.TvDB.Rating?.ToFloat(10), + PremiereDate = episode.TvDB.AirDate, + Overview = description, + }; + } + } else { if (season != null) { result = new Episode { From d703ffdf1323c3103076cf805bd2e97897d8a127 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 14 Sep 2021 22:57:28 +0200 Subject: [PATCH 0186/1103] Add trailers, theme videos genres and tweak the events used --- Shokofin/API/Info/GroupInfo.cs | 4 + Shokofin/API/Info/SeriesInfo.cs | 4 + Shokofin/API/ShokoAPI.cs | 2 +- Shokofin/API/ShokoAPIManager.cs | 34 ++- Shokofin/Configuration/configPage.html | 14 +- Shokofin/Providers/BoxSetProvider.cs | 6 +- Shokofin/Providers/ExtraMetadataProvider.cs | 254 +++++++++++++------- Shokofin/Providers/MovieProvider.cs | 6 +- Shokofin/Providers/SeasonProvider.cs | 5 +- Shokofin/Providers/SeriesProvider.cs | 53 ++-- Shokofin/Utils/TextUtil.cs | 64 +++-- 11 files changed, 296 insertions(+), 150 deletions(-) diff --git a/Shokofin/API/Info/GroupInfo.cs b/Shokofin/API/Info/GroupInfo.cs index ca2bfa8b..98fb8737 100644 --- a/Shokofin/API/Info/GroupInfo.cs +++ b/Shokofin/API/Info/GroupInfo.cs @@ -10,6 +10,10 @@ public class GroupInfo public Group Shoko; + public string[] Tags; + + public string[] Genres; + public SeriesInfo GetSeriesInfoBySeasonNumber(int seasonNumber) { if (seasonNumber == 0) return null; diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index 46066d75..80f56ce4 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -15,6 +15,10 @@ public class SeriesInfo public Series.TvDB TvDB; + public string[] Tags; + + public string[] Genres; + /// <summary> /// All episodes (of all type) that belong to this series. /// diff --git a/Shokofin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPI.cs index bef5efb1..37620332 100644 --- a/Shokofin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPI.cs @@ -173,7 +173,7 @@ public static async Task<IEnumerable<Series>> GetSeriesPathEndsWith(string dirna public static async Task<IEnumerable<Tag>> GetSeriesTags(string id, int filter = 0) { - var responseStream = await CallApi($"/api/v3/Series/{id}/Tags/{filter}"); + var responseStream = await CallApi($"/api/v3/Series/{id}/Tags/{filter}?excludeDescriptions=true"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Tag>>(responseStream) : null; } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 0ad78c9c..f5855d5d 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -13,6 +13,7 @@ using Shokofin.Utils; using ILibraryManager = MediaBrowser.Controller.Library.ILibraryManager; +using CultureInfo = System.Globalization.CultureInfo; namespace Shokofin.API { @@ -146,9 +147,9 @@ public async Task<IEnumerable<PersonInfo>> GetPeople(string seriesId) #endregion #region Tags - public async Task<string[]> GetTags(string seriesId) + private async Task<string[]> GetTags(string seriesId) { - return (await ShokoAPI.GetSeriesTags(seriesId, GetTagFilter()))?.Select(tag => tag.Name).ToArray() ?? new string[0]; + return (await ShokoAPI.GetSeriesTags(seriesId, GetTagFilter()))?.Select(SelectTagName).ToArray() ?? new string[0]; } /// <summary> @@ -158,7 +159,7 @@ public async Task<string[]> GetTags(string seriesId) private int GetTagFilter() { var config = Plugin.Instance.Configuration; - var filter = 0; + var filter = 128; // We exclude genres by default if (config.HideAniDbTags) filter = 1; if (config.HideArtStyleTags) filter |= (filter << 1); @@ -169,6 +170,20 @@ private int GetTagFilter() return filter; } + #endregion + #region Genres + + public async Task<string[]> GetGenresForSeries(string seriesId) + { + // The following magic number is the filter value to allow only genres in the returned list. + return (await ShokoAPI.GetSeriesTags(seriesId, -2147483520))?.Select(SelectTagName).ToArray() ?? new string[0]; + } + + private string SelectTagName(Tag tag) + { + return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tag.Name); + } + #endregion #region File Info @@ -327,7 +342,9 @@ public bool TryGetEpisodePathForId(string episodeId, out string path) case EpisodeType.Trailer: return ExtraType.Trailer; case EpisodeType.Special: { - var title = Text.GetTitleByLanguages(episode.Titles, "en") ?? ""; + var title = Text.GetTitleByLanguages(episode.Titles, "en"); + if (string.IsNullOrEmpty(title)) + return null; // Interview if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) return ExtraType.Interview; @@ -335,6 +352,9 @@ public bool TryGetEpisodePathForId(string episodeId, out string path) if (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) && (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase))) return ExtraType.Clip; + // Music videos + if (title.Contains("music video", System.StringComparison.OrdinalIgnoreCase)) + return ExtraType.Clip; return null; } default: @@ -470,6 +490,8 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = var aniDb = await ShokoAPI.GetSeriesAniDB(seriesId); var tvDbId = series.IDs.TvDB?.FirstOrDefault(); + var tags = await GetTags(seriesId); + var genres = await GetGenresForSeries(seriesId); Dictionary<string, EpisodeInfo> specialsAnchorDictionary = new Dictionary<string, EpisodeInfo>(); var specialsList = new List<EpisodeInfo>(); var episodesList = new List<EpisodeInfo>(); @@ -513,6 +535,8 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = AniDB = aniDb, TvDBId = tvDbId != 0 ? tvDbId.ToString() : null, TvDB = tvDbId != 0 ? (await ShokoAPI.GetSeriesTvDB(seriesId)).FirstOrDefault() : null, + Tags = tags, + Genres = genres, RawEpisodeList = allEpisodesList, EpisodeList = episodesList, ExtrasList = extrasList, @@ -669,6 +693,8 @@ private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Order groupInfo = new GroupInfo { Id = groupId, Shoko = group, + Tags = seriesList.SelectMany(s => s.Tags).Distinct().ToArray(), + Genres = seriesList.SelectMany(s => s.Genres).Distinct().ToArray(), SeriesList = seriesList, DefaultSeries = seriesList[foundIndex], DefaultSeriesIndex = foundIndex, diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 2c8601bc..da77ee79 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -53,9 +53,11 @@ <h3>Synopsis Options</h3> <div class="selectContainer"> <label class="selectLabel" for="DescriptionSource">Synopsis source</label> <select is="emby-select" id="DescriptionSource" name="DescriptionSource" class="emby-select-withcolor emby-select"> - <option value="Default">Use default source</option> - <option value="AllowOthers">Prefer TvDB/TMDB if available, otherwise use AniDb</option> - <option value="OnlyAniDb">Only use AniDb</option> + <option value="Default">Use default for Series Grouping</option> + <option value="OnlyAniDb">Only use AniDB</option> + <option value="PreferAniDb">Prefer AniDB if available, otherwise use TvDB/TMDB</option> + <option value="OnlyOther">Only use TvDB/TMDB</option> + <option value="PreferOther">Prefer TvDB/TMDB if available, otherwise use AniDB</option> </select> </div> <label class="checkboxContainer"> @@ -109,6 +111,10 @@ <h3>Library Options</h3> <input is="emby-checkbox" type="checkbox" id="AddMissingMetadata" /> <span>Add metadata for missing seasons/episodes</span> </label> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="AddExtraVideos" /> + <span>Add trailers, theme videos, and other extras</span> + </label> <div class="selectContainer"> <label class="selectLabel" for="BoxSetGrouping">Box-set grouping</label> <select is="emby-select" id="BoxSetGrouping" name="BoxSetGrouping" class="emby-select-withcolor emby-select"> @@ -201,6 +207,7 @@ <h3>Tag Options</h3> document.querySelector('#MarkSpecialsWhenGrouped').checked = config.MarkSpecialsWhenGrouped; document.querySelector('#DisplaySpecialsInSeason').checked = config.DisplaySpecialsInSeason; document.querySelector('#AddMissingMetadata').checked = config.AddMissingMetadata; + document.querySelector('#AddExtraVideos').checked = config.AddExtraVideos; document.querySelector('#FilterOnLibraryTypes').checked = config.FilterOnLibraryTypes; document.querySelector('#AddAniDBId').checked = config.AddAniDBId; document.querySelector('#PreferAniDbPoster').checked = config.PreferAniDbPoster; @@ -279,6 +286,7 @@ <h3>Tag Options</h3> config.MarkSpecialsWhenGrouped = document.querySelector('#MarkSpecialsWhenGrouped').checked; config.DisplaySpecialsInSeason = document.querySelector('#DisplaySpecialsInSeason').checked; config.AddMissingMetadata = document.querySelector('#AddMissingMetadata').checked; + config.AddExtraVideos = document.querySelector('#AddExtraVideos').checked; config.FilterOnLibraryTypes = document.querySelector('#FilterOnLibraryTypes').checked; config.AddAniDBId = document.querySelector('#AddAniDBId').checked; config.PreferAniDbPoster = document.querySelector('#PreferAniDbPoster').checked; diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 7d699afc..084ccb0e 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -63,7 +63,6 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, Ca } var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.AniDB.Title, info.MetadataLanguage); - var tags = await ApiManager.GetTags(series.Id); result.Item = new BoxSet { Name = displayTitle, @@ -72,7 +71,7 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, Ca PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, - Tags = tags, + Tags = series.Tags, CommunityRating = series.AniDB.Rating.ToFloat(10), }; result.Item.SetProviderId("Shoko Series", series.Id); @@ -101,7 +100,6 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in Logger.LogWarning("Group did not contain multiple movies! Skipping path {Path} (Series={SeriesId},Group={GroupId})", info.Path, group.Id, series.Id); return result; } - var tags = await ApiManager.GetTags(series.Id); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); result.Item = new BoxSet { @@ -111,7 +109,7 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, - Tags = tags, + Tags = group.Tags, CommunityRating = (float)((series.AniDB.Rating.Value * 10) / series.AniDB.Rating.MaxValue) }; result.Item.SetProviderId("Shoko Series", series.Id); diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index c671ed20..29fc9d49 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -46,7 +46,6 @@ public Task RunAsync() LibraryManager.ItemAdded += OnLibraryManagerItemAdded; LibraryManager.ItemUpdated += OnLibraryManagerItemUpdated; LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; - ProviderManager.RefreshCompleted += OnProviderManagerRefreshComplete; return Task.CompletedTask; } @@ -56,7 +55,6 @@ public void Dispose() LibraryManager.ItemAdded -= OnLibraryManagerItemAdded; LibraryManager.ItemUpdated -= OnLibraryManagerItemUpdated; LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; - ProviderManager.RefreshCompleted -= OnProviderManagerRefreshComplete; } public bool IsEnabledForItem(BaseItem item) @@ -79,28 +77,33 @@ public bool IsEnabledForItem(BaseItem item) return libraryOptions != null && libraryOptions.TypeOptions.Any(o => o.Type == nameof (Series) && o.MetadataFetchers.Contains(Plugin.MetadataProviderName)); } - private void OnProviderManagerRefreshComplete(object sender, GenericEventArgs<BaseItem> genericEventArgs) + private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs itemChangeEventArgs) { // No action needed if either 1) the setting is turned of, 2) the provider is not enabled for the item - if (!Plugin.Instance.Configuration.AddMissingMetadata || !IsEnabledForItem(genericEventArgs.Argument)) + if (!Plugin.Instance.Configuration.AddMissingMetadata || !IsEnabledForItem(itemChangeEventArgs.Item)) return; - switch (genericEventArgs.Argument) { - case Series series: - HandleSeries(series); - break; - case Season season: - HandleSeason(season, season.Series); + switch (itemChangeEventArgs.Item) { + case Series series: { + // Abort if we're unable to get the shoko episode id + if (!IsEnabledForSeries(series, out var seriesId)) + return; + + HandleSeries(series, seriesId); break; - } - } + } + case Season season: { + // We're not interested in the dummy season. + if (!season.IndexNumber.HasValue) + return; - private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs itemChangeEventArgs) - { - if (!Plugin.Instance.Configuration.AddMissingMetadata || !IsEnabledForItem(itemChangeEventArgs.Item)) - return; + // Abort if we're unable to get the shoko series id + if (!IsEnabledForSeason(season, out var seriesId)) + return; - switch (itemChangeEventArgs.Item) { + HandleSeason(season, seriesId, season.Series); + break; + } case Episode episode: { // Abort if we're unable to get the shoko episode id if (!IsEnabledForEpisode(episode, out var episodeId)) @@ -142,18 +145,11 @@ private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs item if (!IsEnabledForSeries(series, out var seriesId)) return; - if (!ApiManager.TryLockActionForIdOFType("series", seriesId, "remove")) - return; - - try { - foreach (var season in series.GetSeasons(null, new DtoOptions(true))) { - OnLibraryManagerItemUpdated(this, new ItemChangeEventArgs { Item = season, Parent = series, UpdateReason = ItemUpdateType.None }); - } - } - finally { - ApiManager.TryUnlockActionForIdOFType("series", seriesId, "remove"); + if (Plugin.Instance.Configuration.AddMissingMetadata) { + RemoveDuplicateEpisodes(series, seriesId); } + HandleSeries(series, seriesId); return; } case Season season: { @@ -161,56 +157,26 @@ private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs item if (!season.IndexNumber.HasValue) return; - // Abort if we're unable to get the shoko episode id + // Abort if we're unable to get the shoko series id if (!IsEnabledForSeason(season, out var seriesId)) return; - var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; - if (!ApiManager.TryLockActionForIdOFType("season", seasonId, "remove")) - return; - - try { - foreach (var episode in season.GetEpisodes(null, new DtoOptions(true)).Where(ep => !ep.IsVirtualItem)) { - OnLibraryManagerItemUpdated(this, new ItemChangeEventArgs { Item = episode, Parent = season, UpdateReason = ItemUpdateType.None }); - } - } - finally { - ApiManager.TryUnlockActionForIdOFType("season", seasonId, "remove"); + if (Plugin.Instance.Configuration.AddMissingMetadata) { + RemoveDuplicateEpisodes(season, seriesId); } + HandleSeason(season, seriesId, season.Series); return; } case Episode episode: { + if (!Plugin.Instance.Configuration.AddMissingMetadata) + return; + // Abort if we're unable to get the shoko episode id if (!IsEnabledForEpisode(episode, out var episodeId)) return; - var query = new InternalItemsQuery { - IsVirtualItem = true, - HasAnyProviderId = { ["Shoko Episode"] = episodeId }, - IncludeItemTypes = new [] { nameof (Episode) }, - GroupByPresentationUniqueKey = false, - DtoOptions = new DtoOptions(true), - }; - - var existingVirtualItems = LibraryManager.GetItemList(query); - - var deleteOptions = new DeleteOptions { - DeleteFileLocation = true, - }; - - var count = existingVirtualItems.Count; - // Remove the virtual season/episode that matches the newly updated item - foreach (var item in existingVirtualItems) { - if (episode.IsVirtualItem && System.Guid.Equals(item.Id, episode.Id)) { - count--; - continue; - } - - LibraryManager.DeleteItem(item, deleteOptions); - } - Logger.LogInformation("Removed {Count} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", count, episode.Name, episodeId); - + RemoveDuplicateEpisodes(episode, episodeId); return; } } @@ -219,18 +185,27 @@ private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs item private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs itemChangeEventArgs) { // No action needed if either 1) the setting is turned of, 2) the item is virtual, 3) the provider is not enabled for the item - if (!Plugin.Instance.Configuration.AddMissingMetadata || itemChangeEventArgs.Item.IsVirtualItem || !IsEnabledForItem(itemChangeEventArgs.Item)) + if (itemChangeEventArgs.Item.IsVirtualItem || !IsEnabledForItem(itemChangeEventArgs.Item)) return; switch (itemChangeEventArgs.Item) { // Create a new virtual season if the real one was deleted. - case Season season: - HandleSeason(season, itemChangeEventArgs.Parent as Series, true); + case Season season: { + // Abort if we're unable to get the shoko episode id + if (!IsEnabledForSeason(season, out var seriesId)) + return; + + HandleSeason(season, seriesId, itemChangeEventArgs.Parent as Series, true); break; + } // Similarly, create a new virtual episode if the real one was deleted. - case Episode episode: - HandleEpisode(episode); + case Episode episode: { + if (!IsEnabledForEpisode(episode, out var episodeId)) + return; + + HandleEpisode(episode, episodeId); break; + } } } @@ -261,12 +236,8 @@ private bool IsEnabledForSeries(Series series, out string seriesId) return false; } - private void HandleSeries(Series series) + private void HandleSeries(Series series, string seriesId) { - // Abort if we're unable to get the series id - if (!IsEnabledForSeries(series, out var seriesId)) - return; - if (!ApiManager.TryLockActionForIdOFType("series", seriesId, "update")) return; @@ -332,6 +303,8 @@ private void HandleSeries(Series series) // We add the extras to the season if we're using Shoko Groups. if (Plugin.Instance.Configuration.AddExtraVideos) { + AddExtras(series, groupInfo.DefaultSeries); + foreach (var (seriesInfo, index) in groupInfo.SeriesList.Select((s, i) => (s, i))) { var value = index - groupInfo.DefaultSeriesIndex; var seasonNumber = value < 0 ? value : value + 1; @@ -398,11 +371,8 @@ private bool IsEnabledForSeason(Season season, out string seriesId) return IsEnabledForSeries(season.Series, out seriesId); } - private void HandleSeason(Season season, Series series, bool deleted = false) + private void HandleSeason(Season season, string seriesId, Series series, bool deleted = false) { - if (!IsEnabledForSeason(season, out var seriesId)) - return; - var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; try { if (!ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) @@ -507,12 +477,8 @@ private bool IsEnabledForEpisode(Episode episode, out string episodeId) ) && !string.IsNullOrEmpty(episodeId); } - private void HandleEpisode(Episode episode) + private void HandleEpisode(Episode episode, string episodeId) { - // Abort if we're unable to get the shoko episode id - if (!IsEnabledForEpisode(episode, out var episodeId)) - return; - Info.GroupInfo groupInfo = null; Info.SeriesInfo seriesInfo = ApiManager.GetSeriesInfoForEpisodeSync(episodeId); Info.EpisodeInfo episodeInfo = seriesInfo.EpisodeList.Find(e => e.Id == episodeId); @@ -631,7 +597,6 @@ private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, int seasonNumber, Se return null; } - var tags = ApiManager.GetTags(seriesInfo.Id).GetAwaiter().GetResult(); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(seriesInfo.AniDB.Titles, seriesInfo.Shoko.Name, series.GetPreferredMetadataLanguage()); var sortTitle = $"S{seasonNumber} - {seriesInfo.Shoko.Name}"; @@ -650,7 +615,8 @@ private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, int seasonNumber, Se PremiereDate = seriesInfo.AniDB.AirDate, EndDate = seriesInfo.AniDB.EndDate, ProductionYear = seriesInfo.AniDB.AirDate?.Year, - Tags = tags, + Tags = series.Tags.ToArray(), + Genres = series.Genres.ToArray(), CommunityRating = seriesInfo.AniDB.Rating?.ToFloat(10), SeriesId = series.Id, SeriesName = series.Name, @@ -689,14 +655,122 @@ private void AddVirtualEpisode(Info.GroupInfo groupInfo, Info.SeriesInfo seriesI season.AddChild(result, CancellationToken.None); } - private void AddExtras(BaseItem item, Info.SeriesInfo seriesInfo) + private void AddExtras(Folder parent, Info.SeriesInfo seriesInfo) { + if (seriesInfo.ExtrasList.Count == 0) + return; + + var needsUpdate = false; foreach (var episodeInfo in seriesInfo.ExtrasList) { if (!ApiManager.TryGetEpisodePathForId(episodeInfo.Id, out var episodePath)) continue; - - Logger.LogInformation("TODO: Add {ExtraType} to {ItemName}", episodeInfo.ExtraType, item.Name); - // The extra video is available locally. + + switch (episodeInfo.ExtraType) { + default: + break; + case MediaBrowser.Model.Entities.ExtraType.ThemeSong: + case MediaBrowser.Model.Entities.ExtraType.ThemeVideo: + if (!parent.SupportsThemeMedia) + continue; + break; + } + + var item = LibraryManager.FindByPath(episodePath, false); + if (item != null && item is Video result) { + result.ParentId = Guid.Empty; + result.OwnerId = parent.Id; + result.Name = episodeInfo.Shoko.Name; + result.ExtraType = episodeInfo.ExtraType; + LibraryManager.UpdateItemAsync(result, null, ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } + else { + Logger.LogInformation("Addding {ExtraType} {EpisodeName} to {ParentName}", episodeInfo.ExtraType, parent.Name); + result = new Video { + Id = LibraryManager.GetNewItemId($"{parent.Id} {episodeInfo.ExtraType} {episodeInfo.Id}", typeof (Video)), + Name = episodeInfo.Shoko.Name, + Path = episodePath, + ExtraType = episodeInfo.ExtraType, + ParentId = Guid.Empty, + OwnerId = parent.Id, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + }; + LibraryManager.CreateItem(result, null); + } + + parent.ExtraIds = parent.ExtraIds.Append(result.Id).Distinct().ToArray(); + } + // The extra video is available locally. + if (needsUpdate) { + LibraryManager.UpdateItemAsync(parent, parent.Parent, ItemUpdateType.None, CancellationToken.None).ConfigureAwait(false); + } + } + + private void RemoveDuplicateEpisodes(Episode episode, string episodeId) + { + var query = new InternalItemsQuery { + IsVirtualItem = true, + ExcludeItemIds = new [] { episode.Id }, + HasAnyProviderId = { ["Shoko Episode"] = episodeId }, + IncludeItemTypes = new [] { nameof (Episode) }, + GroupByPresentationUniqueKey = false, + DtoOptions = new DtoOptions(true), + }; + + var existingVirtualItems = LibraryManager.GetItemList(query); + + var deleteOptions = new DeleteOptions { + DeleteFileLocation = true, + }; + + // Remove the virtual season/episode that matches the newly updated item + foreach (var item in existingVirtualItems) { + LibraryManager.DeleteItem(item, deleteOptions); + } + if (existingVirtualItems.Count > 0) + Logger.LogInformation("Removed {Count} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", existingVirtualItems.Count, episode.Name, episodeId); + } + + private void RemoveDuplicateEpisodes(Season season, string seriesId) + { + var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; + if (!ApiManager.TryLockActionForIdOFType("season", seasonId, "remove")) + return; + + try { + foreach (var episode in season.GetEpisodes(null, new DtoOptions(true)).OfType<Episode>()) { + // We're only interested in physical episodes. + if (episode.IsVirtualItem) + continue; + + // Abort if we're unable to get the shoko episode id + if (!IsEnabledForEpisode(episode, out var episodeId)) + continue; + + RemoveDuplicateEpisodes(episode, episodeId); + } + } + finally { + ApiManager.TryUnlockActionForIdOFType("season", seasonId, "remove"); + } + } + + private void RemoveDuplicateEpisodes(Series series, string seriesId) + { + if (!ApiManager.TryLockActionForIdOFType("series", seriesId, "remove")) + return; + + try { + foreach (var season in series.GetSeasons(null, new DtoOptions(true)).OfType<Season>()) { + // We're not interested in any dummy seasons + if (!season.IndexNumber.HasValue) + continue; + + RemoveDuplicateEpisodes(season, seriesId); + } + } + finally { + ApiManager.TryUnlockActionForIdOFType("series", seriesId, "remove"); } } } diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index ce4200af..6d2432eb 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -21,7 +22,6 @@ public class MovieProvider : IRemoteMetadataProvider<Movie, MovieInfo> private readonly ILogger<MovieProvider> Logger; - private readonly ShokoAPIManager ApiManager; public MovieProvider(IHttpClientFactory httpClientFactory, ILogger<MovieProvider> logger, ShokoAPIManager apiManager) @@ -50,7 +50,6 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio var ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); Logger.LogInformation("Found movie {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", displayTitle, file.Id, episode.Id, series.Id); - var tags = await ApiManager.GetTags(series.Id); bool isMultiEntry = series.Shoko.Sizes.Total.Episodes > 1; var rating = isMultiEntry ? episode.AniDB.Rating.ToFloat(10) : series.AniDB.Rating.ToFloat(10); @@ -62,7 +61,8 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio // Use the file description if collection contains more than one movie, otherwise use the collection description. Overview = (isMultiEntry ? Text.GetDescription(episode) : Text.GetDescription(series)), ProductionYear = episode.AniDB.AirDate?.Year, - Tags = tags, + Tags = series.Tags.ToArray(), + Genres = series.Genres.ToArray(), CommunityRating = rating, }; result.Item.SetProviderId("Shoko File", file.Id); diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 1fbf355e..f1ac43a0 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -84,7 +85,6 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in } Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Group={GroupId},Series={SeriesId})", seasonNumber, group.Shoko.Name, groupId, series.Id); - var tags = await ApiManager.GetTags(series.Id); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); var sortTitle = $"I{seasonNumber} - {series.Shoko.Name}"; @@ -98,7 +98,8 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, - Tags = tags, + Tags = series.Tags.ToArray(), + Genres = series.Genres.ToArray(), CommunityRating = series.AniDB.Rating?.ToFloat(10), }; result.Item.ProviderIds.Add("Shoko Series", series.Id); diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 162cf66e..85006bb8 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -69,22 +69,41 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C } } - var tags = await ApiManager.GetTags(series.Id); - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); + var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && series.TvDB != null; + + var defaultSeriesTitle = mergeFriendly ? series.TvDB.Title : series.Shoko.Name; + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, defaultSeriesTitle, info.MetadataLanguage); Logger.LogInformation("Found series {SeriesName} (Series={SeriesId})", displayTitle, series.Id); - result.Item = new Series { - Name = displayTitle, - OriginalTitle = alternateTitle, - Overview = Text.GetDescription(series), - PremiereDate = series.AniDB.AirDate, - EndDate = series.AniDB.EndDate, - ProductionYear = series.AniDB.AirDate?.Year, - Status = series.AniDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = tags, - CommunityRating = series.AniDB.Rating.ToFloat(10), - }; - AddProviderIds(result.Item, series.Id, null, series.AniDB.ID.ToString(), Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly ? series.TvDBId : null); + if (mergeFriendly) { + result.Item = new Series { + Name = displayTitle, + OriginalTitle = alternateTitle, + Overview = Text.GetDescription(series), + PremiereDate = series.TvDB.AirDate, + EndDate = series.TvDB.EndDate, + ProductionYear = series.TvDB.AirDate?.Year, + Status = series.TvDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, + Tags = series.Tags, + Genres = series.Genres.ToArray(), + CommunityRating = series.TvDB.Rating?.ToFloat(10), + }; + } + else { + result.Item = new Series { + Name = displayTitle, + OriginalTitle = alternateTitle, + Overview = Text.GetDescription(series), + PremiereDate = series.AniDB.AirDate, + EndDate = series.AniDB.EndDate, + ProductionYear = series.AniDB.AirDate?.Year, + Status = series.AniDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, + Tags = series.Tags, + Genres = series.Genres.ToArray(), + CommunityRating = series.AniDB.Rating.ToFloat(10), + }; + } + AddProviderIds(result.Item, series.Id, null, series.AniDB.ID.ToString(), mergeFriendly ? series.TvDBId : null); result.HasMetadata = true; @@ -118,7 +137,6 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, group.Shoko.Name, info.MetadataLanguage); Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, series.Id, group.Id); - var tags = await ApiManager.GetTags(series.Id); result.Item = new Series { Name = displayTitle, OriginalTitle = alternateTitle, @@ -127,10 +145,11 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, Status = series.AniDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = tags, + Tags = group.Tags, + Genres = group.Genres.ToArray(), CommunityRating = series.AniDB.Rating.ToFloat(10), }; - AddProviderIds(result.Item, series.Id, group.Id, series.AniDB.ID.ToString(), null); + AddProviderIds(result.Item, series.Id, group.Id, series.AniDB.ID.ToString()); result.HasMetadata = true; diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index 680673ed..2a112064 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -14,19 +14,30 @@ public class Text /// </summary> public enum TextSourceType { /// <summary> - /// Use the default. + /// Use the default source for the current series grouping. /// </summary> Default = 1, /// <summary> - /// Only use AniDb. + /// Only use AniDb, or null if no data is available. /// </summary> OnlyAniDb = 2, /// <summary> - /// Allow other providers, like TvDB/TMDB. + /// Prefer the AniDb data, but use the other provider if there is no + /// AniDb data available. /// </summary> - AllowOthers = 3, + PreferAniDb = 3, + + /// <summary> + /// Prefer the other provider (e.g. TvDB/TMDB) + /// </summary> + PreferOther = 4, + + /// <summary> + /// Only use the other provider, or null if no data is available. + /// </summary> + OnlyOther = 5, } /// <summary> @@ -76,37 +87,38 @@ public enum DisplayTitleType { FullTitle = 3, } + public static string GetDescription(SeriesInfo series) + => GetDescription(series.AniDB.Description, series.TvDB?.Description); + public static string GetDescription(EpisodeInfo episode) + => GetDescription(episode.AniDB.Description, episode.TvDB?.Description); + + private static string GetDescription(string aniDbDescription, string otherDescription) { string overview; switch (Plugin.Instance.Configuration.DescriptionSource) { default: - case Text.TextSourceType.Default: - case Text.TextSourceType.AllowOthers: - overview = episode.TvDB?.Description ?? ""; + switch (Plugin.Instance.Configuration.SeriesGrouping) { + default: + goto preferAniDb; + case Ordering.GroupType.MergeFriendly: + goto preferOther; + } + case TextSourceType.PreferOther: + preferOther: overview = otherDescription ?? ""; if (string.IsNullOrEmpty(overview)) - goto case Text.TextSourceType.OnlyAniDb; - break; - case Text.TextSourceType.OnlyAniDb: - overview = Text.SanitizeTextSummary(episode.AniDB.Description); + goto case TextSourceType.OnlyAniDb; break; - } - return overview; - } - - public static string GetDescription(SeriesInfo series) - { - string overview; - switch (Plugin.Instance.Configuration.DescriptionSource) { - default: - case Text.TextSourceType.Default: - case Text.TextSourceType.AllowOthers: - overview = series.TvDB?.Description ?? ""; + case TextSourceType.PreferAniDb: + preferAniDb: overview = Text.SanitizeTextSummary(aniDbDescription); if (string.IsNullOrEmpty(overview)) - goto case Text.TextSourceType.OnlyAniDb; + goto case TextSourceType.OnlyAniDb; + break; + case TextSourceType.OnlyAniDb: + overview = Text.SanitizeTextSummary(aniDbDescription); break; - case Text.TextSourceType.OnlyAniDb: - overview = Text.SanitizeTextSummary(series.AniDB.Description); + case TextSourceType.OnlyOther: + overview = otherDescription ?? ""; break; } return overview; From b621d6513098a418ee7f7611e806effd258b2b12 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 14 Sep 2021 21:33:25 +0000 Subject: [PATCH 0187/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 6fa9f7fa..38e8ff4d 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.19", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.19/shokofin_1.5.0.19.zip", + "checksum": "512a57c552d36387c010051052f1f994", + "timestamp": "2021-09-14T21:33:24Z" + }, { "version": "1.5.0.18", "changelog": "NA", From e14744c65f81d10dd4309a9ae1d1484c58d8e0cb Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 15 Sep 2021 00:10:23 +0200 Subject: [PATCH 0188/1103] Jellyfin have good support for negative season indexes --- Shokofin/Providers/SeasonProvider.cs | 10 +++++----- Shokofin/Utils/OrderingUtil.cs | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index f1ac43a0..7d63f5e3 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -129,14 +129,14 @@ public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken private string GetSeasonName(int seasonNumber, string seasonName) { switch (seasonNumber) { - case -1: + case 127: + return "Misc."; + case 126: return "Credits"; - case -2: + case 125: return "Trailers"; - case -3: + case 124: return "Others"; - case -4: - return "Misc."; default: return seasonName; } diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 839aee62..7b10b40f 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -192,13 +192,13 @@ public static int GetSeasonNumber(GroupInfo group, SeriesInfo series, EpisodeInf case EpisodeType.Special: return 0; case EpisodeType.Unknown: - return -3; + return 124; case EpisodeType.Trailer: - return -2; + return 125; case EpisodeType.ThemeSong: - return -1; + return 126; default: - return -4; + return 127; } case GroupType.MergeFriendly: { var seasonNumber = episode?.TvDB?.Season; From 42568c128af85a4c64864a24eea0a9579a769331 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 15 Sep 2021 00:26:37 +0200 Subject: [PATCH 0189/1103] Add specials to season in default view --- Shokofin/Providers/EpisodeProvider.cs | 80 +++++++++++++-------------- Shokofin/Utils/OrderingUtil.cs | 14 +++-- 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index f6d9e5ce..851597ca 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -153,7 +153,46 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri } Episode result; - if (group != null && episode.AniDB.Type == EpisodeType.Special) { + if (mergeFriendly) { + if (season != null) { + result = new Episode { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, + AirsAfterSeasonNumber = episode.TvDB.AirsAfterSeason, + AirsBeforeEpisodeNumber = episode.TvDB.AirsBeforeEpisode, + AirsBeforeSeasonNumber = episode.TvDB.AirsBeforeSeason, + Id = episodeId, + IsVirtualItem = true, + SeasonId = season.Id, + SeriesId = season.Series.Id, + Overview = description, + CommunityRating = episode.TvDB.Rating?.ToFloat(10), + PremiereDate = episode.TvDB.AirDate, + SeriesName = season.Series.Name, + SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, + SeasonName = season.Name, + DateLastSaved = DateTime.UtcNow, + }; + result.PresentationUniqueKey = result.GetPresentationUniqueKey(); + } + else { + result = new Episode { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = episodeNumber, + ParentIndexNumber = seasonNumber, + AirsAfterSeasonNumber = episode.TvDB.AirsAfterSeason, + AirsBeforeEpisodeNumber = episode.TvDB.AirsBeforeEpisode, + AirsBeforeSeasonNumber = episode.TvDB.AirsBeforeSeason, + CommunityRating = episode.TvDB.Rating?.ToFloat(10), + PremiereDate = episode.TvDB.AirDate, + Overview = description, + }; + } + } + else if (episode.AniDB.Type == EpisodeType.Special) { var displayInBetween = Plugin.Instance.Configuration.DisplaySpecialsInSeason; int? nextEpisodeNumber = null; if (displayInBetween) { @@ -205,45 +244,6 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri }; } } - else if (mergeFriendly) { - if (season != null) { - result = new Episode { - Name = displayTitle, - OriginalTitle = alternateTitle, - IndexNumber = episodeNumber, - ParentIndexNumber = seasonNumber, - AirsAfterSeasonNumber = episode.TvDB.AirsAfterSeason, - AirsBeforeEpisodeNumber = episode.TvDB.AirsBeforeEpisode, - AirsBeforeSeasonNumber = episode.TvDB.AirsBeforeSeason, - Id = episodeId, - IsVirtualItem = true, - SeasonId = season.Id, - SeriesId = season.Series.Id, - Overview = description, - CommunityRating = episode.TvDB.Rating?.ToFloat(10), - PremiereDate = episode.TvDB.AirDate, - SeriesName = season.Series.Name, - SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, - SeasonName = season.Name, - DateLastSaved = DateTime.UtcNow, - }; - result.PresentationUniqueKey = result.GetPresentationUniqueKey(); - } - else { - result = new Episode { - Name = displayTitle, - OriginalTitle = alternateTitle, - IndexNumber = episodeNumber, - ParentIndexNumber = seasonNumber, - AirsAfterSeasonNumber = episode.TvDB.AirsAfterSeason, - AirsBeforeEpisodeNumber = episode.TvDB.AirsBeforeEpisode, - AirsBeforeSeasonNumber = episode.TvDB.AirsBeforeSeason, - CommunityRating = episode.TvDB.Rating?.ToFloat(10), - PremiereDate = episode.TvDB.AirDate, - Overview = description, - }; - } - } else { if (season != null) { result = new Episode { diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 7b10b40f..9864f1bb 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -128,12 +128,18 @@ public static int GetEpisodeNumber(GroupInfo group, SeriesInfo series, EpisodeIn { default: case GroupType.Default: + if (episode.AniDB.Type == EpisodeType.Special) { + var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); + if (index == -1) + throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Series={series.Id},Episode={episode.Id})"); + return (index + 1); + } return episode.AniDB.EpisodeNumber; case GroupType.MergeFriendly: { - var episodeNumber = episode?.TvDB?.Number ?? 0; - if (episodeNumber == 0) - goto case GroupType.Default; - return episodeNumber; + var episodeNumber = episode?.TvDB?.Number; + if (episodeNumber.HasValue) + return episodeNumber.Value; + goto case GroupType.Default; } case GroupType.ShokoGroup: { int offset = 0; From 3e8a7164456cf8db41877c605424e91bf08074d9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 15 Sep 2021 00:38:16 +0200 Subject: [PATCH 0190/1103] Provide moew detailed season data for default grouping --- Shokofin/Providers/ExtraMetadataProvider.cs | 14 +++++---- Shokofin/Providers/ImageProvider.cs | 6 ++-- Shokofin/Providers/SeasonProvider.cs | 33 +++++++++++++++------ 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index 29fc9d49..8d0148e1 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -332,7 +332,7 @@ private void HandleSeries(Series series, string seriesId) // Add missing seasons var allKnownSeasonNumbers = episodeInfoToSeasonNumberDirectory.Values.Distinct().ToList(); - foreach (var (seasonNumber, season) in CreateMissingSeasons(series, seasons, allKnownSeasonNumbers)) + foreach (var (seasonNumber, season) in CreateMissingSeasons(seriesInfo, series, seasons, allKnownSeasonNumbers)) seasons.Add(seasonNumber, season); // Add missing episodes @@ -506,11 +506,12 @@ private void HandleEpisode(Episode episode, string episodeId) return (seasons, episodes); } - private IEnumerable<(int, Season)> CreateMissingSeasons(Series series, Dictionary<int, Season> existingSeasons, List<int> allSeasonNumbers) + private IEnumerable<(int, Season)> CreateMissingSeasons(Info.SeriesInfo seriesInfo, Series series, Dictionary<int, Season> existingSeasons, List<int> allSeasonNumbers) { var missingSeasonNumbers = allSeasonNumbers.Except(existingSeasons.Keys).ToList(); + var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && seriesInfo.TvDB != null; foreach (var seasonNumber in missingSeasonNumbers) { - var season = AddVirtualSeason(seasonNumber, series); + var season = seasonNumber == 1 && !mergeFriendly ? AddVirtualSeason(seriesInfo, 1, series) : AddVirtualSeason(seasonNumber, series); if (season == null) continue; yield return (seasonNumber, season); @@ -532,8 +533,11 @@ private void HandleEpisode(Episode episode, string episodeId) continue; yield return (seasonNumber, season); } - if (hasSpecials && !seasons.ContainsKey(0)) - yield return (0, AddVirtualSeason(0, series)); + if (hasSpecials && !seasons.ContainsKey(0)) { + var season = AddVirtualSeason(0, series); + if (season != null) + yield return (0, season); + } } private Season AddVirtualSeason(int seasonNumber, Series series) diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 356b2a27..83c860cb 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -63,11 +63,11 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell break; } case Season season: { - if (season.IndexNumber.HasValue && season.Series.ProviderIds.TryGetValue("Shoko Group", out var groupId) && !string.IsNullOrEmpty(groupId)) { - var groupInfo = await ApiManager.GetGroupInfo(groupId, filterLibrary); + if (season.IndexNumber.HasValue && season.Series.ProviderIds.TryGetValue("Shoko Series", out var seriesId) && !string.IsNullOrEmpty(seriesId)) { + var groupInfo = await ApiManager.GetGroupInfoForSeries(seriesId, filterLibrary); seriesInfo = groupInfo?.GetSeriesInfoBySeasonNumber(season.IndexNumber.Value); if (seriesInfo != null) - Logger.LogInformation("Getting images for season {SeasonNumber} in {SeriesName} (Series={SeriesId},Group={GroupId})", season.IndexNumber.Value, groupInfo.Shoko.Name, seriesInfo.Id, groupId); + Logger.LogInformation("Getting images for season {SeasonNumber} in {SeriesName} (Series={SeriesId},Group={GroupId})", season.IndexNumber.Value, groupInfo.Shoko.Name, seriesInfo.Id, groupInfo.Id); } break; } diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 7d63f5e3..04f255a7 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -36,10 +36,14 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat try { switch (Plugin.Instance.Configuration.SeriesGrouping) { default: + if (info.IndexNumber.Value == 1) + return await GetShokoGroupedMetadata(info, cancellationToken); + return GetDefaultMetadata(info, cancellationToken); + case Ordering.GroupType.MergeFriendly: return GetDefaultMetadata(info, cancellationToken); case Ordering.GroupType.ShokoGroup: if (info.IndexNumber.Value == 0) - goto default; + return GetDefaultMetadata(info, cancellationToken); return await GetShokoGroupedMetadata(info, cancellationToken); } } @@ -70,20 +74,31 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in { var result = new MetadataResult<Season>(); - if (!info.SeriesProviderIds.TryGetValue("Shoko Group", out var groupId)) { + if (!info.SeriesProviderIds.TryGetValue("Shoko Series", out var seriesId)) { Logger.LogWarning($"Unable refresh item, Shoko Group Id was not stored for Series."); return result; } var seasonNumber = info.IndexNumber.Value; - var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var group = await ApiManager.GetGroupInfo(groupId, filterLibrary); - var series = group?.GetSeriesInfoBySeasonNumber(seasonNumber); - if (group == null || series == null) { - Logger.LogWarning("Unable to find info for Season {SeasonNumber} in Series {SeriesName}. (Group={GroupId})", seasonNumber, group.Shoko.Name, groupId); - return result; + API.Info.SeriesInfo series; + if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { + var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; + var group = await ApiManager.GetGroupInfoForSeries(seriesId, filterLibrary); + series = group?.GetSeriesInfoBySeasonNumber(seasonNumber); + if (group == null || series == null) { + Logger.LogWarning("Unable to find info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, series.Id); + return result; + } + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Group={GroupId},Series={SeriesId})", seasonNumber, group.Shoko.Name, group.Id, series.Id); + } + else { + series = await ApiManager.GetSeriesInfo(seriesId); + if (series == null) { + Logger.LogWarning("Unable to find info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, series.Id); + return result; + } + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Shoko.Name, series.Id); } - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Group={GroupId},Series={SeriesId})", seasonNumber, group.Shoko.Name, groupId, series.Id); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); var sortTitle = $"I{seasonNumber} - {series.Shoko.Name}"; From 183fe2ccf898a5e5f73e061435416c1415e9b38e Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 14 Sep 2021 22:40:09 +0000 Subject: [PATCH 0191/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 38e8ff4d..8521c89f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.20", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.20/shokofin_1.5.0.20.zip", + "checksum": "538fb912295a30167bf5407231b66fd8", + "timestamp": "2021-09-14T22:40:07Z" + }, { "version": "1.5.0.19", "changelog": "NA", From d9ea947c176cddbe2546a3825a3ba24b7715bfeb Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 17 Sep 2021 02:21:53 +0200 Subject: [PATCH 0192/1103] Add stub studios data --- Shokofin/API/Info/GroupInfo.cs | 2 ++ Shokofin/API/Info/SeriesInfo.cs | 2 ++ Shokofin/API/ShokoAPIManager.cs | 2 ++ Shokofin/Providers/BoxSetProvider.cs | 4 ++-- Shokofin/Providers/ExtraMetadataProvider.cs | 5 +++-- Shokofin/Providers/MovieProvider.cs | 7 ++++--- Shokofin/Providers/SeasonProvider.cs | 1 + Shokofin/Providers/SeriesProvider.cs | 9 ++++++--- 8 files changed, 22 insertions(+), 10 deletions(-) diff --git a/Shokofin/API/Info/GroupInfo.cs b/Shokofin/API/Info/GroupInfo.cs index 98fb8737..8ade32b4 100644 --- a/Shokofin/API/Info/GroupInfo.cs +++ b/Shokofin/API/Info/GroupInfo.cs @@ -14,6 +14,8 @@ public class GroupInfo public string[] Genres; + public string[] Studios; + public SeriesInfo GetSeriesInfoBySeasonNumber(int seasonNumber) { if (seasonNumber == 0) return null; diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index 80f56ce4..e2150e83 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -19,6 +19,8 @@ public class SeriesInfo public string[] Genres; + public string[] Studios; + /// <summary> /// All episodes (of all type) that belong to this series. /// diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index f5855d5d..1539ec91 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -537,6 +537,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = TvDB = tvDbId != 0 ? (await ShokoAPI.GetSeriesTvDB(seriesId)).FirstOrDefault() : null, Tags = tags, Genres = genres, + Studios = new string[0], RawEpisodeList = allEpisodesList, EpisodeList = episodesList, ExtrasList = extrasList, @@ -695,6 +696,7 @@ private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Order Shoko = group, Tags = seriesList.SelectMany(s => s.Tags).Distinct().ToArray(), Genres = seriesList.SelectMany(s => s.Genres).Distinct().ToArray(), + Studios = seriesList.SelectMany(s => s.Studios).Distinct().ToArray(), SeriesList = seriesList, DefaultSeries = seriesList[foundIndex], DefaultSeriesIndex = foundIndex, diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 084ccb0e..fea66fb7 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -71,7 +71,7 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, Ca PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, - Tags = series.Tags, + Tags = series.Tags.ToArray(), CommunityRating = series.AniDB.Rating.ToFloat(10), }; result.Item.SetProviderId("Shoko Series", series.Id); @@ -109,7 +109,7 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in PremiereDate = series.AniDB.AirDate, EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, - Tags = group.Tags, + Tags = group.Tags.ToArray(), CommunityRating = (float)((series.AniDB.Rating.Value * 10) / series.AniDB.Rating.MaxValue) }; result.Item.SetProviderId("Shoko Series", series.Id); diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index 8d0148e1..51fbc69c 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -619,8 +619,9 @@ private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, int seasonNumber, Se PremiereDate = seriesInfo.AniDB.AirDate, EndDate = seriesInfo.AniDB.EndDate, ProductionYear = seriesInfo.AniDB.AirDate?.Year, - Tags = series.Tags.ToArray(), - Genres = series.Genres.ToArray(), + Tags = seriesInfo.Tags.ToArray(), + Genres = seriesInfo.Genres.ToArray(), + Studios = seriesInfo.Studios.ToArray(), CommunityRating = seriesInfo.AniDB.Rating?.ToFloat(10), SeriesId = series.Id, SeriesName = series.Name, diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 6d2432eb..bf4c03c1 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -17,9 +17,9 @@ namespace Shokofin.Providers public class MovieProvider : IRemoteMetadataProvider<Movie, MovieInfo> { public string Name => Plugin.MetadataProviderName; - + private readonly IHttpClientFactory HttpClientFactory; - + private readonly ILogger<MovieProvider> Logger; private readonly ShokoAPIManager ApiManager; @@ -63,6 +63,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio ProductionYear = episode.AniDB.AirDate?.Year, Tags = series.Tags.ToArray(), Genres = series.Genres.ToArray(), + Studios = series.Studios.ToArray(), CommunityRating = rating, }; result.Item.SetProviderId("Shoko File", file.Id); @@ -72,7 +73,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio result.Item.SetProviderId("AniDB", episode.AniDB.ID.ToString()); if (config.BoxSetGrouping == Ordering.GroupType.MergeFriendly && episode.TvDB != null && config.BoxSetGrouping != Ordering.GroupType.ShokoGroup) result.Item.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); - + result.HasMetadata = true; result.ResetPeople(); diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 04f255a7..ff36ddd7 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -115,6 +115,7 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in ProductionYear = series.AniDB.AirDate?.Year, Tags = series.Tags.ToArray(), Genres = series.Genres.ToArray(), + Studios = series.Studios.ToArray(), CommunityRating = series.AniDB.Rating?.ToFloat(10), }; result.Item.ProviderIds.Add("Shoko Series", series.Id); diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 85006bb8..5a3c31df 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -84,8 +84,9 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C EndDate = series.TvDB.EndDate, ProductionYear = series.TvDB.AirDate?.Year, Status = series.TvDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = series.Tags, + Tags = series.Tags.ToArray(), Genres = series.Genres.ToArray(), + Studios = series.Studios.ToArray(), CommunityRating = series.TvDB.Rating?.ToFloat(10), }; } @@ -98,8 +99,9 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, Status = series.AniDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = series.Tags, + Tags = series.Tags.ToArray(), Genres = series.Genres.ToArray(), + Studios = series.Studios.ToArray(), CommunityRating = series.AniDB.Rating.ToFloat(10), }; } @@ -145,8 +147,9 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in EndDate = series.AniDB.EndDate, ProductionYear = series.AniDB.AirDate?.Year, Status = series.AniDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = group.Tags, + Tags = group.Tags.ToArray(), Genres = group.Genres.ToArray(), + Studios = group.Studios.ToArray(), CommunityRating = series.AniDB.Rating.ToFloat(10), }; AddProviderIds(result.Item, series.Id, group.Id, series.AniDB.ID.ToString()); From cb7fbdb9dd1551c6660f0cc3d2d77c5475e35fa0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 17 Sep 2021 02:34:06 +0200 Subject: [PATCH 0193/1103] Split series into a "normal" season and an "alternate" season --- Shokofin/API/Info/GroupInfo.cs | 12 ++-- Shokofin/API/Info/SeriesInfo.cs | 7 ++ Shokofin/API/ShokoAPIManager.cs | 66 +++++++++---------- Shokofin/Providers/BoxSetProvider.cs | 2 +- Shokofin/Providers/EpisodeProvider.cs | 25 ++++--- Shokofin/Providers/ExtraMetadataProvider.cs | 73 +++++++++++---------- Shokofin/Providers/SeasonProvider.cs | 10 +++ Shokofin/Utils/OrderingUtil.cs | 73 +++++++++++++++++---- 8 files changed, 163 insertions(+), 105 deletions(-) diff --git a/Shokofin/API/Info/GroupInfo.cs b/Shokofin/API/Info/GroupInfo.cs index 8ade32b4..42aa9e54 100644 --- a/Shokofin/API/Info/GroupInfo.cs +++ b/Shokofin/API/Info/GroupInfo.cs @@ -17,13 +17,7 @@ public class GroupInfo public string[] Studios; public SeriesInfo GetSeriesInfoBySeasonNumber(int seasonNumber) { - if (seasonNumber == 0) - return null; - - int seriesIndex = seasonNumber > 0 ? seasonNumber - 1 : seasonNumber; - var index = DefaultSeriesIndex + seriesIndex; - var seriesInfo = SeriesList[index]; - if (seriesInfo == null) + if (seasonNumber == 0 || !(SeasonOrderDictionary.TryGetValue(seasonNumber, out var seriesInfo) && seriesInfo != null)) return null; return seriesInfo; @@ -31,6 +25,10 @@ public SeriesInfo GetSeriesInfoBySeasonNumber(int seasonNumber) { public List<SeriesInfo> SeriesList; + public Dictionary<int, SeriesInfo> SeasonOrderDictionary; + + public Dictionary<SeriesInfo, int> SeasonNumberBaseDictionary; + public SeriesInfo DefaultSeries; public int DefaultSeriesIndex; diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index e2150e83..9e221abd 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -35,6 +35,13 @@ public class SeriesInfo /// </summary> public List<EpisodeInfo> EpisodeList; + /// <summary> + /// A pre-filtered list of "other" episodes that belong to this series. + /// + /// Ordered by AniDb air-date. + /// </summary> + public List<EpisodeInfo> AlternateEpisodesList; + /// <summary> /// A pre-filtered list of "extra" videos that belong to this series. /// diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 1539ec91..b2e5ee55 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -295,7 +295,7 @@ private async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string episod info = new EpisodeInfo { Id = episodeId, - ExtraType = GetExtraType(aniDB), + ExtraType = Ordering.GetExtraType(aniDB), Shoko = (await ShokoAPI.GetEpisode(episodeId)), AniDB = aniDB, TvDB = ((await ShokoAPI.GetEpisodeTvDb(episodeId))?.FirstOrDefault()), @@ -328,40 +328,6 @@ public bool TryGetEpisodePathForId(string episodeId, out string path) return EpisodeIdToEpisodePathDictionary.TryGetValue(episodeId, out path); } - private static ExtraType? GetExtraType(Episode.AniDB episode) - { - switch (episode.Type) - { - case EpisodeType.Normal: - case EpisodeType.Other: - return null; - case EpisodeType.ThemeSong: - case EpisodeType.OpeningSong: - case EpisodeType.EndingSong: - return ExtraType.ThemeVideo; - case EpisodeType.Trailer: - return ExtraType.Trailer; - case EpisodeType.Special: { - var title = Text.GetTitleByLanguages(episode.Titles, "en"); - if (string.IsNullOrEmpty(title)) - return null; - // Interview - if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) - return ExtraType.Interview; - // Cinema intro/outro - if (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) && - (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase))) - return ExtraType.Clip; - // Music videos - if (title.Contains("music video", System.StringComparison.OrdinalIgnoreCase)) - return ExtraType.Clip; - return null; - } - default: - return ExtraType.Unknown; - } - } - #endregion #region Series Info @@ -496,6 +462,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = var specialsList = new List<EpisodeInfo>(); var episodesList = new List<EpisodeInfo>(); var extrasList = new List<EpisodeInfo>(); + var altEpisodesList = new List<EpisodeInfo>(); // The episode list is ordered by air date var allEpisodesList = ShokoAPI.GetEpisodesFromSeries(seriesId, Plugin.Instance.Configuration.AddMissingMetadata) @@ -512,6 +479,8 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = EpisodeIdToSeriesIdDictionary[episode.Id] = seriesId; if (episode.AniDB.Type == EpisodeType.Normal) episodesList.Add(episode); + else if (episode.AniDB.Type == EpisodeType.Unknown) + altEpisodesList.Add(episode); else if (episode.ExtraType != null) extrasList.Add(episode); else if (episode.AniDB.Type == EpisodeType.Special) { @@ -540,6 +509,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = Studios = new string[0], RawEpisodeList = allEpisodesList, EpisodeList = episodesList, + AlternateEpisodesList = altEpisodesList, ExtrasList = extrasList, SpesialsAnchors = specialsAnchorDictionary, SpecialsList = specialsList, @@ -691,6 +661,30 @@ private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Order if (foundIndex == -1) throw new System.Exception("Unable to get a base-point for seasions withing the group"); + var seasonOrderDictionary = new Dictionary<int, SeriesInfo>(); + var seasonNumberBaseDictionary = new Dictionary<SeriesInfo, int>(); + var positiveSeasonNumber = 1; + var negativeSeasonNumber = -1; + foreach (var (seriesInfo, index) in seriesList.Select((s, i) => (s, i))) { + int seasonNumber; + var includeAlternateSeason = seriesInfo.AlternateEpisodesList.Count > 0; + + // Series before the default series get a negative season number + if (index < foundIndex) { + seasonNumber = negativeSeasonNumber; + negativeSeasonNumber -= includeAlternateSeason ? 2 : 1; + } + else { + seasonNumber = positiveSeasonNumber; + positiveSeasonNumber += includeAlternateSeason ? 2 : 1; + } + + seasonNumberBaseDictionary.Add(seriesInfo, seasonNumber); + seasonOrderDictionary.Add(seasonNumber, seriesInfo); + if (includeAlternateSeason) + seasonOrderDictionary.Add(index < foundIndex ? seasonNumber - 1 : seasonNumber + 1, seriesInfo); + } + groupInfo = new GroupInfo { Id = groupId, Shoko = group, @@ -698,6 +692,8 @@ private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Order Genres = seriesList.SelectMany(s => s.Genres).Distinct().ToArray(), Studios = seriesList.SelectMany(s => s.Studios).Distinct().ToArray(), SeriesList = seriesList, + SeasonNumberBaseDictionary = seasonNumberBaseDictionary, + SeasonOrderDictionary = seasonOrderDictionary, DefaultSeries = seriesList[foundIndex], DefaultSeriesIndex = foundIndex, }; diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index fea66fb7..efa77eaa 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -96,7 +96,7 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in var series = group.DefaultSeries; - if (group.SeriesList.Count <= 1 && series.EpisodeList.Count <= 1) { + if (group.SeriesList.Count <= 1 && series.EpisodeList.Count <= 1 && series.AlternateEpisodesList.Count == 0) { Logger.LogWarning("Group did not contain multiple movies! Skipping path {Path} (Series={SeriesId},Group={GroupId})", info.Path, group.Id, series.Id); return result; } diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 851597ca..6b2344c6 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -120,7 +120,10 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri var seasonNumber = Ordering.GetSeasonNumber(group, series, episode); var description = Text.GetDescription(episode); - if (group != null && config.MarkSpecialsWhenGrouped && episode.AniDB.Type != EpisodeType.Normal) switch (episode.AniDB.Type) { + if (group != null && config.MarkSpecialsWhenGrouped) switch (episode.AniDB.Type) { + case EpisodeType.Unknown: + case EpisodeType.Normal: + break; case EpisodeType.Special: { // We're guaranteed to find the index, because otherwise it would've thrown when getting the episode number. var index = series.SpecialsList.FindIndex(ep => ep == episode); @@ -131,24 +134,20 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri case EpisodeType.ThemeSong: case EpisodeType.EndingSong: case EpisodeType.OpeningSong: - displayTitle = $"C{episodeNumber} {displayTitle}"; - alternateTitle = $"C{episodeNumber} {alternateTitle}"; + displayTitle = $"C{episode.AniDB.EpisodeNumber} {displayTitle}"; + alternateTitle = $"C{episode.AniDB.EpisodeNumber} {alternateTitle}"; break; case EpisodeType.Trailer: - displayTitle = $"T{episodeNumber} {displayTitle}"; - alternateTitle = $"T{episodeNumber} {alternateTitle}"; + displayTitle = $"T{episode.AniDB.EpisodeNumber} {displayTitle}"; + alternateTitle = $"T{episode.AniDB.EpisodeNumber} {alternateTitle}"; break; case EpisodeType.Parody: - displayTitle = $"P{episodeNumber} {displayTitle}"; - alternateTitle = $"P{episodeNumber} {alternateTitle}"; - break; - case EpisodeType.Unknown: - displayTitle = $"U{episodeNumber} {displayTitle}"; - alternateTitle = $"U{episodeNumber} {alternateTitle}"; + displayTitle = $"P{episode.AniDB.EpisodeNumber} {displayTitle}"; + alternateTitle = $"P{episode.AniDB.EpisodeNumber} {alternateTitle}"; break; default: - displayTitle = $"O{episodeNumber} {displayTitle}"; - alternateTitle = $"O{episodeNumber} {alternateTitle}"; + displayTitle = $"U{episode.AniDB.EpisodeNumber} {displayTitle}"; + alternateTitle = $"U{episode.AniDB.EpisodeNumber} {alternateTitle}"; break; } diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index 51fbc69c..d8ac0c80 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -278,12 +278,12 @@ private void HandleSeries(Series series, string seriesId) } // Add missing episodes - foreach (var (seriesInfo, index) in groupInfo.SeriesList.Select((s, i) => (s, i))) { - var value = index - groupInfo.DefaultSeriesIndex; - var seasonNumber = value < 0 ? value : value + 1; + foreach (var pair in groupInfo.SeasonOrderDictionary) { + var seasonNumber= pair.Key; if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) continue; + var seriesInfo = pair.Value; var seasonId = $"{seriesId}:{seasonNumber}"; if (ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) { try { @@ -305,13 +305,11 @@ private void HandleSeries(Series series, string seriesId) if (Plugin.Instance.Configuration.AddExtraVideos) { AddExtras(series, groupInfo.DefaultSeries); - foreach (var (seriesInfo, index) in groupInfo.SeriesList.Select((s, i) => (s, i))) { - var value = index - groupInfo.DefaultSeriesIndex; - var seasonNumber = value < 0 ? value : value + 1; - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + foreach (var pair in groupInfo.SeasonOrderDictionary) { + if (!seasons.TryGetValue(pair.Key, out var season) || season == null) continue; - AddExtras(season, seriesInfo); + AddExtras(season, pair.Value); } } } @@ -398,8 +396,10 @@ private void HandleSeason(Season season, string seriesId, Series series, bool de return; } - if (addMissing && deleted) - season = seasonNumber == 0 ? AddVirtualSeason(0, series) : AddVirtualSeason(seriesInfo, seasonNumber, series); + if (addMissing && deleted) { + var alternateEpisodes = seasonNumber != groupInfo.SeasonNumberBaseDictionary[seriesInfo]; + season = seasonNumber == 0 ? AddVirtualSeason(0, series) : AddVirtualSeason(seriesInfo, alternateEpisodes, seasonNumber, series); + } } // Provide metadata for other seasons else { @@ -511,7 +511,7 @@ private void HandleEpisode(Episode episode, string episodeId) var missingSeasonNumbers = allSeasonNumbers.Except(existingSeasons.Keys).ToList(); var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && seriesInfo.TvDB != null; foreach (var seasonNumber in missingSeasonNumbers) { - var season = seasonNumber == 1 && !mergeFriendly ? AddVirtualSeason(seriesInfo, 1, series) : AddVirtualSeason(seasonNumber, series); + var season = seasonNumber == 1 && !mergeFriendly ? AddVirtualSeason(seriesInfo, false, 1, series) : AddVirtualSeason(seasonNumber, series); if (season == null) continue; yield return (seasonNumber, season); @@ -521,17 +521,16 @@ private void HandleEpisode(Episode episode, string episodeId) private IEnumerable<(int, Season)> CreateMissingSeasons(Info.GroupInfo groupInfo, Series series, Dictionary<int, Season> seasons) { bool hasSpecials = false; - foreach (var (s, index) in groupInfo.SeriesList.Select((a, b) => (a, b))) { - var value = index - groupInfo.DefaultSeriesIndex; - var seasonNumber = value < 0 ? value : value + 1; - if (seasons.ContainsKey(seasonNumber)) + foreach (var pair in groupInfo.SeasonOrderDictionary) { + if (seasons.ContainsKey(pair.Key)) continue; - if (s.SpecialsList.Count > 0) + if (pair.Value.SpecialsList.Count > 0) hasSpecials = true; - var season = AddVirtualSeason(s, seasonNumber, series); + var alternateEpisodes = pair.Key != groupInfo.SeasonNumberBaseDictionary[pair.Value]; + var season = AddVirtualSeason(pair.Value, alternateEpisodes, pair.Key, series); if (season == null) continue; - yield return (seasonNumber, season); + yield return (pair.Key, season); } if (hasSpecials && !seasons.ContainsKey(0)) { var season = AddVirtualSeason(0, series); @@ -540,9 +539,8 @@ private void HandleEpisode(Episode episode, string episodeId) } } - private Season AddVirtualSeason(int seasonNumber, Series series) + private bool SeasonExists(string seriesPresentationUniqueKey, string seriesName, int seasonNumber) { - var seriesPresentationUniqueKey = series.GetPresentationUniqueKey(); var searchList = LibraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new [] { nameof (Season) }, IndexNumber = seasonNumber, @@ -551,10 +549,19 @@ private Season AddVirtualSeason(int seasonNumber, Series series) }, true); if (searchList.Count > 0) { - Logger.LogDebug("Season {SeasonName} for Series {SeriesName} was created in another concurrent thread, skipping.", searchList[0].Name, series.Name); - return null; + Logger.LogDebug("Season {SeasonName} for Series {SeriesName} was created in another concurrent thread, skipping.", searchList[0].Name, seriesName); + return true; } + return false; + } + + private Season AddVirtualSeason(int seasonNumber, Series series) + { + var seriesPresentationUniqueKey = series.GetPresentationUniqueKey(); + if (SeasonExists(seriesPresentationUniqueKey, series.Name, seasonNumber)) + return null; + string seasonName; if (seasonNumber == 0) seasonName = LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName; @@ -571,7 +578,7 @@ private Season AddVirtualSeason(int seasonNumber, Series series) SortName = seasonName, ForcedSortName = seasonName, Id = LibraryManager.GetNewItemId( - series.Id + seasonNumber.ToString(CultureInfo.InvariantCulture) + seasonName, + series.Id + "Season " + seasonNumber.ToString(CultureInfo.InvariantCulture), typeof(Season)), IsVirtualItem = true, SeriesId = series.Id, @@ -586,24 +593,20 @@ private Season AddVirtualSeason(int seasonNumber, Series series) return season; } - private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, int seasonNumber, Series series) + private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, bool alternateEpisodes, int seasonNumber, Series series) { var seriesPresentationUniqueKey = series.GetPresentationUniqueKey(); - var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { nameof (Season) }, - HasAnyProviderId = { ["Shoko Series"] = seriesInfo.Id }, - SeriesPresentationUniqueKey = seriesPresentationUniqueKey, - DtoOptions = new DtoOptions(true), - }, true); - - if (searchList.Count > 0) { - Logger.LogDebug("Season {SeasonName} for Series {SeriesName} was created in another concurrent thread, skipping.", searchList[0].Name, series.Name); + if (SeasonExists(seriesPresentationUniqueKey, series.Name, seasonNumber)) return null; - } var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(seriesInfo.AniDB.Titles, seriesInfo.Shoko.Name, series.GetPreferredMetadataLanguage()); var sortTitle = $"S{seasonNumber} - {seriesInfo.Shoko.Name}"; + if (alternateEpisodes) { + displayTitle += " (Other Episodes)"; + alternateTitle += " (Other Episodes)"; + } + Logger.LogInformation("Adding virtual season {SeasonName} entry for {SeriesName}", displayTitle, series.Name); var season = new Season { Name = displayTitle, @@ -612,7 +615,7 @@ private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, int seasonNumber, Se SortName = sortTitle, ForcedSortName = sortTitle, Id = LibraryManager.GetNewItemId( - series.Id + "Season " + seriesInfo.Id.ToString(CultureInfo.InvariantCulture), + series.Id + "Season " + seasonNumber.ToString(CultureInfo.InvariantCulture), typeof(Season)), IsVirtualItem = true, Overview = Text.GetDescription(seriesInfo), diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index ff36ddd7..d199db89 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -80,6 +80,7 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in } var seasonNumber = info.IndexNumber.Value; + var alternateEpisodes = false; API.Info.SeriesInfo series; if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; @@ -89,6 +90,10 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in Logger.LogWarning("Unable to find info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, series.Id); return result; } + + if (seasonNumber != group.SeasonNumberBaseDictionary[series]) + alternateEpisodes = true; + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Group={GroupId},Series={SeriesId})", seasonNumber, group.Shoko.Name, group.Id, series.Id); } else { @@ -103,6 +108,11 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); var sortTitle = $"I{seasonNumber} - {series.Shoko.Name}"; + if (alternateEpisodes) { + displayTitle += " (Other Episodes)"; + alternateTitle += " (Other Episodes)"; + } + result.Item = new Season { Name = displayTitle, OriginalTitle = alternateTitle, diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 9864f1bb..055ef32d 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -2,6 +2,8 @@ using Shokofin.API.Info; using Shokofin.API.Models; +using ExtraType = MediaBrowser.Model.Entities.ExtraType; + namespace Shokofin.Utils { public class Ordering @@ -86,16 +88,19 @@ public static int GetMovieIndexNumber(GroupInfo group, SeriesInfo series, Episod case EpisodeType.Normal: // offset += 0; // it's not needed, so it's just here as a comment instead. break; - case EpisodeType.Parody: + case EpisodeType.Special: offset += sizes?.Episodes ?? 0; goto case EpisodeType.Normal; case EpisodeType.Unknown: - offset += sizes?.Parodies ?? 0; - goto case EpisodeType.Parody; + offset += sizes?.Specials ?? 0; + goto case EpisodeType.Special; // Add them to the bottom of the list if we didn't filter them out properly. - case EpisodeType.OpeningSong: + case EpisodeType.Parody: offset += sizes?.Others ?? 0; goto case EpisodeType.Unknown; + case EpisodeType.OpeningSong: + offset += sizes?.Parodies ?? 0; + goto case EpisodeType.Parody; case EpisodeType.Trailer: offset += sizes?.Credits ?? 0; goto case EpisodeType.OpeningSong; @@ -155,16 +160,14 @@ public static int GetEpisodeNumber(GroupInfo group, SeriesInfo series, EpisodeIn } var sizes = series.Shoko.Sizes.Total; switch (episode.AniDB.Type) { + case EpisodeType.Unknown: case EpisodeType.Normal: // offset += 0; // it's not needed, so it's just here as a comment instead. break; + // Add them to the bottom of the list if we didn't filter them out properly. case EpisodeType.Parody: offset += sizes?.Episodes ?? 0; goto case EpisodeType.Normal; - case EpisodeType.Unknown: - offset += sizes?.Parodies ?? 0; - goto case EpisodeType.Parody; - // Add them to the bottom of the list if we didn't filter them out properly. case EpisodeType.OpeningSong: offset += sizes?.Others ?? 0; goto case EpisodeType.Unknown; @@ -208,20 +211,62 @@ public static int GetSeasonNumber(GroupInfo group, SeriesInfo series, EpisodeInf } case GroupType.MergeFriendly: { var seasonNumber = episode?.TvDB?.Season; - if (seasonNumber == null) + if (!seasonNumber.HasValue) goto case GroupType.Default; - return seasonNumber ?? 1; + return seasonNumber.Value; } case GroupType.ShokoGroup: { var id = series.Id; if (series == group.DefaultSeries) return 1; - var index = group.SeriesList.FindIndex(s => s.Id == id); - if (index == -1) + if (!group.SeasonNumberBaseDictionary.TryGetValue(series, out var seasonNumber)) throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={id})"); - var value = index - group.DefaultSeriesIndex; - return value < 0 ? value : value + 1; + + // Alternate Season List + if (series.AlternateEpisodesList.Count > 0 && episode.AniDB.Type == EpisodeType.Unknown) + return seasonNumber > 0 ? seasonNumber + 1 : seasonNumber - 1; + + return seasonNumber; + } + } + } + + /// <summary> + /// Get the extra type for an episode. + /// </summary> + /// <param name="episode"></param> + /// <returns></returns> + public static ExtraType? GetExtraType(Episode.AniDB episode) + { + switch (episode.Type) + { + case EpisodeType.Normal: + case EpisodeType.Unknown: + return null; + case EpisodeType.ThemeSong: + case EpisodeType.OpeningSong: + case EpisodeType.EndingSong: + return ExtraType.ThemeVideo; + case EpisodeType.Trailer: + return ExtraType.Trailer; + case EpisodeType.Special: { + var title = Text.GetTitleByLanguages(episode.Titles, "en"); + if (string.IsNullOrEmpty(title)) + return null; + // Interview + if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) + return ExtraType.Interview; + // Cinema intro/outro + if (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) && + (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase))) + return ExtraType.Clip; + // Music videos + if (title.Contains("music video", System.StringComparison.OrdinalIgnoreCase)) + return ExtraType.Clip; + return null; } + default: + return ExtraType.Unknown; } } } From d47b2acfd12032cb8877456982e267222d942a7c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 17 Sep 2021 02:34:23 +0200 Subject: [PATCH 0194/1103] Remove ImDB id for episode --- Shokofin/Providers/EpisodeProvider.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 6b2344c6..d8390b32 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -276,10 +276,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri }; } } - // NOTE: This next line will remain here till they fix the series merging for providers outside the MetadataProvider enum. - if (config.SeriesGrouping == Ordering.GroupType.ShokoGroup) - result.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{episode.Id}"); - else if (config.SeriesGrouping == Ordering.GroupType.MergeFriendly && episode.TvDB != null && config.SeriesGrouping != Ordering.GroupType.ShokoGroup) + if (mergeFriendly) result.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); result.SetProviderId("Shoko Episode", episode.Id); if (!string.IsNullOrEmpty(fileId)) From 63fefcde9a718563d7889042d6cefaa72f0a5627 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 17 Sep 2021 02:34:55 +0200 Subject: [PATCH 0195/1103] Don't clear the data twice in the post-scan task --- Shokofin/Tasks/PostScanTask.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs index 433e5b27..a2d7a2e8 100644 --- a/Shokofin/Tasks/PostScanTask.cs +++ b/Shokofin/Tasks/PostScanTask.cs @@ -17,12 +17,7 @@ public PostScanTask(ShokoAPIManager apiManager) public async Task Run(IProgress<double> progress, CancellationToken token) { - try { - await ApiManager.PostProcess(progress, token); - } - finally { - ApiManager.Clear(); - } + await ApiManager.PostProcess(progress, token).ConfigureAwait(false); } } } From b6621158758871f917088ce5ade628cc58e64cd5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 17 Sep 2021 03:24:19 +0200 Subject: [PATCH 0196/1103] Remove extra videos on delete --- Shokofin/Providers/ExtraMetadataProvider.cs | 117 ++++++++++++++------ 1 file changed, 85 insertions(+), 32 deletions(-) diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index d8ac0c80..30468d17 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -89,7 +89,7 @@ private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs itemCh if (!IsEnabledForSeries(series, out var seriesId)) return; - HandleSeries(series, seriesId); + UpdateSeries(series, seriesId); break; } case Season season: { @@ -97,11 +97,14 @@ private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs itemCh if (!season.IndexNumber.HasValue) return; + if (!(itemChangeEventArgs.Parent is Series series)) + return; + // Abort if we're unable to get the shoko series id if (!IsEnabledForSeason(season, out var seriesId)) return; - HandleSeason(season, seriesId, season.Series); + UpdateSeason(season, seriesId, series); break; } case Episode episode: { @@ -145,11 +148,10 @@ private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs item if (!IsEnabledForSeries(series, out var seriesId)) return; - if (Plugin.Instance.Configuration.AddMissingMetadata) { + if (Plugin.Instance.Configuration.AddMissingMetadata) RemoveDuplicateEpisodes(series, seriesId); - } - HandleSeries(series, seriesId); + UpdateSeries(series, seriesId); return; } case Season season: { @@ -161,11 +163,13 @@ private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs item if (!IsEnabledForSeason(season, out var seriesId)) return; - if (Plugin.Instance.Configuration.AddMissingMetadata) { + if (Plugin.Instance.Configuration.AddMissingMetadata) RemoveDuplicateEpisodes(season, seriesId); - } - HandleSeason(season, seriesId, season.Series); + if (!(itemChangeEventArgs.Parent is Series series)) + return; + + UpdateSeason(season, seriesId, series); return; } case Episode episode: { @@ -184,18 +188,35 @@ private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs item private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs itemChangeEventArgs) { - // No action needed if either 1) the setting is turned of, 2) the item is virtual, 3) the provider is not enabled for the item - if (itemChangeEventArgs.Item.IsVirtualItem || !IsEnabledForItem(itemChangeEventArgs.Item)) + if (itemChangeEventArgs.Item.IsVirtualItem) return; switch (itemChangeEventArgs.Item) { - // Create a new virtual season if the real one was deleted. + // Clean up after removing a series. + case Series series: { + if (!IsEnabledForSeries(series, out var seriesId)) + return; + + RemoveExtras(series, seriesId); + + if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { + foreach (var season in series.Children.OfType<Season>()) { + OnLibraryManagerItemRemoved(this, new ItemChangeEventArgs { Item = season, Parent = series, UpdateReason = ItemUpdateType.None }); + } + } + + return; + } + // Create a new virtual season if the real one was deleted and clean up extras if the season was deleted. case Season season: { // Abort if we're unable to get the shoko episode id - if (!IsEnabledForSeason(season, out var seriesId)) + if (!(IsEnabledForSeason(season, out var seriesId) && (itemChangeEventArgs.Parent is Series series))) return; - HandleSeason(season, seriesId, itemChangeEventArgs.Parent as Series, true); + if (itemChangeEventArgs.UpdateReason == ItemUpdateType.None) + RemoveExtras(season, seriesId); + else + UpdateSeason(season, seriesId, series, true); break; } // Similarly, create a new virtual episode if the real one was deleted. @@ -203,7 +224,9 @@ private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs item if (!IsEnabledForEpisode(episode, out var episodeId)) return; - HandleEpisode(episode, episodeId); + RemoveDuplicateEpisodes(episode, episodeId); + + UpdateEpisode(episode, episodeId); break; } } @@ -236,7 +259,7 @@ private bool IsEnabledForSeries(Series series, out string seriesId) return false; } - private void HandleSeries(Series series, string seriesId) + private void UpdateSeries(Series series, string seriesId) { if (!ApiManager.TryLockActionForIdOFType("series", seriesId, "update")) return; @@ -369,7 +392,7 @@ private bool IsEnabledForSeason(Season season, out string seriesId) return IsEnabledForSeries(season.Series, out seriesId); } - private void HandleSeason(Season season, string seriesId, Series series, bool deleted = false) + private void UpdateSeason(Season season, string seriesId, Series series, bool deleted = false) { var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; try { @@ -477,7 +500,7 @@ private bool IsEnabledForEpisode(Episode episode, out string episodeId) ) && !string.IsNullOrEmpty(episodeId); } - private void HandleEpisode(Episode episode, string episodeId) + private void UpdateEpisode(Episode episode, string episodeId) { Info.GroupInfo groupInfo = null; Info.SeriesInfo seriesInfo = ApiManager.GetSeriesInfoForEpisodeSync(episodeId); @@ -669,6 +692,7 @@ private void AddExtras(Folder parent, Info.SeriesInfo seriesInfo) return; var needsUpdate = false; + var extraIds = new List<Guid>(); foreach (var episodeInfo in seriesInfo.ExtrasList) { if (!ApiManager.TryGetEpisodePathForId(episodeInfo.Id, out var episodePath)) continue; @@ -684,16 +708,22 @@ private void AddExtras(Folder parent, Info.SeriesInfo seriesInfo) } var item = LibraryManager.FindByPath(episodePath, false); - if (item != null && item is Video result) { - result.ParentId = Guid.Empty; - result.OwnerId = parent.Id; - result.Name = episodeInfo.Shoko.Name; - result.ExtraType = episodeInfo.ExtraType; - LibraryManager.UpdateItemAsync(result, null, ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + if (item != null && item is Video video) { + video.ParentId = Guid.Empty; + video.OwnerId = parent.Id; + video.Name = episodeInfo.Shoko.Name; + video.ExtraType = episodeInfo.ExtraType; + video.ProviderIds.TryAdd("Shoko Episode", episodeInfo.Id); + video.ProviderIds.TryAdd("Shoko Series", seriesInfo.Id); + LibraryManager.UpdateItemAsync(video, null, ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + if (!parent.ExtraIds.Contains(video.Id)) { + needsUpdate = true; + extraIds.Add(video.Id); + } } else { - Logger.LogInformation("Addding {ExtraType} {EpisodeName} to {ParentName}", episodeInfo.ExtraType, parent.Name); - result = new Video { + Logger.LogInformation("Addding {ExtraType} {VideoName} to parent {ParentName} (Series={SeriesId})", episodeInfo.ExtraType, parent.Name, seriesInfo.Id); + video = new Video { Id = LibraryManager.GetNewItemId($"{parent.Id} {episodeInfo.ExtraType} {episodeInfo.Id}", typeof (Video)), Name = episodeInfo.Shoko.Name, Path = episodePath, @@ -703,17 +733,40 @@ private void AddExtras(Folder parent, Info.SeriesInfo seriesInfo) DateCreated = DateTime.UtcNow, DateModified = DateTime.UtcNow, }; - LibraryManager.CreateItem(result, null); + video.ProviderIds.Add("Shoko Episode", episodeInfo.Id); + video.ProviderIds.Add("Shoko Series", seriesInfo.Id); + LibraryManager.CreateItem(video, null); + needsUpdate = true; + extraIds.Add(video.Id); } - - parent.ExtraIds = parent.ExtraIds.Append(result.Id).Distinct().ToArray(); } - // The extra video is available locally. if (needsUpdate) { + parent.ExtraIds = parent.ExtraIds.Concat(extraIds).Distinct().ToArray(); LibraryManager.UpdateItemAsync(parent, parent.Parent, ItemUpdateType.None, CancellationToken.None).ConfigureAwait(false); } } + public void RemoveExtras(Folder parent, string seriesId) + { + var searchList = LibraryManager.GetItemList(new InternalItemsQuery { + IsVirtualItem = false, + IncludeItemTypes = new [] { nameof (Video) }, + HasOwnerId = true, + HasAnyProviderId = { ["Shoko Series"] = seriesId}, + DtoOptions = new DtoOptions(true), + }, true); + + var deleteOptions = new DeleteOptions { + DeleteFileLocation = false, + }; + + foreach (var video in searchList) + LibraryManager.DeleteItem(video, deleteOptions); + + if (searchList.Count > 0) + Logger.LogInformation("Removed {Count} extras from parent {ParentName}. (Series={SeriesId})", searchList.Count, parent.Name, seriesId); + } + private void RemoveDuplicateEpisodes(Episode episode, string episodeId) { var query = new InternalItemsQuery { @@ -732,9 +785,9 @@ private void RemoveDuplicateEpisodes(Episode episode, string episodeId) }; // Remove the virtual season/episode that matches the newly updated item - foreach (var item in existingVirtualItems) { + foreach (var item in existingVirtualItems) LibraryManager.DeleteItem(item, deleteOptions); - } + if (existingVirtualItems.Count > 0) Logger.LogInformation("Removed {Count} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", existingVirtualItems.Count, episode.Name, episodeId); } @@ -750,7 +803,7 @@ private void RemoveDuplicateEpisodes(Season season, string seriesId) // We're only interested in physical episodes. if (episode.IsVirtualItem) continue; - + // Abort if we're unable to get the shoko episode id if (!IsEnabledForEpisode(episode, out var episodeId)) continue; From 13d225d5c62de3e158fbbade9c47dd3e27cb2db7 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 17 Sep 2021 01:58:56 +0000 Subject: [PATCH 0197/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 8521c89f..166be1c7 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.21", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.21/shokofin_1.5.0.21.zip", + "checksum": "e2a2f7351fce440436d53d32b56d5218", + "timestamp": "2021-09-17T01:58:55Z" + }, { "version": "1.5.0.20", "changelog": "NA", From 4d334f032fb7b63b808ebbd368a0912676404d93 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 17 Sep 2021 22:18:40 +0200 Subject: [PATCH 0198/1103] Make missing metadata and extras mandatory --- Shokofin/API/ShokoAPI.cs | 4 +- Shokofin/API/ShokoAPIManager.cs | 2 +- Shokofin/Configuration/PluginConfiguration.cs | 6 - Shokofin/Configuration/configPage.html | 12 - Shokofin/Providers/ExtraMetadataProvider.cs | 248 ++++++++---------- 5 files changed, 118 insertions(+), 154 deletions(-) diff --git a/Shokofin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPI.cs index 37620332..6c556850 100644 --- a/Shokofin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPI.cs @@ -81,9 +81,9 @@ public static async Task<Episode> GetEpisode(string id) return responseStream != null ? await JsonSerializer.DeserializeAsync<Episode>(responseStream) : null; } - public static async Task<List<Episode>> GetEpisodesFromSeries(string seriesId, bool includeMissing) + public static async Task<List<Episode>> GetEpisodesFromSeries(string seriesId) { - var responseStream = await CallApi($"/api/v3/Series/{seriesId}/Episode?includeMissing={includeMissing}"); + var responseStream = await CallApi($"/api/v3/Series/{seriesId}/Episode?includeMissing=true"); return responseStream != null ? await JsonSerializer.DeserializeAsync<List<Episode>>(responseStream) : null; } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index b2e5ee55..2feed272 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -465,7 +465,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = var altEpisodesList = new List<EpisodeInfo>(); // The episode list is ordered by air date - var allEpisodesList = ShokoAPI.GetEpisodesFromSeries(seriesId, Plugin.Instance.Configuration.AddMissingMetadata) + var allEpisodesList = ShokoAPI.GetEpisodesFromSeries(seriesId) .ContinueWith(task => Task.WhenAll(task.Result.Select(e => CreateEpisodeInfo(e)))) .Unwrap() .GetAwaiter() diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index d70f9f55..18893c87 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -60,10 +60,6 @@ public class PluginConfiguration : BasePluginConfiguration public DisplayLanguageType TitleAlternateType { get; set; } - public bool AddMissingMetadata { get; set; } - - public bool AddExtraVideos { get; set; } - public PluginConfiguration() { Host = "http://127.0.0.1:8111"; @@ -91,8 +87,6 @@ public PluginConfiguration() DisplaySpecialsInSeason = false; BoxSetGrouping = SeriesAndBoxSetGroupType.Default; MovieOrdering = OrderType.Default; - AddMissingMetadata = false; - AddExtraVideos = false; FilterOnLibraryTypes = false; } } diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index da77ee79..99646d6d 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -107,14 +107,6 @@ <h3>Library Options</h3> <input is="emby-checkbox" type="checkbox" id="DisplaySpecialsInSeason" /> <span>Display specials in-between normal episodes</span> </label> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="AddMissingMetadata" /> - <span>Add metadata for missing seasons/episodes</span> - </label> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="AddExtraVideos" /> - <span>Add trailers, theme videos, and other extras</span> - </label> <div class="selectContainer"> <label class="selectLabel" for="BoxSetGrouping">Box-set grouping</label> <select is="emby-select" id="BoxSetGrouping" name="BoxSetGrouping" class="emby-select-withcolor emby-select"> @@ -206,8 +198,6 @@ <h3>Tag Options</h3> document.querySelector('#MovieOrdering').value = config.MovieOrdering; document.querySelector('#MarkSpecialsWhenGrouped').checked = config.MarkSpecialsWhenGrouped; document.querySelector('#DisplaySpecialsInSeason').checked = config.DisplaySpecialsInSeason; - document.querySelector('#AddMissingMetadata').checked = config.AddMissingMetadata; - document.querySelector('#AddExtraVideos').checked = config.AddExtraVideos; document.querySelector('#FilterOnLibraryTypes').checked = config.FilterOnLibraryTypes; document.querySelector('#AddAniDBId').checked = config.AddAniDBId; document.querySelector('#PreferAniDbPoster').checked = config.PreferAniDbPoster; @@ -285,8 +275,6 @@ <h3>Tag Options</h3> config.MovieOrdering = document.querySelector('#MovieOrdering').value; config.MarkSpecialsWhenGrouped = document.querySelector('#MarkSpecialsWhenGrouped').checked; config.DisplaySpecialsInSeason = document.querySelector('#DisplaySpecialsInSeason').checked; - config.AddMissingMetadata = document.querySelector('#AddMissingMetadata').checked; - config.AddExtraVideos = document.querySelector('#AddExtraVideos').checked; config.FilterOnLibraryTypes = document.querySelector('#FilterOnLibraryTypes').checked; config.AddAniDBId = document.querySelector('#AddAniDBId').checked; config.PreferAniDbPoster = document.querySelector('#PreferAniDbPoster').checked; diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index 30468d17..9ffb2340 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -57,32 +57,8 @@ public void Dispose() LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; } - public bool IsEnabledForItem(BaseItem item) - { - if (item == null) - return false; - - BaseItem seriesOrItem = item switch - { - Episode e => e.Series, - Series s => s, - Season s => s.Series, - _ => item, - }; - - if (seriesOrItem == null) - return false; - - var libraryOptions = LibraryManager.GetLibraryOptions(seriesOrItem); - return libraryOptions != null && libraryOptions.TypeOptions.Any(o => o.Type == nameof (Series) && o.MetadataFetchers.Contains(Plugin.MetadataProviderName)); - } - private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs itemChangeEventArgs) { - // No action needed if either 1) the setting is turned of, 2) the provider is not enabled for the item - if (!Plugin.Instance.Configuration.AddMissingMetadata || !IsEnabledForItem(itemChangeEventArgs.Item)) - return; - switch (itemChangeEventArgs.Item) { case Series series: { // Abort if we're unable to get the shoko episode id @@ -139,17 +115,13 @@ private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs itemCh private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs itemChangeEventArgs) { - if (!Plugin.Instance.Configuration.AddMissingMetadata || !IsEnabledForItem(itemChangeEventArgs.Item)) - return; - switch (itemChangeEventArgs.Item) { case Series series: { // Abort if we're unable to get the shoko episode id if (!IsEnabledForSeries(series, out var seriesId)) return; - if (Plugin.Instance.Configuration.AddMissingMetadata) - RemoveDuplicateEpisodes(series, seriesId); + RemoveDuplicateEpisodes(series, seriesId); UpdateSeries(series, seriesId); return; @@ -163,8 +135,7 @@ private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs item if (!IsEnabledForSeason(season, out var seriesId)) return; - if (Plugin.Instance.Configuration.AddMissingMetadata) - RemoveDuplicateEpisodes(season, seriesId); + RemoveDuplicateEpisodes(season, seriesId); if (!(itemChangeEventArgs.Parent is Series series)) return; @@ -173,9 +144,6 @@ private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs item return; } case Episode episode: { - if (!Plugin.Instance.Configuration.AddMissingMetadata) - return; - // Abort if we're unable to get the shoko episode id if (!IsEnabledForEpisode(episode, out var episodeId)) return; @@ -272,68 +240,65 @@ private void UpdateSeries(Series series, string seriesId) Logger.LogWarning("Unable to find group info for series. (Series={SeriesID})", seriesId); return; } + // Get the existing seasons and episode ids var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); - if (Plugin.Instance.Configuration.AddMissingMetadata) { - // Add missing seasons - foreach (var (seasonNumber, season) in CreateMissingSeasons(groupInfo, series, seasons)) - seasons.TryAdd(seasonNumber, season); - - // Handle specials when grouped. - if (seasons.TryGetValue(0, out var zeroSeason)) { - var seasonId = $"{seriesId}:0"; - if (ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) { - try { - foreach (var seriesInfo in groupInfo.SeriesList) { - foreach (var episodeInfo in seriesInfo.SpecialsList) { - if (episodeIds.Contains(episodeInfo.Id)) - continue; - - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, zeroSeason); - } + // Add missing seasons + foreach (var (seasonNumber, season) in CreateMissingSeasons(groupInfo, series, seasons)) + seasons.TryAdd(seasonNumber, season); + + // Handle specials when grouped. + if (seasons.TryGetValue(0, out var zeroSeason)) { + var seasonId = $"{seriesId}:0"; + if (ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) { + try { + foreach (var seriesInfo in groupInfo.SeriesList) { + foreach (var episodeInfo in seriesInfo.SpecialsList) { + if (episodeIds.Contains(episodeInfo.Id)) + continue; + + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, zeroSeason); } } - finally { - ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); - } + } + finally { + ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); } } + } - // Add missing episodes - foreach (var pair in groupInfo.SeasonOrderDictionary) { - var seasonNumber= pair.Key; - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; + // Add missing episodes + foreach (var pair in groupInfo.SeasonOrderDictionary) { + var seasonNumber= pair.Key; + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; - var seriesInfo = pair.Value; - var seasonId = $"{seriesId}:{seasonNumber}"; - if (ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) { - try { - foreach (var episodeInfo in seriesInfo.EpisodeList) { - if (episodeIds.Contains(episodeInfo.Id)) - continue; + var seriesInfo = pair.Value; + var seasonId = $"{seriesId}:{seasonNumber}"; + if (ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) { + try { + foreach (var episodeInfo in seriesInfo.EpisodeList) { + if (episodeIds.Contains(episodeInfo.Id)) + continue; - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); - } - } - finally { - ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); } } + finally { + ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); + } } } // We add the extras to the season if we're using Shoko Groups. - if (Plugin.Instance.Configuration.AddExtraVideos) { - AddExtras(series, groupInfo.DefaultSeries); + AddExtras(series, groupInfo.DefaultSeries); - foreach (var pair in groupInfo.SeasonOrderDictionary) { - if (!seasons.TryGetValue(pair.Key, out var season) || season == null) - continue; + foreach (var pair in groupInfo.SeasonOrderDictionary) { + if (!seasons.TryGetValue(pair.Key, out var season) || season == null) + continue; - AddExtras(season, pair.Value); - } + AddExtras(season, pair.Value); } } // Provide metadata for other series @@ -344,38 +309,34 @@ private void UpdateSeries(Series series, string seriesId) return; } - if (Plugin.Instance.Configuration.AddMissingMetadata) { - // Get the existing seasons and episode ids - var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); + // Get the existing seasons and episode ids + var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); - // Compute the season numbers for each episode in the series in advance, since we need to filter out the missing seasons - var episodeInfoToSeasonNumberDirectory = seriesInfo.RawEpisodeList.ToDictionary(e => e, e => Ordering.GetSeasonNumber(null, seriesInfo, e)); + // Compute the season numbers for each episode in the series in advance, since we need to filter out the missing seasons + var episodeInfoToSeasonNumberDirectory = seriesInfo.RawEpisodeList.ToDictionary(e => e, e => Ordering.GetSeasonNumber(null, seriesInfo, e)); - // Add missing seasons - var allKnownSeasonNumbers = episodeInfoToSeasonNumberDirectory.Values.Distinct().ToList(); - foreach (var (seasonNumber, season) in CreateMissingSeasons(seriesInfo, series, seasons, allKnownSeasonNumbers)) - seasons.Add(seasonNumber, season); + // Add missing seasons + var allKnownSeasonNumbers = episodeInfoToSeasonNumberDirectory.Values.Distinct().ToList(); + foreach (var (seasonNumber, season) in CreateMissingSeasons(seriesInfo, series, seasons, allKnownSeasonNumbers)) + seasons.Add(seasonNumber, season); - // Add missing episodes - foreach (var episodeInfo in seriesInfo.RawEpisodeList) { - if (episodeInfo.ExtraType != null) - continue; + // Add missing episodes + foreach (var episodeInfo in seriesInfo.RawEpisodeList) { + if (episodeInfo.ExtraType != null) + continue; - if (episodeIds.Contains(episodeInfo.Id)) - continue; + if (episodeIds.Contains(episodeInfo.Id)) + continue; - var seasonNumber = episodeInfoToSeasonNumberDirectory[episodeInfo]; - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; + var seasonNumber = episodeInfoToSeasonNumberDirectory[episodeInfo]; + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; - AddVirtualEpisode(null, seriesInfo, episodeInfo, season); - } + AddVirtualEpisode(null, seriesInfo, episodeInfo, season); } // We add the extras to the series if not. - if (Plugin.Instance.Configuration.AddExtraVideos) { - AddExtras(series, seriesInfo); - } + AddExtras(series, seriesInfo); } } finally { @@ -400,7 +361,6 @@ private void UpdateSeason(Season season, string seriesId, Series series, bool de return; var seasonNumber = season.IndexNumber!.Value; - var addMissing = Plugin.Instance.Configuration.AddMissingMetadata; var seriesGrouping = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; Info.GroupInfo groupInfo = null; Info.SeriesInfo seriesInfo = null; @@ -412,14 +372,13 @@ private void UpdateSeason(Season season, string seriesId, Series series, bool de return; } - seriesInfo = groupInfo.GetSeriesInfoBySeasonNumber(seasonNumber); if (seriesInfo == null) { Logger.LogWarning("Unable to find series info for {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, groupInfo.Id); return; } - if (addMissing && deleted) { + if (deleted) { var alternateEpisodes = seasonNumber != groupInfo.SeasonNumberBaseDictionary[seriesInfo]; season = seasonNumber == 0 ? AddVirtualSeason(0, series) : AddVirtualSeason(seriesInfo, alternateEpisodes, seasonNumber, series); } @@ -432,41 +391,38 @@ private void UpdateSeason(Season season, string seriesId, Series series, bool de return; } - if (addMissing && deleted) + if (deleted) season = AddVirtualSeason(seasonNumber, series); } - if (addMissing) { - // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - var existingEpisodes = new HashSet<string>(); - foreach (var episode in season.Children.OfType<Episode>()) - if (IsEnabledForEpisode(episode, out var episodeId)) - existingEpisodes.Add(episodeId); - - // Handle specials when grouped. - if (seasonNumber == 0) { - if (seriesGrouping) { - foreach (var sI in groupInfo.SeriesList) { - foreach (var episodeInfo in sI.SpecialsList) { - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; + // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. + var existingEpisodes = new HashSet<string>(); + foreach (var episode in season.Children.OfType<Episode>()) + if (IsEnabledForEpisode(episode, out var episodeId)) + existingEpisodes.Add(episodeId); - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); - } - } - } - else { - foreach (var episodeInfo in seriesInfo.SpecialsList) { + // Handle specials when grouped. + if (seasonNumber == 0) { + if (seriesGrouping) { + foreach (var sI in groupInfo.SeriesList) { + foreach (var episodeInfo in sI.SpecialsList) { if (existingEpisodes.Contains(episodeInfo.Id)) continue; AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); } } - - return; } + else { + foreach (var episodeInfo in seriesInfo.SpecialsList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + } + } + } + else { foreach (var episodeInfo in seriesInfo.EpisodeList) { var episodeParentIndex = Ordering.GetSeasonNumber(groupInfo, seriesInfo, episodeInfo); if (episodeParentIndex != seasonNumber) @@ -477,10 +433,11 @@ private void UpdateSeason(Season season, string seriesId, Series series, bool de AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); } + } // We add the extras to the season if we're using Shoko Groups. - if (Plugin.Instance.Configuration.AddExtraVideos && seriesGrouping) { + if (seriesGrouping) { AddExtras(season, seriesInfo); } } @@ -571,6 +528,15 @@ private bool SeasonExists(string seriesPresentationUniqueKey, string seriesName, DtoOptions = new DtoOptions(true), }, true); + if (searchList.Count > 1) { + var deleteOptions = new DeleteOptions { + DeleteFileLocation = false, + }; + foreach (var item in searchList.Skip(1)) + LibraryManager.DeleteItem(item, deleteOptions); + Logger.LogDebug("Removing duplicatees for Season {SeasonName} for Series {SeriesName}.", searchList[0].Name, seriesName); + } + if (searchList.Count > 0) { Logger.LogDebug("Season {SeasonName} for Series {SeriesName} was created in another concurrent thread, skipping.", searchList[0].Name, seriesName); return true; @@ -664,19 +630,35 @@ private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, bool alternateEpisod return season; } - private void AddVirtualEpisode(Info.GroupInfo groupInfo, Info.SeriesInfo seriesInfo, Info.EpisodeInfo episodeInfo, MediaBrowser.Controller.Entities.TV.Season season) + private bool EpisodeExists(string episodeId, string seriesId, string groupId) { - var groupId = groupInfo?.Id ?? null; var searchList = LibraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new [] { nameof (Episode) }, - HasAnyProviderId = { ["Shoko Episode"] = episodeInfo.Id }, + HasAnyProviderId = { ["Shoko Episode"] = episodeId }, DtoOptions = new DtoOptions(true) }, true); + if (searchList.Count > 1) { + var deleteOptions = new DeleteOptions { + DeleteFileLocation = false, + }; + foreach (var item in searchList.Skip(1)) + LibraryManager.DeleteItem(item, deleteOptions); + Logger.LogDebug("Removing duplicatees for Episode {EpisodeName}.", searchList[0].Name); + } + if (searchList.Count > 0) { - Logger.LogDebug("A virtual or physical episode entry already exists. Ignoreing. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episodeInfo.Id, seriesInfo.Id, groupId); - return; + Logger.LogDebug("A virtual or physical episode entry already exists for Episode {EpisodeName}. Ignoreing. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", searchList[0].Name, episodeId, seriesId, groupId); + return true; } + return false; + } + + private void AddVirtualEpisode(Info.GroupInfo groupInfo, Info.SeriesInfo seriesInfo, Info.EpisodeInfo episodeInfo, MediaBrowser.Controller.Entities.TV.Season season) + { + var groupId = groupInfo?.Id ?? null; + if (EpisodeExists(episodeInfo.Id, seriesInfo.Id, groupId)) + return; var episodeId = LibraryManager.GetNewItemId(season.Series.Id + "Season " + seriesInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); var result = EpisodeProvider.CreateMetadata(groupInfo, seriesInfo, episodeInfo, season, episodeId); From 07f8085a70fb265b81fc13416abd55273391fce6 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 17 Sep 2021 20:19:28 +0000 Subject: [PATCH 0199/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 166be1c7..1776ec75 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.22", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.22/shokofin_1.5.0.22.zip", + "checksum": "1ca1227c0eb19d6c506e58c5a40d014b", + "timestamp": "2021-09-17T20:19:26Z" + }, { "version": "1.5.0.21", "changelog": "NA", From 069e5890f952579a74dc72bc4d31f8cd7bd36b09 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 Sep 2021 13:35:22 +0200 Subject: [PATCH 0200/1103] Update description for specials --- 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 99646d6d..83873386 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -101,7 +101,7 @@ <h3>Library Options</h3> </div> <label id="MarkSpecialsWhenGroupedItem" class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> - <span>Excluding normal episodes, add type and number to title (e.g. "S1 title", "C1 title", "O1 title")</span> + <span>Add type and number to title, excluding normal episodes</span> </label> <label id="DisplaySpecialsInSeasonItem" class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="DisplaySpecialsInSeason" /> From 6f53ef987d81916a97969fa22d6580fb1c2f0539 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 Sep 2021 22:19:58 +0200 Subject: [PATCH 0201/1103] Collect studio metadata from Shoko if available --- Shokofin/API/Models/Role.cs | 55 +++++++++++++++++++++++++++++++-- Shokofin/API/ShokoAPI.cs | 6 ++++ Shokofin/API/ShokoAPIManager.cs | 37 +++++++++++++++++----- 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/Shokofin/API/Models/Role.cs b/Shokofin/API/Models/Role.cs index 8a0f879b..a7f7e23d 100644 --- a/Shokofin/API/Models/Role.cs +++ b/Shokofin/API/Models/Role.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace Shokofin.API.Models { public class Role @@ -7,8 +9,9 @@ public class Role public Person Staff { get; set; } public Person Character { get; set; } - - public string RoleName { get; set; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public CreatorRoleType RoleName { get; set; } public string RoleDetails { get; set; } @@ -22,5 +25,53 @@ public class Person public Image Image { get; set; } } + + public enum CreatorRoleType + { + /// <summary> + /// Voice actor or voice actress. + /// </summary> + Seiyuu, + + /// <summary> + /// This can be anything involved in writing the show. + /// </summary> + Staff, + + /// <summary> + /// The studio responsible for publishing the show. + /// </summary> + Studio, + + /// <summary> + /// The main producer(s) for the show. + /// </summary> + Producer, + + /// <summary> + /// Direction. + /// </summary> + Director, + + /// <summary> + /// Series Composition. + /// </summary> + SeriesComposer, + + /// <summary> + /// Character Design. + /// </summary> + CharacterDesign, + + /// <summary> + /// Music composer. + /// </summary> + Music, + + /// <summary> + /// Responsible for the creation of the source work this show is detrived from. + /// </summary> + SourceWork, + } } } \ No newline at end of file diff --git a/Shokofin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPI.cs index 6c556850..9cc9a68f 100644 --- a/Shokofin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPI.cs @@ -159,6 +159,12 @@ public static async Task<IEnumerable<Role>> GetSeriesCast(string id) return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Role>>(responseStream) : null; } + public static async Task<IEnumerable<Role>> GetSeriesCast(string id, Role.CreatorRoleType role) + { + var responseStream = await CallApi($"/api/v3/Series/{id}/Cast?roleType={role.ToString()}"); + return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Role>>(responseStream) : null; + } + public static async Task<Images> GetSeriesImages(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/Images"); diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 2feed272..841a6ca6 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -133,13 +133,21 @@ public async Task<IEnumerable<PersonInfo>> GetPeople(string seriesId) var roles = await ShokoAPI.GetSeriesCast(seriesId); foreach (var role in roles) { - list.Add(new PersonInfo - { - Type = PersonType.Actor, - Name = role.Staff.Name, - Role = role.Character.Name, - ImageUrl = role.Staff.Image?.ToURLString(), - }); + switch (role.RoleName) { + case Role.CreatorRoleType.Studio: + break; + case Role.CreatorRoleType.Seiyuu: + list.Add(new PersonInfo + { + Type = PersonType.Actor, + Name = role.Staff.Name, + Role = role.Character.Name, + ImageUrl = role.Staff.Image?.ToURLString(), + }); + break; + default: + break; + } } return list; } @@ -184,6 +192,18 @@ private string SelectTagName(Tag tag) return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tag.Name); } + #endregion + #region Studios + + public async Task<string[]> GetStudiosForSeries(string seriesId) + { + var cast = await ShokoAPI.GetSeriesCast(seriesId, Role.CreatorRoleType.Studio); + // * NOTE: Shoko Server version <4.1.2 don't support filtered cast, nor other role types besides Role.CreatorRoleType.Seiyuu. + if (cast.Any(p => p.RoleName != Role.CreatorRoleType.Studio)) + return new string[0]; + return cast.Select(p => p.Staff.Name).ToArray(); + } + #endregion #region File Info @@ -458,6 +478,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = var tvDbId = series.IDs.TvDB?.FirstOrDefault(); var tags = await GetTags(seriesId); var genres = await GetGenresForSeries(seriesId); + var studios = await GetStudiosForSeries(seriesId); Dictionary<string, EpisodeInfo> specialsAnchorDictionary = new Dictionary<string, EpisodeInfo>(); var specialsList = new List<EpisodeInfo>(); var episodesList = new List<EpisodeInfo>(); @@ -506,7 +527,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = TvDB = tvDbId != 0 ? (await ShokoAPI.GetSeriesTvDB(seriesId)).FirstOrDefault() : null, Tags = tags, Genres = genres, - Studios = new string[0], + Studios = studios, RawEpisodeList = allEpisodesList, EpisodeList = episodesList, AlternateEpisodesList = altEpisodesList, From 94ecd4df09b03ada7b3b70a04d7c06f5b6a7d43b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 Sep 2021 22:29:42 +0200 Subject: [PATCH 0202/1103] Add an id lookup, add better logging [...] Add an id lookup, add better logging for the image provider, and fix the locking mechanism and add logic to remove dummy seasons and duplicate seasons. --- Shokofin/API/ShokoAPIManager.cs | 40 +- Shokofin/IdLookup.cs | 261 ++++++++ Shokofin/LibraryScanner.cs | 17 +- Shokofin/PluginServiceRegistrator.cs | 1 + Shokofin/Providers/ExtraMetadataProvider.cs | 700 +++++++++++--------- Shokofin/Providers/ImageProvider.cs | 119 ++-- 6 files changed, 741 insertions(+), 397 deletions(-) create mode 100644 Shokofin/IdLookup.cs diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 841a6ca6..b2ac3ee9 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -27,6 +27,8 @@ public class ShokoAPIManager private static readonly ConcurrentDictionary<string, string> SeriesPathToIdDictionary = new ConcurrentDictionary<string, string>(); + private static readonly ConcurrentDictionary<string, string> SeriesIdToPathDictionary = new ConcurrentDictionary<string, string>(); + private static readonly ConcurrentDictionary<string, string> SeriesIdToGroupIdDictionary = new ConcurrentDictionary<string, string>(); private static readonly ConcurrentDictionary<string, string> EpisodePathToEpisodeIdDictionary = new ConcurrentDictionary<string, string>(); @@ -76,7 +78,7 @@ public Folder FindMediaFolder(string path, Folder parent, Folder root) public string StripMediaFolder(string fullPath) { - var mediaFolder = MediaFolderList.Find((folder) => fullPath.StartsWith(folder.Path)); + var mediaFolder = MediaFolderList.FirstOrDefault((folder) => fullPath.StartsWith(folder.Path)); // If no root folder was found, then we _most likely_ already stripped it out beforehand. if (mediaFolder == null || string.IsNullOrEmpty(mediaFolder?.Path)) return fullPath; @@ -100,11 +102,19 @@ public bool TryLockActionForIdOFType(string type, string id, string action) public bool TryUnlockActionForIdOFType(string type, string id, string action) { var key = $"{type}:{id}"; - if (!LockedIdDictionary.TryGetValue(key, out var hashSet)) - return false; - return hashSet.Remove(action); + if (LockedIdDictionary.TryGetValue(key, out var hashSet)) + return hashSet.Remove(action); + return false; } + + public bool IsActionForIdOfTypeLocked(string type, string id, string action) + { + var key = $"{type}:{id}"; + if (LockedIdDictionary.TryGetValue(key, out var hashSet)) + return hashSet.Contains(action); + return false; + } #endregion #region Clear @@ -118,6 +128,7 @@ public void Clear() EpisodePathToEpisodeIdDictionary.Clear(); EpisodeIdToEpisodePathDictionary.Clear(); SeriesPathToIdDictionary.Clear(); + SeriesIdToPathDictionary.Clear(); SeriesIdToGroupIdDictionary.Clear(); DataCache = (new MemoryCache((new MemoryCacheOptions() { ExpirationScanFrequency = ExpirationScanFrequency, @@ -222,7 +233,7 @@ public async Task<string[]> GetStudiosForSeries(string seriesId) if (file == null) return (null, null, null, null); - var series = file?.SeriesIDs.FirstOrDefault(); + var series = file?.SeriesIDs?.FirstOrDefault(); var seriesId = series?.SeriesID.ID.ToString(); var episodes = series?.EpisodeIDs?.FirstOrDefault(); var episodeId = episodes?.ID.ToString(); @@ -230,8 +241,8 @@ public async Task<string[]> GetStudiosForSeries(string seriesId) return (null, null, null, null); GroupInfo groupInfo = null; - if (filterGroupByType != null) { - groupInfo = await GetGroupInfoForSeries(seriesId, (Ordering.GroupFilterType)filterGroupByType); + if (filterGroupByType.HasValue) { + groupInfo = await GetGroupInfoForSeries(seriesId, filterGroupByType.Value); if (groupInfo == null) return (null, null, null, null); } @@ -348,6 +359,11 @@ public bool TryGetEpisodePathForId(string episodeId, out string path) return EpisodeIdToEpisodePathDictionary.TryGetValue(episodeId, out path); } + public bool TryGetSeriesIdForEpisodeId(string episodeId, out string seriesId) + { + return EpisodeIdToSeriesIdDictionary.TryGetValue(episodeId, out seriesId); + } + #endregion #region Series Info @@ -378,6 +394,7 @@ public async Task<SeriesInfo> GetSeriesInfoByPath(string path) seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); SeriesPathToIdDictionary[path] = seriesId; + SeriesIdToPathDictionary.TryAdd(seriesId, path); } if (string.IsNullOrEmpty(seriesId)) @@ -455,6 +472,15 @@ public bool TryGetSeriesIdForPath(string path, out string seriesId) return SeriesPathToIdDictionary.TryGetValue(path, out seriesId); } + public bool TryGetSeriesPathForId(string seriesId, out string path) + { + if (string.IsNullOrEmpty(seriesId)) { + path = null; + return false; + } + return SeriesIdToPathDictionary.TryGetValue(seriesId, out path); + } + public bool TryGetGroupIdForSeriesId(string seriesId, out string groupId) { return SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out groupId); diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs new file mode 100644 index 00000000..4c34cdb7 --- /dev/null +++ b/Shokofin/IdLookup.cs @@ -0,0 +1,261 @@ +using System.Linq; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Shokofin.API; +using Shokofin.Providers; +using Shokofin.Utils; + +namespace Shokofin + +{ + public interface IIdLookup + { + #region Base Item + + /// <summary> + /// Check if the plugin is enabled for <see cref="MediaBrowser.Controller.Entities.BaseItem" >the item</see>. + /// </summary> + /// <param name="item">The <see cref="MediaBrowser.Controller.Entities.BaseItem" /> to check.</param> + /// <returns>True if the plugin is enabled for the <see cref="MediaBrowser.Controller.Entities.BaseItem" /></returns> + bool IsEnabledForItem(BaseItem item); + + #endregion + #region Group Id + + bool TryGetGroupIdForSeriesId(string seriesId, out string groupId); + + #endregion + #region Series Id + + bool TryGetSeriesIdForPath(string path, out string seriesId); + + bool TryGetSeriesIdForEpisodeId(string episodeId, out string seriesId); + + /// <summary> + /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Series" />. + /// </summary> + /// <param name="series">The <see cref="MediaBrowser.Controller.Entities.TV.Series" /> to check for.</param> + /// <param name="seriesId">The variable to put the id in.</param> + /// <returns>True if it successfully retrived the id for the <see cref="MediaBrowser.Controller.Entities.TV.Series" />.</returns> + bool TryGetSeriesIdForSeries(Series series, out string seriesId); + + /// <summary> + /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. + /// </summary> + /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> + /// <param name="seriesId">The variable to put the id in.</param> + /// <returns>True if it successfully retrived the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> + bool TryGetSeriesIdForSeason(Season season, out string seriesId); + + /// <summary> + /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. + /// </summary> + /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> + /// <param name="seriesId">The variable to put the id in.</param> + /// <returns>True if it successfully retrived the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> + bool TryGetSeriesIdForBoxSet(BoxSet boxSet, out string seriesId); + + /// <summary> + /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. + /// </summary> + /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> + /// <param name="seriesId">The variable to put the id in.</param> + /// <returns>True if it successfully retrived the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> + bool TryGetSeriesIdForMovie(Movie movie, out string seriesId); + + #endregion + #region Series Path + + bool TryGetPathForSeriesId(string seriesId, out string path); + + #endregion + #region Episode Id + + bool TryGetEpisodeIdForPath(string path, out string episodeId); + + bool TryGetEpisodeIdForEpisode(Episode episode, out string episodeId); + + bool TryGetEpisodeIdForMovie(Movie movie, out string episodeId); + + #endregion + #region Episode Path + + bool TryGetPathForEpisodeId(string episodeId, out string path); + + #endregion + } + + public class IdLookup : IIdLookup + { + private readonly ShokoAPIManager ApiManager; + + private readonly ILibraryManager LibraryManager; + + public IdLookup(ShokoAPIManager apiManager, ILibraryManager libraryManager) + { + ApiManager = apiManager; + LibraryManager = libraryManager; + } + + #region Base Item + + public bool IsEnabledForItem(BaseItem item) + { + var reItem = item switch { + Series s => s, + Season s => s.Series, + Episode e => e.Series, + _ => item, + }; + if (reItem == null) + return false; + var libraryOptions = LibraryManager.GetLibraryOptions(reItem); + return libraryOptions != null && + libraryOptions.TypeOptions.Any(o => o.Type == nameof (Series) && o.MetadataFetchers.Contains(Plugin.MetadataProviderName)); + } + + #endregion + #region Group Id + + public bool TryGetGroupIdForSeriesId(string seriesId, out string groupId) + { + return ApiManager.TryGetGroupIdForSeriesId(seriesId, out groupId); + } + + #endregion + #region Series Id + + public bool TryGetSeriesIdForPath(string path, out string seriesId) + { + return ApiManager.TryGetSeriesIdForPath(path, out seriesId); + } + + public bool TryGetSeriesIdForEpisodeId(string episodeId, out string seriesId) + { + return ApiManager.TryGetSeriesIdForEpisodeId(episodeId, out seriesId); + } + + public bool TryGetSeriesIdForSeries(Series series, out string seriesId) + { + if (series.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { + return true; + } + + if (TryGetSeriesIdForPath(series.Path, out seriesId)) { + // Set the "Shoko Group" and "Shoko Series" provider ids for the series, since it haven't been set again. It doesn't matter if it's not saved to the database, since we only need it while running the following code. + if (TryGetGroupIdForSeriesId(seriesId, out var groupId)) { + var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; + var groupInfo = ApiManager.GetGroupInfoSync(groupId, filterByType); + seriesId = groupInfo.DefaultSeries.Id; + + SeriesProvider.AddProviderIds(series, seriesId, groupInfo.Id); + } + // Same as above, but only set the "Shoko Series" id. + else { + SeriesProvider.AddProviderIds(series, seriesId); + } + // Make sure the presentation unique is not cached, so we won't reuse the cache key. + series.PresentationUniqueKey = null; + return true; + } + + return false; + } + + public bool TryGetSeriesIdForSeason(Season season, out string seriesId) + { + if (!season.IndexNumber.HasValue) { + seriesId = null; + return false; + } + return TryGetSeriesIdForSeries(season.Series, out seriesId); + } + + public bool TryGetSeriesIdForMovie(Movie movie, out string seriesId) + { + if (movie.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { + return true; + } + + if (ApiManager.TryGetEpisodeIdForPath(movie.Path, out var episodeId) && ApiManager.TryGetSeriesIdForEpisodeId(episodeId, out seriesId)) { + return true; + } + + return false; + } + + public bool TryGetSeriesIdForBoxSet(BoxSet boxSet, out string seriesId) + { + if (boxSet.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { + return true; + } + + if (ApiManager.TryGetSeriesIdForPath(boxSet.Path, out seriesId)) { + if (ApiManager.TryGetGroupIdForSeriesId(seriesId, out var groupId)) { + var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; + var groupInfo = ApiManager.GetGroupInfoSync(groupId, filterByType); + seriesId = groupInfo.DefaultSeries.Id; + } + return true; + } + + return false; + } + + #endregion + #region Series Path + + public bool TryGetPathForSeriesId(string seriesId, out string path) + { + return ApiManager.TryGetSeriesPathForId(seriesId, out path); + } + + #endregion + #region Episode Id + + public bool TryGetEpisodeIdForPath(string path, out string episodeId) + { + return ApiManager.TryGetEpisodeIdForPath(path, out episodeId); + } + + public bool TryGetEpisodeIdForEpisode(Episode episode, out string episodeId) + { + // This will account for virtual episodes and existing episodes + if (episode.ProviderIds.TryGetValue("Shoko Episode", out episodeId) && !string.IsNullOrEmpty(episodeId)) { + return true; + } + + // This will account for new episodes that haven't received their first metadata update yet. + if (TryGetEpisodeIdForPath(episode.Path, out episodeId)) { + return true; + } + + return false; + } + + public bool TryGetEpisodeIdForMovie(Movie movie, out string episodeId) + { + if (movie.ProviderIds.TryGetValue("Shoko Episode", out episodeId) && !string.IsNullOrEmpty(episodeId)) { + return true; + } + + if (TryGetEpisodeIdForPath(movie.Path, out episodeId)) { + return true; + } + + return false; + } + + #endregion + #region Episode Path + + public bool TryGetPathForEpisodeId(string episodeId, out string path) + { + return ApiManager.TryGetEpisodePathForId(episodeId, out path); + } + + #endregion + } +} \ No newline at end of file diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index 56303ab3..921c8466 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -5,7 +5,6 @@ using Shokofin.API; using Shokofin.API.Models; using Shokofin.Utils; -using System.Linq; namespace Shokofin { @@ -13,26 +12,20 @@ public class LibraryScanner : IResolverIgnoreRule { private readonly ShokoAPIManager ApiManager; + private readonly IIdLookup Lookup; + private readonly ILibraryManager LibraryManager; private readonly ILogger<LibraryScanner> Logger; - public LibraryScanner(ShokoAPIManager apiManager, ILibraryManager libraryManager, ILogger<LibraryScanner> logger) + public LibraryScanner(ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager, ILogger<LibraryScanner> logger) { ApiManager = apiManager; + Lookup = lookup; LibraryManager = libraryManager; Logger = logger; } - public bool IsEnabledForItem(BaseItem item) - { - if (item == null) - return false; - var libraryOptions = LibraryManager.GetLibraryOptions(item); - return libraryOptions != null && - libraryOptions.TypeOptions.Any(o => o.Type == nameof (Series) && o.MetadataFetchers.Contains(Plugin.MetadataProviderName)); - } - /// <summary> /// It's not really meant to be used this way, but this is our library /// "scanner". It scans the files and folders, and conditionally filters @@ -50,7 +43,7 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base try { // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. - if (!IsEnabledForItem(parent)) + if (!Lookup.IsEnabledForItem(parent)) return false; var fullPath = fileInfo.FullName; diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index 175a2af4..423e772c 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -11,6 +11,7 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator public void RegisterServices(IServiceCollection serviceCollection) { serviceCollection.AddSingleton<Shokofin.API.ShokoAPIManager>(); + serviceCollection.AddSingleton<IIdLookup, IdLookup>(); } } } \ No newline at end of file diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index 9ffb2340..bb76a0b0 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -24,6 +24,8 @@ public class ExtraMetadataProvider : IServerEntryPoint { private readonly ShokoAPIManager ApiManager; + private readonly IIdLookup Lookup; + private readonly ILibraryManager LibraryManager; private readonly IProviderManager ProviderManager; @@ -32,9 +34,10 @@ public class ExtraMetadataProvider : IServerEntryPoint private readonly ILogger<ExtraMetadataProvider> Logger; - public ExtraMetadataProvider(ShokoAPIManager apiManager, ILibraryManager libraryManager, IProviderManager providerManager, ILocalizationManager localizationManager, ILogger<ExtraMetadataProvider> logger) + public ExtraMetadataProvider(ShokoAPIManager apiManager, IIdLookup lookUp, ILibraryManager libraryManager, IProviderManager providerManager, ILocalizationManager localizationManager, ILogger<ExtraMetadataProvider> logger) { ApiManager = apiManager; + Lookup = lookUp; LibraryManager = libraryManager; ProviderManager = providerManager; LocalizationManager = localizationManager; @@ -61,12 +64,23 @@ private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs itemCh { switch (itemChangeEventArgs.Item) { case Series series: { - // Abort if we're unable to get the shoko episode id - if (!IsEnabledForSeries(series, out var seriesId)) + // Abort if we're unable to get the shoko series id + if (!Lookup.TryGetSeriesIdForSeries(series, out var seriesId)) return; - UpdateSeries(series, seriesId); - break; + if (!ApiManager.TryLockActionForIdOFType("series", seriesId, "update")) + return; + + try { + UpdateSeries(series, seriesId); + + RemoveDummySeasons(series, seriesId); + } + finally { + ApiManager.TryUnlockActionForIdOFType("series", seriesId, "update"); + } + + return; } case Season season: { // We're not interested in the dummy season. @@ -77,38 +91,69 @@ private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs itemCh return; // Abort if we're unable to get the shoko series id - if (!IsEnabledForSeason(season, out var seriesId)) + if (!Lookup.TryGetSeriesIdForSeason(season, out var seriesId)) return; - UpdateSeason(season, seriesId, series); - break; + if (ApiManager.IsActionForIdOfTypeLocked("series", seriesId, "update")) + return; + + var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; + if (!ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) + return; + + try { + UpdateSeason(season, series, seriesId); + } + finally { + ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); + } + + return; } case Episode episode: { // Abort if we're unable to get the shoko episode id - if (!IsEnabledForEpisode(episode, out var episodeId)) + if (!(Lookup.TryGetEpisodeIdForEpisode(episode, out var episodeId) && Lookup.TryGetSeriesIdForEpisodeId(episodeId, out var seriesId))) return; - var query = new InternalItemsQuery { - IsVirtualItem = true, - HasAnyProviderId = { ["Shoko Episode"] = episodeId }, - IncludeItemTypes = new [] { nameof (Episode) }, - GroupByPresentationUniqueKey = false, - DtoOptions = new DtoOptions(true), - }; + if (ApiManager.IsActionForIdOfTypeLocked("series", seriesId, "update")) + return; - var existingVirtualItems = LibraryManager.GetItemList(query); - var deleteOptions = new DeleteOptions { - DeleteFileLocation = true, - }; + if (episode.ParentIndexNumber.HasValue) { + var seasonId = $"{seriesId}:{episode.ParentIndexNumber.Value}"; + if (ApiManager.IsActionForIdOfTypeLocked("season", seasonId, "update")) + return; + } - // Remove the old virtual episode that matches the newly created item - foreach (var item in existingVirtualItems) { - if (episode.IsVirtualItem && System.Guid.Equals(item.Id, episode.Id)) - continue; + if (!ApiManager.TryLockActionForIdOFType("episode", episodeId, "update")) + return; - LibraryManager.DeleteItem(item, deleteOptions); + try { + var query = new InternalItemsQuery { + IsVirtualItem = true, + HasAnyProviderId = { ["Shoko Episode"] = episodeId }, + IncludeItemTypes = new [] { nameof (Episode) }, + GroupByPresentationUniqueKey = false, + DtoOptions = new DtoOptions(true), + }; + + var existingVirtualItems = LibraryManager.GetItemList(query); + var deleteOptions = new DeleteOptions { + DeleteFileLocation = true, + }; + + // Remove the old virtual episode that matches the newly created item + foreach (var item in existingVirtualItems) { + if (episode.IsVirtualItem && System.Guid.Equals(item.Id, episode.Id)) + continue; + + LibraryManager.DeleteItem(item, deleteOptions); + } } - break; + finally { + ApiManager.TryUnlockActionForIdOFType("episode", episodeId, "update"); + } + + return; } } } @@ -118,12 +163,25 @@ private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs item switch (itemChangeEventArgs.Item) { case Series series: { // Abort if we're unable to get the shoko episode id - if (!IsEnabledForSeries(series, out var seriesId)) + if (!Lookup.TryGetSeriesIdForSeries(series, out var seriesId)) + return; + + if (!ApiManager.TryLockActionForIdOFType("series", seriesId, "update")) return; - RemoveDuplicateEpisodes(series, seriesId); + try { + UpdateSeries(series, seriesId); + + RemoveDummySeasons(series, seriesId); + + RemoveDuplicateSeasons(series, seriesId); + + RemoveDuplicateEpisodes(series, seriesId); + } + finally { + ApiManager.TryUnlockActionForIdOFType("series", seriesId, "update"); + } - UpdateSeries(series, seriesId); return; } case Season season: { @@ -132,23 +190,55 @@ private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs item return; // Abort if we're unable to get the shoko series id - if (!IsEnabledForSeason(season, out var seriesId)) + if (!Lookup.TryGetSeriesIdForSeason(season, out var seriesId)) return; - RemoveDuplicateEpisodes(season, seriesId); + if (ApiManager.IsActionForIdOfTypeLocked("series", seriesId, "update")) + return; - if (!(itemChangeEventArgs.Parent is Series series)) + var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; + if (!ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) return; - UpdateSeason(season, seriesId, series); + try { + var series = season.Series; + UpdateSeason(season, series, seriesId); + + RemoveDuplicateSeasons(season, series, season.IndexNumber.Value, seriesId); + + RemoveDuplicateEpisodes(season, seriesId); + } + finally { + ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); + } + + return; } case Episode episode: { // Abort if we're unable to get the shoko episode id - if (!IsEnabledForEpisode(episode, out var episodeId)) + if (!(Lookup.TryGetEpisodeIdForEpisode(episode, out var episodeId) && Lookup.TryGetSeriesIdForEpisodeId(episodeId, out var seriesId))) return; - RemoveDuplicateEpisodes(episode, episodeId); + if (ApiManager.IsActionForIdOfTypeLocked("series", seriesId, "update")) + return; + + if (episode.ParentIndexNumber.HasValue) { + var seasonId = $"{seriesId}:{episode.ParentIndexNumber.Value}"; + if (ApiManager.IsActionForIdOfTypeLocked("season", seasonId, "update")) + return; + } + + if (!ApiManager.TryLockActionForIdOFType("episode", episodeId, "update")) + return; + + try { + RemoveDuplicateEpisodes(episode, episodeId); + } + finally { + ApiManager.TryUnlockActionForIdOFType("episode", episodeId, "update"); + } + return; } } @@ -162,7 +252,7 @@ private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs item switch (itemChangeEventArgs.Item) { // Clean up after removing a series. case Series series: { - if (!IsEnabledForSeries(series, out var seriesId)) + if (!Lookup.TryGetSeriesIdForSeries(series, out var seriesId)) return; RemoveExtras(series, seriesId); @@ -178,243 +268,171 @@ private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs item // Create a new virtual season if the real one was deleted and clean up extras if the season was deleted. case Season season: { // Abort if we're unable to get the shoko episode id - if (!(IsEnabledForSeason(season, out var seriesId) && (itemChangeEventArgs.Parent is Series series))) + if (!(Lookup.TryGetSeriesIdForSeason(season, out var seriesId) && (itemChangeEventArgs.Parent is Series series))) return; if (itemChangeEventArgs.UpdateReason == ItemUpdateType.None) RemoveExtras(season, seriesId); else - UpdateSeason(season, seriesId, series, true); - break; + UpdateSeason(season, series, seriesId, true); + + return; } // Similarly, create a new virtual episode if the real one was deleted. case Episode episode: { - if (!IsEnabledForEpisode(episode, out var episodeId)) + if (!Lookup.TryGetEpisodeIdForEpisode(episode, out var episodeId)) return; RemoveDuplicateEpisodes(episode, episodeId); UpdateEpisode(episode, episodeId); - break; - } - } - } - private bool IsEnabledForSeries(Series series, out string seriesId) - { - if (series.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { - return true; - } - - if (ApiManager.TryGetSeriesIdForPath(series.Path, out seriesId)) { - // Set the "Shoko Group" and "Shoko Series" provider ids for the series, since it haven't been set again. It doesn't matter if it's not saved to the database, since we only need it while running the following code. - if (ApiManager.TryGetGroupIdForSeriesId(seriesId, out var groupId)) { - var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var groupInfo = ApiManager.GetGroupInfoSync(groupId, filterByType); - seriesId = groupInfo.DefaultSeries.Id; - - SeriesProvider.AddProviderIds(series, seriesId, groupInfo.Id); - } - // Same as above, but only set the "Shoko Series" id. - else { - SeriesProvider.AddProviderIds(series, seriesId); + return; } - // Make sure the presentation unique is not cached, so we won't reuse the cache key. - series.PresentationUniqueKey = null; - return true; } - - return false; } private void UpdateSeries(Series series, string seriesId) { - if (!ApiManager.TryLockActionForIdOFType("series", seriesId, "update")) - return; + // Provide metadata for a series using Shoko's Group feature + if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { + var groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); + if (groupInfo == null) { + Logger.LogWarning("Unable to find group info for series. (Series={SeriesID})", seriesId); + return; + } - try { - // Provide metadata for a series using Shoko's Group feature - if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { - var groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); - if (groupInfo == null) { - Logger.LogWarning("Unable to find group info for series. (Series={SeriesID})", seriesId); - return; - } + // Get the existing seasons and episode ids + var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); - // Get the existing seasons and episode ids - var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); - - // Add missing seasons - foreach (var (seasonNumber, season) in CreateMissingSeasons(groupInfo, series, seasons)) - seasons.TryAdd(seasonNumber, season); - - // Handle specials when grouped. - if (seasons.TryGetValue(0, out var zeroSeason)) { - var seasonId = $"{seriesId}:0"; - if (ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) { - try { - foreach (var seriesInfo in groupInfo.SeriesList) { - foreach (var episodeInfo in seriesInfo.SpecialsList) { - if (episodeIds.Contains(episodeInfo.Id)) - continue; - - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, zeroSeason); - } - } - } - finally { - ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); - } - } - } + // Add missing seasons + foreach (var (seasonNumber, season) in CreateMissingSeasons(groupInfo, series, seasons)) + seasons.TryAdd(seasonNumber, season); - // Add missing episodes - foreach (var pair in groupInfo.SeasonOrderDictionary) { - var seasonNumber= pair.Key; - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; + // Handle specials when grouped. + if (seasons.TryGetValue(0, out var zeroSeason)) { + foreach (var seriesInfo in groupInfo.SeriesList) { + foreach (var episodeInfo in seriesInfo.SpecialsList) { + if (episodeIds.Contains(episodeInfo.Id)) + continue; - var seriesInfo = pair.Value; - var seasonId = $"{seriesId}:{seasonNumber}"; - if (ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) { - try { - foreach (var episodeInfo in seriesInfo.EpisodeList) { - if (episodeIds.Contains(episodeInfo.Id)) - continue; - - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); - } - } - finally { - ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); - } + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, zeroSeason); } } + } - // We add the extras to the season if we're using Shoko Groups. - AddExtras(series, groupInfo.DefaultSeries); + // Add missing episodes + foreach (var pair in groupInfo.SeasonOrderDictionary) { + var seasonNumber= pair.Key; + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; - foreach (var pair in groupInfo.SeasonOrderDictionary) { - if (!seasons.TryGetValue(pair.Key, out var season) || season == null) + var seriesInfo = pair.Value; + foreach (var episodeInfo in seriesInfo.EpisodeList) { + if (episodeIds.Contains(episodeInfo.Id)) continue; - AddExtras(season, pair.Value); + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); } } - // Provide metadata for other series - else { - var seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); - if (seriesInfo == null) { - Logger.LogWarning("Unable to find series info. (Series={SeriesID})", seriesId); - return; - } - // Get the existing seasons and episode ids - var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); + // We add the extras to the season if we're using Shoko Groups. + AddExtras(series, groupInfo.DefaultSeries); - // Compute the season numbers for each episode in the series in advance, since we need to filter out the missing seasons - var episodeInfoToSeasonNumberDirectory = seriesInfo.RawEpisodeList.ToDictionary(e => e, e => Ordering.GetSeasonNumber(null, seriesInfo, e)); + foreach (var pair in groupInfo.SeasonOrderDictionary) { + if (!seasons.TryGetValue(pair.Key, out var season) || season == null) + continue; - // Add missing seasons - var allKnownSeasonNumbers = episodeInfoToSeasonNumberDirectory.Values.Distinct().ToList(); - foreach (var (seasonNumber, season) in CreateMissingSeasons(seriesInfo, series, seasons, allKnownSeasonNumbers)) - seasons.Add(seasonNumber, season); + AddExtras(season, pair.Value); + } + } + // Provide metadata for other series + else { + var seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); + if (seriesInfo == null) { + Logger.LogWarning("Unable to find series info. (Series={SeriesID})", seriesId); + return; + } - // Add missing episodes - foreach (var episodeInfo in seriesInfo.RawEpisodeList) { - if (episodeInfo.ExtraType != null) - continue; + // Get the existing seasons and episode ids + var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); - if (episodeIds.Contains(episodeInfo.Id)) - continue; + // Compute the season numbers for each episode in the series in advance, since we need to filter out the missing seasons + var episodeInfoToSeasonNumberDirectory = seriesInfo.RawEpisodeList.ToDictionary(e => e, e => Ordering.GetSeasonNumber(null, seriesInfo, e)); - var seasonNumber = episodeInfoToSeasonNumberDirectory[episodeInfo]; - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; + // Add missing seasons + var allKnownSeasonNumbers = episodeInfoToSeasonNumberDirectory.Values.Distinct().ToList(); + foreach (var (seasonNumber, season) in CreateMissingSeasons(seriesInfo, series, seasons, allKnownSeasonNumbers)) + seasons.Add(seasonNumber, season); - AddVirtualEpisode(null, seriesInfo, episodeInfo, season); - } + // Add missing episodes + foreach (var episodeInfo in seriesInfo.RawEpisodeList) { + if (episodeInfo.ExtraType != null) + continue; - // We add the extras to the series if not. - AddExtras(series, seriesInfo); + if (episodeIds.Contains(episodeInfo.Id)) + continue; + + var seasonNumber = episodeInfoToSeasonNumberDirectory[episodeInfo]; + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; + + AddVirtualEpisode(null, seriesInfo, episodeInfo, season); } - } - finally { - ApiManager.TryUnlockActionForIdOFType("series", seriesId, "update"); - } - } - private bool IsEnabledForSeason(Season season, out string seriesId) - { - if (!season.IndexNumber.HasValue) { - seriesId = null; - return false; + // We add the extras to the series if not. + AddExtras(series, seriesInfo); } - return IsEnabledForSeries(season.Series, out seriesId); } - private void UpdateSeason(Season season, string seriesId, Series series, bool deleted = false) + private void UpdateSeason(Season season, Series series, string seriesId, bool deleted = false) { - var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; - try { - if (!ApiManager.TryLockActionForIdOFType("season", seasonId, "update")) + var seasonNumber = season.IndexNumber!.Value; + var seriesGrouping = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; + Info.GroupInfo groupInfo = null; + Info.SeriesInfo seriesInfo = null; + // Provide metadata for a season using Shoko's Group feature + if (seriesGrouping) { + groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); + if (groupInfo == null) { + Logger.LogWarning("Unable to find group info for series. (Series={SeriesId})", seriesId); return; + } - var seasonNumber = season.IndexNumber!.Value; - var seriesGrouping = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; - Info.GroupInfo groupInfo = null; - Info.SeriesInfo seriesInfo = null; - // Provide metadata for a season using Shoko's Group feature - if (seriesGrouping) { - groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); - if (groupInfo == null) { - Logger.LogWarning("Unable to find group info for series. (Series={SeriesId})", seriesId); - return; - } - - seriesInfo = groupInfo.GetSeriesInfoBySeasonNumber(seasonNumber); - if (seriesInfo == null) { - Logger.LogWarning("Unable to find series info for {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, groupInfo.Id); - return; - } - - if (deleted) { - var alternateEpisodes = seasonNumber != groupInfo.SeasonNumberBaseDictionary[seriesInfo]; - season = seasonNumber == 0 ? AddVirtualSeason(0, series) : AddVirtualSeason(seriesInfo, alternateEpisodes, seasonNumber, series); - } + seriesInfo = groupInfo.GetSeriesInfoBySeasonNumber(seasonNumber); + if (seriesInfo == null) { + Logger.LogWarning("Unable to find series info for {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, groupInfo.Id); + return; } - // Provide metadata for other seasons - else { - seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); - if (seriesInfo == null) { - Logger.LogWarning("Unable to find series info. (Series={SeriesId})", seriesId); - return; - } - if (deleted) - season = AddVirtualSeason(seasonNumber, series); + if (deleted) { + var alternateEpisodes = seasonNumber != groupInfo.SeasonNumberBaseDictionary[seriesInfo]; + season = seasonNumber == 0 ? AddVirtualSeason(0, series) : AddVirtualSeason(seriesInfo, alternateEpisodes, seasonNumber, series); + } + } + // Provide metadata for other seasons + else { + seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); + if (seriesInfo == null) { + Logger.LogWarning("Unable to find series info. (Series={SeriesId})", seriesId); + return; } - // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - var existingEpisodes = new HashSet<string>(); - foreach (var episode in season.Children.OfType<Episode>()) - if (IsEnabledForEpisode(episode, out var episodeId)) - existingEpisodes.Add(episodeId); + if (deleted) + season = AddVirtualSeason(seasonNumber, series); + } - // Handle specials when grouped. - if (seasonNumber == 0) { - if (seriesGrouping) { - foreach (var sI in groupInfo.SeriesList) { - foreach (var episodeInfo in sI.SpecialsList) { - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; - - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); - } - } - } - else { - foreach (var episodeInfo in seriesInfo.SpecialsList) { + // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. + var existingEpisodes = new HashSet<string>(); + foreach (var episode in season.Children.OfType<Episode>()) + if (Lookup.TryGetEpisodeIdForEpisode(episode, out var episodeId)) + existingEpisodes.Add(episodeId); + + // Handle specials when grouped. + if (seasonNumber == 0) { + if (seriesGrouping) { + foreach (var sI in groupInfo.SeriesList) { + foreach (var episodeInfo in sI.SpecialsList) { if (existingEpisodes.Contains(episodeInfo.Id)) continue; @@ -423,38 +441,32 @@ private void UpdateSeason(Season season, string seriesId, Series series, bool de } } else { - foreach (var episodeInfo in seriesInfo.EpisodeList) { - var episodeParentIndex = Ordering.GetSeasonNumber(groupInfo, seriesInfo, episodeInfo); - if (episodeParentIndex != seasonNumber) - continue; - + foreach (var episodeInfo in seriesInfo.SpecialsList) { if (existingEpisodes.Contains(episodeInfo.Id)) continue; AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); } - } + } + else { + foreach (var episodeInfo in seriesInfo.EpisodeList) { + var episodeParentIndex = Ordering.GetSeasonNumber(groupInfo, seriesInfo, episodeInfo); + if (episodeParentIndex != seasonNumber) + continue; - // We add the extras to the season if we're using Shoko Groups. - if (seriesGrouping) { - AddExtras(season, seriesInfo); + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); } - } - finally { - ApiManager.TryUnlockActionForIdOFType("season", seasonId, "update"); - } - } + } - private bool IsEnabledForEpisode(Episode episode, out string episodeId) - { - return ( - // This will account for virtual episodes and existing episodes - episode.ProviderIds.TryGetValue("Shoko Episode", out episodeId) || - // This will account for new episodes that haven't received their first metadata update yet - ApiManager.TryGetEpisodeIdForPath(episode.Path, out episodeId) - ) && !string.IsNullOrEmpty(episodeId); + // We add the extras to the season if we're using Shoko Groups. + if (seriesGrouping) { + AddExtras(season, seriesInfo); + } } private void UpdateEpisode(Episode episode, string episodeId) @@ -479,13 +491,15 @@ private void UpdateEpisode(Episode episode, string episodeId) break; case Episode episode: // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - if (IsEnabledForEpisode(episode, out var episodeId)) + if (Lookup.TryGetEpisodeIdForEpisode(episode, out var episodeId)) episodes.Add(episodeId); break; } return (seasons, episodes); } + #region Seasons + private IEnumerable<(int, Season)> CreateMissingSeasons(Info.SeriesInfo seriesInfo, Series series, Dictionary<int, Season> existingSeasons, List<int> allSeasonNumbers) { var missingSeasonNumbers = allSeasonNumbers.Except(existingSeasons.Keys).ToList(); @@ -528,15 +542,6 @@ private bool SeasonExists(string seriesPresentationUniqueKey, string seriesName, DtoOptions = new DtoOptions(true), }, true); - if (searchList.Count > 1) { - var deleteOptions = new DeleteOptions { - DeleteFileLocation = false, - }; - foreach (var item in searchList.Skip(1)) - LibraryManager.DeleteItem(item, deleteOptions); - Logger.LogDebug("Removing duplicatees for Season {SeasonName} for Series {SeriesName}.", searchList[0].Name, seriesName); - } - if (searchList.Count > 0) { Logger.LogDebug("Season {SeasonName} for Series {SeriesName} was created in another concurrent thread, skipping.", searchList[0].Name, seriesName); return true; @@ -630,6 +635,62 @@ private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, bool alternateEpisod return season; } + public void RemoveDummySeasons(Series series, string seriesId) + { + var searchList = LibraryManager.GetItemList(new InternalItemsQuery { + IncludeItemTypes = new [] { nameof (Season) }, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), + DtoOptions = new DtoOptions(true), + }, true).Where(item => !item.IndexNumber.HasValue).ToList(); + + if (searchList.Count == 0) + return; + + Logger.LogWarning("Removing {Count} dummy seasons from {SeriesName} (Series={SeriesId})", searchList.Count, series.Name, seriesId); + var deleteOptions = new DeleteOptions { + DeleteFileLocation = false, + }; + foreach (var item in searchList) + LibraryManager.DeleteItem(item, deleteOptions); + } + + public void RemoveDuplicateSeasons(Series series, string seriesId) + { + var seasonNumbers = new List<int>(); + foreach (var season in series.GetSeasons(null, new DtoOptions(true)).OfType<Season>()) { + if (!season.IndexNumber.HasValue) + continue; + + var seasonNumber = season.IndexNumber.Value; + if (seasonNumbers.Contains(seasonNumber)) + continue; + + seasonNumbers.Add(seasonNumber); + RemoveDuplicateSeasons(season, series, seasonNumber, seriesId); + } + + } + + public void RemoveDuplicateSeasons(Season season, Series series, int seasonNumber, string seriesId) + { + var searchList = LibraryManager.GetItemList(new InternalItemsQuery { + IncludeItemTypes = new [] { nameof (Season) }, + ExcludeItemIds = new [] { season.Id }, + IndexNumber = seasonNumber, + DtoOptions = new DtoOptions(true), + }, true).Where(item => !item.IndexNumber.HasValue).ToList(); + + Logger.LogWarning("Removing {Count} dummy seasons from {SeriesName} (Series={SeriesId})", searchList.Count, series.Name, seriesId); + var deleteOptions = new DeleteOptions { + DeleteFileLocation = false, + }; + foreach (var item in searchList) + LibraryManager.DeleteItem(item, deleteOptions); + } + + #endregion + #region Episodes + private bool EpisodeExists(string episodeId, string seriesId, string groupId) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { @@ -638,15 +699,6 @@ private bool EpisodeExists(string episodeId, string seriesId, string groupId) DtoOptions = new DtoOptions(true) }, true); - if (searchList.Count > 1) { - var deleteOptions = new DeleteOptions { - DeleteFileLocation = false, - }; - foreach (var item in searchList.Skip(1)) - LibraryManager.DeleteItem(item, deleteOptions); - Logger.LogDebug("Removing duplicatees for Episode {EpisodeName}.", searchList[0].Name); - } - if (searchList.Count > 0) { Logger.LogDebug("A virtual or physical episode entry already exists for Episode {EpisodeName}. Ignoreing. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", searchList[0].Name, episodeId, seriesId, groupId); return true; @@ -663,11 +715,65 @@ private void AddVirtualEpisode(Info.GroupInfo groupInfo, Info.SeriesInfo seriesI var episodeId = LibraryManager.GetNewItemId(season.Series.Id + "Season " + seriesInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); var result = EpisodeProvider.CreateMetadata(groupInfo, seriesInfo, episodeInfo, season, episodeId); - Logger.LogInformation("Creating virtual episode for {SeriesName} S{SeasonNumber}:E{EpisodeNumber} (Episode={EpisodeId},Series={SeriesId},Group={GroupId}),", groupInfo?.Shoko.Name ?? seriesInfo.Shoko.Name, season.IndexNumber, result.IndexNumber, episodeInfo.Id, seriesInfo.Id, groupId); + Logger.LogInformation("Creating virtual episode for {SeriesName} S{SeasonNumber}:E{EpisodeNumber} (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", groupInfo?.Shoko.Name ?? seriesInfo.Shoko.Name, season.IndexNumber, result.IndexNumber, episodeInfo.Id, seriesInfo.Id, groupId); season.AddChild(result, CancellationToken.None); } + private void RemoveDuplicateEpisodes(Series series, string seriesId) + { + foreach (var season in series.GetSeasons(null, new DtoOptions(true)).OfType<Season>()) { + // We're not interested in any dummy seasons + if (!season.IndexNumber.HasValue) + continue; + + RemoveDuplicateEpisodes(season, seriesId); + } + } + + private void RemoveDuplicateEpisodes(Season season, string seriesId) + { + foreach (var episode in season.GetEpisodes(null, new DtoOptions(true)).OfType<Episode>()) { + // We're only interested in physical episodes. + if (episode.IsVirtualItem) + continue; + + // Abort if we're unable to get the shoko episode id + if (!Lookup.TryGetEpisodeIdForEpisode(episode, out var episodeId)) + continue; + + RemoveDuplicateEpisodes(episode, episodeId); + } + } + + private void RemoveDuplicateEpisodes(Episode episode, string episodeId) + { + var query = new InternalItemsQuery { + IsVirtualItem = true, + ExcludeItemIds = new [] { episode.Id }, + HasAnyProviderId = { ["Shoko Episode"] = episodeId }, + IncludeItemTypes = new [] { nameof (Episode) }, + GroupByPresentationUniqueKey = false, + DtoOptions = new DtoOptions(true), + }; + + var existingVirtualItems = LibraryManager.GetItemList(query); + + var deleteOptions = new DeleteOptions { + DeleteFileLocation = true, + }; + + // Remove the virtual season/episode that matches the newly updated item + foreach (var item in existingVirtualItems) + LibraryManager.DeleteItem(item, deleteOptions); + + if (existingVirtualItems.Count > 0) + Logger.LogInformation("Removed {Count} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", existingVirtualItems.Count, episode.Name, episodeId); + } + + #endregion + #region Extras + private void AddExtras(Folder parent, Info.SeriesInfo seriesInfo) { if (seriesInfo.ExtrasList.Count == 0) @@ -704,7 +810,7 @@ private void AddExtras(Folder parent, Info.SeriesInfo seriesInfo) } } else { - Logger.LogInformation("Addding {ExtraType} {VideoName} to parent {ParentName} (Series={SeriesId})", episodeInfo.ExtraType, parent.Name, seriesInfo.Id); + Logger.LogInformation("Addding {ExtraType} {VideoName} to parent {ParentName} (Series={SeriesId})", episodeInfo.ExtraType, episodeInfo.Shoko.Name, parent.Name, seriesInfo.Id); video = new Video { Id = LibraryManager.GetNewItemId($"{parent.Id} {episodeInfo.ExtraType} {episodeInfo.Id}", typeof (Video)), Name = episodeInfo.Shoko.Name, @@ -749,72 +855,6 @@ public void RemoveExtras(Folder parent, string seriesId) Logger.LogInformation("Removed {Count} extras from parent {ParentName}. (Series={SeriesId})", searchList.Count, parent.Name, seriesId); } - private void RemoveDuplicateEpisodes(Episode episode, string episodeId) - { - var query = new InternalItemsQuery { - IsVirtualItem = true, - ExcludeItemIds = new [] { episode.Id }, - HasAnyProviderId = { ["Shoko Episode"] = episodeId }, - IncludeItemTypes = new [] { nameof (Episode) }, - GroupByPresentationUniqueKey = false, - DtoOptions = new DtoOptions(true), - }; - - var existingVirtualItems = LibraryManager.GetItemList(query); - - var deleteOptions = new DeleteOptions { - DeleteFileLocation = true, - }; - - // Remove the virtual season/episode that matches the newly updated item - foreach (var item in existingVirtualItems) - LibraryManager.DeleteItem(item, deleteOptions); - - if (existingVirtualItems.Count > 0) - Logger.LogInformation("Removed {Count} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", existingVirtualItems.Count, episode.Name, episodeId); - } - - private void RemoveDuplicateEpisodes(Season season, string seriesId) - { - var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; - if (!ApiManager.TryLockActionForIdOFType("season", seasonId, "remove")) - return; - - try { - foreach (var episode in season.GetEpisodes(null, new DtoOptions(true)).OfType<Episode>()) { - // We're only interested in physical episodes. - if (episode.IsVirtualItem) - continue; - - // Abort if we're unable to get the shoko episode id - if (!IsEnabledForEpisode(episode, out var episodeId)) - continue; - - RemoveDuplicateEpisodes(episode, episodeId); - } - } - finally { - ApiManager.TryUnlockActionForIdOFType("season", seasonId, "remove"); - } - } - - private void RemoveDuplicateEpisodes(Series series, string seriesId) - { - if (!ApiManager.TryLockActionForIdOFType("series", seriesId, "remove")) - return; - - try { - foreach (var season in series.GetSeasons(null, new DtoOptions(true)).OfType<Season>()) { - // We're not interested in any dummy seasons - if (!season.IndexNumber.HasValue) - continue; - - RemoveDuplicateEpisodes(season, seriesId); - } - } - finally { - ApiManager.TryUnlockActionForIdOFType("series", seriesId, "remove"); - } - } + #endregion } } \ No newline at end of file diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 83c860cb..ee1aa91b 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -24,82 +24,85 @@ public class ImageProvider : IRemoteImageProvider private readonly ShokoAPIManager ApiManager; - public ImageProvider(IHttpClientFactory httpClientFactory, ILogger<ImageProvider> logger, ShokoAPIManager apiManager) + private readonly IIdLookup Lookup; + + public ImageProvider(IHttpClientFactory httpClientFactory, ILogger<ImageProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup) { HttpClientFactory = httpClientFactory; Logger = logger; ApiManager = apiManager; + Lookup = lookup; } public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var list = new List<RemoteImageInfo>(); try { - Shokofin.API.Info.EpisodeInfo episodeInfo = null; - Shokofin.API.Info.SeriesInfo seriesInfo = null; var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Utils.Ordering.GroupFilterType.Others : Utils.Ordering.GroupFilterType.Default; switch (item) { - case Episode: { - if (item.ProviderIds.TryGetValue("Shoko Episode", out var episodeId) && !string.IsNullOrEmpty(episodeId)) { - episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); - if (episodeInfo != null) - Logger.LogInformation("Getting images for episode {EpisodeName} (Episode={EpisodeId})", episodeInfo.Shoko.Name, episodeId); + case Episode episode: { + if (Lookup.TryGetEpisodeIdForEpisode(episode, out var episodeId)) { + var episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); + if (episodeInfo != null) { + AddImagesForEpisode(ref list, episodeInfo); + Logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId})", list.Count, episodeInfo.Shoko.Name, episodeId); + } } break; } - case Series: { - if (item.ProviderIds.TryGetValue("Shoko Group", out var groupId) && !string.IsNullOrEmpty(groupId)) { - var groupInfo = await ApiManager.GetGroupInfo(groupId, filterLibrary); - seriesInfo = groupInfo?.DefaultSeries; - if (seriesInfo != null) - Logger.LogInformation("Getting images for series {SeriesName} (Series={SeriesId},Group={GroupId})", groupInfo.Shoko.Name, seriesInfo.Id, groupId); - } - else if (item.ProviderIds.TryGetValue("Shoko Series", out var seriesId) && !string.IsNullOrEmpty(seriesId)) { - seriesInfo = await ApiManager.GetSeriesInfo(seriesId); - if (seriesInfo != null) - Logger.LogInformation("Getting images for series {SeriesName} (Series={SeriesId})", seriesInfo.Shoko.Name, seriesId); + case Series series: { + if (Lookup.TryGetSeriesIdForSeries(series, out var seriesId)) { + if (Plugin.Instance.Configuration.SeriesGrouping == Utils.Ordering.GroupType.ShokoGroup) { + var groupInfo = await ApiManager.GetGroupInfoForSeries(seriesId, filterLibrary); + var seriesInfo = groupInfo?.DefaultSeries; + if (seriesInfo != null) { + AddImagesForSeries(ref list, seriesInfo); + Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId},Group={GroupId})", list.Count, groupInfo.Shoko.Name, seriesInfo.Id, groupInfo.Id); + } + } + else { + var seriesInfo = await ApiManager.GetSeriesInfo(seriesId); + if (seriesInfo != null) { + AddImagesForSeries(ref list, seriesInfo); + Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId})", list.Count, seriesInfo.Shoko.Name, seriesId); + } + } } break; } case Season season: { - if (season.IndexNumber.HasValue && season.Series.ProviderIds.TryGetValue("Shoko Series", out var seriesId) && !string.IsNullOrEmpty(seriesId)) { + if (Lookup.TryGetSeriesIdForSeason(season, out var seriesId)) { var groupInfo = await ApiManager.GetGroupInfoForSeries(seriesId, filterLibrary); - seriesInfo = groupInfo?.GetSeriesInfoBySeasonNumber(season.IndexNumber.Value); - if (seriesInfo != null) - Logger.LogInformation("Getting images for season {SeasonNumber} in {SeriesName} (Series={SeriesId},Group={GroupId})", season.IndexNumber.Value, groupInfo.Shoko.Name, seriesInfo.Id, groupInfo.Id); + var seriesInfo = groupInfo?.GetSeriesInfoBySeasonNumber(season.IndexNumber.Value); + if (seriesInfo != null) { + AddImagesForSeries(ref list, seriesInfo); + Logger.LogInformation("Getting {Count} images for season {SeasonNumber} in {SeriesName} (Series={SeriesId},Group={GroupId})", list.Count, season.IndexNumber.Value, groupInfo.Shoko.Name, seriesInfo.Id, groupInfo.Id); + } } break; } - case Movie: - case BoxSet: { - if (item.ProviderIds.TryGetValue("Shoko Series", out var seriesId) && !string.IsNullOrEmpty(seriesId)) { - seriesInfo = await ApiManager.GetSeriesInfo(seriesId); - if (seriesInfo != null) - Logger.LogInformation("Getting images for movie or box-set {MovieName} (Series={SeriesId})", seriesInfo.Shoko.Name, seriesId); + case Movie movie: { + if (Lookup.TryGetSeriesIdForMovie(movie, out var seriesId)) { + var seriesInfo = await ApiManager.GetSeriesInfo(seriesId); + if (seriesInfo != null) { + AddImagesForSeries(ref list, seriesInfo); + Logger.LogInformation("Getting {Count} images for movie {MovieName} (Series={SeriesId})", list.Count, movie.Name, seriesId); + } + } + break; + } + case BoxSet boxSet: { + if (Lookup.TryGetSeriesIdForBoxSet(boxSet, out var seriesId)) { + var seriesInfo = await ApiManager.GetSeriesInfo(seriesId); + if (seriesInfo != null) { + AddImagesForSeries(ref list, seriesInfo); + Logger.LogInformation("Getting {Count} images for box-set {BoxSetName} (Series={SeriesId})", list.Count, boxSet.Name, seriesId); + } } break; } } - - if (episodeInfo != null) { - AddImage(ref list, ImageType.Primary, episodeInfo?.TvDB?.Thumbnail); - } - if (seriesInfo != null) { - var images = seriesInfo.Shoko.Images; - if (Plugin.Instance.Configuration.PreferAniDbPoster) - AddImage(ref list, ImageType.Primary, seriesInfo.AniDB.Poster); - foreach (var image in images?.Posters) - AddImage(ref list, ImageType.Primary, image); - if (!Plugin.Instance.Configuration.PreferAniDbPoster) - AddImage(ref list, ImageType.Primary, seriesInfo.AniDB.Poster); - foreach (var image in images?.Fanarts) - AddImage(ref list, ImageType.Backdrop, image); - foreach (var image in images?.Banners) - AddImage(ref list, ImageType.Banner, image); - } - - Logger.LogInformation("List got {Count} item(s). (Series={SeriesId},Episode={EpisodeId})", list.Count, seriesInfo?.Id ?? null, episodeInfo?.Id ?? null); return list; } catch (Exception e) { @@ -108,6 +111,26 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell } } + private void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Info.EpisodeInfo episodeInfo) + { + AddImage(ref list, ImageType.Primary, episodeInfo?.TvDB?.Thumbnail); + } + + private void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Info.SeriesInfo seriesInfo) + { + var images = seriesInfo.Shoko.Images; + if (Plugin.Instance.Configuration.PreferAniDbPoster) + AddImage(ref list, ImageType.Primary, seriesInfo.AniDB.Poster); + foreach (var image in images?.Posters) + AddImage(ref list, ImageType.Primary, image); + if (!Plugin.Instance.Configuration.PreferAniDbPoster) + AddImage(ref list, ImageType.Primary, seriesInfo.AniDB.Poster); + foreach (var image in images?.Fanarts) + AddImage(ref list, ImageType.Backdrop, image); + foreach (var image in images?.Banners) + AddImage(ref list, ImageType.Banner, image); + } + private void AddImage(ref List<RemoteImageInfo> list, ImageType imageType, API.Models.Image image) { var imageInfo = GetImage(image, imageType); From 4ac10f71ac71ab4bf39a14e580475183b4928648 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 19 Sep 2021 20:30:34 +0000 Subject: [PATCH 0203/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 1776ec75..a5e32c91 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.23", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.23/shokofin_1.5.0.23.zip", + "checksum": "e4df3ec9d96d4d2eac68c7b7dfdd741b", + "timestamp": "2021-09-19T20:30:33Z" + }, { "version": "1.5.0.22", "changelog": "NA", From c2fd1ef0be697951f4ca7d5e95905915724845f3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 Sep 2021 22:56:44 +0200 Subject: [PATCH 0204/1103] Provide metadata for non-virtual seasons --- Shokofin/Providers/SeasonProvider.cs | 87 ++++++++++++++++++++-------- 1 file changed, 62 insertions(+), 25 deletions(-) diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index d199db89..fb79deb6 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -30,20 +30,22 @@ public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvid public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) { - if (!info.IndexNumber.HasValue) { - return new MetadataResult<Season>(); - } try { switch (Plugin.Instance.Configuration.SeriesGrouping) { default: + if (!info.IndexNumber.HasValue) + return new MetadataResult<Season>(); + if (info.IndexNumber.Value == 1) return await GetShokoGroupedMetadata(info, cancellationToken); + return GetDefaultMetadata(info, cancellationToken); case Ordering.GroupType.MergeFriendly: return GetDefaultMetadata(info, cancellationToken); case Ordering.GroupType.ShokoGroup: - if (info.IndexNumber.Value == 0) + if (info.IndexNumber.HasValue && info.IndexNumber.Value == 0) return GetDefaultMetadata(info, cancellationToken); + return await GetShokoGroupedMetadata(info, cancellationToken); } } @@ -74,37 +76,72 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in { var result = new MetadataResult<Season>(); - if (!info.SeriesProviderIds.TryGetValue("Shoko Series", out var seriesId)) { - Logger.LogWarning($"Unable refresh item, Shoko Group Id was not stored for Series."); - return result; - } - - var seasonNumber = info.IndexNumber.Value; - var alternateEpisodes = false; + int seasonNumber; API.Info.SeriesInfo series; - if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { - var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var group = await ApiManager.GetGroupInfoForSeries(seriesId, filterLibrary); - series = group?.GetSeriesInfoBySeasonNumber(seasonNumber); - if (group == null || series == null) { - Logger.LogWarning("Unable to find info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, series.Id); + var alternateEpisodes = false; + // Virtual seasons + if (info.Path == null) { + if (!info.IndexNumber.HasValue) + return result; + + seasonNumber = info.IndexNumber.Value; + if (!info.SeriesProviderIds.TryGetValue("Shoko Series", out var seriesId)) { + Logger.LogWarning($"Unable refresh item, Shoko Group Id was not stored for Series."); return result; } - if (seasonNumber != group.SeasonNumberBaseDictionary[series]) - alternateEpisodes = true; + if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { + var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; + var group = await ApiManager.GetGroupInfoForSeries(seriesId, filterLibrary); + series = group?.GetSeriesInfoBySeasonNumber(seasonNumber); + if (group == null || series == null) { + Logger.LogWarning("Unable to find info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, series.Id); + return result; + } - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Group={GroupId},Series={SeriesId})", seasonNumber, group.Shoko.Name, group.Id, series.Id); + if (seasonNumber != group.SeasonNumberBaseDictionary[series]) + alternateEpisodes = true; + + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Group={GroupId},Series={SeriesId})", seasonNumber, group.Shoko.Name, group.Id, series.Id); + } + else { + series = await ApiManager.GetSeriesInfo(seriesId); + if (series == null) { + Logger.LogWarning("Unable to find info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, series.Id); + return result; + } + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Shoko.Name, series.Id); + } } + // Non-virtual seasons. else { - series = await ApiManager.GetSeriesInfo(seriesId); - if (series == null) { - Logger.LogWarning("Unable to find info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, series.Id); - return result; + if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { + var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; + series = await ApiManager.GetSeriesInfoByPath(info.Path); + var group = await ApiManager.GetGroupInfoForSeries(series?.Id); + if (group == null || series == null) { + Logger.LogWarning("Unable to find info for Season {SeasonNumber} by path {Path}", info.IndexNumber, info.Path); + return result; + } + seasonNumber = Ordering.GetSeasonNumber(group, series, series.EpisodeList[0]); + + if (seasonNumber != group.SeasonNumberBaseDictionary[series]) + alternateEpisodes = true; + + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Group={GroupId},Series={SeriesId})", seasonNumber, group.Shoko.Name, group.Id, series.Id); + } + else { + series = await ApiManager.GetSeriesInfoByPath(info.Path); + if (series == null) { + Logger.LogWarning("Unable to find info for Season {SeasonNumber} by path {Path}", info.IndexNumber, info.Path); + return result; + } + seasonNumber = Ordering.GetSeasonNumber(null, series, series.EpisodeList[0]); + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Shoko.Name, series.Id); } - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Shoko.Name, series.Id); } + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); var sortTitle = $"I{seasonNumber} - {series.Shoko.Name}"; From ec5a4fd96c6f244b805b319442ff5576e44cff2f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 Sep 2021 22:56:59 +0200 Subject: [PATCH 0205/1103] Remove duplicate code. --- Shokofin/Providers/ExtraMetadataProvider.cs | 25 +++------------------ 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index bb76a0b0..d7bda399 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -128,26 +128,7 @@ private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs itemCh return; try { - var query = new InternalItemsQuery { - IsVirtualItem = true, - HasAnyProviderId = { ["Shoko Episode"] = episodeId }, - IncludeItemTypes = new [] { nameof (Episode) }, - GroupByPresentationUniqueKey = false, - DtoOptions = new DtoOptions(true), - }; - - var existingVirtualItems = LibraryManager.GetItemList(query); - var deleteOptions = new DeleteOptions { - DeleteFileLocation = true, - }; - - // Remove the old virtual episode that matches the newly created item - foreach (var item in existingVirtualItems) { - if (episode.IsVirtualItem && System.Guid.Equals(item.Id, episode.Id)) - continue; - - LibraryManager.DeleteItem(item, deleteOptions); - } + RemoveDuplicateEpisodes(episode, episodeId); } finally { ApiManager.TryUnlockActionForIdOFType("episode", episodeId, "update"); @@ -749,7 +730,7 @@ private void RemoveDuplicateEpisodes(Season season, string seriesId) private void RemoveDuplicateEpisodes(Episode episode, string episodeId) { var query = new InternalItemsQuery { - IsVirtualItem = true, + IsVirtualItem = true, ExcludeItemIds = new [] { episode.Id }, HasAnyProviderId = { ["Shoko Episode"] = episodeId }, IncludeItemTypes = new [] { nameof (Episode) }, @@ -760,7 +741,7 @@ private void RemoveDuplicateEpisodes(Episode episode, string episodeId) var existingVirtualItems = LibraryManager.GetItemList(query); var deleteOptions = new DeleteOptions { - DeleteFileLocation = true, + DeleteFileLocation = false, }; // Remove the virtual season/episode that matches the newly updated item From b952795ec2a7b1f9dbcca8f2a7be78c2520d44cd Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 Sep 2021 22:57:23 +0200 Subject: [PATCH 0206/1103] Check for null before continuing --- Shokofin/API/ShokoAPIManager.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index b2ac3ee9..24f3d780 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -613,11 +613,14 @@ public async Task<GroupInfo> GetGroupInfo(string groupId, Ordering.GroupFilterTy public GroupInfo GetGroupInfoForSeriesSync(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) { + if (string.IsNullOrEmpty(seriesId)) + return null; + if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) return info; - return GetGroupInfoSync(groupId, filterByType); + return GetGroupInfo(groupId, filterByType).GetAwaiter().GetResult(); } return GetGroupInfoForSeries(seriesId, filterByType).GetAwaiter().GetResult(); @@ -625,6 +628,9 @@ public GroupInfo GetGroupInfoForSeriesSync(string seriesId, Ordering.GroupFilter public async Task<GroupInfo> GetGroupInfoForSeries(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) { + if (string.IsNullOrEmpty(seriesId)) + return null; + if (!SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { var group = await ShokoAPI.GetGroupFromSeries(seriesId); if (group == null) From beac59664cf11e207429d0e874390238350a7ea7 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 19 Sep 2021 20:58:41 +0000 Subject: [PATCH 0207/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index a5e32c91..0637539b 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.24", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.24/shokofin_1.5.0.24.zip", + "checksum": "2c3ce7332b5821efef6680a94f220d6f", + "timestamp": "2021-09-19T20:58:40Z" + }, { "version": "1.5.0.23", "changelog": "NA", From e20e38961fc81ae8e350ac7de06dd659409ec62b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 21 Sep 2021 20:23:21 +0200 Subject: [PATCH 0208/1103] Add different special placement methods --- Shokofin/API/Info/SeriesInfo.cs | 2 +- Shokofin/API/Models/Episode.cs | 6 +- Shokofin/API/ShokoAPIManager.cs | 4 +- Shokofin/Configuration/PluginConfiguration.cs | 5 +- Shokofin/Configuration/configPage.html | 29 +++-- Shokofin/Providers/EpisodeProvider.cs | 74 +++-------- Shokofin/Utils/OrderingUtil.cs | 116 ++++++++++++++++++ 7 files changed, 157 insertions(+), 79 deletions(-) diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index 9e221abd..0242ba8d 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -52,7 +52,7 @@ public class SeriesInfo /// <summary> /// A dictionary holding mappings for the previous normal episode for every special episode in a series. /// </summary> - public Dictionary<string, EpisodeInfo> SpesialsAnchors; + public Dictionary<EpisodeInfo, EpisodeInfo> SpesialsAnchors; /// <summary> /// A pre-filtered list of special episodes without an ExtraType diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index 855fc56a..c70d2ddf 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -44,11 +44,11 @@ public class TvDB public DateTime? AirDate { get; set; } - public int AirsAfterSeason { get; set; } + public int? AirsAfterSeason { get; set; } - public int AirsBeforeSeason { get; set; } + public int? AirsBeforeSeason { get; set; } - public int AirsBeforeEpisode { get; set; } + public int? AirsBeforeEpisode { get; set; } public Rating Rating { get; set; } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 24f3d780..5da4ff07 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -505,7 +505,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = var tags = await GetTags(seriesId); var genres = await GetGenresForSeries(seriesId); var studios = await GetStudiosForSeries(seriesId); - Dictionary<string, EpisodeInfo> specialsAnchorDictionary = new Dictionary<string, EpisodeInfo>(); + Dictionary<EpisodeInfo, EpisodeInfo> specialsAnchorDictionary = new Dictionary<EpisodeInfo, EpisodeInfo>(); var specialsList = new List<EpisodeInfo>(); var episodesList = new List<EpisodeInfo>(); var extrasList = new List<EpisodeInfo>(); @@ -536,7 +536,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = .GetRange(0, index) .LastOrDefault(e => e.AniDB.Type == EpisodeType.Normal); if (previousEpisode != null) - specialsAnchorDictionary[episode.Id] = previousEpisode; + specialsAnchorDictionary[episode] = previousEpisode; } } diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 18893c87..8abc824d 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -3,6 +3,7 @@ using DisplayLanguageType = Shokofin.Utils.Text.DisplayLanguageType; using SeriesAndBoxSetGroupType = Shokofin.Utils.Ordering.GroupType; using OrderType = Shokofin.Utils.Ordering.OrderType; +using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; namespace Shokofin.Configuration { @@ -48,7 +49,7 @@ public class PluginConfiguration : BasePluginConfiguration public bool MarkSpecialsWhenGrouped { get; set; } - public bool DisplaySpecialsInSeason { get; set; } + public SpecialOrderType SpecialsPlacement { get; set; } public SeriesAndBoxSetGroupType BoxSetGrouping { get; set; } @@ -83,8 +84,8 @@ public PluginConfiguration() DescriptionSource = TextSourceType.Default; SeriesGrouping = SeriesAndBoxSetGroupType.Default; SeasonOrdering = OrderType.Default; + SpecialsPlacement = SpecialOrderType.Default; MarkSpecialsWhenGrouped = true; - DisplaySpecialsInSeason = false; BoxSetGrouping = SeriesAndBoxSetGroupType.Default; MovieOrdering = OrderType.Default; FilterOnLibraryTypes = false; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 83873386..2d0f748d 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -53,7 +53,7 @@ <h3>Synopsis Options</h3> <div class="selectContainer"> <label class="selectLabel" for="DescriptionSource">Synopsis source</label> <select is="emby-select" id="DescriptionSource" name="DescriptionSource" class="emby-select-withcolor emby-select"> - <option value="Default">Use default for Series Grouping</option> + <option value="Default">Use default source for Series Grouping</option> <option value="OnlyAniDb">Only use AniDB</option> <option value="PreferAniDb">Prefer AniDB if available, otherwise use TvDB/TMDB</option> <option value="OnlyOther">Only use TvDB/TMDB</option> @@ -86,8 +86,8 @@ <h3>Library Options</h3> <label class="selectLabel" for="SeriesGrouping">Series grouping</label> <select is="emby-select" id="SeriesGrouping" name="SeriesGrouping" class="emby-select-withcolor emby-select"> <option value="Default" selected>Do not group series</option> - <option value="MergeFriendly">Make series merge-friendly with TvDB/TMDB</option> - <option value="ShokoGroup">Group series based on Shoko's group feature</option> + <option value="MergeFriendly">Group series using the TvDB/TMDB data stored in Shoko</option> + <option value="ShokoGroup">Group series using Shoko's Group feature</option> </select> <div id="SG_ShokoGroup_Warning" class="fieldDescription"><strong>Warning:</strong> Series merging must be enabled in the <strong>library settings</strong> when using this option.</div> </div> @@ -99,14 +99,22 @@ <h3>Library Options</h3> <!--<option value="Chronological">Chronological order</option>--> </select> </div> + <div class="selectContainer"> + <label class="selectLabel" for="SpecialsPlacement">Specials placement</label> + <select is="emby-select" id="SpecialsPlacement" name="SpecialsPlacement" class="emby-select-withcolor emby-select"> + <option value="Default">Use default placement for Series Grouping</option> + <option value="Excluded">Don't show specials within seasons</option> + <option value="AfterSeason">Always place specials after the normal episodes</option> + <option value="InBetweenSeasonByAirDate">Use release date to place specials</option> + <option value="InBetweenSeasonByOtherData">Use the TvDB/TMDB data (if available) to place specials</option> + <option value="InBetweenSeasonMixed">Use either the TvDB/TMDB data or release date if the data is not available to place specials</option> + </select> + <div class="fieldDescription"><strong>Warning:</strong> Modifying this setting requires a rebuild (and not a full-refresh) of any libraries using this plugin, else you will have mixed metadata.</div> + </div> <label id="MarkSpecialsWhenGroupedItem" class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> <span>Add type and number to title, excluding normal episodes</span> </label> - <label id="DisplaySpecialsInSeasonItem" class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="DisplaySpecialsInSeason" /> - <span>Display specials in-between normal episodes</span> - </label> <div class="selectContainer"> <label class="selectLabel" for="BoxSetGrouping">Box-set grouping</label> <select is="emby-select" id="BoxSetGrouping" name="BoxSetGrouping" class="emby-select-withcolor emby-select"> @@ -194,10 +202,10 @@ <h3>Tag Options</h3> document.querySelector('#DescriptionSource').value = config.DescriptionSource; document.querySelector('#SeriesGrouping').value = config.SeriesGrouping; document.querySelector('#SeasonOrdering').value = config.SeasonOrdering; + document.querySelector('#SpecialsPlacement').value = config.SpecialsPlacement; document.querySelector('#BoxSetGrouping').value = config.BoxSetGrouping; document.querySelector('#MovieOrdering').value = config.MovieOrdering; document.querySelector('#MarkSpecialsWhenGrouped').checked = config.MarkSpecialsWhenGrouped; - document.querySelector('#DisplaySpecialsInSeason').checked = config.DisplaySpecialsInSeason; document.querySelector('#FilterOnLibraryTypes').checked = config.FilterOnLibraryTypes; document.querySelector('#AddAniDBId').checked = config.AddAniDBId; document.querySelector('#PreferAniDbPoster').checked = config.PreferAniDbPoster; @@ -205,13 +213,11 @@ <h3>Tag Options</h3> document.querySelector('#SG_ShokoGroup_Warning').removeAttribute("hidden"); document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); document.querySelector('#MarkSpecialsWhenGroupedItem').removeAttribute("hidden"); - document.querySelector('#DisplaySpecialsInSeasonItem').removeAttribute("hidden"); } else { document.querySelector('#SG_ShokoGroup_Warning').setAttribute("hidden", ""); document.querySelector('#SeasonOrderingItem').setAttribute("hidden", ""); document.querySelector('#MarkSpecialsWhenGroupedItem').setAttribute("hidden", ""); - document.querySelector('#DisplaySpecialsInSeasonItem').setAttribute("hidden", ""); } if (config.BoxSetGrouping === "ShokoGroup") { document.querySelector('#MovieOrderingItem').removeAttribute("hidden"); @@ -228,13 +234,11 @@ <h3>Tag Options</h3> document.querySelector('#SG_ShokoGroup_Warning').removeAttribute("hidden"); document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); document.querySelector('#MarkSpecialsWhenGroupedItem').removeAttribute("hidden"); - document.querySelector('#DisplaySpecialsInSeasonItem').removeAttribute("hidden"); } else { document.querySelector('#SG_ShokoGroup_Warning').setAttribute("hidden", ""); document.querySelector('#SeasonOrderingItem').setAttribute("hidden", ""); document.querySelector('#MarkSpecialsWhenGroupedItem').setAttribute("hidden", ""); - document.querySelector('#DisplaySpecialsInSeasonItem').setAttribute("hidden", ""); } }); document.querySelector('#BoxSetGrouping') @@ -271,6 +275,7 @@ <h3>Tag Options</h3> config.DescriptionSource = document.querySelector('#DescriptionSource').value; config.SeriesGrouping = document.querySelector('#SeriesGrouping').value; config.SeasonOrdering = document.querySelector('#SeasonOrdering').value; + config.SpecialsPlacement = document.querySelector('#SpecialsPlacement').value; config.BoxSetGrouping = document.querySelector('#BoxSetGrouping').value; config.MovieOrdering = document.querySelector('#MovieOrdering').value; config.MarkSpecialsWhenGrouped = document.querySelector('#MarkSpecialsWhenGrouped').checked; diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index d8390b32..f4205d69 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -152,6 +152,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri } Episode result; + var (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber) = Ordering.GetSpecialPlacement(group, series, episode); if (mergeFriendly) { if (season != null) { result = new Episode { @@ -159,9 +160,9 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri OriginalTitle = alternateTitle, IndexNumber = episodeNumber, ParentIndexNumber = seasonNumber, - AirsAfterSeasonNumber = episode.TvDB.AirsAfterSeason, - AirsBeforeEpisodeNumber = episode.TvDB.AirsBeforeEpisode, - AirsBeforeSeasonNumber = episode.TvDB.AirsBeforeSeason, + AirsAfterSeasonNumber = airsAfterSeasonNumber, + AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, + AirsBeforeSeasonNumber = airsBeforeSeasonNumber, Id = episodeId, IsVirtualItem = true, SeasonId = season.Id, @@ -182,66 +183,16 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri OriginalTitle = alternateTitle, IndexNumber = episodeNumber, ParentIndexNumber = seasonNumber, - AirsAfterSeasonNumber = episode.TvDB.AirsAfterSeason, - AirsBeforeEpisodeNumber = episode.TvDB.AirsBeforeEpisode, - AirsBeforeSeasonNumber = episode.TvDB.AirsBeforeSeason, + AirsAfterSeasonNumber = airsAfterSeasonNumber, + AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, + AirsBeforeSeasonNumber = airsBeforeSeasonNumber, CommunityRating = episode.TvDB.Rating?.ToFloat(10), PremiereDate = episode.TvDB.AirDate, Overview = description, }; } - } - else if (episode.AniDB.Type == EpisodeType.Special) { - var displayInBetween = Plugin.Instance.Configuration.DisplaySpecialsInSeason; - int? nextEpisodeNumber = null; - if (displayInBetween) { - int? previousEpisodeNumber = null; - if (series.SpesialsAnchors.TryGetValue(episode.Id, out var previousEpisode)) - previousEpisodeNumber = Ordering.GetEpisodeNumber(group, series, previousEpisode); - nextEpisodeNumber = previousEpisodeNumber.HasValue && previousEpisodeNumber.Value < series.EpisodeList.Count ? previousEpisodeNumber.Value + 1 : null; - - // If the next episode was not found, then append it at the end of the season instead. - if (!nextEpisodeNumber.HasValue) - displayInBetween = false; - } - if (season != null) { - result = new Episode { - Name = displayTitle, - OriginalTitle = alternateTitle, - IndexNumber = episodeNumber, - ParentIndexNumber = 0, - AirsAfterSeasonNumber = displayInBetween ? null : seasonNumber, - AirsBeforeEpisodeNumber = nextEpisodeNumber, - AirsBeforeSeasonNumber = displayInBetween ? seasonNumber : null, - Id = episodeId, - IsVirtualItem = true, - SeasonId = season.Id, - SeriesId = season.Series.Id, - Overview = description, - CommunityRating = episode.AniDB.Rating.ToFloat(10), - PremiereDate = episode.AniDB.AirDate, - SeriesName = season.Series.Name, - SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, - SeasonName = season.Name, - DateLastSaved = DateTime.UtcNow, - }; - result.PresentationUniqueKey = result.GetPresentationUniqueKey(); - } - else { - result = new Episode { - IndexNumber = episodeNumber, - ParentIndexNumber = 0, - AirsAfterSeasonNumber = displayInBetween ? null : seasonNumber, - AirsBeforeEpisodeNumber = nextEpisodeNumber, - AirsBeforeSeasonNumber = displayInBetween ? seasonNumber : null, - Name = displayTitle, - OriginalTitle = alternateTitle, - PremiereDate = episode.AniDB.AirDate, - Overview = description, - CommunityRating = episode.AniDB.Rating.ToFloat(10), - }; - } + result.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); } else { if (season != null) { @@ -250,6 +201,9 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri OriginalTitle = alternateTitle, IndexNumber = episodeNumber, ParentIndexNumber = seasonNumber, + AirsAfterSeasonNumber = airsAfterSeasonNumber, + AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, + AirsBeforeSeasonNumber = airsBeforeSeasonNumber, Id = episodeId, IsVirtualItem = true, SeasonId = season.Id, @@ -270,14 +224,16 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri OriginalTitle = alternateTitle, IndexNumber = episodeNumber, ParentIndexNumber = seasonNumber, + AirsAfterSeasonNumber = airsAfterSeasonNumber, + AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, + AirsBeforeSeasonNumber = airsBeforeSeasonNumber, PremiereDate = episode.AniDB.AirDate, Overview = description, CommunityRating = episode.AniDB.Rating.ToFloat(10), }; } } - if (mergeFriendly) - result.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); + result.SetProviderId("Shoko Episode", episode.Id); if (!string.IsNullOrEmpty(fileId)) result.SetProviderId("Shoko File", fileId); diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 055ef32d..bf7d80cc 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -61,6 +61,38 @@ public enum OrderType Chronological = 2, } + public enum SpecialOrderType { + /// <summary> + /// Use the default for the type. + /// </summary> + Default = 0, + + /// <summary> + /// Always exclude the specials from the season. + /// </summary> + Excluded = 1, + + /// <summary> + /// Always place the specials after the normal episodes in the season. + /// </summary> + AfterSeason = 2, + + /// <summary> + /// Use a mix of <see cref="Shokofin.Utils.Ordering.SpecialOrderType.InBetweenSeasonByOtherData" /> and <see cref="Shokofin.Utils.Ordering.SpecialOrderType.InBetweenSeasonByAirDate" />. + /// </summary> + InBetweenSeasonMixed = 3, + + /// <summary> + /// Place the specials in-between normal episodes based on the time the episodes aired. + /// </summary> + InBetweenSeasonByAirDate = 4, + + /// <summary> + /// Place the specials in-between normal episodes based upon the data from TvDB or TMDB. + /// </summary> + InBetweenSeasonByOtherData = 5, + } + /// <summary> /// Get index number for a movie in a box-set. /// </summary> @@ -183,6 +215,90 @@ public static int GetEpisodeNumber(GroupInfo group, SeriesInfo series, EpisodeIn } } + public static (int?, int?, int?) GetSpecialPlacement(GroupInfo group, SeriesInfo series, EpisodeInfo episode) + { + if (episode.AniDB.Type != EpisodeType.Special) + return (null, null, null); + + var order = Plugin.Instance.Configuration.SpecialsPlacement; + if (order == SpecialOrderType.Excluded) + return (null, null, null); + + int? episodeNumber = null; + int? seasonNumber = null; + int? airsBeforeEpisodeNumber = null; + int? airsBeforeSeasonNumber = null; + int? airsAfterSeasonNumber = null; + switch (order) { + default: + switch (Plugin.Instance.Configuration.SeriesGrouping) { + default: + goto byAirdate; + case GroupType.MergeFriendly: + goto byOtherData; + } + case SpecialOrderType.InBetweenSeasonByAirDate: + byAirdate: + // Reset the order if we come from `SpecialOrderType.InBetweenSeasonMixed`. + episodeNumber = null; + if (series.SpesialsAnchors.TryGetValue(episode, out var previousEpisode)) + episodeNumber = GetEpisodeNumber(group, series, previousEpisode); + + seasonNumber = GetSeasonNumber(group, series, episode); + if (episodeNumber.HasValue && episodeNumber.Value < series.EpisodeList.Count) { + airsBeforeEpisodeNumber = episodeNumber.Value + 1; + airsBeforeSeasonNumber = seasonNumber; + } + else { + airsAfterSeasonNumber = seasonNumber; + } + break; + case SpecialOrderType.InBetweenSeasonMixed: + case SpecialOrderType.InBetweenSeasonByOtherData: + byOtherData: + // We need to have TvDB/TMDB data in the first place to do this method. + if (episode.TvDB == null) { + if (order == SpecialOrderType.InBetweenSeasonMixed) + goto byAirdate; + + break; + } + + episodeNumber = episode.TvDB.AirsBeforeEpisode; + if (!episodeNumber.HasValue) { + if (episode.TvDB.AirsAfterSeason.HasValue) { + airsAfterSeasonNumber = episode.TvDB.AirsAfterSeason.Value; + break; + } + + if (order == SpecialOrderType.InBetweenSeasonMixed) + goto byAirdate; + + break; + } + + seasonNumber = episode.TvDB.AirsBeforeEpisode ?? episode.TvDB.AirsAfterSeason; + if (!seasonNumber.HasValue) { + if (order == SpecialOrderType.InBetweenSeasonMixed) + goto byAirdate; + + break; + } + + var nextEpisode = series.EpisodeList.FirstOrDefault(e => e.TvDB != null && e.TvDB.Season == seasonNumber && e.TvDB.Number == episodeNumber); + if (nextEpisode != null) { + airsBeforeEpisodeNumber = GetEpisodeNumber(group, series, nextEpisode); + airsBeforeSeasonNumber = seasonNumber; + } + else if (order == SpecialOrderType.InBetweenSeasonMixed) + goto byAirdate; + + break; + } + + return (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber); + } + /// <summary> /// Get season number for an episode in a series. /// </summary> From 2e4f74b5208e70fce14d033f31625e622162e174 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 22 Sep 2021 01:24:07 +0200 Subject: [PATCH 0209/1103] Allow others and unknowns to get their own season when using shoko groups to group series into seasons --- Shokofin/API/Info/SeriesInfo.cs | 9 ++++- Shokofin/API/ShokoAPIManager.cs | 55 ++++++++++++++++++--------- Shokofin/Providers/EpisodeProvider.cs | 1 + Shokofin/Providers/SeasonProvider.cs | 31 +++++++++++---- Shokofin/Utils/OrderingUtil.cs | 34 +++++++++++------ 5 files changed, 92 insertions(+), 38 deletions(-) diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index 0242ba8d..6a3b84f3 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -36,12 +36,19 @@ public class SeriesInfo public List<EpisodeInfo> EpisodeList; /// <summary> - /// A pre-filtered list of "other" episodes that belong to this series. + /// A pre-filtered list of "unknown" episodes that belong to this series. /// /// Ordered by AniDb air-date. /// </summary> public List<EpisodeInfo> AlternateEpisodesList; + /// <summary> + /// A pre-filtered list of "other" episodes that belong to this series. + /// + /// Ordered by AniDb air-date. + /// </summary> + public List<EpisodeInfo> OthersList; + /// <summary> /// A pre-filtered list of "extra" videos that belong to this series. /// diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 5da4ff07..f82138e2 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -510,6 +510,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = var episodesList = new List<EpisodeInfo>(); var extrasList = new List<EpisodeInfo>(); var altEpisodesList = new List<EpisodeInfo>(); + var othersList = new List<EpisodeInfo>(); // The episode list is ordered by air date var allEpisodesList = ShokoAPI.GetEpisodesFromSeries(seriesId) @@ -518,25 +519,36 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = .GetAwaiter() .GetResult() .Where(e => e != null && e.Shoko != null && e.AniDB != null) + .OrderBy(e => e.AniDB.AirDate) .ToList(); // Iterate over the episodes once and store some values for later use. - for (var index = 0; index < allEpisodesList.Count; index++) { + for (int index = 0, lastNormalEpisode = 0; index < allEpisodesList.Count; index++) { var episode = allEpisodesList[index]; EpisodeIdToSeriesIdDictionary[episode.Id] = seriesId; - if (episode.AniDB.Type == EpisodeType.Normal) - episodesList.Add(episode); - else if (episode.AniDB.Type == EpisodeType.Unknown) - altEpisodesList.Add(episode); - else if (episode.ExtraType != null) - extrasList.Add(episode); - else if (episode.AniDB.Type == EpisodeType.Special) { - specialsList.Add(episode); - var previousEpisode = allEpisodesList - .GetRange(0, index) - .LastOrDefault(e => e.AniDB.Type == EpisodeType.Normal); - if (previousEpisode != null) - specialsAnchorDictionary[episode] = previousEpisode; + switch (episode.AniDB.Type) { + case EpisodeType.Normal: + episodesList.Add(episode); + lastNormalEpisode = index; + break; + case EpisodeType.Other: + othersList.Add(episode); + break; + case EpisodeType.Unknown: + altEpisodesList.Add(episode); + break; + default: + if (episode.ExtraType != null) + extrasList.Add(episode); + else if (episode.AniDB.Type == EpisodeType.Special) { + specialsList.Add(episode); + var previousEpisode = allEpisodesList + .GetRange(lastNormalEpisode, index - lastNormalEpisode) + .FirstOrDefault(e => e.AniDB.Type == EpisodeType.Normal); + if (previousEpisode != null) + specialsAnchorDictionary[episode] = previousEpisode; + } + break; } } @@ -557,6 +569,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = RawEpisodeList = allEpisodesList, EpisodeList = episodesList, AlternateEpisodesList = altEpisodesList, + OthersList = othersList, ExtrasList = extrasList, SpesialsAnchors = specialsAnchorDictionary, SpecialsList = specialsList, @@ -720,22 +733,26 @@ private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Order var negativeSeasonNumber = -1; foreach (var (seriesInfo, index) in seriesList.Select((s, i) => (s, i))) { int seasonNumber; - var includeAlternateSeason = seriesInfo.AlternateEpisodesList.Count > 0; + var offset = 0; + if (seriesInfo.AlternateEpisodesList.Count > 0) + offset++; + if (seriesInfo.OthersList.Count > 0) + offset++; // Series before the default series get a negative season number if (index < foundIndex) { seasonNumber = negativeSeasonNumber; - negativeSeasonNumber -= includeAlternateSeason ? 2 : 1; + negativeSeasonNumber -= offset + 1; } else { seasonNumber = positiveSeasonNumber; - positiveSeasonNumber += includeAlternateSeason ? 2 : 1; + positiveSeasonNumber += offset + 1; } seasonNumberBaseDictionary.Add(seriesInfo, seasonNumber); seasonOrderDictionary.Add(seasonNumber, seriesInfo); - if (includeAlternateSeason) - seasonOrderDictionary.Add(index < foundIndex ? seasonNumber - 1 : seasonNumber + 1, seriesInfo); + for (var i = 0; i < offset; i++) + seasonOrderDictionary.Add(seasonNumber + (index < foundIndex ? -(i + 1) : (i + 1)), seriesInfo); } groupInfo = new GroupInfo { diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index f4205d69..2cf18a17 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -122,6 +122,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri if (group != null && config.MarkSpecialsWhenGrouped) switch (episode.AniDB.Type) { case EpisodeType.Unknown: + case EpisodeType.Other: case EpisodeType.Normal: break; case EpisodeType.Special: { diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index fb79deb6..eaaab6ad 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -78,7 +78,7 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in int seasonNumber; API.Info.SeriesInfo series; - var alternateEpisodes = false; + var offset = 0; // Virtual seasons if (info.Path == null) { if (!info.IndexNumber.HasValue) @@ -100,7 +100,7 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in } if (seasonNumber != group.SeasonNumberBaseDictionary[series]) - alternateEpisodes = true; + offset = seasonNumber - group.SeasonNumberBaseDictionary[series]; Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Group={GroupId},Series={SeriesId})", seasonNumber, group.Shoko.Name, group.Id, series.Id); } @@ -126,7 +126,7 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in seasonNumber = Ordering.GetSeasonNumber(group, series, series.EpisodeList[0]); if (seasonNumber != group.SeasonNumberBaseDictionary[series]) - alternateEpisodes = true; + offset = seasonNumber - group.SeasonNumberBaseDictionary[series]; Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Group={GroupId},Series={SeriesId})", seasonNumber, group.Shoko.Name, group.Id, series.Id); } @@ -141,13 +141,28 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in } } - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); var sortTitle = $"I{seasonNumber} - {series.Shoko.Name}"; - if (alternateEpisodes) { - displayTitle += " (Other Episodes)"; - alternateTitle += " (Other Episodes)"; + if (offset > 0) { + string type = ""; + switch (offset) { + default: + break; + case -1: + case 1: + if (series.AlternateEpisodesList.Count > 0) + type = "Alternate Stories"; + else + type = "Other Episodes"; + break; + case -2: + case 2: + type = "Other Episodes"; + break; + } + displayTitle += $" ({type})"; + alternateTitle += $" ({type})"; } result.Item = new Season { @@ -200,6 +215,8 @@ private string GetSeasonName(int seasonNumber, string seasonName) return "Trailers"; case 124: return "Others"; + case 123: + return "Unknown"; default: return seasonName; } diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index bf7d80cc..cd0609c1 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -192,6 +192,7 @@ public static int GetEpisodeNumber(GroupInfo group, SeriesInfo series, EpisodeIn } var sizes = series.Shoko.Sizes.Total; switch (episode.AniDB.Type) { + case EpisodeType.Other: case EpisodeType.Unknown: case EpisodeType.Normal: // offset += 0; // it's not needed, so it's just here as a comment instead. @@ -201,8 +202,8 @@ public static int GetEpisodeNumber(GroupInfo group, SeriesInfo series, EpisodeIn offset += sizes?.Episodes ?? 0; goto case EpisodeType.Normal; case EpisodeType.OpeningSong: - offset += sizes?.Others ?? 0; - goto case EpisodeType.Unknown; + offset += sizes?.Parodies ?? 0; + goto case EpisodeType.Parody; case EpisodeType.Trailer: offset += sizes?.Credits ?? 0; goto case EpisodeType.OpeningSong; @@ -317,6 +318,8 @@ public static int GetSeasonNumber(GroupInfo group, SeriesInfo series, EpisodeInf case EpisodeType.Special: return 0; case EpisodeType.Unknown: + return 123; + case EpisodeType.Other: return 124; case EpisodeType.Trailer: return 125; @@ -332,17 +335,25 @@ public static int GetSeasonNumber(GroupInfo group, SeriesInfo series, EpisodeInf return seasonNumber.Value; } case GroupType.ShokoGroup: { - var id = series.Id; - if (series == group.DefaultSeries) - return 1; if (!group.SeasonNumberBaseDictionary.TryGetValue(series, out var seasonNumber)) - throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={id})"); - - // Alternate Season List - if (series.AlternateEpisodesList.Count > 0 && episode.AniDB.Type == EpisodeType.Unknown) - return seasonNumber > 0 ? seasonNumber + 1 : seasonNumber - 1; + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id})"); - return seasonNumber; + + var offset = 0; + switch (episode.AniDB.Type) { + default: + break; + case EpisodeType.Unknown: { + offset = 1; + break; + } + case EpisodeType.Other: { + offset = series.AlternateEpisodesList.Count > 0 ? 2 : 1; + break; + } + } + + return seasonNumber + (seasonNumber < 0 ? -offset : offset); } } } @@ -357,6 +368,7 @@ public static int GetSeasonNumber(GroupInfo group, SeriesInfo series, EpisodeInf switch (episode.Type) { case EpisodeType.Normal: + case EpisodeType.Other: case EpisodeType.Unknown: return null; case EpisodeType.ThemeSong: From 5a48d97f3052c2a097ff791fed3664e3d4cd6637 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 22 Sep 2021 01:26:41 +0200 Subject: [PATCH 0210/1103] Update the configuration page --- Shokofin/Configuration/configPage.html | 140 +++++++++++++------------ 1 file changed, 72 insertions(+), 68 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 2d0f748d..ceaf8f4e 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -14,7 +14,7 @@ <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> - <h3>Connection Options</h3> + <h3>Connection Settings</h3> <div class="inputContainer"> <input is="emby-input" type="text" id="Host" required label="Host" /> <div class="fieldDescription">This is the URL leading to where Shoko is running.</div> @@ -29,7 +29,74 @@ <h3>Connection Options</h3> <input is="emby-input" type="text" id="ApiKey" label="API Key" /> <div class="fieldDescription">This field is auto-generated using the credentials. Only set this manually if that doesn't work!</div> </div> - <h3>Title Options</h3> + <h3>Library Settings</h3> + <h4>Shared</h4> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="UpdateWatchedStatus" /> + <span>Sync-back watched status on Shoko (Scrobble)</span> + </label> + <label class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="FilterOnLibraryTypes" /> + <span>Filter out files for 'Shows' and 'Movie' library-types</span> + </label> + <h4>Series</h4> + <div class="selectContainer"> + <label class="selectLabel" for="SeriesGrouping">Series/Season Grouping</label> + <select is="emby-select" id="SeriesGrouping" name="SeriesGrouping" class="emby-select-withcolor emby-select"> + <option value="Default" selected>Do not group series into seasons</option> + <option value="MergeFriendly">Group series into seasons using the TvDB/TMDB data stored in Shoko</option> + <option value="ShokoGroup">Group series into seasons based on Shoko's groups</option> + </select> + <div class="fieldDescription">Determines how to group Series together and divide them into Seasons.</div> + <div class="fieldDescription"><strong>Warning:</strong> Series merging must be enabled in the <strong>library settings</strong> when using this option.</div> + </div> + <div id="SeasonOrderingItem" class="selectContainer"> + <label class="selectLabel" for="SeasonOrdering">Shoko Group's Season Order</label> + <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select"> + <option value="Default">Let Shoko decide the order</option> + <option value="ReleaseDate">Order seasons by their release date</option> + <!--<option value="Chronological">Chronological order</option>--> + </select> + <div class="fieldDescription">Determines the Season order within each Series.</div> + </div> + <h4>Specials</h4> + <label id="MarkSpecialsWhenGroupedItem" class="checkboxContainer"> + <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> + <span>Add a number to the title of each Specials</span> + </label> + <div class="selectContainer"> + <label class="selectLabel" for="SpecialsPlacement">Specials placement</label> + <select is="emby-select" id="SpecialsPlacement" name="SpecialsPlacement" class="emby-select-withcolor emby-select"> + <option value="Default">Use default placement for Series Grouping</option> + <option value="Excluded">Don't place specials within seasons</option> + <option value="AfterSeason">Always place specials after the normal episodes</option> + <option value="InBetweenSeasonByAirDate">Use release date to place specials</option> + <option value="InBetweenSeasonByOtherData">Use the TvDB/TMDB data (if available) to place specials</option> + <option value="InBetweenSeasonMixed">Use either the TvDB/TMDB data or release date if the data is not available to place specials</option> + </select> + <div class="fieldDescription">Determines how to place Specials within Seasons</div> + <div class="fieldDescription"><strong>Warning:</strong> Modifying this setting requires a rebuild (and not just a full-refresh) of any libraries using this plugin, or else you will have mixed metadata.</div> + </div> + <h4>Movies</h4> + <div class="selectContainer"> + <label class="selectLabel" for="BoxSetGrouping">Box-Set Creation</label> + <select is="emby-select" id="BoxSetGrouping" name="BoxSetGrouping" class="emby-select-withcolor emby-select"> + <option value="Default" selected>Do not create Bos-Sets for Movies</option> + <option value="ShokoSeries">Create Box-Sets based upon Shoko's Series entries</option> + <option value="ShokoGroup">Create Box-Sets based upon Shoko's Groups and Series entries</option> + </select> + <div class="fieldDescription">Determines how to group Movies together into Box-Sets.</div> + </div> + <div id="MovieOrderingItem" class="selectContainer"> + <label class="selectLabel" for="MovieOrdering">Box-Set Movie Order</label> + <select is="emby-select" id="MovieOrdering" name="MovieOrdering" class="emby-select-withcolor emby-select"> + <option value="Default">Let Shoko decide the order</option> + <option value="ReleaseDate">Order Movies by their release date</option> + <!--<option value="Chronological">Chronological order</option>--> + </select> + <div class="fieldDescription">Determines the Movie order within each Box-Set</div> + </div> + <h3>Title Settings</h3> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="TitleMainType">Main Title</label> <select is="emby-select" id="TitleMainType" name="TitleMainType" class="emby-select-withcolor emby-select"> @@ -49,7 +116,7 @@ <h3>Title Options</h3> </select> <div class="fieldDescription">Titles will fallback to Default if not found for the target language.</div> </div> - <h3>Synopsis Options</h3> + <h3>Synopsis Settings</h3> <div class="selectContainer"> <label class="selectLabel" for="DescriptionSource">Synopsis source</label> <select is="emby-select" id="DescriptionSource" name="DescriptionSource" class="emby-select-withcolor emby-select"> @@ -76,75 +143,16 @@ <h3>Synopsis Options</h3> <input is="emby-checkbox" type="checkbox" id="SynopsisCleanMultiEmptyLines" /> <span>Collapse excessive empty lines</span> </label> - <h3>Image Options</h3> + <h3>Provider Settings</h3> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="PreferAniDbPoster" /> <span>Prefer poster image from AniDb for Series/Seasons</span> </label> - <h3>Library Options</h3> - <div class="selectContainer"> - <label class="selectLabel" for="SeriesGrouping">Series grouping</label> - <select is="emby-select" id="SeriesGrouping" name="SeriesGrouping" class="emby-select-withcolor emby-select"> - <option value="Default" selected>Do not group series</option> - <option value="MergeFriendly">Group series using the TvDB/TMDB data stored in Shoko</option> - <option value="ShokoGroup">Group series using Shoko's Group feature</option> - </select> - <div id="SG_ShokoGroup_Warning" class="fieldDescription"><strong>Warning:</strong> Series merging must be enabled in the <strong>library settings</strong> when using this option.</div> - </div> - <div id="SeasonOrderingItem" class="selectContainer"> - <label class="selectLabel" for="SeasonOrdering">Season ordering</label> - <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select"> - <option value="Default">Let Shoko decide</option> - <option value="ReleaseDate">By release date</option> - <!--<option value="Chronological">Chronological order</option>--> - </select> - </div> - <div class="selectContainer"> - <label class="selectLabel" for="SpecialsPlacement">Specials placement</label> - <select is="emby-select" id="SpecialsPlacement" name="SpecialsPlacement" class="emby-select-withcolor emby-select"> - <option value="Default">Use default placement for Series Grouping</option> - <option value="Excluded">Don't show specials within seasons</option> - <option value="AfterSeason">Always place specials after the normal episodes</option> - <option value="InBetweenSeasonByAirDate">Use release date to place specials</option> - <option value="InBetweenSeasonByOtherData">Use the TvDB/TMDB data (if available) to place specials</option> - <option value="InBetweenSeasonMixed">Use either the TvDB/TMDB data or release date if the data is not available to place specials</option> - </select> - <div class="fieldDescription"><strong>Warning:</strong> Modifying this setting requires a rebuild (and not a full-refresh) of any libraries using this plugin, else you will have mixed metadata.</div> - </div> - <label id="MarkSpecialsWhenGroupedItem" class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> - <span>Add type and number to title, excluding normal episodes</span> - </label> - <div class="selectContainer"> - <label class="selectLabel" for="BoxSetGrouping">Box-set grouping</label> - <select is="emby-select" id="BoxSetGrouping" name="BoxSetGrouping" class="emby-select-withcolor emby-select"> - <option value="Default" selected>Do not group movies into box-sets</option> - <option value="ShokoSeries">Group movies within Shoko's series only</option> - <option value="ShokoGroup">Group movies in Shoko's groups and series</option> - </select> - </div> - <div id="MovieOrderingItem" class="selectContainer"> - <label class="selectLabel" for="MovieOrdering">Movie ordering</label> - <select is="emby-select" id="MovieOrdering" name="MovieOrdering" class="emby-select-withcolor emby-select"> - <option value="Default">Let Shoko decide</option> - <option value="ReleaseDate">By release date</option> - <!--<option value="Chronological">Chronological order</option>--> - </select> - </div> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="FilterOnLibraryTypes" /> - <span>Disallow overlap of Movies and Series library types</span> - </label> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="UpdateWatchedStatus" /> - <span>Update watched status on Shoko (Scrobble)</span> - </label> - <h3>Provider Options</h3> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="AddAniDBId" /> <span>Add AniDB Id to all entries</span> </label> - <h3>Tag Options</h3> + <h3>Tag Settings</h3> <label class="checkboxContainer"> <input is="emby-checkbox" type="checkbox" id="HideArtStyleTags" /> <span>Hide art style related tags</span> @@ -210,12 +218,10 @@ <h3>Tag Options</h3> document.querySelector('#AddAniDBId').checked = config.AddAniDBId; document.querySelector('#PreferAniDbPoster').checked = config.PreferAniDbPoster; if (config.SeriesGrouping === "ShokoGroup") { - document.querySelector('#SG_ShokoGroup_Warning').removeAttribute("hidden"); document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); document.querySelector('#MarkSpecialsWhenGroupedItem').removeAttribute("hidden"); } else { - document.querySelector('#SG_ShokoGroup_Warning').setAttribute("hidden", ""); document.querySelector('#SeasonOrderingItem').setAttribute("hidden", ""); document.querySelector('#MarkSpecialsWhenGroupedItem').setAttribute("hidden", ""); } @@ -231,12 +237,10 @@ <h3>Tag Options</h3> document.querySelector('#SeriesGrouping') .addEventListener('input', function () { if (document.querySelector('#SeriesGrouping').value === "ShokoGroup") { - document.querySelector('#SG_ShokoGroup_Warning').removeAttribute("hidden"); document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); document.querySelector('#MarkSpecialsWhenGroupedItem').removeAttribute("hidden"); } else { - document.querySelector('#SG_ShokoGroup_Warning').setAttribute("hidden", ""); document.querySelector('#SeasonOrderingItem').setAttribute("hidden", ""); document.querySelector('#MarkSpecialsWhenGroupedItem').setAttribute("hidden", ""); } From 2ef43ea1a1d50320a2bd5b3281af7d8224c79ada Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 21 Sep 2021 23:27:50 +0000 Subject: [PATCH 0211/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 0637539b..65edd932 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.25", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.25/shokofin_1.5.0.25.zip", + "checksum": "2e7143bebb23853a17c1b23e61c74797", + "timestamp": "2021-09-21T23:27:49Z" + }, { "version": "1.5.0.24", "changelog": "NA", From 272888b38c1c33a117b982fccbec7f99418a7f04 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 22 Sep 2021 01:33:35 +0200 Subject: [PATCH 0212/1103] Fix: don't try to remove seasons if the list is empty --- Shokofin/Providers/ExtraMetadataProvider.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index d7bda399..676a2579 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -637,16 +637,15 @@ public void RemoveDummySeasons(Series series, string seriesId) public void RemoveDuplicateSeasons(Series series, string seriesId) { - var seasonNumbers = new List<int>(); + var seasonNumbers = new HashSet<int>(); foreach (var season in series.GetSeasons(null, new DtoOptions(true)).OfType<Season>()) { if (!season.IndexNumber.HasValue) continue; var seasonNumber = season.IndexNumber.Value; - if (seasonNumbers.Contains(seasonNumber)) + if (!seasonNumbers.Add(seasonNumber)) continue; - - seasonNumbers.Add(seasonNumber); + RemoveDuplicateSeasons(season, series, seasonNumber, seriesId); } @@ -661,7 +660,10 @@ public void RemoveDuplicateSeasons(Season season, Series series, int seasonNumbe DtoOptions = new DtoOptions(true), }, true).Where(item => !item.IndexNumber.HasValue).ToList(); - Logger.LogWarning("Removing {Count} dummy seasons from {SeriesName} (Series={SeriesId})", searchList.Count, series.Name, seriesId); + if (searchList.Count == 0) + return; + + Logger.LogWarning("Removing {Count} duplicate seasons from {SeriesName} (Series={SeriesId})", searchList.Count, series.Name, seriesId); var deleteOptions = new DeleteOptions { DeleteFileLocation = false, }; From 7700df74dc35e9d1ea9364fe1d6789c5b943b40b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 21 Sep 2021 23:34:26 +0000 Subject: [PATCH 0213/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 65edd932..1b0c9355 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.26", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.26/shokofin_1.5.0.26.zip", + "checksum": "2f644d90b0e70056a5f77127fddb6468", + "timestamp": "2021-09-21T23:34:25Z" + }, { "version": "1.5.0.25", "changelog": "NA", From c0d611f7ed8241b0e7c0f331cb4d2f6da955f796 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 22 Sep 2021 01:53:23 +0200 Subject: [PATCH 0214/1103] Fix configuration --- Shokofin/Configuration/configPage.html | 1 - 1 file changed, 1 deletion(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index ceaf8f4e..2b1fe056 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -283,7 +283,6 @@ <h3>Tag Settings</h3> config.BoxSetGrouping = document.querySelector('#BoxSetGrouping').value; config.MovieOrdering = document.querySelector('#MovieOrdering').value; config.MarkSpecialsWhenGrouped = document.querySelector('#MarkSpecialsWhenGrouped').checked; - config.DisplaySpecialsInSeason = document.querySelector('#DisplaySpecialsInSeason').checked; config.FilterOnLibraryTypes = document.querySelector('#FilterOnLibraryTypes').checked; config.AddAniDBId = document.querySelector('#AddAniDBId').checked; config.PreferAniDbPoster = document.querySelector('#PreferAniDbPoster').checked; From 18e2d2f4ff2a5739d8310ae3d43babbde1fa6480 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 21 Sep 2021 23:54:02 +0000 Subject: [PATCH 0215/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 1b0c9355..e5307c15 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.27", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.27/shokofin_1.5.0.27.zip", + "checksum": "ab548ef8ab5d7f4fad8318c19a689fc0", + "timestamp": "2021-09-21T23:54:00Z" + }, { "version": "1.5.0.26", "changelog": "NA", From c3c8c71e8e0d4531b376210bc427f0e11e5637f3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 23 Sep 2021 23:17:14 +0200 Subject: [PATCH 0216/1103] Fix up the confiugation and add external urls Synchronization is a placeholder for now. --- Shokofin/Configuration/PluginConfiguration.cs | 9 + Shokofin/Configuration/configPage.html | 506 ++++++++++-------- Shokofin/ExternalIds.cs | 14 +- Shokofin/Utils/TextUtil.cs | 2 +- 4 files changed, 295 insertions(+), 236 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 8abc824d..9739cf27 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,4 +1,6 @@ using MediaBrowser.Model.Plugins; +using System.Text.Json.Serialization; + using TextSourceType = Shokofin.Utils.Text.TextSourceType; using DisplayLanguageType = Shokofin.Utils.Text.DisplayLanguageType; using SeriesAndBoxSetGroupType = Shokofin.Utils.Ordering.GroupType; @@ -11,6 +13,12 @@ public class PluginConfiguration : BasePluginConfiguration { public string Host { get; set; } + public string PublicHost { get; set; } + + [JsonIgnore] + public virtual string PrettyHost + => string.IsNullOrEmpty(PublicHost) ? Host : PublicHost; + public string Username { get; set; } public string Password { get; set; } @@ -64,6 +72,7 @@ public class PluginConfiguration : BasePluginConfiguration public PluginConfiguration() { Host = "http://127.0.0.1:8111"; + PublicHost = ""; Username = "Default"; Password = ""; ApiKey = ""; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 2b1fe056..3cb1a3c8 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -14,165 +14,214 @@ <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> - <h3>Connection Settings</h3> - <div class="inputContainer"> - <input is="emby-input" type="text" id="Host" required label="Host" /> - <div class="fieldDescription">This is the URL leading to where Shoko is running.</div> - </div> - <div class="inputContainer"> - <input is="emby-input" type="text" id="Username" required label="Username" /> - </div> - <div class="inputContainer"> - <input is="emby-input" type="text" id="Password" label="Password" /> - </div> - <div class="inputContainer"> - <input is="emby-input" type="text" id="ApiKey" label="API Key" /> - <div class="fieldDescription">This field is auto-generated using the credentials. Only set this manually if that doesn't work!</div> - </div> - <h3>Library Settings</h3> - <h4>Shared</h4> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="UpdateWatchedStatus" /> - <span>Sync-back watched status on Shoko (Scrobble)</span> - </label> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="FilterOnLibraryTypes" /> - <span>Filter out files for 'Shows' and 'Movie' library-types</span> - </label> - <h4>Series</h4> - <div class="selectContainer"> - <label class="selectLabel" for="SeriesGrouping">Series/Season Grouping</label> - <select is="emby-select" id="SeriesGrouping" name="SeriesGrouping" class="emby-select-withcolor emby-select"> - <option value="Default" selected>Do not group series into seasons</option> - <option value="MergeFriendly">Group series into seasons using the TvDB/TMDB data stored in Shoko</option> - <option value="ShokoGroup">Group series into seasons based on Shoko's groups</option> - </select> - <div class="fieldDescription">Determines how to group Series together and divide them into Seasons.</div> - <div class="fieldDescription"><strong>Warning:</strong> Series merging must be enabled in the <strong>library settings</strong> when using this option.</div> - </div> - <div id="SeasonOrderingItem" class="selectContainer"> - <label class="selectLabel" for="SeasonOrdering">Shoko Group's Season Order</label> - <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select"> - <option value="Default">Let Shoko decide the order</option> - <option value="ReleaseDate">Order seasons by their release date</option> - <!--<option value="Chronological">Chronological order</option>--> - </select> - <div class="fieldDescription">Determines the Season order within each Series.</div> - </div> - <h4>Specials</h4> - <label id="MarkSpecialsWhenGroupedItem" class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> - <span>Add a number to the title of each Specials</span> - </label> - <div class="selectContainer"> - <label class="selectLabel" for="SpecialsPlacement">Specials placement</label> - <select is="emby-select" id="SpecialsPlacement" name="SpecialsPlacement" class="emby-select-withcolor emby-select"> - <option value="Default">Use default placement for Series Grouping</option> - <option value="Excluded">Don't place specials within seasons</option> - <option value="AfterSeason">Always place specials after the normal episodes</option> - <option value="InBetweenSeasonByAirDate">Use release date to place specials</option> - <option value="InBetweenSeasonByOtherData">Use the TvDB/TMDB data (if available) to place specials</option> - <option value="InBetweenSeasonMixed">Use either the TvDB/TMDB data or release date if the data is not available to place specials</option> - </select> - <div class="fieldDescription">Determines how to place Specials within Seasons</div> - <div class="fieldDescription"><strong>Warning:</strong> Modifying this setting requires a rebuild (and not just a full-refresh) of any libraries using this plugin, or else you will have mixed metadata.</div> - </div> - <h4>Movies</h4> - <div class="selectContainer"> - <label class="selectLabel" for="BoxSetGrouping">Box-Set Creation</label> - <select is="emby-select" id="BoxSetGrouping" name="BoxSetGrouping" class="emby-select-withcolor emby-select"> - <option value="Default" selected>Do not create Bos-Sets for Movies</option> - <option value="ShokoSeries">Create Box-Sets based upon Shoko's Series entries</option> - <option value="ShokoGroup">Create Box-Sets based upon Shoko's Groups and Series entries</option> - </select> - <div class="fieldDescription">Determines how to group Movies together into Box-Sets.</div> - </div> - <div id="MovieOrderingItem" class="selectContainer"> - <label class="selectLabel" for="MovieOrdering">Box-Set Movie Order</label> - <select is="emby-select" id="MovieOrdering" name="MovieOrdering" class="emby-select-withcolor emby-select"> - <option value="Default">Let Shoko decide the order</option> - <option value="ReleaseDate">Order Movies by their release date</option> - <!--<option value="Chronological">Chronological order</option>--> - </select> - <div class="fieldDescription">Determines the Movie order within each Box-Set</div> - </div> - <h3>Title Settings</h3> - <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="TitleMainType">Main Title</label> - <select is="emby-select" id="TitleMainType" name="TitleMainType" class="emby-select-withcolor emby-select"> - <option value="Default">Let Shoko decide</option> - <option value="MetadataPreferred">Preferred metadata language</option> - <option value="Origin">Language in country of origin</option> - </select> - <div class="fieldDescription">Titles will fallback to Default if not found for the target language.</div> - </div> - <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="TitleAlternateType">Alternate Title</label> - <select is="emby-select" id="TitleAlternateType" name="TitleAlternateType" class="emby-select-withcolor emby-select"> - <option value="Default">Let Shoko decide</option> - <option value="MetadataPreferred">Preferred metadata language</option> - <option value="Origin">Language in country of origin</option> - <option value="Ignore">Do not use alternate titles</option> - </select> - <div class="fieldDescription">Titles will fallback to Default if not found for the target language.</div> - </div> - <h3>Synopsis Settings</h3> - <div class="selectContainer"> - <label class="selectLabel" for="DescriptionSource">Synopsis source</label> - <select is="emby-select" id="DescriptionSource" name="DescriptionSource" class="emby-select-withcolor emby-select"> - <option value="Default">Use default source for Series Grouping</option> - <option value="OnlyAniDb">Only use AniDB</option> - <option value="PreferAniDb">Prefer AniDB if available, otherwise use TvDB/TMDB</option> - <option value="OnlyOther">Only use TvDB/TMDB</option> - <option value="PreferOther">Prefer TvDB/TMDB if available, otherwise use AniDB</option> - </select> - </div> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="SynopsisCleanLinks" /> - <span>Remove links</span> - </label> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="SynopsisCleanMiscLines" /> - <span>Remove the line if it starts with ('* ' / '-- ' / '~ ')</span> - </label> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="SynopsisRemoveSummary" /> - <span>Remove anything after Source, Note or Summary</span> - </label> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="SynopsisCleanMultiEmptyLines" /> - <span>Collapse excessive empty lines</span> - </label> - <h3>Provider Settings</h3> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="PreferAniDbPoster" /> - <span>Prefer poster image from AniDb for Series/Seasons</span> - </label> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="AddAniDBId" /> - <span>Add AniDB Id to all entries</span> - </label> - <h3>Tag Settings</h3> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="HideArtStyleTags" /> - <span>Hide art style related tags</span> - </label> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="HideSourceTags" /> - <span>Hide source related tags</span> - </label> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="HideMiscTags" /> - <span>Hide misc info tags that may be useful</span> - </label> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="HidePlotTags" /> - <span>Hide potentially plot-spoiling tags</span> - </label> - <label class="checkboxContainer"> - <input is="emby-checkbox" type="checkbox" id="HideAniDbTags" /> - <span>Hide any miscellaneous tags</span> - </label> + <fieldset class="verticalSection verticalSection-extrabottompadding"> + <legend> + <h3>Connection Settings</h3> + </legend> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="Host" required label="Shoko host url:" /> + <div class="fieldDescription">This is the URL leading to where Shoko is running. It should 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:" /> + <div class="fieldDescription">The user should be an administrator in Shoko, preferably without any filtering applied.</div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="Password" label="Password:" /> + <div class="fieldDescription">The password for account. It can be empty.</div> + </div> + </fieldset> + <fieldset class="verticalSection verticalSection-extrabottompadding"> + <legend> + <h3>Metadata Settings</h3> + </legend> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="TitleMainType">Main title source:</label> + <select is="emby-select" id="TitleMainType" name="TitleMainType" class="emby-select-withcolor emby-select"> + <option value="Default">Let Shoko decide the title</option> + <option value="MetadataPreferred">Use the preferred library metadata language</option> + <option value="Origin">Use the language from country of origin</option> + </select> + <div class="fieldDescription selectFieldDescription">How to select the main title for each item. The plugin will fallback to the default title if no title can be found in the target language.</div> + </div> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="TitleAlternateType">Alternate title source:</label> + <select is="emby-select" id="TitleAlternateType" name="TitleAlternateType" class="emby-select-withcolor emby-select"> + <option value="Default">Let Shoko decide the title</option> + <option value="MetadataPreferred">Use the preferred library metadata language</option> + <option value="Origin">Use the language from country of origin</option> + <option value="Ignore">Do not use alternate titles</option> + </select> + <div class="fieldDescription selectFieldDescription">How to select the alternate title for each item. The plugin will fallback to the default title if no title can be found in the target language.</div> + </div> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="DescriptionSource">Description source:</label> + <select is="emby-select" id="DescriptionSource" name="DescriptionSource" class="emby-select-withcolor emby-select"> + <option value="Default">Use default source for selected Series/Season grouping</option> + <option value="OnlyAniDb">Only use AniDB</option> + <option value="PreferAniDb">Prefer AniDB if available, otherwise use TvDB/TMDB</option> + <option value="OnlyOther">Only use TvDB/TMDB</option> + <option value="PreferOther">Prefer TvDB/TMDB if available, otherwise use AniDB</option> + </select> + <div class="fieldDescription selectFieldDescription">How to select the description to use for each item.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="CleanupAniDBDescriptions" /> + <span>Cleanup AniDB descriptions</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Remove links and collapse multiple empty lines into one empty line</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="MinimalAniDBDescriptions" /> + <span>Minimalistic AniDB descriptions</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Remove any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summery'</div> + </div> + </fieldset> + <fieldset class="verticalSection verticalSection-extrabottompadding"> + <legend> + <h3>Library Settings</h3> + </legend> + <div class="fieldDescription verticalSection-extrabottompadding">Series merging must be enabled in the <a href="#!/library.html">Library Settings</a>, or else unexpected results will occur.</div> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="SeriesGrouping">Series/season grouping:</label> + <select is="emby-select" id="SeriesGrouping" name="SeriesGrouping" class="emby-select-withcolor emby-select"> + <option value="Default" selected>Do not group Series into Seasons</option> + <option value="MergeFriendly">Group series into Seasons using the TvDB/TMDB data stored in Shoko</option> + <option value="ShokoGroup">Group series into Seasons based on Shoko's Groups</option> + </select> + <div class="fieldDescription selectFieldDescription">Determines how to group Series together and divide them into Seasons.</div> + </div> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="BoxSetGrouping">Box-set/Movie grouping:</label> + <select is="emby-select" id="BoxSetGrouping" name="BoxSetGrouping" class="emby-select-withcolor emby-select"> + <option value="Default" selected>Do not create Box-sets for Movies</option> + <option value="ShokoSeries">Create Box-sets based upon Shoko's Series entries</option> + <option value="ShokoGroup">Create Box-sets based upon Shoko's Groups and Series entries</option> + </select> + <div class="fieldDescription">Determines how to group Movies together into Box-sets.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="FilterOnLibraryTypes" /> + <span>Enable library seperation</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Split Shoko Groups in two, and actively filter out folders and files that don't belong to the selected library type.</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"> + <option value="Default">Use default placement for selected Series/Season grouping</option> + <option value="AfterSeason">Always place Specials after the normal episodes</option> + <option value="InBetweenSeasonByAirDate">Use release date to place Specials</option> + <option value="InBetweenSeasonByOtherData">Use the TvDB/TMDB data (if available) to place Specials</option> + <option value="InBetweenSeasonMixed">Use either the TvDB/TMDB data or release date if the data is not available to place Specials</option> + <option value="Excluded">Don't place specials within Seasons</option> + </select> + <div class="fieldDescription selectFieldDescription">Determines how Specials are placed within Seasons. <strong>Warning:</strong> Modifying this setting requires a rebuild (and not just a full-refresh) of any libraries using this plugin — otherwise you <strong>will</strong> have mixed metadata.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> + <span>Mark specials</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add a number to the title of each Special episode</div> + </div> + </fieldset> + <fieldset class="verticalSection verticalSection-extrabottompadding"> + <legend> + <h3>Synchronization Settings</h3> + </legend> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="UpdateWatchedStatus" /> + <span>Enable synchronization</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this will sync-back watched status to Shoko under and after play-back, and sync-back watch status from Shoko on import or refresh.</div> + </div> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="UserSelector">Select user:</label> + <select is="emby-select" id="MovieOrdering" name="MovieOrdering" class="emby-select-withcolor emby-select"> + <option value="root">root</option> + </select> + <div class="fieldDescription selectFieldDescription">Select a user to add or modify a mapping for watch-state synchronization to work.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="UserEnabled" /> + <span>Enable for user</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enable the mapping for the current user</div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="UserUsername" required label="Username:" /> + <div class="fieldDescription">.</div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="UserPassword" label="Password:" /> + <div class="fieldDescription">.</div> + </div> + </fieldset> + <fieldset class="verticalSection verticalSection-extrabottompadding"> + <legend> + <h3>Tag Settings</h3> + </legend> + <div class="checkboxContainer"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="HideArtStyleTags" /> + <span>Hide art style related tags</span> + </label> + </div> + <div class="checkboxContainer"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="HideSourceTags" /> + <span>Hide source related tags</span> + </label> + </div> + <div class="checkboxContainer"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="HideMiscTags" /> + <span>Hide misc info tags that may be useful</span> + </label> + </div> + <div class="checkboxContainer"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="HidePlotTags" /> + <span>Hide potentially plot-spoiling tags</span> + </label> + </div> + <div class="checkboxContainer"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="HideAniDbTags" /> + <span>Hide any miscellaneous tags</span> + </label> + </div> + </fieldset> + <fieldset class="verticalSection verticalSection-extrabottompadding"> + <legend> + <h3>Advanced Settings</h3> + </legend> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="PublicHost" required label="Public Shoko host url:" /> + <div class="fieldDescription">This is the public URL leading to where Shoko is running. It is 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. It should include both the protocol and the ip/dns-name.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="PreferAniDbPoster" /> + <span>Prefer AniDB poster</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this will prefer the poster image from AniDb over other sources for the default Series/Seasons poster</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="AddAniDBId" /> + <span>Add AniDB Id to items</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this add the AniDB id for all supported item types where an id is available.</div> + </div> + </fieldset> <div> <button is="emby-button" type="submit" class="raised button-submit block emby-button"> <span>Save</span> @@ -191,101 +240,102 @@ <h3>Tag Settings</h3> .addEventListener('pageshow', function () { Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { + // Connection settings document.querySelector('#Host').value = config.Host; document.querySelector('#Username').value = config.Username; document.querySelector('#Password').value = config.Password; - document.querySelector('#ApiKey').value = config.ApiKey; - document.querySelector('#UpdateWatchedStatus').checked = config.UpdateWatchedStatus; - document.querySelector('#HideArtStyleTags').checked = config.HideArtStyleTags; - document.querySelector('#HideSourceTags').checked = config.HideSourceTags; - document.querySelector('#HideMiscTags').checked = config.HideMiscTags; - document.querySelector('#HidePlotTags').checked = config.HidePlotTags; - document.querySelector('#HideAniDbTags').checked = config.HideAniDbTags; - document.querySelector('#SynopsisCleanLinks').checked = config.SynopsisCleanLinks; - document.querySelector('#SynopsisCleanMiscLines').checked = config.SynopsisCleanMiscLines; - document.querySelector('#SynopsisRemoveSummary').checked = config.SynopsisRemoveSummary; - document.querySelector('#SynopsisCleanMultiEmptyLines').checked = config.SynopsisCleanMultiEmptyLines; + + // Metadata settings document.querySelector('#TitleMainType').value = config.TitleMainType; document.querySelector('#TitleAlternateType').value = config.TitleAlternateType; document.querySelector('#DescriptionSource').value = config.DescriptionSource; + document.querySelector('#CleanupAniDBDescriptions').checked = config.SynopsisCleanMultiEmptyLines || config.SynopsisCleanLinks; + document.querySelector('#MinimalAniDBDescriptions').checked = config.SynopsisRemoveSummary || config.SynopsisCleanMiscLines; + + // Library settings document.querySelector('#SeriesGrouping').value = config.SeriesGrouping; - document.querySelector('#SeasonOrdering').value = config.SeasonOrdering; - document.querySelector('#SpecialsPlacement').value = config.SpecialsPlacement; document.querySelector('#BoxSetGrouping').value = config.BoxSetGrouping; - document.querySelector('#MovieOrdering').value = config.MovieOrdering; - document.querySelector('#MarkSpecialsWhenGrouped').checked = config.MarkSpecialsWhenGrouped; document.querySelector('#FilterOnLibraryTypes').checked = config.FilterOnLibraryTypes; - document.querySelector('#AddAniDBId').checked = config.AddAniDBId; + document.querySelector('#SpecialsPlacement').value = config.SpecialsPlacement; + document.querySelector('#MarkSpecialsWhenGrouped').checked = config.MarkSpecialsWhenGrouped; + + // Synchronization settings + document.querySelector('#UpdateWatchedStatus').checked = config.UpdateWatchedStatus; + + // Tag settings + document.querySelector('#HideArtStyleTags').checked = config.HideArtStyleTags; + document.querySelector('#HideSourceTags').checked = config.HideSourceTags; + document.querySelector('#HideMiscTags').checked = config.HideMiscTags; + document.querySelector('#HidePlotTags').checked = config.HidePlotTags; + document.querySelector('#HideAniDbTags').checked = config.HideAniDbTags; + + // Advanced settings + document.querySelector('#PublicHost').value = config.PublicHost; document.querySelector('#PreferAniDbPoster').checked = config.PreferAniDbPoster; - if (config.SeriesGrouping === "ShokoGroup") { - document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); - document.querySelector('#MarkSpecialsWhenGroupedItem').removeAttribute("hidden"); - } - else { - document.querySelector('#SeasonOrderingItem').setAttribute("hidden", ""); - document.querySelector('#MarkSpecialsWhenGroupedItem').setAttribute("hidden", ""); - } - if (config.BoxSetGrouping === "ShokoGroup") { - document.querySelector('#MovieOrderingItem').removeAttribute("hidden"); - } - else { - document.querySelector('#MovieOrderingItem').setAttribute("hidden", ""); - } + document.querySelector('#AddAniDBId').checked = config.AddAniDBId; + Dashboard.hideLoadingMsg(); }); }); - document.querySelector('#SeriesGrouping') - .addEventListener('input', function () { - if (document.querySelector('#SeriesGrouping').value === "ShokoGroup") { - document.querySelector('#SeasonOrderingItem').removeAttribute("hidden"); - document.querySelector('#MarkSpecialsWhenGroupedItem').removeAttribute("hidden"); - } - else { - document.querySelector('#SeasonOrderingItem').setAttribute("hidden", ""); - document.querySelector('#MarkSpecialsWhenGroupedItem').setAttribute("hidden", ""); - } - }); - document.querySelector('#BoxSetGrouping') - .addEventListener('input', function () { - if (document.querySelector('#BoxSetGrouping').value === "ShokoGroup") { - document.querySelector('#MovieOrderingItem').removeAttribute("hidden"); - } - else { - document.querySelector('#MovieOrderingItem').setAttribute("hidden", ""); - } - }); document.querySelector('.shokoConfigForm') .addEventListener('submit', function (e) { Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { - config.Host = document.querySelector('#Host').value; - config.Username = document.querySelector('#Username').value; - config.Password = document.querySelector('#Password').value; - config.ApiKey = document.querySelector('#ApiKey').value; - config.UpdateWatchedStatus = document.querySelector('#UpdateWatchedStatus').checked; - config.HideArtStyleTags = document.querySelector('#HideArtStyleTags').checked; - config.HideSourceTags = document.querySelector('#HideSourceTags').checked; - config.HideMiscTags = document.querySelector('#HideMiscTags').checked; - config.HidePlotTags = document.querySelector('#HidePlotTags').checked; - config.HideAniDbTags = document.querySelector('#HideAniDbTags').checked; - config.SynopsisCleanLinks = document.querySelector('#SynopsisCleanLinks').checked; - config.SynopsisCleanMiscLines = document.querySelector('#SynopsisCleanMiscLines').checked; - config.SynopsisRemoveSummary = document.querySelector('#SynopsisRemoveSummary').checked; - config.SynopsisCleanMultiEmptyLines = document.querySelector('#SynopsisCleanMultiEmptyLines').checked; + let host = document.querySelector('#Host').value; + if (host.endsWith("/")) { + host = host.slice(0, -1); + document.querySelector('#Host').value = host; + } + let publicHost = document.querySelector('#PublicHost').value; + if (publicHost.endsWith("/")) { + publicHost = publicHost.slice(0, -1); + document.querySelector('#PublicHost').value = publicHost; + } + + const username = document.querySelector('#Username').value; + const password = document.querySelector('#Password').value; + // Reset the api-key if the username and/or password have changed. + if (config.Username != username || config.Password !== password) + config.ApiKey = ""; + + // Connection settings + config.Host = host; + config.Username = username; + config.Password = password; + + // Metadata settings config.TitleMainType = document.querySelector('#TitleMainType').value; config.TitleAlternateType = document.querySelector('#TitleAlternateType').value; config.DescriptionSource = document.querySelector('#DescriptionSource').value; + config.SynopsisCleanLinks = document.querySelector('#CleanupAniDBDescriptions').checked; + config.SynopsisCleanMultiEmptyLines = document.querySelector('#CleanupAniDBDescriptions').checked; + config.SynopsisCleanMiscLines = document.querySelector('#MinimalAniDBDescriptions').checked; + config.SynopsisRemoveSummary = document.querySelector('#MinimalAniDBDescriptions').checked; + + // Library settings config.SeriesGrouping = document.querySelector('#SeriesGrouping').value; - config.SeasonOrdering = document.querySelector('#SeasonOrdering').value; - config.SpecialsPlacement = document.querySelector('#SpecialsPlacement').value; config.BoxSetGrouping = document.querySelector('#BoxSetGrouping').value; - config.MovieOrdering = document.querySelector('#MovieOrdering').value; - config.MarkSpecialsWhenGrouped = document.querySelector('#MarkSpecialsWhenGrouped').checked; config.FilterOnLibraryTypes = document.querySelector('#FilterOnLibraryTypes').checked; - config.AddAniDBId = document.querySelector('#AddAniDBId').checked; + config.SpecialsPlacement = document.querySelector('#SpecialsPlacement').value; + config.MarkSpecialsWhenGrouped = document.querySelector('#MarkSpecialsWhenGrouped').checked; + + // Synchronization settings + config.UpdateWatchedStatus = document.querySelector('#UpdateWatchedStatus').checked; + + // Tag settings + config.HideArtStyleTags = document.querySelector('#HideArtStyleTags').checked; + config.HideSourceTags = document.querySelector('#HideSourceTags').checked; + config.HideMiscTags = document.querySelector('#HideMiscTags').checked; + config.HidePlotTags = document.querySelector('#HidePlotTags').checked; + config.HideAniDbTags = document.querySelector('#HideAniDbTags').checked; + + // Advanced settings + config.PublicHost = publicHost; config.PreferAniDbPoster = document.querySelector('#PreferAniDbPoster').checked; + config.AddAniDBId = document.querySelector('#AddAniDBId').checked; + ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult); }); e.preventDefault(); diff --git a/Shokofin/ExternalIds.cs b/Shokofin/ExternalIds.cs index 732963ea..7c4141bc 100644 --- a/Shokofin/ExternalIds.cs +++ b/Shokofin/ExternalIds.cs @@ -20,8 +20,8 @@ public string Key public ExternalIdMediaType? Type => null; - public string UrlFormatString - => null; + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyHost}/webui/group/{{0}}"; } public class ShokoSeriesExternalId : IExternalId @@ -38,8 +38,8 @@ public string Key public ExternalIdMediaType? Type => null; - public string UrlFormatString - => null; + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyHost}/webui/series/{{0}}"; } public class ShokoEpisodeExternalId : IExternalId @@ -56,8 +56,8 @@ public string Key public ExternalIdMediaType? Type => null; - public string UrlFormatString - => null; + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyHost}/webui/episode/{{0}}"; } public class ShokoFileExternalId : IExternalId @@ -74,7 +74,7 @@ public string Key public ExternalIdMediaType? Type => null; - public string UrlFormatString + public virtual string UrlFormatString => null; } } \ No newline at end of file diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index 2a112064..73b87d0e 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -143,7 +143,7 @@ private static string SanitizeTextSummary(string summary) summary = Regex.Replace(summary, @"\n(Source|Note|Summary):.*", "", RegexOptions.Singleline); if (config.SynopsisCleanMultiEmptyLines) - summary = Regex.Replace(summary, @"\n\n+", "", RegexOptions.Singleline); + summary = Regex.Replace(summary, @"\n{2,}", "\n", RegexOptions.Singleline); return summary; } From 292db2b1ea846c138bbc74b863a263db35b68734 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 23 Sep 2021 23:25:35 +0200 Subject: [PATCH 0217/1103] Sqush some bugs --- Shokofin/Providers/ExtraMetadataProvider.cs | 59 +++++++++++++-------- Shokofin/Providers/SeasonProvider.cs | 4 +- Shokofin/Utils/OrderingUtil.cs | 4 +- 3 files changed, 42 insertions(+), 25 deletions(-) diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index 676a2579..c6d52a8f 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -381,26 +381,28 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de } seriesInfo = groupInfo.GetSeriesInfoBySeasonNumber(seasonNumber); - if (seriesInfo == null) { - Logger.LogWarning("Unable to find series info for {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, groupInfo.Id); + if (seasonNumber != 0 && seriesInfo == null) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, groupInfo.Id); return; } if (deleted) { - var alternateEpisodes = seasonNumber != groupInfo.SeasonNumberBaseDictionary[seriesInfo]; - season = seasonNumber == 0 ? AddVirtualSeason(0, series) : AddVirtualSeason(seriesInfo, alternateEpisodes, seasonNumber, series); + var offset = seasonNumber - groupInfo.SeasonNumberBaseDictionary[seriesInfo]; + season = seasonNumber == 0 ? AddVirtualSeason(0, series) : AddVirtualSeason(seriesInfo, offset, seasonNumber, series); } } // Provide metadata for other seasons else { seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); if (seriesInfo == null) { - Logger.LogWarning("Unable to find series info. (Series={SeriesId})", seriesId); + Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); return; } - if (deleted) - season = AddVirtualSeason(seasonNumber, series); + if (deleted) { + var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && seriesInfo.TvDB != null; + season = seasonNumber == 1 && (!mergeFriendly) ? AddVirtualSeason(seriesInfo, 0, 1, series) : AddVirtualSeason(seasonNumber, series); + } } // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. @@ -417,7 +419,7 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de if (existingEpisodes.Contains(episodeInfo.Id)) continue; - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + AddVirtualEpisode(groupInfo, sI, episodeInfo, season); } } } @@ -442,11 +444,10 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); } - } - - // We add the extras to the season if we're using Shoko Groups. - if (seriesGrouping) { - AddExtras(season, seriesInfo); + // We add the extras to the season if we're using Shoko Groups. + if (seriesGrouping) { + AddExtras(season, seriesInfo); + } } } @@ -486,7 +487,7 @@ private void UpdateEpisode(Episode episode, string episodeId) var missingSeasonNumbers = allSeasonNumbers.Except(existingSeasons.Keys).ToList(); var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && seriesInfo.TvDB != null; foreach (var seasonNumber in missingSeasonNumbers) { - var season = seasonNumber == 1 && !mergeFriendly ? AddVirtualSeason(seriesInfo, false, 1, series) : AddVirtualSeason(seasonNumber, series); + var season = seasonNumber == 1 && !mergeFriendly ? AddVirtualSeason(seriesInfo, 0, 1, series) : AddVirtualSeason(seasonNumber, series); if (season == null) continue; yield return (seasonNumber, season); @@ -501,8 +502,8 @@ private void UpdateEpisode(Episode episode, string episodeId) continue; if (pair.Value.SpecialsList.Count > 0) hasSpecials = true; - var alternateEpisodes = pair.Key != groupInfo.SeasonNumberBaseDictionary[pair.Value]; - var season = AddVirtualSeason(pair.Value, alternateEpisodes, pair.Key, series); + var offset = pair.Key - groupInfo.SeasonNumberBaseDictionary[pair.Value]; + var season = AddVirtualSeason(pair.Value, offset, pair.Key, series); if (season == null) continue; yield return (pair.Key, season); @@ -568,7 +569,7 @@ private Season AddVirtualSeason(int seasonNumber, Series series) return season; } - private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, bool alternateEpisodes, int seasonNumber, Series series) + private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, int offset, int seasonNumber, Series series) { var seriesPresentationUniqueKey = series.GetPresentationUniqueKey(); if (SeasonExists(seriesPresentationUniqueKey, series.Name, seasonNumber)) @@ -577,9 +578,25 @@ private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, bool alternateEpisod var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(seriesInfo.AniDB.Titles, seriesInfo.Shoko.Name, series.GetPreferredMetadataLanguage()); var sortTitle = $"S{seasonNumber} - {seriesInfo.Shoko.Name}"; - if (alternateEpisodes) { - displayTitle += " (Other Episodes)"; - alternateTitle += " (Other Episodes)"; + if (offset > 0) { + string type = ""; + switch (offset) { + default: + break; + case -1: + case 1: + if (seriesInfo.AlternateEpisodesList.Count > 0) + type = "Alternate Stories"; + else + type = "Other Episodes"; + break; + case -2: + case 2: + type = "Other Episodes"; + break; + } + displayTitle += $" ({type})"; + alternateTitle += $" ({type})"; } Logger.LogInformation("Adding virtual season {SeasonName} entry for {SeriesName}", displayTitle, series.Name); @@ -645,7 +662,7 @@ public void RemoveDuplicateSeasons(Series series, string seriesId) var seasonNumber = season.IndexNumber.Value; if (!seasonNumbers.Add(seasonNumber)) continue; - + RemoveDuplicateSeasons(season, series, seasonNumber, seriesId); } diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index eaaab6ad..c2c80c59 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -95,7 +95,7 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in var group = await ApiManager.GetGroupInfoForSeries(seriesId, filterLibrary); series = group?.GetSeriesInfoBySeasonNumber(seasonNumber); if (group == null || series == null) { - Logger.LogWarning("Unable to find info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, series.Id); + Logger.LogWarning("Unable to find info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); return result; } @@ -107,7 +107,7 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in else { series = await ApiManager.GetSeriesInfo(seriesId); if (series == null) { - Logger.LogWarning("Unable to find info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, series.Id); + Logger.LogWarning("Unable to find info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); return result; } Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Shoko.Name, series.Id); diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index cd0609c1..45c3af1a 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -268,7 +268,7 @@ public static (int?, int?, int?) GetSpecialPlacement(GroupInfo group, SeriesInfo episodeNumber = episode.TvDB.AirsBeforeEpisode; if (!episodeNumber.HasValue) { if (episode.TvDB.AirsAfterSeason.HasValue) { - airsAfterSeasonNumber = episode.TvDB.AirsAfterSeason.Value; + airsAfterSeasonNumber = GetSeasonNumber(group, series, episode); break; } @@ -289,7 +289,7 @@ public static (int?, int?, int?) GetSpecialPlacement(GroupInfo group, SeriesInfo var nextEpisode = series.EpisodeList.FirstOrDefault(e => e.TvDB != null && e.TvDB.Season == seasonNumber && e.TvDB.Number == episodeNumber); if (nextEpisode != null) { airsBeforeEpisodeNumber = GetEpisodeNumber(group, series, nextEpisode); - airsBeforeSeasonNumber = seasonNumber; + airsBeforeSeasonNumber = GetSeasonNumber(group, series, episode); } else if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirdate; From 3d8273deaa63334ce89d56f911d7cac468b8abe6 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 23 Sep 2021 23:34:18 +0200 Subject: [PATCH 0218/1103] Fix lookup usage --- Shokofin/IdLookup.cs | 6 +++--- Shokofin/Providers/ExtraMetadataProvider.cs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index 4c34cdb7..a6d11318 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -179,7 +179,7 @@ public bool TryGetSeriesIdForMovie(Movie movie, out string seriesId) return true; } - if (ApiManager.TryGetEpisodeIdForPath(movie.Path, out var episodeId) && ApiManager.TryGetSeriesIdForEpisodeId(episodeId, out seriesId)) { + if (TryGetEpisodeIdForPath(movie.Path, out var episodeId) && TryGetSeriesIdForEpisodeId(episodeId, out seriesId)) { return true; } @@ -192,8 +192,8 @@ public bool TryGetSeriesIdForBoxSet(BoxSet boxSet, out string seriesId) return true; } - if (ApiManager.TryGetSeriesIdForPath(boxSet.Path, out seriesId)) { - if (ApiManager.TryGetGroupIdForSeriesId(seriesId, out var groupId)) { + if (TryGetSeriesIdForPath(boxSet.Path, out seriesId)) { + if (TryGetGroupIdForSeriesId(seriesId, out var groupId)) { var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; var groupInfo = ApiManager.GetGroupInfoSync(groupId, filterByType); seriesId = groupInfo.DefaultSeries.Id; diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index c6d52a8f..ce7ddd6c 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -782,7 +782,7 @@ private void AddExtras(Folder parent, Info.SeriesInfo seriesInfo) var needsUpdate = false; var extraIds = new List<Guid>(); foreach (var episodeInfo in seriesInfo.ExtrasList) { - if (!ApiManager.TryGetEpisodePathForId(episodeInfo.Id, out var episodePath)) + if (!Lookup.TryGetPathForEpisodeId(episodeInfo.Id, out var episodePath)) continue; switch (episodeInfo.ExtraType) { From e9267564907a0b3cefabe361937231353804f414 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 23 Sep 2021 23:55:34 +0200 Subject: [PATCH 0219/1103] Convert the client to a singleton and add logging Also add some optimisations to the getfileinfobypath and getgroupinfobypath methods in the manager --- .../API/{ShokoAPI.cs => ShokoAPIClient.cs} | 74 ++++---- Shokofin/API/ShokoAPIManager.cs | 171 ++++++++++++------ Shokofin/LibraryScanner.cs | 1 - Shokofin/PluginServiceRegistrator.cs | 1 + Shokofin/Providers/SeriesProvider.cs | 7 +- Shokofin/Scrobbler.cs | 7 +- 6 files changed, 162 insertions(+), 99 deletions(-) rename Shokofin/API/{ShokoAPI.cs => ShokoAPIClient.cs} (74%) diff --git a/Shokofin/API/ShokoAPI.cs b/Shokofin/API/ShokoAPIClient.cs similarity index 74% rename from Shokofin/API/ShokoAPI.cs rename to Shokofin/API/ShokoAPIClient.cs index 9cc9a68f..7fb89b61 100644 --- a/Shokofin/API/ShokoAPI.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -6,6 +6,7 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Shokofin.API.Models; using File = Shokofin.API.Models.File; @@ -14,31 +15,36 @@ namespace Shokofin.API /// <summary> /// All API calls to Shoko needs to go through this gateway. /// </summary> - internal class ShokoAPI + public class ShokoAPIClient { - private static readonly HttpClient _httpClient; + private readonly HttpClient _httpClient; - static ShokoAPI() + private readonly ILogger<ShokoAPIClient> Logger; + + public ShokoAPIClient(ILogger<ShokoAPIClient> logger) { + Logger = logger; _httpClient = new HttpClient(); _httpClient.DefaultRequestHeaders.Add("apikey", Plugin.Instance.Configuration.ApiKey); } - private static async Task<Stream> CallApi(string url, string requestType = "GET") + private async Task<Stream> CallApi(string url, string requestType = "GET", string apiKey = null) { - if (!(await CheckApiKey())) return null; + if (!CheckApiKey()) return null; try { var apiBaseUrl = Plugin.Instance.Configuration.Host; + url = $"{apiBaseUrl}{url}"; + Logger.LogTrace("{HTTPVerb} {Url}", requestType, url); switch (requestType) { case "PATCH": case "POST": - var response = await _httpClient.PostAsync($"{apiBaseUrl}{url}", new StringContent("")); + var response = await _httpClient.PostAsync(url, new StringContent("")); return response.StatusCode == HttpStatusCode.OK ? response.Content.ReadAsStreamAsync().Result : null; default: - return await _httpClient.GetStreamAsync($"{apiBaseUrl}{url}"); + return await _httpClient.GetStreamAsync(url); } } catch (HttpRequestException) @@ -47,11 +53,11 @@ private static async Task<Stream> CallApi(string url, string requestType = "GET" } } - private static async Task<bool> CheckApiKey() + private bool CheckApiKey() { if (!string.IsNullOrEmpty(Plugin.Instance.Configuration.ApiKey)) return true; - var apikey = (await GetApiKey())?.apikey; + var apikey = GetApiKey().GetAwaiter().GetResult(); if (string.IsNullOrEmpty(apikey)) return false; Plugin.Instance.Configuration.ApiKey = apikey; _httpClient.DefaultRequestHeaders.Clear(); @@ -59,149 +65,149 @@ private static async Task<bool> CheckApiKey() return true; } - private static async Task<ApiKey> GetApiKey() + private async Task<string> GetApiKey() { var postData = JsonSerializer.Serialize(new Dictionary<string, string> { {"user", Plugin.Instance.Configuration.Username}, {"pass", Plugin.Instance.Configuration.Password}, - {"device", "Shoko Jellyfin Plugin"} + {"device", "Shoko Jellyfin Plugin (Shokofin)"}, }); var apiBaseUrl = Plugin.Instance.Configuration.Host; var response = await _httpClient.PostAsync($"{apiBaseUrl}/api/auth", new StringContent(postData, Encoding.UTF8, "application/json")); if (response.StatusCode == HttpStatusCode.OK) - return await JsonSerializer.DeserializeAsync<ApiKey>(response.Content.ReadAsStreamAsync().Result); + return (await JsonSerializer.DeserializeAsync<ApiKey>(response.Content.ReadAsStreamAsync().Result))?.apikey ?? null; return null; } - public static async Task<Episode> GetEpisode(string id) + public async Task<Episode> GetEpisode(string id) { var responseStream = await CallApi($"/api/v3/Episode/{id}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<Episode>(responseStream) : null; } - public static async Task<List<Episode>> GetEpisodesFromSeries(string seriesId) + public async Task<List<Episode>> GetEpisodesFromSeries(string seriesId) { var responseStream = await CallApi($"/api/v3/Series/{seriesId}/Episode?includeMissing=true"); return responseStream != null ? await JsonSerializer.DeserializeAsync<List<Episode>>(responseStream) : null; } - public static async Task<List<Episode>> GetEpisodeFromFile(string id) + public async Task<List<Episode>> GetEpisodeFromFile(string id) { var responseStream = await CallApi($"/api/v3/File/{id}/Episode"); return responseStream != null ? await JsonSerializer.DeserializeAsync<List<Episode>>(responseStream) : null; } - public static async Task<Episode.AniDB> GetEpisodeAniDb(string id) + public async Task<Episode.AniDB> GetEpisodeAniDb(string id) { var responseStream = await CallApi($"/api/v3/Episode/{id}/AniDB"); return responseStream != null ? await JsonSerializer.DeserializeAsync<Episode.AniDB>(responseStream) : null; } - public static async Task<IEnumerable<Episode.TvDB>> GetEpisodeTvDb(string id) + public async Task<IEnumerable<Episode.TvDB>> GetEpisodeTvDb(string id) { var responseStream = await CallApi($"/api/v3/Episode/{id}/TvDB"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Episode.TvDB>>(responseStream) : null; } - public static async Task<File> GetFile(string id) + public async Task<File> GetFile(string id) { var responseStream = await CallApi($"/api/v3/File/{id}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<File>(responseStream) : null; } - public static async Task<bool> ScrobbleFile(string id, bool watched, long? progress) + public async Task<bool> ScrobbleFile(string id, bool watched, long? progress) { var responseStream = await CallApi($"/api/v3/File/{id}/Scrobble?watched={watched}&resumePosition={progress ?? 0}", "PATCH"); return responseStream != null; } - public static async Task<IEnumerable<File.FileDetailed>> GetFileByPath(string filename) + public async Task<IEnumerable<File.FileDetailed>> GetFileByPath(string filename) { var responseStream = await CallApi($"/api/v3/File/PathEndsWith/{Uri.EscapeDataString(filename)}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<File.FileDetailed>>(responseStream) : null; } - public static async Task<Series> GetSeries(string id) + public async Task<Series> GetSeries(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<Series>(responseStream) : null; } - public static async Task<Series> GetSeriesFromEpisode(string id) + public async Task<Series> GetSeriesFromEpisode(string id) { var responseStream = await CallApi($"/api/v3/Episode/{id}/Series"); return responseStream != null ? await JsonSerializer.DeserializeAsync<Series>(responseStream) : null; } - public static async Task<List<Series>> GetSeriesInGroup(string id) + public async Task<List<Series>> GetSeriesInGroup(string id) { var responseStream = await CallApi($"/api/v3/Filter/0/Group/{id}/Series"); return responseStream != null ? await JsonSerializer.DeserializeAsync<List<Series>>(responseStream) : null; } - public static async Task<Series.AniDB> GetSeriesAniDB(string id) + public async Task<Series.AniDB> GetSeriesAniDB(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/AniDB"); return responseStream != null ? await JsonSerializer.DeserializeAsync<Series.AniDB>(responseStream) : null; } - public static async Task<IEnumerable<Series.TvDB>> GetSeriesTvDB(string id) + public async Task<IEnumerable<Series.TvDB>> GetSeriesTvDB(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/TvDB"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Series.TvDB>>(responseStream) : null; } - public static async Task<IEnumerable<Role>> GetSeriesCast(string id) + public async Task<IEnumerable<Role>> GetSeriesCast(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/Cast"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Role>>(responseStream) : null; } - public static async Task<IEnumerable<Role>> GetSeriesCast(string id, Role.CreatorRoleType role) + public async Task<IEnumerable<Role>> GetSeriesCast(string id, Role.CreatorRoleType role) { var responseStream = await CallApi($"/api/v3/Series/{id}/Cast?roleType={role.ToString()}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Role>>(responseStream) : null; } - public static async Task<Images> GetSeriesImages(string id) + public async Task<Images> GetSeriesImages(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/Images"); return responseStream != null ? await JsonSerializer.DeserializeAsync<Images>(responseStream) : null; } - public static async Task<IEnumerable<Series>> GetSeriesPathEndsWith(string dirname) + public async Task<IEnumerable<Series>> GetSeriesPathEndsWith(string dirname) { var responseStream = await CallApi($"/api/v3/Series/PathEndsWith/{Uri.EscapeDataString(dirname)}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Series>>(responseStream) : null; } - public static async Task<IEnumerable<Tag>> GetSeriesTags(string id, int filter = 0) + public async Task<IEnumerable<Tag>> GetSeriesTags(string id, int filter = 0) { var responseStream = await CallApi($"/api/v3/Series/{id}/Tags/{filter}?excludeDescriptions=true"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<Tag>>(responseStream) : null; } - public static async Task<Group> GetGroup(string id) + public async Task<Group> GetGroup(string id) { var responseStream = await CallApi($"/api/v3/Group/{id}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<Group>(responseStream) : null; } - public static async Task<Group> GetGroupFromSeries(string id) + public async Task<Group> GetGroupFromSeries(string id) { var responseStream = await CallApi($"/api/v3/Series/{id}/Group"); return responseStream != null ? await JsonSerializer.DeserializeAsync<Group>(responseStream) : null; } - public static async Task<IEnumerable<SeriesSearchResult>> SeriesSearch(string query) + public async Task<IEnumerable<SeriesSearchResult>> SeriesSearch(string query) { var responseStream = await CallApi($"/api/v3/Series/Search/{Uri.EscapeDataString(query)}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<SeriesSearchResult>>(responseStream) : null; } - public static async Task<IEnumerable<SeriesSearchResult>> SeriesStartsWith(string query) + public async Task<IEnumerable<SeriesSearchResult>> SeriesStartsWith(string query) { var responseStream = await CallApi($"/api/v3/Series/StartsWith/{Uri.EscapeDataString(query)}"); return responseStream != null ? await JsonSerializer.DeserializeAsync<IEnumerable<SeriesSearchResult>>(responseStream) : null; diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index f82138e2..d2c39c1c 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -23,26 +23,33 @@ public class ShokoAPIManager private readonly ILibraryManager LibraryManager; - private static readonly List<Folder> MediaFolderList = new List<Folder>(); + private readonly ShokoAPIClient APIClient; - private static readonly ConcurrentDictionary<string, string> SeriesPathToIdDictionary = new ConcurrentDictionary<string, string>(); + private readonly List<Folder> MediaFolderList = new List<Folder>(); - private static readonly ConcurrentDictionary<string, string> SeriesIdToPathDictionary = new ConcurrentDictionary<string, string>(); + private readonly ConcurrentDictionary<string, string> SeriesPathToIdDictionary = new ConcurrentDictionary<string, string>(); - private static readonly ConcurrentDictionary<string, string> SeriesIdToGroupIdDictionary = new ConcurrentDictionary<string, string>(); + private readonly ConcurrentDictionary<string, string> SeriesIdToPathDictionary = new ConcurrentDictionary<string, string>(); - private static readonly ConcurrentDictionary<string, string> EpisodePathToEpisodeIdDictionary = new ConcurrentDictionary<string, string>(); + private readonly ConcurrentDictionary<string, string> SeriesIdToGroupIdDictionary = new ConcurrentDictionary<string, string>(); - private static readonly ConcurrentDictionary<string, string> EpisodeIdToEpisodePathDictionary = new ConcurrentDictionary<string, string>(); + private readonly ConcurrentDictionary<string, string> EpisodePathToEpisodeIdDictionary = new ConcurrentDictionary<string, string>(); - private static readonly ConcurrentDictionary<string, string> EpisodeIdToSeriesIdDictionary = new ConcurrentDictionary<string, string>(); + private readonly ConcurrentDictionary<string, string> EpisodeIdToEpisodePathDictionary = new ConcurrentDictionary<string, string>(); - public static readonly ConcurrentDictionary<string, HashSet<string>> LockedIdDictionary = new ConcurrentDictionary<string, HashSet<string>>(); + private readonly ConcurrentDictionary<string, string> EpisodeIdToSeriesIdDictionary = new ConcurrentDictionary<string, string>(); - public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ILibraryManager libraryManager) + private readonly ConcurrentDictionary<string, (string, int, string, string)> FilePathToFileIdAndEpisodeCountDictionary = new ConcurrentDictionary<string, (string, int, string, string)>(); + + private readonly ConcurrentDictionary<string, string> FileIdToEpisodeIdDictionary = new ConcurrentDictionary<string, string>(); + + private readonly ConcurrentDictionary<string, HashSet<string>> LockedIdDictionary = new ConcurrentDictionary<string, HashSet<string>>(); + + public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ILibraryManager libraryManager, ShokoAPIClient apiClient) { Logger = logger; LibraryManager = libraryManager; + APIClient = apiClient; } private static IMemoryCache DataCache = new MemoryCache(new MemoryCacheOptions() { @@ -124,6 +131,8 @@ public void Clear() DataCache.Dispose(); LockedIdDictionary.Clear(); MediaFolderList.Clear(); + FileIdToEpisodeIdDictionary.Clear(); + FilePathToFileIdAndEpisodeCountDictionary.Clear(); EpisodeIdToSeriesIdDictionary.Clear(); EpisodePathToEpisodeIdDictionary.Clear(); EpisodeIdToEpisodePathDictionary.Clear(); @@ -141,7 +150,7 @@ public void Clear() public async Task<IEnumerable<PersonInfo>> GetPeople(string seriesId) { var list = new List<PersonInfo>(); - var roles = await ShokoAPI.GetSeriesCast(seriesId); + var roles = await APIClient.GetSeriesCast(seriesId); foreach (var role in roles) { switch (role.RoleName) { @@ -168,7 +177,7 @@ public async Task<IEnumerable<PersonInfo>> GetPeople(string seriesId) private async Task<string[]> GetTags(string seriesId) { - return (await ShokoAPI.GetSeriesTags(seriesId, GetTagFilter()))?.Select(SelectTagName).ToArray() ?? new string[0]; + return (await APIClient.GetSeriesTags(seriesId, GetTagFilter()))?.Select(SelectTagName).ToArray() ?? new string[0]; } /// <summary> @@ -195,7 +204,7 @@ private int GetTagFilter() public async Task<string[]> GetGenresForSeries(string seriesId) { // The following magic number is the filter value to allow only genres in the returned list. - return (await ShokoAPI.GetSeriesTags(seriesId, -2147483520))?.Select(SelectTagName).ToArray() ?? new string[0]; + return (await APIClient.GetSeriesTags(seriesId, -2147483520))?.Select(SelectTagName).ToArray() ?? new string[0]; } private string SelectTagName(Tag tag) @@ -208,7 +217,7 @@ private string SelectTagName(Tag tag) public async Task<string[]> GetStudiosForSeries(string seriesId) { - var cast = await ShokoAPI.GetSeriesCast(seriesId, Role.CreatorRoleType.Studio); + var cast = await APIClient.GetSeriesCast(seriesId, Role.CreatorRoleType.Studio); // * NOTE: Shoko Server version <4.1.2 don't support filtered cast, nor other role types besides Role.CreatorRoleType.Seiyuu. if (cast.Any(p => p.RoleName != Role.CreatorRoleType.Studio)) return new string[0]; @@ -220,14 +229,24 @@ public async Task<string[]> GetStudiosForSeries(string seriesId) public (FileInfo, EpisodeInfo, SeriesInfo, GroupInfo) GetFileInfoByPathSync(string path, Ordering.GroupFilterType? filterGroupByType) { + if (FilePathToFileIdAndEpisodeCountDictionary.ContainsKey(path)) { + var (fileId, extraEpisodesCount, episodeId, seriesId) = FilePathToFileIdAndEpisodeCountDictionary[path]; + return (GetFileInfoSync(fileId, extraEpisodesCount), GetEpisodeInfoSync(episodeId), GetSeriesInfoSync(seriesId), filterGroupByType.HasValue ? GetGroupInfoForSeriesSync(seriesId, filterGroupByType.Value) : null); + } + return GetFileInfoByPath(path, filterGroupByType).GetAwaiter().GetResult(); } public async Task<(FileInfo, EpisodeInfo, SeriesInfo, GroupInfo)> GetFileInfoByPath(string path, Ordering.GroupFilterType? filterGroupByType) { + if (FilePathToFileIdAndEpisodeCountDictionary.ContainsKey(path)) { + var (fI, eC, eI, sI) = FilePathToFileIdAndEpisodeCountDictionary[path]; + return (GetFileInfoSync(fI, eC), GetEpisodeInfoSync(eI), GetSeriesInfoSync(sI), filterGroupByType.HasValue ? GetGroupInfoForSeriesSync(sI, filterGroupByType.Value) : null); + } + var partialPath = StripMediaFolder(path); Logger.LogDebug("Looking for file matching {Path}", partialPath); - var result = await ShokoAPI.GetFileByPath(partialPath); + var result = await APIClient.GetFileByPath(partialPath); var file = result?.FirstOrDefault(); if (file == null) @@ -255,30 +274,59 @@ public async Task<string[]> GetStudiosForSeries(string seriesId) if (episodeInfo == null) return (null, null, null, null); - var fileInfo = CreateFileInfo(file, file.ID.ToString(), series?.EpisodeIDs?.Count ?? 0); + var fileId = file.ID.ToString(); + var episodeCount = series?.EpisodeIDs?.Count ?? 0; + var fileInfo = CreateFileInfo(file, fileId, episodeCount); + // Add pointers for faster lookup. + EpisodePathToEpisodeIdDictionary.TryAdd(path, episodeId); + EpisodeIdToEpisodePathDictionary.TryAdd(episodeId, path); + FilePathToFileIdAndEpisodeCountDictionary.TryAdd(path, (fileId, episodeCount, episodeId, seriesId)); return (fileInfo, episodeInfo, seriesInfo, groupInfo); } - public async Task<FileInfo> GetFileInfo(string fileId) + public FileInfo GetFileInfoSync(string fileId, int episodeCount = 0) { - var file = await ShokoAPI.GetFile(fileId); - if (file == null) + if (string.IsNullOrEmpty(fileId)) + return null; + + var cacheKey = $"file:{fileId}:{episodeCount}"; + FileInfo info = null; + if (DataCache.TryGetValue<FileInfo>(cacheKey, out info)) + return info; + + var file = APIClient.GetFile(fileId).GetAwaiter().GetResult(); + return CreateFileInfo(file, fileId, episodeCount); + } + + public async Task<FileInfo> GetFileInfo(string fileId, int episodeCount = 0) + { + if (string.IsNullOrEmpty(fileId)) return null; - return CreateFileInfo(file); + + var cacheKey = $"file:{fileId}:{episodeCount}"; + FileInfo info = null; + if (DataCache.TryGetValue<FileInfo>(cacheKey, out info)) + return info; + + var file = await APIClient.GetFile(fileId); + return CreateFileInfo(file, fileId, episodeCount); } private FileInfo CreateFileInfo(File file, string fileId = null, int episodeCount = 0) { if (file == null) return null; + if (string.IsNullOrEmpty(fileId)) fileId = file.ID.ToString(); + var cacheKey = $"file:{fileId}:{episodeCount}"; FileInfo info = null; if (DataCache.TryGetValue<FileInfo>(cacheKey, out info)) return info; - Logger.LogDebug("Creating info object for file. (File={FileId})", fileId); + + Logger.LogTrace("Creating info object for file. (File={FileId})", fileId); info = new FileInfo { Id = fileId, @@ -307,7 +355,7 @@ public async Task<EpisodeInfo> GetEpisodeInfo(string episodeId) return null; if (DataCache.TryGetValue<EpisodeInfo>($"episode:{episodeId}", out var info)) return info; - var episode = await ShokoAPI.GetEpisode(episodeId); + var episode = await APIClient.GetEpisode(episodeId); return await CreateEpisodeInfo(episode, episodeId); } @@ -321,26 +369,20 @@ private async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string episod EpisodeInfo info = null; if (DataCache.TryGetValue<EpisodeInfo>(cacheKey, out info)) return info; - Logger.LogDebug("Creating info object for episode {EpisodeName}. (Episode={EpisodeId})", episode.Name, episodeId); - var aniDB = (await ShokoAPI.GetEpisodeAniDb(episodeId)); + Logger.LogTrace("Creating info object for episode {EpisodeName}. (Episode={EpisodeId})", episode.Name, episodeId); + var aniDB = (await APIClient.GetEpisodeAniDb(episodeId)); info = new EpisodeInfo { Id = episodeId, ExtraType = Ordering.GetExtraType(aniDB), - Shoko = (await ShokoAPI.GetEpisode(episodeId)), + Shoko = episode, AniDB = aniDB, - TvDB = ((await ShokoAPI.GetEpisodeTvDb(episodeId))?.FirstOrDefault()), + TvDB = ((await APIClient.GetEpisodeTvDb(episodeId))?.FirstOrDefault()), }; DataCache.Set<EpisodeInfo>(cacheKey, info, DefaultTimeSpan); return info; } - public void MarkEpisodeAsFound(string episodeId, string fullPath) - { - EpisodePathToEpisodeIdDictionary.TryAdd(fullPath, episodeId); - EpisodeIdToEpisodePathDictionary.TryAdd(episodeId, fullPath); - } - public bool TryGetEpisodeIdForPath(string path, out string episodeId) { if (string.IsNullOrEmpty(path)) { @@ -384,26 +426,22 @@ public async Task<SeriesInfo> GetSeriesInfoByPath(string path) var partialPath = StripMediaFolder(path); Logger.LogDebug("Looking for series matching {Path}", partialPath); string seriesId; - if (SeriesPathToIdDictionary.ContainsKey(path)) - { - seriesId = SeriesPathToIdDictionary[path]; - } - else + if (!SeriesPathToIdDictionary.TryGetValue(path, out seriesId)) { - var result = await ShokoAPI.GetSeriesPathEndsWith(partialPath); + var result = await APIClient.GetSeriesPathEndsWith(partialPath); seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); + if (string.IsNullOrEmpty(seriesId)) + return null; + SeriesPathToIdDictionary[path] = seriesId; SeriesIdToPathDictionary.TryAdd(seriesId, path); } - if (string.IsNullOrEmpty(seriesId)) - return null; - if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) return info; - var series = await ShokoAPI.GetSeries(seriesId); + var series = await APIClient.GetSeries(seriesId); return await CreateSeriesInfo(series, seriesId); } @@ -420,7 +458,7 @@ public SeriesInfo GetSeriesInfoSync(string seriesId) return null; if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) return info; - var series = ShokoAPI.GetSeries(seriesId).GetAwaiter().GetResult(); + var series = APIClient.GetSeries(seriesId).GetAwaiter().GetResult(); return CreateSeriesInfo(series, seriesId).GetAwaiter().GetResult(); } @@ -430,7 +468,7 @@ public async Task<SeriesInfo> GetSeriesInfo(string seriesId) return null; if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) return info; - var series = await ShokoAPI.GetSeries(seriesId); + var series = await APIClient.GetSeries(seriesId); return await CreateSeriesInfo(series, seriesId); } @@ -454,7 +492,7 @@ public async Task<SeriesInfo> GetSeriesInfoForEpisode(string episodeId) seriesId = EpisodeIdToSeriesIdDictionary[episodeId]; } else { - var group = await ShokoAPI.GetGroupFromSeries(episodeId); + var group = await APIClient.GetGroupFromSeries(episodeId); if (group == null) return null; seriesId = group.IDs.ID.ToString(); @@ -498,9 +536,9 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = var cacheKey = $"series:{seriesId}"; if (DataCache.TryGetValue<SeriesInfo>(cacheKey, out info)) return info; - Logger.LogDebug("Creating info object for series {SeriesName}. (Series={SeriesId})", series.Name, seriesId); + Logger.LogTrace("Creating info object for series {SeriesName}. (Series={SeriesId})", series.Name, seriesId); - var aniDb = await ShokoAPI.GetSeriesAniDB(seriesId); + var aniDb = await APIClient.GetSeriesAniDB(seriesId); var tvDbId = series.IDs.TvDB?.FirstOrDefault(); var tags = await GetTags(seriesId); var genres = await GetGenresForSeries(seriesId); @@ -513,7 +551,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = var othersList = new List<EpisodeInfo>(); // The episode list is ordered by air date - var allEpisodesList = ShokoAPI.GetEpisodesFromSeries(seriesId) + var allEpisodesList = APIClient.GetEpisodesFromSeries(seriesId) .ContinueWith(task => Task.WhenAll(task.Result.Select(e => CreateEpisodeInfo(e)))) .Unwrap() .GetAwaiter() @@ -562,7 +600,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = Shoko = series, AniDB = aniDb, TvDBId = tvDbId != 0 ? tvDbId.ToString() : null, - TvDB = tvDbId != 0 ? (await ShokoAPI.GetSeriesTvDB(seriesId)).FirstOrDefault() : null, + TvDB = tvDbId != 0 ? (await APIClient.GetSeriesTvDB(seriesId)).FirstOrDefault() : null, Tags = tags, Genres = genres, Studios = studios, @@ -591,17 +629,30 @@ public async Task<GroupInfo> GetGroupInfoByPath(string path, Ordering.GroupFilte { var partialPath = StripMediaFolder(path); Logger.LogDebug("Looking for group matching {Path}", partialPath); - var result = await ShokoAPI.GetSeriesPathEndsWith(partialPath); - var seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); - if (string.IsNullOrEmpty(seriesId)) - return null; + string seriesId; + if (SeriesPathToIdDictionary.TryGetValue(path, out seriesId)) + { + if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { + if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) + return info; - var groupInfo = await GetGroupInfoForSeries(seriesId, filterByType); - if (groupInfo == null) - return null; + return await GetGroupInfo(groupId, filterByType); + } + } + else + { + var result = await APIClient.GetSeriesPathEndsWith(partialPath); + seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); - return groupInfo; + if (string.IsNullOrEmpty(seriesId)) + return null; + + SeriesPathToIdDictionary[path] = seriesId; + SeriesIdToPathDictionary.TryAdd(seriesId, path); + } + + return await GetGroupInfoForSeries(seriesId, filterByType); } public GroupInfo GetGroupInfoSync(string groupId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) @@ -620,7 +671,7 @@ public async Task<GroupInfo> GetGroupInfo(string groupId, Ordering.GroupFilterTy if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) return info; - var group = await ShokoAPI.GetGroup(groupId); + var group = await APIClient.GetGroup(groupId); return await CreateGroupInfo(group, groupId, filterByType); } @@ -645,7 +696,7 @@ public async Task<GroupInfo> GetGroupInfoForSeries(string seriesId, Ordering.Gro return null; if (!SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { - var group = await ShokoAPI.GetGroupFromSeries(seriesId); + var group = await APIClient.GetGroupFromSeries(seriesId); if (group == null) return null; @@ -667,9 +718,9 @@ private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Order GroupInfo groupInfo = null; if (DataCache.TryGetValue<GroupInfo>(cacheKey, out groupInfo)) return groupInfo; - Logger.LogDebug("Creating info object for group {GroupName}. (Group={GroupId})", group.Name, groupId); + Logger.LogTrace("Creating info object for group {GroupName}. (Group={GroupId})", group.Name, groupId); - var seriesList = (await ShokoAPI.GetSeriesInGroup(groupId) + var seriesList = (await APIClient.GetSeriesInGroup(groupId) .ContinueWith(task => Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s)))) .Unwrap()) .Where(s => s != null) diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index 921c8466..b103337d 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -117,7 +117,6 @@ private bool ScanFile(string partialPath, string fullPath) return false; } - ApiManager.MarkEpisodeAsFound(episode.Id, fullPath); Logger.LogInformation("Found episode for {SeriesName} (Series={SeriesId},Episode={EpisodeId},File={FileId})", series.Shoko.Name, series.Id, episode.Id, file.Id); // We're going to post process this file later, but we don't want to include it in our library for now. diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index 423e772c..9e963387 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -10,6 +10,7 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator /// <inheritdoc /> public void RegisterServices(IServiceCollection serviceCollection) { + serviceCollection.AddSingleton<Shokofin.API.ShokoAPIClient>(); serviceCollection.AddSingleton<Shokofin.API.ShokoAPIManager>(); serviceCollection.AddSingleton<IIdLookup, IdLookup>(); } diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 5a3c31df..d615d603 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -23,14 +23,17 @@ public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> private readonly ILogger<SeriesProvider> Logger; + private readonly ShokoAPIClient ApiClient; + private readonly ShokoAPIManager ApiManager; private readonly IFileSystem FileSystem; - public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager, IFileSystem fileSystem) + public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIClient apiClient, ShokoAPIManager apiManager, IFileSystem fileSystem) { Logger = logger; HttpClientFactory = httpClientFactory; + ApiClient = apiClient; ApiManager = apiManager; FileSystem = fileSystem; } @@ -182,7 +185,7 @@ public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo i { try { var results = new List<RemoteSearchResult>(); - var searchResults = await ShokoAPI.SeriesSearch(info.Name).ContinueWith((e) => e.Result.ToList()); + var searchResults = await ApiClient.SeriesSearch(info.Name).ContinueWith((e) => e.Result.ToList()); Logger.LogInformation($"Series search returned {searchResults.Count} results."); foreach (var series in searchResults) { diff --git a/Shokofin/Scrobbler.cs b/Shokofin/Scrobbler.cs index e1a61cf0..d529cbac 100644 --- a/Shokofin/Scrobbler.cs +++ b/Shokofin/Scrobbler.cs @@ -14,10 +14,13 @@ public class Scrobbler : IServerEntryPoint private readonly ILogger<Scrobbler> Logger; - public Scrobbler(ISessionManager sessionManager, ILogger<Scrobbler> logger) + private readonly ShokoAPIClient APIClient; + + public Scrobbler(ISessionManager sessionManager, ILogger<Scrobbler> logger, ShokoAPIClient apiClient) { SessionManager = sessionManager; Logger = logger; + APIClient = apiClient; } public Task RunAsync() @@ -43,7 +46,7 @@ private async void OnPlaybackStopped(object sender, PlaybackStopEventArgs e) var watched = e.PlayedToCompletion; var resumePosition = e.PlaybackPositionTicks ?? 0; Logger.LogInformation("Playback was stopped. Syncing watch state of file back to Shoko. (File={FileId},Watched={WatchState},ResumePosition={ResumePosition})", fileId, watched, resumePosition); - var result = await ShokoAPI.ScrobbleFile(fileId, watched, resumePosition); + var result = await APIClient.ScrobbleFile(fileId, watched, resumePosition); if (result) Logger.LogInformation("File marked as watched! (File={FileId})", fileId); else From e8c65fe79ed031c96bf4e622e29f8a1ea7aad934 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 23 Sep 2021 23:55:45 +0200 Subject: [PATCH 0220/1103] to-be-merged-with-config --- Shokofin/Configuration/configPage.html | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 3cb1a3c8..93c40767 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -143,25 +143,25 @@ <h3>Synchronization Settings</h3> </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="UserSelector">Select user:</label> - <select is="emby-select" id="MovieOrdering" name="MovieOrdering" class="emby-select-withcolor emby-select"> - <option value="root">root</option> + <select is="emby-select" id="MovieOrdering" name="MovieOrdering" class="emby-select-withcolor emby-select" disabled> + <option value="root">WIP, non-functional atm</option> </select> <div class="fieldDescription selectFieldDescription">Select a user to add or modify a mapping for watch-state synchronization to work.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="UserEnabled" /> + <input is="emby-checkbox" type="checkbox" id="UserEnabled" disabled /> <span>Enable for user</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enable the mapping for the current user</div> + <div class="fieldDescription checkboxFieldDescription">Enable user-mapping for the currently selected user</div> </div> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="UserUsername" required label="Username:" /> - <div class="fieldDescription">.</div> + <input is="emby-input" type="text" id="UserUsername" label="Username:" disabled /> + <div class="fieldDescription">The username of the account to synchronize with the currently selected user.</div> </div> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="UserPassword" label="Password:" /> - <div class="fieldDescription">.</div> + <input is="emby-input" type="text" id="UserPassword" label="Password:" disabled /> + <div class="fieldDescription">The password for account. It can be empty.</div> </div> </fieldset> <fieldset class="verticalSection verticalSection-extrabottompadding"> @@ -204,7 +204,7 @@ <h3>Tag Settings</h3> <h3>Advanced Settings</h3> </legend> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="PublicHost" required label="Public Shoko host url:" /> + <input is="emby-input" type="text" id="PublicHost" label="Public Shoko host url:" /> <div class="fieldDescription">This is the public URL leading to where Shoko is running. It is 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. It should include both the protocol and the ip/dns-name.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> From 968482b325a71cfd3d25b583810b4638c06c53a6 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 23 Sep 2021 21:58:04 +0000 Subject: [PATCH 0221/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index e5307c15..6c88b0dc 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.28", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.28/shokofin_1.5.0.28.zip", + "checksum": "76f2086a5750fc3fe6b9e25b5996d572", + "timestamp": "2021-09-23T21:58:03Z" + }, { "version": "1.5.0.27", "changelog": "NA", From 92776fd368df135474b9b1fe5812b7f5a85e7330 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Fri, 24 Sep 2021 03:40:42 +0530 Subject: [PATCH 0222/1103] Update configPage.html --- Shokofin/Configuration/configPage.html | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 93c40767..ab67c86f 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -19,7 +19,7 @@ <h2 class="sectionTitle">Shoko</h2> <h3>Connection Settings</h3> </legend> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="Host" required label="Shoko host url:" /> + <input is="emby-input" type="text" id="Host" required label="Shoko host URL:" /> <div class="fieldDescription">This is the URL leading to where Shoko is running. It should include both the protocol and the ip/dns-name.</div> </div> <div class="inputContainer inputContainer-withDescription"> @@ -204,22 +204,22 @@ <h3>Tag Settings</h3> <h3>Advanced Settings</h3> </legend> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="PublicHost" label="Public Shoko host url:" /> - <div class="fieldDescription">This is the public URL leading to where Shoko is running. It is 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. It should include both the protocol and the ip/dns-name.</div> + <input is="emby-input" type="text" id="PublicHost" label="Public Shoko host URL:" /> + <div class="fieldDescription">This is the public URL leading to where Shoko is running. It is 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. It should include both the protocol and the IP/DNS name.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="PreferAniDbPoster" /> <span>Prefer AniDB poster</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this will prefer the poster image from AniDb over other sources for the default Series/Seasons poster</div> + <div class="fieldDescription checkboxFieldDescription">Enabling this will prefer the poster image from AniDB over other sources for the default Series/Seasons poster</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="AddAniDBId" /> - <span>Add AniDB Id to items</span> + <span>Add AniDB ID to items</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this add the AniDB id for all supported item types where an id is available.</div> + <div class="fieldDescription checkboxFieldDescription">Enabling this will add the AniDB ID for all supported item types where an ID is available.</div> </div> </fieldset> <div> From 298c38854d099bc042b966b02db0c19f1e0cd64b Mon Sep 17 00:00:00 2001 From: Elemental Crisis <9443295+ElementalCrisis@users.noreply.github.com> Date: Thu, 23 Sep 2021 16:18:32 -0700 Subject: [PATCH 0223/1103] Add logo. --- LogoWide.png | Bin 0 -> 43157 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 LogoWide.png diff --git a/LogoWide.png b/LogoWide.png new file mode 100644 index 0000000000000000000000000000000000000000..1e961fb718c1310e2a71bf9d0530af534dd0316d GIT binary patch literal 43157 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 literal 0 HcmV?d00001 From b77bf994612a0a60ad3d4da6cc9b79bea3749ba1 Mon Sep 17 00:00:00 2001 From: ElementalCrisis <ElementalCrisis@users.noreply.github.com> Date: Thu, 23 Sep 2021 23:19:17 +0000 Subject: [PATCH 0224/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 6c88b0dc..85c7ac55 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -7,6 +7,14 @@ "owner": "shoko", "category": "Metadata", "versions": [ + { + "version": "1.5.0.29", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.29/shokofin_1.5.0.29.zip", + "checksum": "59e565cfa5b59feb8791f8fb726ba0a2", + "timestamp": "2021-09-23T23:19:15Z" + }, { "version": "1.5.0.28", "changelog": "NA", From 120f988e9f75344bc1adc14f60a07c2f382ec912 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 24 Sep 2021 01:21:08 +0200 Subject: [PATCH 0225/1103] Add image url to manifest --- build.yaml | 1 + manifest-unstable.json | 1 + manifest.json | 1 + 3 files changed, 3 insertions(+) diff --git a/build.yaml b/build.yaml index 88cc70e8..37b9c615 100644 --- a/build.yaml +++ b/build.yaml @@ -1,5 +1,6 @@ name: "Shokofin" guid: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052" +imageUrl: https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png targetAbi: "10.7.0.0" owner: "shoko" overview: "Manage your anime from Jellyfin using metadata from Shoko" diff --git a/manifest-unstable.json b/manifest-unstable.json index 85c7ac55..1b06305d 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -6,6 +6,7 @@ "overview": "Manage your anime from Jellyfin using metadata from Shoko", "owner": "shoko", "category": "Metadata", + "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ { "version": "1.5.0.29", diff --git a/manifest.json b/manifest.json index 3717d9d1..503168f9 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,7 @@ "overview": "Manage your anime from Jellyfin using metadata from Shoko", "owner": "shoko", "category": "Metadata", + "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ { "version": "1.5.0.0", From d4a1b13c3853fe58b9fec6124c085b5053908dd6 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 23 Sep 2021 23:21:46 +0000 Subject: [PATCH 0226/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 1b06305d..953c50a7 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.5.0.30", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.30/shokofin_1.5.0.30.zip", + "checksum": "7f9e5da97481f96037839f5da18dffc4", + "timestamp": "2021-09-23T23:21:44Z" + }, { "version": "1.5.0.29", "changelog": "NA", From ed06c30d72ab82a072d0cd8433fe41b9b299adae Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 24 Sep 2021 18:30:37 +0200 Subject: [PATCH 0227/1103] Add user-spesific configuration --- Shokofin/API/ShokoAPIClient.cs | 25 +- Shokofin/Configuration/PluginConfiguration.cs | 7 +- Shokofin/Configuration/UserConfiguration.cs | 30 + Shokofin/Configuration/configController.js | 316 ++++++++++ Shokofin/Configuration/configPage.html | 553 ++++++++---------- Shokofin/Plugin.cs | 5 + Shokofin/Shokofin.csproj | 6 +- Shokofin/Web/WebController.cs | 54 ++ 8 files changed, 648 insertions(+), 348 deletions(-) create mode 100644 Shokofin/Configuration/UserConfiguration.cs create mode 100644 Shokofin/Configuration/configController.js create mode 100644 Shokofin/Web/WebController.cs diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 7fb89b61..f943eb86 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -30,7 +30,10 @@ public ShokoAPIClient(ILogger<ShokoAPIClient> logger) private async Task<Stream> CallApi(string url, string requestType = "GET", string apiKey = null) { - if (!CheckApiKey()) return null; + if (string.IsNullOrEmpty(Plugin.Instance.Configuration.ApiKey)) { + _httpClient.DefaultRequestHeaders.Clear(); + throw new Exception("Unable to call the API before an connection is established to Shoko Server!"); + } try { @@ -53,30 +56,18 @@ private async Task<Stream> CallApi(string url, string requestType = "GET", strin } } - private bool CheckApiKey() - { - if (!string.IsNullOrEmpty(Plugin.Instance.Configuration.ApiKey)) return true; - - var apikey = GetApiKey().GetAwaiter().GetResult(); - if (string.IsNullOrEmpty(apikey)) return false; - Plugin.Instance.Configuration.ApiKey = apikey; - _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("apikey", apikey); - return true; - } - - private async Task<string> GetApiKey() + public async Task<ApiKey> GetApiKey(string username, string password) { var postData = JsonSerializer.Serialize(new Dictionary<string, string> { - {"user", Plugin.Instance.Configuration.Username}, - {"pass", Plugin.Instance.Configuration.Password}, + {"user", username}, + {"pass", password}, {"device", "Shoko Jellyfin Plugin (Shokofin)"}, }); var apiBaseUrl = Plugin.Instance.Configuration.Host; var response = await _httpClient.PostAsync($"{apiBaseUrl}/api/auth", new StringContent(postData, Encoding.UTF8, "application/json")); if (response.StatusCode == HttpStatusCode.OK) - return (await JsonSerializer.DeserializeAsync<ApiKey>(response.Content.ReadAsStreamAsync().Result))?.apikey ?? null; + return (await JsonSerializer.DeserializeAsync<ApiKey>(response.Content.ReadAsStreamAsync().Result)); return null; } diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 9739cf27..e9d76201 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,4 +1,5 @@ using MediaBrowser.Model.Plugins; +using System; using System.Text.Json.Serialization; using TextSourceType = Shokofin.Utils.Text.TextSourceType; @@ -21,8 +22,6 @@ public virtual string PrettyHost public string Username { get; set; } - public string Password { get; set; } - public string ApiKey { get; set; } public bool UpdateWatchedStatus { get; set; } @@ -69,12 +68,13 @@ public virtual string PrettyHost public DisplayLanguageType TitleAlternateType { get; set; } + public UserConfiguration[] UserList { get; set; } + public PluginConfiguration() { Host = "http://127.0.0.1:8111"; PublicHost = ""; Username = "Default"; - Password = ""; ApiKey = ""; UpdateWatchedStatus = false; HideArtStyleTags = false; @@ -98,6 +98,7 @@ public PluginConfiguration() BoxSetGrouping = SeriesAndBoxSetGroupType.Default; MovieOrdering = OrderType.Default; FilterOnLibraryTypes = false; + UserList = Array.Empty<UserConfiguration>(); } } } diff --git a/Shokofin/Configuration/UserConfiguration.cs b/Shokofin/Configuration/UserConfiguration.cs new file mode 100644 index 00000000..0adca14c --- /dev/null +++ b/Shokofin/Configuration/UserConfiguration.cs @@ -0,0 +1,30 @@ +using System; + +namespace Shokofin.Configuration +{ + /// <summary> + /// Per user configuration. + /// </summary> + public class UserConfiguration + { + /// <summary> + /// The Jellyfin user id this configuration is for. + /// </summary> + public Guid UserId { get; set; } = Guid.Empty; + + /// <summary> + /// Enables watch-state synchronization for the user. + /// </summary> + public bool EnableSynchronization { get; set; } + + /// <summary> + /// The username of the linked user in Shoko. + /// </summary> + public string Username { get; set; } = string.Empty; + + /// <summary> + /// The API Token for authentication/authorization with Shoko Server. + /// </summary> + public string Token { get; set; } = string.Empty; + } +} diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js new file mode 100644 index 00000000..c7124abb --- /dev/null +++ b/Shokofin/Configuration/configController.js @@ -0,0 +1,316 @@ +const PluginConfig = { + pluginId: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052" +}; + +function loadUserConfig(page, userId) { + if (!userId) { + page.querySelector("#UserSettingsContainer").setAttribute("hidden", ""); + Dashboard.hideLoadingMsg(); + return; + } + + page.querySelector("#UserSettingsContainer").removeAttribute("hidden"); + Dashboard.showLoadingMsg(); + + return ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) { + const userConfig = config.UserList.find((c) => userId === c.UserId) || { UserId: userId }; + + page.querySelector("#UserEnableSynchronization").checked = userConfig.EnableSynchronization || false; + page.querySelector("#UserUsername").value = userConfig.Username || ""; + page.querySelector("#UserPassword").value = ""; + + if (userConfig.Token) { + page.querySelector("#UserDeleteContainer").removeAttribute("hidden"); + page.querySelector("#UserUsername").setAttribute("disabled", ""); + page.querySelector("#UserPasswordContainer").setAttribute("hidden", ""); + page.querySelector("#UserUsername").removeAttribute("required"); + } + else { + page.querySelector("#UserDeleteContainer").setAttribute("hidden", ""); + page.querySelector("#UserUsername").removeAttribute("disabled"); + page.querySelector("#UserPasswordContainer").removeAttribute("hidden"); + page.querySelector("#UserUsername").setAttribute("required", ""); + } + + Dashboard.hideLoadingMsg(); + }); +} + +function getApiKey(username, password) { + return ApiClient.fetch({ + dataType: "json", + data: JSON.stringify({ + username, + password, + }), + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + type: "POST", + url: ApiClient.getUrl("Plugin/Shokofin/GetApiKey"), + }); +} + +async function resetConnectionSettings(form) { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + let host = form.querySelector("#Host").value; + if (host.endsWith("/")) { + host = host.slice(0, -1); + form.querySelector("#Host").value = host; + } + + const username = form.querySelector("#Username").value = config.Username; + form.querySelector("#Password").value = ""; + + // Connection settings + config.Host = host; + config.Username = username; + config.ApiKey = ""; + + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + Dashboard.processPluginConfigurationUpdateResult(result); + + return config; +} +async function setConnectionSettings(form) { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + let host = form.querySelector("#Host").value; + if (host.endsWith("/")) { + host = host.slice(0, -1); + form.querySelector("#Host").value = host; + } + + const username = form.querySelector("#Username").value; + const password = form.querySelector("#Password").value; + + const response = await getApiKey(username, password); + + // Connection settings + config.Host = host; + config.Username = username; + config.ApiKey = response.apikey; + + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + Dashboard.processPluginConfigurationUpdateResult(result); + + return config; +} + +async function syncSettings(form) { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + let publicHost = form.querySelector("#PublicHost").value; + if (publicHost.endsWith("/")) { + publicHost = publicHost.slice(0, -1); + form.querySelector("#PublicHost").value = publicHost; + } + + // Metadata settings + config.TitleMainType = form.querySelector("#TitleMainType").value; + config.TitleAlternateType = form.querySelector("#TitleAlternateType").value; + config.DescriptionSource = form.querySelector("#DescriptionSource").value; + config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; + config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; + config.SynopsisCleanMiscLines = form.querySelector("#MinimalAniDBDescriptions").checked; + config.SynopsisRemoveSummary = form.querySelector("#MinimalAniDBDescriptions").checked; + + // Library settings + config.SeriesGrouping = form.querySelector("#SeriesGrouping").value; + config.BoxSetGrouping = form.querySelector("#BoxSetGrouping").value; + config.FilterOnLibraryTypes = form.querySelector("#FilterOnLibraryTypes").checked; + config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; + config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; + + // Tag settings + config.HideArtStyleTags = form.querySelector("#HideArtStyleTags").checked; + config.HideSourceTags = form.querySelector("#HideSourceTags").checked; + config.HideMiscTags = form.querySelector("#HideMiscTags").checked; + config.HidePlotTags = form.querySelector("#HidePlotTags").checked; + config.HideAniDbTags = form.querySelector("#HideAniDbTags").checked; + + // Advanced settings + config.PublicHost = publicHost; + config.PreferAniDbPoster = form.querySelector("#PreferAniDbPoster").checked; + config.AddAniDBId = form.querySelector("#AddAniDBId").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 syncUserSettings(form) { + const userId = form.querySelector("#UserSelector").value; + if (!userId) return; + + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + 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; + + // 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); + userConfig.Username = username; + userConfig.Token = response.apikey; + } + catch (err) { + Dashboard.alert(`An error occured while trying to authenticating the user using the provided credentials; ${err.message}`); + userConfig.Username = ""; + userConfig.Token = ""; + } + + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config) + Dashboard.processPluginConfigurationUpdateResult(result); + + return config; +} + +export default function (page) { + const form = page.querySelector("#ShokoConfigForm"); + const userSelector = form.querySelector("#UserSelector"); + const refershSettings = (config) => { + if (config.ApiKey) { + form.querySelector("#Host").setAttribute("disabled", ""); + form.querySelector("#Username").setAttribute("disabled", ""); + form.querySelector("#Password").value = ""; + form.querySelector("#ConnectionSetContainer").setAttribute("hidden", ""); + form.querySelector("#ConnectionResetContainer").removeAttribute("hidden"); + form.querySelector("#MetadataSection").removeAttribute("hidden"); + form.querySelector("#MetadataSection").removeAttribute("hidden"); + form.querySelector("#LibrarySection").removeAttribute("hidden"); + form.querySelector("#UserSection").removeAttribute("hidden"); + form.querySelector("#TagSection").removeAttribute("hidden"); + form.querySelector("#AdvancedSection").removeAttribute("hidden"); + } + else { + form.querySelector("#Host").removeAttribute("disabled"); + form.querySelector("#Username").removeAttribute("disabled"); + form.querySelector("#ConnectionSetContainer").removeAttribute("hidden"); + form.querySelector("#ConnectionResetContainer").setAttribute("hidden", ""); + form.querySelector("#MetadataSection").setAttribute("hidden", ""); + form.querySelector("#MetadataSection").setAttribute("hidden", ""); + form.querySelector("#LibrarySection").setAttribute("hidden", ""); + form.querySelector("#UserSection").setAttribute("hidden", ""); + form.querySelector("#TagSection").setAttribute("hidden", ""); + form.querySelector("#AdvancedSection").setAttribute("hidden", ""); + } + + userSelector.dispatchEvent(new Event("change", { + bubbles: true, + cancelable: false + })); + }; + const onError = (err) => { + console.error(err); + Dashboard.alert(`An error occurred; ${err.message}`); + Dashboard.hideLoadingMsg(); + }; + + userSelector.addEventListener("change", function () { + loadUserConfig(page, this.value); + }); + + page.addEventListener("viewshow", async function () { + Dashboard.showLoadingMsg(); + try { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + const users = await ApiClient.getUsers(); + + // Connection settings + form.querySelector("#Host").value = config.Host; + form.querySelector("#Username").value = config.Username; + form.querySelector("#Password").value = ""; + + // Metadata settings + form.querySelector("#TitleMainType").value = config.TitleMainType; + form.querySelector("#TitleAlternateType").value = config.TitleAlternateType; + form.querySelector("#DescriptionSource").value = config.DescriptionSource; + form.querySelector("#CleanupAniDBDescriptions").checked = config.SynopsisCleanMultiEmptyLines || config.SynopsisCleanLinks; + form.querySelector("#MinimalAniDBDescriptions").checked = config.SynopsisRemoveSummary || config.SynopsisCleanMiscLines; + + // Library settings + form.querySelector("#SeriesGrouping").value = config.SeriesGrouping; + form.querySelector("#BoxSetGrouping").value = config.BoxSetGrouping; + form.querySelector("#FilterOnLibraryTypes").checked = config.FilterOnLibraryTypes; + form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement; + form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; + + // User settings + userSelector.innerHTML += users.map((user) => `<option value="${user.Id}">${user.Name}</option>`); + + // Tag settings + form.querySelector("#HideArtStyleTags").checked = config.HideArtStyleTags; + form.querySelector("#HideSourceTags").checked = config.HideSourceTags; + form.querySelector("#HideMiscTags").checked = config.HideMiscTags; + form.querySelector("#HidePlotTags").checked = config.HidePlotTags; + form.querySelector("#HideAniDbTags").checked = config.HideAniDbTags; + + // Advanced settings + form.querySelector("#PublicHost").value = config.PublicHost; + form.querySelector("#PreferAniDbPoster").checked = config.PreferAniDbPoster; + form.querySelector("#AddAniDBId").checked = config.AddAniDBId; + + if (!config.ApiKey) { + Dashboard.alert("Please establish a connection to a running instance of Shoko Server before you continue."); + } + + refershSettings(config); + } + catch { + Dashboard.alert("There was an error loading the page, please refresh once to see if that will fix it."); + Dashboard.hideLoadingMsg(); + } + }); + + form.addEventListener("submit", function (event) { + event.preventDefault(); + if (!event.submitter) return; + switch (event.submitter.name) { + default: + break; + case "settings": + Dashboard.showLoadingMsg(); + syncSettings(form).then(refershSettings).catch(onError); + break; + case "reset-connection": + Dashboard.showLoadingMsg(); + resetConnectionSettings(form).then(refershSettings).catch(onError); + break; + case "set-connection": + Dashboard.showLoadingMsg(); + setConnectionSettings(form).then(refershSettings).catch(onError); + break; + case "unlink-user": + unlinkUser(form).then(refershSettings).catch(onError); + break; + case "user-settings": + Dashboard.showLoadingMsg(); + syncUserSettings(form).then(refershSettings).catch(onError); + break; + } + return false; + }); +} diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index ab67c86f..e04361f8 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1,347 +1,250 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <title>Shoko - - -
-
-
-
-
-
-

Shoko

- Help +
+
+
+ +
+
+

Shoko

+ Help +
+
+ +

Connection Settings

+
+
+ +
This is the URL leading to where Shoko is running. It should include both the protocol and the ip/dns-name.
-
- -

Connection Settings

-
-
- -
This is the URL leading to where Shoko is running. It should include both the protocol and the ip/dns-name.
-
-
- -
The user should be an administrator in Shoko, preferably without any filtering applied.
-
+
+ +
The user should be an administrator in Shoko, preferably without any filtering applied.
+
+ +
The password for account. It can be empty.
-
-
- -

Metadata Settings

-
-
- - -
How to select the main title for each item. The plugin will fallback to the default title if no title can be found in the target language.
-
-
- - -
How to select the alternate title for each item. The plugin will fallback to the default title if no title can be found in the target language.
-
-
- - -
How to select the description to use for each item.
-
-
- -
Remove links and collapse multiple empty lines into one empty line
-
-
- -
Remove any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summery'
-
-
-
- -

Library Settings

-
-
Series merging must be enabled in the Library Settings, or else unexpected results will occur.
-
- - -
Determines how to group Series together and divide them into Seasons.
-
-
- - -
Determines how to group Movies together into Box-sets.
-
-
- -
Split Shoko Groups in two, and actively filter out folders and files that don't belong to the selected library type.
-
-
- - -
Determines how Specials are placed within Seasons. Warning: Modifying this setting requires a rebuild (and not just a full-refresh) of any libraries using this plugin — otherwise you will have mixed metadata.
-
-
- -
Add a number to the title of each Special episode
-
-
-
- -

Synchronization Settings

-
+ +
Establish a connection to Shoko Server using the provided credentials.
+
+ + + + -
- -

Tag Settings

-
-
- + -
- -
-
- -
-
- -
-
- -
-
-
- -

Advanced Settings

-
-
- -
This is the public URL leading to where Shoko is running. It is 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. It should include both the protocol and the IP/DNS name.
-
-
- -
Enabling this will prefer the poster image from AniDB over other sources for the default Series/Seasons poster
-
-
- -
Enabling this will add the AniDB ID for all supported item types where an ID is available.
-
-
-
-
-
- -
+ + + +
+
-
- - +
\ No newline at end of file diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index faa1d140..799ba0d1 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -31,6 +31,11 @@ public IEnumerable GetPages() { Name = Name, EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configPage.html", + }, + new PluginPageInfo + { + Name = "ShokoController.js", + EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configController.js", } }; } diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj index a8bea081..5329e77b 100644 --- a/Shokofin/Shokofin.csproj +++ b/Shokofin/Shokofin.csproj @@ -5,15 +5,15 @@ - - - + + + diff --git a/Shokofin/Web/WebController.cs b/Shokofin/Web/WebController.cs new file mode 100644 index 00000000..d6010327 --- /dev/null +++ b/Shokofin/Web/WebController.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Mime; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using MediaBrowser.Common.Json; +using MediaBrowser.Common.Net; +using MediaBrowser.Model.Serialization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Shokofin.API; +using Shokofin.API.Models; + +namespace Shokofin.Web +{ + + /// + /// Pushbullet notifications controller. + /// + [ApiController] + [Route("Plugin/Shokofin")] + [Produces(MediaTypeNames.Application.Json)] + public class WebController : ControllerBase + { + private readonly ShokoAPIClient APIClient; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public WebController(IHttpClientFactory httpClientFactory, ShokoAPIClient apiClient) + { + APIClient = apiClient; + } + + [HttpPost("GetApiKey")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task> PostAsync([FromBody] ApiLoginRequest body) + { + var apiKey = await APIClient.GetApiKey(body.username, body.password).ConfigureAwait(false); + if (apiKey == null) + return new StatusCodeResult(StatusCodes.Status401Unauthorized); + return apiKey; + } + } + + public class ApiLoginRequest { + public string username { get; set; } + public string password { get; set; } + } +} \ No newline at end of file From 2921bf69878e3577be8776d23b30d6792643d7e5 Mon Sep 17 00:00:00 2001 From: revam Date: Fri, 24 Sep 2021 22:52:12 +0000 Subject: [PATCH 0228/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 953c50a7..1e0375fb 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.5.0.31", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.31/shokofin_1.5.0.31.zip", + "checksum": "e2786dea21a2ed1579d9a3138fa153f7", + "timestamp": "2021-09-24T22:52:10Z" + }, { "version": "1.5.0.30", "changelog": "NA", From 26e611aba71118b1bc148bb682db63c8d70da483 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Sat, 25 Sep 2021 00:57:01 +0200 Subject: [PATCH 0229/1103] Fix: remove the 'required' attribute when hiding the setting --- Shokofin/Configuration/configController.js | 1 + 1 file changed, 1 insertion(+) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index c7124abb..185d4389 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -5,6 +5,7 @@ const PluginConfig = { function loadUserConfig(page, userId) { if (!userId) { page.querySelector("#UserSettingsContainer").setAttribute("hidden", ""); + page.querySelector("#UserUsername").removeAttribute("required"); Dashboard.hideLoadingMsg(); return; } From d477fddd0b9a4b34daaef86e8ace6eb1dfd21d31 Mon Sep 17 00:00:00 2001 From: revam Date: Fri, 24 Sep 2021 22:57:52 +0000 Subject: [PATCH 0230/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 1e0375fb..264929a8 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.5.0.32", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.32/shokofin_1.5.0.32.zip", + "checksum": "b7c321018194e48d83b7d3301bc4a3ac", + "timestamp": "2021-09-24T22:57:51Z" + }, { "version": "1.5.0.31", "changelog": "NA", From 09c030786bc8317665c414fa20924e1310c85657 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Sat, 25 Sep 2021 22:18:08 +0200 Subject: [PATCH 0231/1103] Don't show any settings until the configuration is initially loaded --- Shokofin/Configuration/configController.js | 2 ++ Shokofin/Configuration/configPage.html | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 185d4389..86d17095 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -199,6 +199,7 @@ export default function (page) { 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("#MetadataSection").removeAttribute("hidden"); form.querySelector("#LibrarySection").removeAttribute("hidden"); @@ -211,6 +212,7 @@ export default function (page) { 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("#MetadataSection").setAttribute("hidden", ""); form.querySelector("#LibrarySection").setAttribute("hidden", ""); diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index e04361f8..d1419c34 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -7,7 +7,7 @@

Shoko

Help
-
+
-
The password for account. It can be empty.
-
Establish a connection to Shoko Server using the provided credentials.
+ +
From 267e41249864cbdec8c178cce334fc7eea7b1344 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Thu, 30 Sep 2021 01:39:11 +0200 Subject: [PATCH 0246/1103] Check if image exists before adding the image --- Shokofin/API/Models/Image.cs | 8 ++- Shokofin/API/ShokoAPIClient.cs | 92 ++++++++++++++++------------ Shokofin/API/ShokoAPIManager.cs | 17 +++-- Shokofin/Providers/ImageProvider.cs | 25 +++----- Shokofin/Providers/SeriesProvider.cs | 2 +- 5 files changed, 83 insertions(+), 61 deletions(-) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index ad48b192..b5b9eb20 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace Shokofin.API.Models { public class Image @@ -14,9 +16,13 @@ public class Image public bool Disabled { get; set; } + [JsonIgnore] + public virtual string Path + => $"/api/v3/Image/{Source}/{Type}/{ID}"; + public string ToURLString() { - return $"{Plugin.Instance.Configuration.Host}/api/v3/Image/{Source}/{Type}/{ID}"; + return string.Concat(Plugin.Instance.Configuration.Host, Path); } } } \ No newline at end of file diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 03edfcd0..0fa6e5aa 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -27,10 +27,17 @@ public ShokoAPIClient(ILogger logger) Logger = logger; } - private Task CallApi(string url, string apiKey = null) - => CallApi(url, HttpMethod.Get, apiKey); + private Task GetAsync(string url, string apiKey = null) + => GetAsync(url, HttpMethod.Get, apiKey); - private async Task CallApi(string url, HttpMethod method, string apiKey = null) + private async Task GetAsync(string url, HttpMethod method, string apiKey = null) + { + var response = await GetAsync(url, method, apiKey); + var responseStream = response.StatusCode == HttpStatusCode.OK ? response.Content.ReadAsStreamAsync().Result : null; + return responseStream != null ? await JsonSerializer.DeserializeAsync(responseStream) : default(ReturnType); + } + + private async Task GetAsync(string url, HttpMethod method, string apiKey = null) { // Use the default key if no key was provided. if (apiKey == null) @@ -47,21 +54,26 @@ private async Task CallApi(string url, HttpMethod method using (var requestMessage = new HttpRequestMessage(method, remoteUrl)) { requestMessage.Content = (new StringContent("")); requestMessage.Headers.Add("apikey", apiKey); - var response = await _httpClient.SendAsync(requestMessage); - var responseStream = response.StatusCode == HttpStatusCode.OK ? await response.Content.ReadAsStreamAsync() : null; - return responseStream != null ? await JsonSerializer.DeserializeAsync(responseStream) : default(ReturnType); + return await _httpClient.SendAsync(requestMessage); } } catch (HttpRequestException) { - return default(ReturnType); + return null; } } - private Task CallApi(string url, Type body, string apiKey = null) - => CallApi(url, HttpMethod.Post, body, apiKey); + private Task PostAsync(string url, Type body, string apiKey = null) + => PostAsync(url, HttpMethod.Post, body, apiKey); - private async Task CallApi(string url, HttpMethod method, Type body, string apiKey = null) + private async Task PostAsync(string url, HttpMethod method, Type body, string apiKey = null) + { + var response = await PostAsync(url, method, body, apiKey); + var responseStream = response.StatusCode == HttpStatusCode.OK ? response.Content.ReadAsStreamAsync().Result : null; + return responseStream != null ? await JsonSerializer.DeserializeAsync(responseStream) : default(ReturnType); + } + + private async Task PostAsync(string url, HttpMethod method, Type body, string apiKey = null) { // Use the default key if no key was provided. if (apiKey == null) @@ -78,14 +90,12 @@ private async Task CallApi(string url, HttpMethod using (var requestMessage = new HttpRequestMessage(method, remoteUrl)) { requestMessage.Content = (new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json")); requestMessage.Headers.Add("apikey", apiKey); - var response = await _httpClient.SendAsync(requestMessage); - var responseStream = response.StatusCode == HttpStatusCode.OK ? response.Content.ReadAsStreamAsync().Result : null; - return responseStream != null ? await JsonSerializer.DeserializeAsync(responseStream) : default(ReturnType); + return await _httpClient.SendAsync(requestMessage); } } catch (HttpRequestException) { - return default(ReturnType); + return null; } } @@ -105,124 +115,130 @@ public async Task GetApiKey(string username, string password) return null; } + public bool CheckImage(string imagePath) + { + var response = GetAsync(imagePath, HttpMethod.Head).ConfigureAwait(false).GetAwaiter().GetResult(); + return response != null && response.StatusCode == HttpStatusCode.OK; + } + public Task GetEpisode(string id) { - return CallApi($"/api/v3/Episode/{id}"); + return GetAsync($"/api/v3/Episode/{id}"); } public Task> GetEpisodesFromSeries(string seriesId) { - return CallApi>($"/api/v3/Series/{seriesId}/Episode?includeMissing=true"); + return GetAsync>($"/api/v3/Series/{seriesId}/Episode?includeMissing=true"); } public Task> GetEpisodeFromFile(string id) { - return CallApi>($"/api/v3/File/{id}/Episode"); + return GetAsync>($"/api/v3/File/{id}/Episode"); } public Task GetEpisodeAniDb(string id) { - return CallApi($"/api/v3/Episode/{id}/AniDB"); + return GetAsync($"/api/v3/Episode/{id}/AniDB"); } public Task> GetEpisodeTvDb(string id) { - return CallApi>($"/api/v3/Episode/{id}/TvDB"); + return GetAsync>($"/api/v3/Episode/{id}/TvDB"); } public Task GetFile(string id) { - return CallApi($"/api/v3/File/{id}"); + return GetAsync($"/api/v3/File/{id}"); } public Task> GetFileByPath(string filename) { - return CallApi>($"/api/v3/File/PathEndsWith/{Uri.EscapeDataString(filename)}"); + return GetAsync>($"/api/v3/File/PathEndsWith/{Uri.EscapeDataString(filename)}"); } public Task GetFileUserData(string fileId, string apiKey) { - return CallApi($"/api/v3/File/UserData"); + return GetAsync($"/api/v3/File/UserData"); } public Task ScrobbleFile(string id, bool watched, string apiKey) { - return CallApi($"/api/v3/File/{id}/Scrobble?watched={watched}", HttpMethod.Patch, apiKey); + return GetAsync($"/api/v3/File/{id}/Scrobble?watched={watched}", HttpMethod.Patch, apiKey); } public Task ScrobbleFile(string id, long progress, string apiKey) { - return CallApi($"/api/v3/File/{id}/Scrobble?resumePosition={progress}", HttpMethod.Patch, apiKey); + return GetAsync($"/api/v3/File/{id}/Scrobble?resumePosition={progress}", HttpMethod.Patch, apiKey); } public Task ScrobbleFile(string id, bool watched, long? progress, string apiKey) { - return CallApi($"/api/v3/File/{id}/Scrobble?watched={watched}&resumePosition={progress ?? 0}", HttpMethod.Patch, apiKey); + return GetAsync($"/api/v3/File/{id}/Scrobble?watched={watched}&resumePosition={progress ?? 0}", HttpMethod.Patch, apiKey); } public Task GetSeries(string id) { - return CallApi($"/api/v3/Series/{id}"); + return GetAsync($"/api/v3/Series/{id}"); } public Task GetSeriesFromEpisode(string id) { - return CallApi($"/api/v3/Episode/{id}/Series"); + return GetAsync($"/api/v3/Episode/{id}/Series"); } public Task> GetSeriesInGroup(string id) { - return CallApi>($"/api/v3/Filter/0/Group/{id}/Series"); + return GetAsync>($"/api/v3/Filter/0/Group/{id}/Series"); } public Task GetSeriesAniDB(string id) { - return CallApi($"/api/v3/Series/{id}/AniDB"); + return GetAsync($"/api/v3/Series/{id}/AniDB"); } public Task> GetSeriesTvDB(string id) { - return CallApi>($"/api/v3/Series/{id}/TvDB"); + return GetAsync>($"/api/v3/Series/{id}/TvDB"); } public Task> GetSeriesCast(string id) { - return CallApi>($"/api/v3/Series/{id}/Cast"); + return GetAsync>($"/api/v3/Series/{id}/Cast"); } public Task> GetSeriesCast(string id, Role.CreatorRoleType role) { - return CallApi>($"/api/v3/Series/{id}/Cast?roleType={role.ToString()}"); + return GetAsync>($"/api/v3/Series/{id}/Cast?roleType={role.ToString()}"); } public Task GetSeriesImages(string id) { - return CallApi($"/api/v3/Series/{id}/Images"); + return GetAsync($"/api/v3/Series/{id}/Images"); } public Task> GetSeriesPathEndsWith(string dirname) { - return CallApi>($"/api/v3/Series/PathEndsWith/{Uri.EscapeDataString(dirname)}"); + return GetAsync>($"/api/v3/Series/PathEndsWith/{Uri.EscapeDataString(dirname)}"); } public Task> GetSeriesTags(string id, int filter = 0) { - return CallApi>($"/api/v3/Series/{id}/Tags/{filter}?excludeDescriptions=true"); + return GetAsync>($"/api/v3/Series/{id}/Tags/{filter}?excludeDescriptions=true"); } public Task GetGroup(string id) { - return CallApi($"/api/v3/Group/{id}"); + return GetAsync($"/api/v3/Group/{id}"); } public Task GetGroupFromSeries(string id) { - return CallApi($"/api/v3/Series/{id}/Group"); + return GetAsync($"/api/v3/Series/{id}/Group"); } public Task> SeriesSearch(string query) { - return CallApi>($"/api/v3/Series/Search/{Uri.EscapeDataString(query)}"); + return GetAsync>($"/api/v3/Series/Search/{Uri.EscapeDataString(query)}"); } } } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 65f8667f..5602ebb1 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -147,6 +147,11 @@ public void Clear() #endregion #region People + private string GetImagePath(Image image) + { + return image != null && APIClient.CheckImage(image.Path) ? image.ToURLString() : null; + } + private PersonInfo RoleToPersonInfo(Role role) { switch (role.RoleName) { @@ -157,41 +162,41 @@ private PersonInfo RoleToPersonInfo(Role role) Type = PersonType.Director, Name = role.Staff.Name, Role = role.RoleDetails, - ImageUrl = role.Staff.Image?.ToURLString(), + ImageUrl = GetImagePath(role.Staff.Image), }; case Role.CreatorRoleType.Producer: return new PersonInfo { Type = PersonType.Producer, Name = role.Staff.Name, Role = role.RoleDetails, - ImageUrl = role.Staff.Image?.ToURLString(), + ImageUrl = GetImagePath(role.Staff.Image), }; case Role.CreatorRoleType.Music: return new PersonInfo { Type = PersonType.Lyricist, Name = role.Staff.Name, Role = role.RoleDetails, - ImageUrl = role.Staff.Image?.ToURLString(), + ImageUrl = GetImagePath(role.Staff.Image), }; case Role.CreatorRoleType.SourceWork: return new PersonInfo { Type = PersonType.Writer, Name = role.Staff.Name, Role = role.RoleDetails, - ImageUrl = role.Staff.Image?.ToURLString(), + ImageUrl = GetImagePath(role.Staff.Image), }; case Role.CreatorRoleType.SeriesComposer: return new PersonInfo { Type = PersonType.Composer, Name = role.Staff.Name, - ImageUrl = role.Staff.Image?.ToURLString(), + ImageUrl = GetImagePath(role.Staff.Image), }; case Role.CreatorRoleType.Seiyuu: return new PersonInfo { Type = PersonType.Actor, Name = role.Staff.Name, Role = role.Character.Name, - ImageUrl = role.Staff.Image?.ToURLString(), + ImageUrl = GetImagePath(role.Staff.Image), }; } } diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 5edf2d9a..d76f3f5b 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -22,14 +22,17 @@ public class ImageProvider : IRemoteImageProvider private readonly ILogger Logger; + private readonly ShokoAPIClient ApiClient; + private readonly ShokoAPIManager ApiManager; private readonly IIdLookup Lookup; - public ImageProvider(IHttpClientFactory httpClientFactory, ILogger logger, ShokoAPIManager apiManager, IIdLookup lookup) + public ImageProvider(IHttpClientFactory httpClientFactory, ILogger logger, ShokoAPIClient apiClient, ShokoAPIManager apiManager, IIdLookup lookup) { HttpClientFactory = httpClientFactory; Logger = logger; + ApiClient = apiClient; ApiManager = apiManager; Lookup = lookup; } @@ -133,21 +136,13 @@ private void AddImagesForSeries(ref List list, API.Info.SeriesI private void AddImage(ref List list, ImageType imageType, API.Models.Image image) { - var imageInfo = GetImage(image, imageType); - if (imageInfo != null) - list.Add(imageInfo); - } - - private RemoteImageInfo GetImage(API.Models.Image image, ImageType imageType) - { - var imageUrl = image?.ToURLString(); - if (string.IsNullOrEmpty(imageUrl) || image.RelativeFilepath.Equals("/")) - return null; - return new RemoteImageInfo { - ProviderName = "Shoko", + if (image == null || !ApiClient.CheckImage(image.Path)) + return; + list.Add(new RemoteImageInfo { + ProviderName = Plugin.MetadataProviderName, Type = imageType, - Url = imageUrl - }; + Url = image.ToURLString(), + }); } public IEnumerable GetSupportedImages(BaseItem item) diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 4382a5fa..56d63f97 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -191,7 +191,7 @@ public async Task> GetSearchResults(SeriesInfo i foreach (var series in searchResults) { var seriesId = series.IDs.ID.ToString(); var seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); - var imageUrl = seriesInfo.AniDB.Poster?.ToURLString(); + var imageUrl = seriesInfo.AniDB.Poster != null && ApiClient.CheckImage(seriesInfo.AniDB.Poster.Path) ? seriesInfo.AniDB.Poster.ToURLString() : null; var parsedSeries = new RemoteSearchResult { Name = Text.GetSeriesTitle(seriesInfo.AniDB.Titles, seriesInfo.Shoko.Name, info.MetadataLanguage), SearchProviderName = Name, From ba4ce544849c4b3e45d286f49195f17a609c41fb Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Thu, 30 Sep 2021 01:40:24 +0200 Subject: [PATCH 0247/1103] Fix lookup of series id for season --- Shokofin/IdLookup.cs | 8 ++++---- Shokofin/Providers/ExtraMetadataProvider.cs | 6 +++--- Shokofin/Providers/ImageProvider.cs | 11 +++++------ 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index e9de423d..dd3b1bce 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -169,11 +169,11 @@ public bool TryGetSeriesIdFor(Series series, out string seriesId) public bool TryGetSeriesIdFor(Season season, out string seriesId) { - if (!season.IndexNumber.HasValue) { - seriesId = null; - return false; + if (season.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { + return true; } - return TryGetSeriesIdFor(season.Series, out seriesId); + + return TryGetSeriesIdFor(season.Path, out seriesId); } public bool TryGetSeriesIdFor(Movie movie, out string seriesId) diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index fd63db67..3501675c 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -94,7 +94,7 @@ private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs e) return; // Abort if we're unable to get the shoko series id - if (!Lookup.TryGetSeriesIdFor(season, out var seriesId)) + if (!Lookup.TryGetSeriesIdFor(season.Series, out var seriesId)) return; if (ApiManager.IsActionForIdOfTypeLocked("series", seriesId, "update")) @@ -175,7 +175,7 @@ private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs e) return; // Abort if we're unable to get the shoko series id - if (!Lookup.TryGetSeriesIdFor(season, out var seriesId)) + if (!Lookup.TryGetSeriesIdFor(season.Series, out var seriesId)) return; if (ApiManager.IsActionForIdOfTypeLocked("series", seriesId, "update")) @@ -254,7 +254,7 @@ private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs e) // Create a new virtual season if the real one was deleted and clean up extras if the season was deleted. case Season season: { // Abort if we're unable to get the shoko episode id - if (!(Lookup.TryGetSeriesIdFor(season, out var seriesId) && (e.Parent is Series series))) + if (!(Lookup.TryGetSeriesIdFor(season.Series, out var seriesId) && (e.Parent is Series series))) return; if (e.UpdateReason == ItemUpdateType.None) diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index d76f3f5b..e454858c 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -49,7 +49,7 @@ public async Task> GetImages(BaseItem item, Cancell var episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); if (episodeInfo != null) { AddImagesForEpisode(ref list, episodeInfo); - Logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId})", list.Count, episodeInfo.Shoko.Name, episodeId); + Logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId})", list.Count, episode.Name, episodeId); } } break; @@ -61,14 +61,14 @@ public async Task> GetImages(BaseItem item, Cancell var seriesInfo = groupInfo?.DefaultSeries; if (seriesInfo != null) { AddImagesForSeries(ref list, seriesInfo); - Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId},Group={GroupId})", list.Count, groupInfo.Shoko.Name, seriesInfo.Id, groupInfo.Id); + Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId},Group={GroupId})", list.Count, series.Name, seriesInfo.Id, groupInfo.Id); } } else { var seriesInfo = await ApiManager.GetSeriesInfo(seriesId); if (seriesInfo != null) { AddImagesForSeries(ref list, seriesInfo); - Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId})", list.Count, seriesInfo.Shoko.Name, seriesId); + Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId})", list.Count, series.Name, seriesId); } } } @@ -76,11 +76,10 @@ public async Task> GetImages(BaseItem item, Cancell } case Season season: { if (Lookup.TryGetSeriesIdFor(season, out var seriesId)) { - var groupInfo = await ApiManager.GetGroupInfoForSeries(seriesId, filterLibrary); - var seriesInfo = groupInfo?.GetSeriesInfoBySeasonNumber(season.IndexNumber.Value); + var seriesInfo = await ApiManager.GetSeriesInfo(seriesId); if (seriesInfo != null) { AddImagesForSeries(ref list, seriesInfo); - Logger.LogInformation("Getting {Count} images for season {SeasonNumber} in {SeriesName} (Series={SeriesId},Group={GroupId})", list.Count, season.IndexNumber.Value, groupInfo.Shoko.Name, seriesInfo.Id, groupInfo.Id); + Logger.LogInformation("Getting {Count} images for season {SeasonNumber} in {SeriesName} (Series={SeriesId})", list.Count, season.IndexNumber, season.SeriesName, seriesInfo.Id); } } break; From 1b2ce402f7409a8257b048af5ddf57a7d6850785 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Thu, 30 Sep 2021 01:41:29 +0200 Subject: [PATCH 0248/1103] Rename and update user data sync manager --- ...rSyncManager.cs => UserDataSyncManager.cs} | 80 ++++++++++++++----- 1 file changed, 61 insertions(+), 19 deletions(-) rename Shokofin/{UserSyncManager.cs => UserDataSyncManager.cs} (62%) diff --git a/Shokofin/UserSyncManager.cs b/Shokofin/UserDataSyncManager.cs similarity index 62% rename from Shokofin/UserSyncManager.cs rename to Shokofin/UserDataSyncManager.cs index 20590bad..92782989 100644 --- a/Shokofin/UserSyncManager.cs +++ b/Shokofin/UserDataSyncManager.cs @@ -14,19 +14,19 @@ namespace Shokofin { - public class UserSyncManager + public class UserDataSyncManager { private readonly IUserDataManager UserDataManager; private readonly ILibraryManager LibraryManager; - private readonly ILogger Logger; + private readonly ILogger Logger; private readonly ShokoAPIClient APIClient; private readonly IIdLookup Lookup; - public UserSyncManager(IUserDataManager userDataManager, ILibraryManager libraryManager, ILogger logger, ShokoAPIClient apiClient, IIdLookup lookup) + public UserDataSyncManager(IUserDataManager userDataManager, ILibraryManager libraryManager, ILogger logger, ShokoAPIClient apiClient, IIdLookup lookup) { UserDataManager = userDataManager; LibraryManager = libraryManager; @@ -48,11 +48,11 @@ public void Dispose() private bool TryGetUserConfiguration(Guid userId, out UserConfiguration config) { - config = Plugin.Instance.Configuration.UserList.FirstOrDefault(c => c.UserId == userId); + config = Plugin.Instance.Configuration.UserList.FirstOrDefault(c => c.UserId == userId && c.EnableSynchronization); return config != null; } - #region Export + #region Export/Scrobble public void OnUserDataSaved(object sender, UserDataSaveEventArgs e) { @@ -72,33 +72,68 @@ public void OnUserDataSaved(object sender, UserDataSaveEventArgs e) )) return; + var userData = e.UserData; var config = Plugin.Instance.Configuration; switch (e.SaveReason) { case UserDataSaveReason.PlaybackStart: case UserDataSaveReason.PlaybackProgress: if (!config.SyncUserDataUnderPlayback || !userConfig.EnableSynchronization) return; - SyncVideo(userConfig, e.Item as Video, fileId, episodeId).ConfigureAwait(false); + Logger.LogDebug("Scrobbled during playback. (File={FileId})", fileId); + APIClient.ScrobbleFile(fileId, userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); break; case UserDataSaveReason.PlaybackFinished: if (!config.SyncUserDataAfterPlayback || !userConfig.EnableSynchronization) return; - SyncVideo(userConfig, e.Item as Video, fileId, episodeId).ConfigureAwait(false); + Logger.LogDebug("Scrobbled after playback. (File={FileId})", fileId); + APIClient.ScrobbleFile(fileId, userData.Played, userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); break; case UserDataSaveReason.TogglePlayed: - SyncVideo(userConfig, e.Item as Video, fileId, episodeId).ConfigureAwait(false); + Logger.LogDebug("Scrobbled when toggled. (File={FileId})", fileId); + if (userData.PlaybackPositionTicks == 0) + APIClient.ScrobbleFile(fileId, userData.Played, userConfig.Token).ConfigureAwait(false); + else + APIClient.ScrobbleFile(fileId, userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); break; } } + // Updates to favotite state and/or user rating. private void OnUserRatingSaved(object sender, UserDataSaveEventArgs e) { - // TODO: Sync user ratings. - Logger.LogDebug("Sync user rating for {ItemName}.", e.Item.Name); + if (!TryGetUserConfiguration(e.UserId, out var userConfig)) + return; + var userData = e.UserData; + var config = Plugin.Instance.Configuration; + switch (e.Item) { + case Episode: + case Movie: { + var video = e.Item as Video; + if (!Lookup.TryGetEpisodeIdFor(video, out var episodeId)) + return; + + Logger.LogDebug("TODO; Sync user rating for video {VideoName}. (Episode={EpisodeId})", e.Item.Name, episodeId); + break; + } + case Season season: { + if (!Lookup.TryGetSeriesIdFor(season, out var seriesId)) + return; + + Logger.LogDebug("TODO; Sync user rating for season {SeasonNumber} in series {SeriesName}. (Series={SeriesId})", season.IndexNumber, season.SeriesName, seriesId); + break; + } + case Series series: { + if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + return; + + Logger.LogDebug("TODO; Sync user rating for series {SeriesName}. (Series={SeriesId})", e.Item.Name, seriesId); + break; + } + } } #endregion - #region Import + #region Import/Sync public async Task ScanAndSync(IProgress progress, CancellationToken cancellationToken) { @@ -107,7 +142,7 @@ public async Task ScanAndSync(IProgress progress, CancellationToken canc progress.Report(100); return; } - + var videos = LibraryManager.GetItemList(new InternalItemsQuery { MediaTypes = new[] { MediaType.Video }, IsFolder = false, @@ -132,7 +167,7 @@ public async Task ScanAndSync(IProgress progress, CancellationToken canc continue; foreach (var userConfig in enabledUsers) { - await SyncVideo(userConfig, video, fileId, episodeId).ConfigureAwait(false); + await SyncVideo(userConfig, null, video, fileId, episodeId).ConfigureAwait(false); numComplete++; double percent = numComplete; @@ -146,12 +181,15 @@ public async Task ScanAndSync(IProgress progress, CancellationToken canc public void OnItemAddedOrUpdated(object sender, ItemChangeEventArgs e) { + if (Plugin.Instance.Configuration.SyncUserDataOnImport) + return; + if (e == null || e.Item == null || e.Parent == null || !(e.UpdateReason.HasFlag(ItemUpdateType.MetadataImport) || e.UpdateReason.HasFlag(ItemUpdateType.MetadataDownload))) return; if (!(e.Item is Video video)) return; - + if (!(Lookup.TryGetFileIdFor(video, out var fileId) && Lookup.TryGetEpisodeIdFor(video, out var episodeId))) return; @@ -159,27 +197,31 @@ public void OnItemAddedOrUpdated(object sender, ItemChangeEventArgs e) if (!userConfig.EnableSynchronization) continue; - SyncVideo(userConfig, video, fileId, episodeId).ConfigureAwait(false); + SyncVideo(userConfig, null, video, fileId, episodeId).ConfigureAwait(false); } } #endregion - private async Task SyncVideo(UserConfiguration userConfig, Video item, string fileId, string episodeId) + private async Task SyncVideo(UserConfiguration userConfig, UserItemData userData, Video item, string fileId, string episodeId) { // var remoteUserData = await APIClient.GetFileUserData(fileId, userConfig.Token); // if (remoteUserData == null) // return; - var userData = UserDataManager.GetUserData(userConfig.UserId, item); - if (userData == null) + // Try to load the user-data if it was not provided + if (userData == null) + userData = UserDataManager.GetUserData(userConfig.UserId, item); + // Create some new user-data if none exists. + if (userData == null) userData = new UserItemData { UserId = userConfig.UserId, + LastPlayedDate = null, }; // TODO: Check what needs to be done, e.g. update JF, update SS, or nothing. - Logger.LogDebug("Sync user data for {ItemName}.", item.Name); + Logger.LogDebug("TODO: Sync user data for video {ItemName}. (File={FileId},Episode={EpisodeId})", item.Name, fileId, episodeId); } } } From 725928a268ffbfe2761ded8b62efe1dce09c99b3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Thu, 30 Sep 2021 01:42:24 +0200 Subject: [PATCH 0249/1103] Add missing changes for last commit --- Shokofin/PluginServiceRegistrator.cs | 2 +- Shokofin/Tasks/SyncUserDataTask.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index bb6bed31..0c3c5e68 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -13,7 +13,7 @@ public void RegisterServices(IServiceCollection serviceCollection) serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); } } } \ No newline at end of file diff --git a/Shokofin/Tasks/SyncUserDataTask.cs b/Shokofin/Tasks/SyncUserDataTask.cs index ad072174..30d3a53e 100644 --- a/Shokofin/Tasks/SyncUserDataTask.cs +++ b/Shokofin/Tasks/SyncUserDataTask.cs @@ -20,12 +20,12 @@ public class SyncUserDataTask : IScheduledTask /// /// The _library manager. /// - private readonly UserSyncManager _userSyncManager; + private readonly UserDataSyncManager _userSyncManager; /// /// Initializes a new instance of the class. /// - public SyncUserDataTask(UserSyncManager userSyncManager) + public SyncUserDataTask(UserDataSyncManager userSyncManager) { _userSyncManager = userSyncManager; } From 7f40bb2c9f86db756f678826aa42e948cf6db40b Mon Sep 17 00:00:00 2001 From: revam Date: Wed, 29 Sep 2021 23:43:09 +0000 Subject: [PATCH 0250/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 5131f5a4..7c0c7554 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.5.0.37", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.37/shokofin_1.5.0.37.zip", + "checksum": "4fe510cdd9a1ed2bda424f87622a52ff", + "timestamp": "2021-09-29T23:43:06Z" + }, { "version": "1.5.0.36", "changelog": "NA", From 8b4aec4efa8b8e6842f9f8fa75b111470a1231cc Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Thu, 30 Sep 2021 21:25:05 +0200 Subject: [PATCH 0251/1103] Add a missing null check and some extra logging to the api client --- Shokofin/API/ShokoAPIClient.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 0fa6e5aa..539a2417 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -33,7 +33,7 @@ private Task GetAsync(string url, string apiKey = null) private async Task GetAsync(string url, HttpMethod method, string apiKey = null) { var response = await GetAsync(url, method, apiKey); - var responseStream = response.StatusCode == HttpStatusCode.OK ? response.Content.ReadAsStreamAsync().Result : null; + var responseStream = response != null && response.StatusCode == HttpStatusCode.OK ? response.Content.ReadAsStreamAsync().Result : null; return responseStream != null ? await JsonSerializer.DeserializeAsync(responseStream) : default(ReturnType); } @@ -57,8 +57,9 @@ private async Task GetAsync(string url, HttpMethod method, return await _httpClient.SendAsync(requestMessage); } } - catch (HttpRequestException) + catch (HttpRequestException ex) { + Logger.LogWarning(ex, "Unable to connection to complete the request to Shoko."); return null; } } @@ -69,7 +70,7 @@ private Task PostAsync(string url, Type body, stri private async Task PostAsync(string url, HttpMethod method, Type body, string apiKey = null) { var response = await PostAsync(url, method, body, apiKey); - var responseStream = response.StatusCode == HttpStatusCode.OK ? response.Content.ReadAsStreamAsync().Result : null; + var responseStream = response != null && response.StatusCode == HttpStatusCode.OK ? response.Content.ReadAsStreamAsync().Result : null; return responseStream != null ? await JsonSerializer.DeserializeAsync(responseStream) : default(ReturnType); } @@ -93,8 +94,9 @@ private async Task PostAsync(string url, HttpMethod m return await _httpClient.SendAsync(requestMessage); } } - catch (HttpRequestException) + catch (HttpRequestException ex) { + Logger.LogWarning(ex, "Unable to connection to complete the request to Shoko."); return null; } } From f2495bd1d949fb19d3b1b24c3ccf1ef2dfeb7427 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Thu, 30 Sep 2021 22:38:48 +0200 Subject: [PATCH 0252/1103] Update the user data sync manager to use per-user sync settings. --- Shokofin/Configuration/PluginConfiguration.cs | 9 -- Shokofin/Configuration/UserConfiguration.cs | 6 + Shokofin/Configuration/configController.js | 60 ++++---- Shokofin/Configuration/configPage.html | 50 +++---- Shokofin/UserDataSyncManager.cs | 135 +++++++++++++++--- 5 files changed, 168 insertions(+), 92 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index c9f76104..b5a899bd 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -24,12 +24,6 @@ public virtual string PrettyHost public string ApiKey { get; set; } - public bool SyncUserDataAfterPlayback { get; set; } - - public bool SyncUserDataUnderPlayback { get; set; } - - public bool SyncUserDataOnImport { get; set; } - public bool HideArtStyleTags { get; set; } public bool HideSourceTags { get; set; } @@ -80,9 +74,6 @@ public PluginConfiguration() PublicHost = ""; Username = "Default"; ApiKey = ""; - SyncUserDataAfterPlayback = false; - SyncUserDataUnderPlayback = false; - SyncUserDataOnImport = false; HideArtStyleTags = false; HideSourceTags = false; HideMiscTags = false; diff --git a/Shokofin/Configuration/UserConfiguration.cs b/Shokofin/Configuration/UserConfiguration.cs index 0adca14c..5bad20b4 100644 --- a/Shokofin/Configuration/UserConfiguration.cs +++ b/Shokofin/Configuration/UserConfiguration.cs @@ -17,6 +17,12 @@ public class UserConfiguration /// public bool EnableSynchronization { get; set; } + public bool SyncUserDataAfterPlayback { get; set; } + + public bool SyncUserDataUnderPlayback { get; set; } + + public bool SyncUserDataOnImport { get; set; } + /// /// The username of the linked user in Shoko. /// diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index d251f1ed..265ba6c1 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -8,10 +8,10 @@ const Messages = { UnableToRender: "There was an error loading the page, please refresh once to see if that will fix it.", }; -async function loadUserConfig(page, userId, config) { +async function loadUserConfig(form, userId, config) { if (!userId) { - page.querySelector("#UserSettingsContainer").setAttribute("hidden", ""); - page.querySelector("#UserUsername").removeAttribute("required"); + form.querySelector("#UserSettingsContainer").setAttribute("hidden", ""); + form.querySelector("#UserUsername").removeAttribute("required"); Dashboard.hideLoadingMsg(); return; } @@ -23,24 +23,28 @@ async function loadUserConfig(page, userId, config) { const userConfig = config.UserList.find((c) => userId === c.UserId) || { UserId: userId }; // Configure the elements within the user container - page.querySelector("#UserEnableSynchronization").checked = userConfig.EnableSynchronization || false; - page.querySelector("#UserUsername").value = userConfig.Username || ""; - page.querySelector("#UserPassword").value = ""; + form.querySelector("#UserEnableSynchronization").checked = userConfig.EnableSynchronization || false; + form.querySelector("#SyncUserDataOnImport").checked = userConfig.SyncUserDataOnImport; + form.querySelector("#SyncUserDataAfterPlayback").checked = userConfig.SyncUserDataAfterPlayback; + form.querySelector("#SyncUserDataUnderPlayback").checked = userConfig.SyncUserDataAfterPlayback && userConfig.SyncUserDataUnderPlayback; + form.querySelector("#UserUsername").value = userConfig.Username || ""; + // Synchronization settings + form.querySelector("#UserPassword").value = ""; if (userConfig.Token) { - page.querySelector("#UserDeleteContainer").removeAttribute("hidden"); - page.querySelector("#UserUsername").setAttribute("disabled", ""); - page.querySelector("#UserPasswordContainer").setAttribute("hidden", ""); - page.querySelector("#UserUsername").removeAttribute("required"); + form.querySelector("#UserDeleteContainer").removeAttribute("hidden"); + form.querySelector("#UserUsername").setAttribute("disabled", ""); + form.querySelector("#UserPasswordContainer").setAttribute("hidden", ""); + form.querySelector("#UserUsername").removeAttribute("required"); } else { - page.querySelector("#UserDeleteContainer").setAttribute("hidden", ""); - page.querySelector("#UserUsername").removeAttribute("disabled"); - page.querySelector("#UserPasswordContainer").removeAttribute("hidden"); - page.querySelector("#UserUsername").setAttribute("required", ""); + 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. - page.querySelector("#UserSettingsContainer").removeAttribute("hidden"); + form.querySelector("#UserSettingsContainer").removeAttribute("hidden"); Dashboard.hideLoadingMsg(); } @@ -99,11 +103,6 @@ async function defaultSubmit(form) { config.FilterOnLibraryTypes = form.querySelector("#FilterOnLibraryTypes").checked; config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; - - // Synchronization settings - config.SyncUserDataOnImport = form.querySelector("#SyncUserDataOnImport").checked; - config.SyncUserDataAfterPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked; - config.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked && form.querySelector("#SyncUserDataUnderPlayback").checked; // Tag settings config.HideArtStyleTags = form.querySelector("#HideArtStyleTags").checked; @@ -128,6 +127,9 @@ async function defaultSubmit(form) { // 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("#SyncUserDataAfterPlayback").checked && form.querySelector("#SyncUserDataUnderPlayback").checked; // Only try to save a new token if a token is not already present. const username = form.querySelector("#UserUsername").value; @@ -220,11 +222,6 @@ async function syncSettings(form) { config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; - // Synchronization settings - config.SyncUserDataOnImport = form.querySelector("#SyncUserDataOnImport").checked; - config.SyncUserDataAfterPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked; - config.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked && form.querySelector("#SyncUserDataUnderPlayback").checked; - // Tag settings config.HideArtStyleTags = form.querySelector("#HideArtStyleTags").checked; config.HideSourceTags = form.querySelector("#HideSourceTags").checked; @@ -273,6 +270,9 @@ async function syncUserSettings(form) { // 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("#SyncUserDataAfterPlayback").checked && form.querySelector("#SyncUserDataUnderPlayback").checked; // Only try to save a new token if a token is not already present. const username = form.querySelector("#UserUsername").value; @@ -310,7 +310,6 @@ export default function (page) { form.querySelector("#MetadataSection").removeAttribute("hidden"); form.querySelector("#MetadataSection").removeAttribute("hidden"); form.querySelector("#LibrarySection").removeAttribute("hidden"); - form.querySelector("#SyncSection").removeAttribute("hidden"); form.querySelector("#UserSection").removeAttribute("hidden"); form.querySelector("#TagSection").removeAttribute("hidden"); form.querySelector("#AdvancedSection").removeAttribute("hidden"); @@ -324,15 +323,13 @@ export default function (page) { form.querySelector("#MetadataSection").setAttribute("hidden", ""); form.querySelector("#MetadataSection").setAttribute("hidden", ""); form.querySelector("#LibrarySection").setAttribute("hidden", ""); - form.querySelector("#SyncSection").setAttribute("hidden", ""); form.querySelector("#UserSection").setAttribute("hidden", ""); form.querySelector("#TagSection").setAttribute("hidden", ""); form.querySelector("#AdvancedSection").setAttribute("hidden", ""); } const userId = form.querySelector("#UserSelector").value; - loadUserConfig(page, userId, config); - toggleSyncUnderPlayback(page, config.SyncUserDataAfterPlayback); + loadUserConfig(form, userId, config); }; const onError = (err) => { @@ -374,11 +371,6 @@ export default function (page) { form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement; form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; - // Synchronization settings - form.querySelector("#SyncUserDataOnImport").checked = config.SyncUserDataOnImport; - form.querySelector("#SyncUserDataAfterPlayback").checked = config.SyncUserDataAfterPlayback; - form.querySelector("#SyncUserDataUnderPlayback").checked = config.SyncUserDataAfterPlayback && config.SyncUserDataUnderPlayback; - // User settings userSelector.innerHTML += users.map((user) => ``); diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 9401c5c0..4dc4e66e 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -141,35 +141,6 @@

Library Settings

Save -
+
+ +
Enabling this will import the watch-state for items from Shoko on import or refresh.
+
+
+ +
Enabling this will sync-back the watch-state to Shoko after playback has ended.
+
+
+ +
Enabling this will sync-back back the watch-state to Shoko during playback. "Sync watch-state after playback" must be enabled first to enable this setting.
+
The username of the account to synchronize with the currently selected user.
diff --git a/Shokofin/UserDataSyncManager.cs b/Shokofin/UserDataSyncManager.cs index 92782989..5c4b022e 100644 --- a/Shokofin/UserDataSyncManager.cs +++ b/Shokofin/UserDataSyncManager.cs @@ -14,6 +14,15 @@ namespace Shokofin { + + [Flags] + public enum SyncDirection { + None = 0, + Import = 1, + Export = 2, + Both = 3, + } + public class UserDataSyncManager { private readonly IUserDataManager UserDataManager; @@ -77,13 +86,13 @@ public void OnUserDataSaved(object sender, UserDataSaveEventArgs e) switch (e.SaveReason) { case UserDataSaveReason.PlaybackStart: case UserDataSaveReason.PlaybackProgress: - if (!config.SyncUserDataUnderPlayback || !userConfig.EnableSynchronization) + if (!userConfig.SyncUserDataUnderPlayback) return; Logger.LogDebug("Scrobbled during playback. (File={FileId})", fileId); APIClient.ScrobbleFile(fileId, userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); break; case UserDataSaveReason.PlaybackFinished: - if (!config.SyncUserDataAfterPlayback || !userConfig.EnableSynchronization) + if (!userConfig.SyncUserDataAfterPlayback) return; Logger.LogDebug("Scrobbled after playback. (File={FileId})", fileId); APIClient.ScrobbleFile(fileId, userData.Played, userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); @@ -112,21 +121,21 @@ private void OnUserRatingSaved(object sender, UserDataSaveEventArgs e) if (!Lookup.TryGetEpisodeIdFor(video, out var episodeId)) return; - Logger.LogDebug("TODO; Sync user rating for video {VideoName}. (Episode={EpisodeId})", e.Item.Name, episodeId); + SyncVideo(video, userConfig, userData, SyncDirection.Export, episodeId).ConfigureAwait(false); break; } case Season season: { if (!Lookup.TryGetSeriesIdFor(season, out var seriesId)) return; - Logger.LogDebug("TODO; Sync user rating for season {SeasonNumber} in series {SeriesName}. (Series={SeriesId})", season.IndexNumber, season.SeriesName, seriesId); + SyncSeason(season, userConfig, userData, SyncDirection.Export, seriesId).ConfigureAwait(false); break; } case Series series: { if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) return; - Logger.LogDebug("TODO; Sync user rating for series {SeriesName}. (Series={SeriesId})", e.Item.Name, seriesId); + SyncSeries(series, userConfig, userData, SyncDirection.Export, seriesId).ConfigureAwait(false); break; } } @@ -167,7 +176,7 @@ public async Task ScanAndSync(IProgress progress, CancellationToken canc continue; foreach (var userConfig in enabledUsers) { - await SyncVideo(userConfig, null, video, fileId, episodeId).ConfigureAwait(false); + await SyncVideo(video, userConfig, null, SyncDirection.Both, fileId, episodeId).ConfigureAwait(false); numComplete++; double percent = numComplete; @@ -181,37 +190,119 @@ public async Task ScanAndSync(IProgress progress, CancellationToken canc public void OnItemAddedOrUpdated(object sender, ItemChangeEventArgs e) { - if (Plugin.Instance.Configuration.SyncUserDataOnImport) - return; - if (e == null || e.Item == null || e.Parent == null || !(e.UpdateReason.HasFlag(ItemUpdateType.MetadataImport) || e.UpdateReason.HasFlag(ItemUpdateType.MetadataDownload))) return; - if (!(e.Item is Video video)) - return; + switch (e.Item) { + case Video video: { + if (!(Lookup.TryGetFileIdFor(video, out var fileId) && Lookup.TryGetEpisodeIdFor(video, out var episodeId))) + return; - if (!(Lookup.TryGetFileIdFor(video, out var fileId) && Lookup.TryGetEpisodeIdFor(video, out var episodeId))) - return; + foreach (var userConfig in Plugin.Instance.Configuration.UserList) { + if (!userConfig.EnableSynchronization) + continue; - foreach (var userConfig in Plugin.Instance.Configuration.UserList) { - if (!userConfig.EnableSynchronization) - continue; + if (!userConfig.SyncUserDataOnImport) + continue; - SyncVideo(userConfig, null, video, fileId, episodeId).ConfigureAwait(false); + SyncVideo(video, userConfig, null, SyncDirection.Import, fileId, episodeId).ConfigureAwait(false); + } + break; + } + case Season season: { + if (!Lookup.TryGetSeriesIdFor(season, out var seriesId)) + return; + + foreach (var userConfig in Plugin.Instance.Configuration.UserList) { + if (!userConfig.EnableSynchronization) + continue; + + if (!userConfig.SyncUserDataOnImport) + continue; + + SyncSeason(season, userConfig, null, SyncDirection.Import, seriesId).ConfigureAwait(false); + } + break; + } + case Series series: { + if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + return; + + foreach (var userConfig in Plugin.Instance.Configuration.UserList) { + if (!userConfig.EnableSynchronization) + continue; + + if (!userConfig.SyncUserDataOnImport) + continue; + + SyncSeries(series, userConfig, null, SyncDirection.Import, seriesId).ConfigureAwait(false); + } + break; + } } + } #endregion - private async Task SyncVideo(UserConfiguration userConfig, UserItemData userData, Video item, string fileId, string episodeId) + private async Task SyncSeries(Series series, UserConfiguration userConfig, UserItemData userData, SyncDirection direction, string seriesId) { + if (userData == null) + userData = UserDataManager.GetUserData(userConfig.UserId, series); + // Create some new user-data if none exists. + if (userData == null) + userData = new UserItemData { + UserId = userConfig.UserId, + + LastPlayedDate = null, + }; + + // TODO: Check what needs to be done, e.g. update JF, update SS, or nothing. + Logger.LogDebug("TODO; Sync user rating for series {SeriesName}. (Series={SeriesId})", series.Name, seriesId); + } + + private async Task SyncSeason(Season season, UserConfiguration userConfig, UserItemData userData, SyncDirection direction, string seriesId) + { + if (userData == null) + userData = UserDataManager.GetUserData(userConfig.UserId, season); + // Create some new user-data if none exists. + if (userData == null) + userData = new UserItemData { + UserId = userConfig.UserId, + + LastPlayedDate = null, + }; + + // TODO: Check what needs to be done, e.g. update JF, update SS, or nothing. + Logger.LogDebug("TODO; Sync user rating for season {SeasonNumber} in series {SeriesName}. (Series={SeriesId})", season.IndexNumber, season.SeriesName, seriesId); + } + + private async Task SyncVideo(Video video, UserConfiguration userConfig, UserItemData userData, SyncDirection direction, string episodeId) + { + // Try to load the user-data if it was not provided + if (userData == null) + userData = UserDataManager.GetUserData(userConfig.UserId, video); + // Create some new user-data if none exists. + if (userData == null) + userData = new UserItemData { + UserId = userConfig.UserId, + + LastPlayedDate = null, + }; + // var remoteUserData = await APIClient.GetFileUserData(fileId, userConfig.Token); // if (remoteUserData == null) // return; + // TODO: Check what needs to be done, e.g. update JF, update SS, or nothing. + Logger.LogDebug("TODO: Sync user data for video {ItemName}. (Episode={EpisodeId})", video.Name, episodeId); + } + + private async Task SyncVideo(Video video, UserConfiguration userConfig, UserItemData userData, SyncDirection direction, string fileId, string episodeId) + { // Try to load the user-data if it was not provided if (userData == null) - userData = UserDataManager.GetUserData(userConfig.UserId, item); + userData = UserDataManager.GetUserData(userConfig.UserId, video); // Create some new user-data if none exists. if (userData == null) userData = new UserItemData { @@ -220,8 +311,12 @@ private async Task SyncVideo(UserConfiguration userConfig, UserItemData userData LastPlayedDate = null, }; + // var remoteUserData = await APIClient.GetFileUserData(fileId, userConfig.Token); + // if (remoteUserData == null) + // return; + // TODO: Check what needs to be done, e.g. update JF, update SS, or nothing. - Logger.LogDebug("TODO: Sync user data for video {ItemName}. (File={FileId},Episode={EpisodeId})", item.Name, fileId, episodeId); + Logger.LogDebug("TODO: Sync user data for video {ItemName}. (File={FileId},Episode={EpisodeId})", video.Name, fileId, episodeId); } } } From f0cca7845d73821ba9d69999a1cb9a3fc74a5ce3 Mon Sep 17 00:00:00 2001 From: revam Date: Thu, 30 Sep 2021 20:39:40 +0000 Subject: [PATCH 0253/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 7c0c7554..9448ac16 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.5.0.38", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.38/shokofin_1.5.0.38.zip", + "checksum": "1eb233f6014d379460dd056899d5b148", + "timestamp": "2021-09-30T20:39:38Z" + }, { "version": "1.5.0.37", "changelog": "NA", From 9499101b427b8966ca47feb426d4f84fe868635f Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Fri, 1 Oct 2021 00:53:18 +0200 Subject: [PATCH 0254/1103] Add an ignore filter for the library scanner --- Shokofin/Configuration/PluginConfiguration.cs | 3 +++ Shokofin/Configuration/configController.js | 7 +++++++ Shokofin/Configuration/configPage.html | 4 ++++ Shokofin/LibraryScanner.cs | 5 +++++ Shokofin/Plugin.cs | 12 ++++++++++++ 5 files changed, 31 insertions(+) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index b5a899bd..c1a521d4 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -68,6 +68,8 @@ public virtual string PrettyHost public UserConfiguration[] UserList { get; set; } + public string[] IgnoredFileExtensions { get; set; } + public PluginConfiguration() { Host = "http://127.0.0.1:8111"; @@ -96,6 +98,7 @@ public PluginConfiguration() MovieOrdering = OrderType.Default; FilterOnLibraryTypes = false; UserList = Array.Empty(); + IgnoredFileExtensions  = new [] { ".nfo", ".jpg", ".jpeg", ".png", ".srt", ".stl", ".sub", ".scc" }; } } } diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 265ba6c1..9afc4359 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -87,6 +87,7 @@ async function defaultSubmit(form) { publicHost = publicHost.slice(0, -1); form.querySelector("#PublicHost").value = publicHost; } + const ignoredFileExtensions = form.querySelector("#IgnoredFileExtensions").value.split(/[\s,]+/g).map(str =>  { str = str.trim(); if (str[0] !== ".") str = "." + str; return str; }); // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; @@ -113,6 +114,8 @@ async function defaultSubmit(form) { // Advanced settings config.PublicHost = publicHost; + config.IgnoredFileExtensions = ignoredFileExtensions; + form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); config.PreferAniDbPoster = form.querySelector("#PreferAniDbPoster").checked; config.AddAniDBId = form.querySelector("#AddAniDBId").checked; @@ -205,6 +208,7 @@ async function syncSettings(form) { publicHost = publicHost.slice(0, -1); form.querySelector("#PublicHost").value = publicHost; } + const ignoredFileExtensions = form.querySelector("#IgnoredFileExtensions").value.split(/[\s,]+/g).map(str =>  { str = str.trim(); if (str[0] !== ".") str = "." + str; return str; }); // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; @@ -231,6 +235,8 @@ async function syncSettings(form) { // Advanced settings config.PublicHost = publicHost; + config.IgnoredFileExtensions = ignoredFileExtensions; + form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); config.PreferAniDbPoster = form.querySelector("#PreferAniDbPoster").checked; config.AddAniDBId = form.querySelector("#AddAniDBId").checked; @@ -383,6 +389,7 @@ export default function (page) { // Advanced settings form.querySelector("#PublicHost").value = config.PublicHost; + form.querySelector("#IgnoredFileExtensions").value = config.IgnoredFileExtensions.join(" "); form.querySelector("#PreferAniDbPoster").checked = config.PreferAniDbPoster; form.querySelector("#AddAniDBId").checked = config.AddAniDBId; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 4dc4e66e..4085c85f 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -246,6 +246,10 @@

Advanced Settings

This is the public URL leading to where Shoko is running. It is 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. It should include both the protocol and the IP/DNS name.
+
+ +
A space seperated list of file extensions which will be ignored during the library scan.
+
-
- -
Enabling this will prefer the poster image from AniDB over other sources for the default Series/Seasons poster
-
Enabling this will add the AniDB ID for all supported item types where an ID is available.
+
+ +
Enabling this will add the TvDB/TMDB ID for all supported item types where an ID is available when using the default Series/Season grouping.
+
diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index dc902015..76e18f60 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -230,6 +230,9 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri CommunityRating = episode.AniDB.Rating.ToFloat(10), }; } + + if (config.SeriesGrouping == Ordering.GroupType.Default && config.AddOtherId) + result.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); } result.SetProviderId("Shoko Episode", episode.Id); From 0c5c8c1d58faf34ee8c67af957cf93af3076272f Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Fri, 22 Oct 2021 14:39:38 +0200 Subject: [PATCH 0300/1103] chore: update descriptions for the plugin library settings --- Shokofin/Configuration/configPage.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 23752742..6f08aa7e 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -92,15 +92,15 @@

Metadata Settings

Library Settings

-
Series merging must be enabled in the Library Settings, or else unexpected results will occur.
+
"A setting" must be disabled in the Library Settings if the Series/Season grouping set to "Do not group Series into Seasons", otherwise it must be enabled. If you do do this then the plugin will not work as expected.
- + -
Determines how to group Series together and divide them into Seasons.
+
Determines how to group Series together and divide them into Seasons. Warning: Modifying this setting requires a recreation (read as; delete existing then create a new) of any libraries using this plugin — otherwise you will have mixed metadata.
@@ -128,7 +128,7 @@

Library Settings

-
Determines how Specials are placed within Seasons. Warning: Modifying this setting requires a rebuild (and not just a full-refresh) of any libraries using this plugin — otherwise you will have mixed metadata.
+
Determines how Specials are placed within Seasons. Warning: Modifying this setting requires a recreation (read as; delete existing then create a new) of any libraries using this plugin — otherwise you will have mixed metadata.
-
Enabling this will import the watch-state for items from Shoko on import or refresh.
+
Enabling this setting will import the watch-state for items from Shoko on import or refresh. Note: This feature is currently not fully implemented. Enabling this setting will only print debug messages in your console/log until the needed functionality is implemented in (the v3 API in) Shoko Server.
-
Enabling this will sync-back the watch-state to Shoko after playback has ended.
+
Enabling this setting will sync-back the watch-state to Shoko after playback has ended.
-
Enabling this will sync-back back the watch-state to Shoko during playback. "Sync watch-state after playback" must be enabled first to enable this setting.
+
Enabling this setting will sync-back back the watch-state to Shoko during playback. "Sync watch-state after playback" must be enabled first to enable this setting.
From 50e1c8b371e0ceb6a3655ba4a5ff7b59cc7b71c0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Wed, 12 Jan 2022 19:49:13 +0100 Subject: [PATCH 0309/1103] chore: update the description for the library seperation setting --- 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 6fffd84d..eb213ad7 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -116,7 +116,7 @@

Library Settings

Enable library seperation -
Split Shoko Groups in two, and actively filter out folders and files that don't belong to the selected library type.
+
This setting can be used to have one shared root folder on your disk for two libraries in Shoko — one library for movies and one for shows. Enabling this will cause the plugin to actively filter out movies from the show library and everything but movies from the movies library. Also, if you've selected to use Shoko's Group feature to create Series/Seasons then it will also exclude the Movies from within the series — i.e. the "season" for the movie won't appear  — even if they share a group in Shoko.
From fe65993d41d6ac758abecc581058d867fa4cb4eb Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Wed, 12 Jan 2022 19:54:59 +0100 Subject: [PATCH 0310/1103] chore: update the name of the setting that needs to be toggled --- 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 eb213ad7..007c782a 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -92,7 +92,7 @@

Metadata Settings

Library Settings

-
"A setting" must be disabled in the Library Settings if the Series/Season grouping set to "Do not group Series into Seasons", otherwise it must be enabled. If you do do this then the plugin will not work as expected.
+
"Automatically merge series that are spread across multiple folders" must be disabled in the Library Settings if the Show/Season grouping set to "Do not group Series into Seasons", otherwise it must be enabled. If you do not do this then the plugin will not work as expected.
- - - - - - + + + + +
Determines how Specials are placed within Seasons. Warning: Modifying this setting requires a recreation (read as; delete existing then create a new) of any libraries using this plugin — otherwise you will have mixed metadata.
diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 29fd621f..ced2da32 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -234,12 +234,8 @@ public static (int?, int?, int?) GetSpecialPlacement(GroupInfo group, SeriesInfo int? airsAfterSeasonNumber = null; switch (order) { default: - switch (Plugin.Instance.Configuration.SeriesGrouping) { - default: - goto byAirdate; - case GroupType.MergeFriendly: - goto byOtherData; - } + airsAfterSeasonNumber = seasonNumber; + break; case SpecialOrderType.InBetweenSeasonByAirDate: byAirdate: // Reset the order if we come from `SpecialOrderType.InBetweenSeasonMixed`. @@ -255,13 +251,8 @@ public static (int?, int?, int?) GetSpecialPlacement(GroupInfo group, SeriesInfo airsAfterSeasonNumber = seasonNumber; } break; - case SpecialOrderType.AfterSeason: { - airsAfterSeasonNumber = seasonNumber; - break; - } case SpecialOrderType.InBetweenSeasonMixed: case SpecialOrderType.InBetweenSeasonByOtherData: - byOtherData: // We need to have TvDB/TMDB data in the first place to do this method. if (episode.TvDB == null) { if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirdate; From 0bf8b5ccc8d0eb5203fec2201a7d9e10555b365c Mon Sep 17 00:00:00 2001 From: revam Date: Wed, 12 Jan 2022 19:07:54 +0000 Subject: [PATCH 0312/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 60eee117..8a3f5f34 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.0.1", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.0.1/shoko_1.7.0.1.zip", + "checksum": "b27f97c6b135f382a2c16d6f92e9535f", + "timestamp": "2022-01-12T19:07:52Z" + }, { "version": "1.6.3.1", "changelog": "NA", From 4997830ab92a27f8298f8b0e5fd889be11ee37e3 Mon Sep 17 00:00:00 2001 From: revam Date: Wed, 12 Jan 2022 19:27:11 +0000 Subject: [PATCH 0313/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index f5225a11..18e0f9e5 100644 --- a/manifest.json +++ b/manifest.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.0.0", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.0/shoko_1.7.0.0.zip", + "checksum": "e6604c4c9729b2f8a82bb9d4dfb0bfab", + "timestamp": "2022-01-12T19:27:09Z" + }, { "version": "1.6.3.0", "changelog": "NA", From 0361d8447f6aceb1197af91fba7e8e3c826e5006 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Fri, 21 Jan 2022 17:36:25 +0100 Subject: [PATCH 0314/1103] fix: Fix movie provider --- Shokofin/Providers/MovieProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 7cf2401d..81a04924 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -38,7 +38,7 @@ public async Task> GetMetadata(MovieInfo info, Cancellatio var includeGroup = Plugin.Instance.Configuration.BoxSetGrouping == Ordering.GroupType.ShokoGroup; var config = Plugin.Instance.Configuration; - Ordering.GroupFilterType? filterByType = config.SeriesGrouping == Ordering.GroupType.ShokoGroup ? config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Movies : Ordering.GroupFilterType.Default : null; + Ordering.GroupFilterType? filterByType = config.BoxSetGrouping == Ordering.GroupType.ShokoGroup ? config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Movies : Ordering.GroupFilterType.Default : null; var (file, episode, series, group) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); // if file is null then series and episode is also null. From b9f17ce23121eba48a5a0ae5b945c77cb03da688 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Fri, 21 Jan 2022 17:37:07 +0100 Subject: [PATCH 0315/1103] fix: Fix descriptions for some movies --- Shokofin/Providers/MovieProvider.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 81a04924..d968f590 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -51,6 +51,7 @@ public async Task> GetMetadata(MovieInfo info, Cancellatio Logger.LogInformation("Found movie {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", displayTitle, file.Id, episode.Id, series.Id); bool isMultiEntry = series.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) : series.AniDB.Rating.ToFloat(10); result.Item = new Movie { @@ -58,8 +59,8 @@ public async Task> GetMetadata(MovieInfo info, Cancellatio Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, - // Use the file description if collection contains more than one movie, otherwise use the collection description. - Overview = (isMultiEntry ? Text.GetDescription(episode) : Text.GetDescription(series)), + // 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(series)), ProductionYear = episode.AniDB.AirDate?.Year, Tags = series.Tags.ToArray(), Genres = series.Genres.ToArray(), From c0943553ff363ec7c0261be25bc2c69e74c6060d Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Fri, 21 Jan 2022 18:51:27 +0100 Subject: [PATCH 0316/1103] chore: update readme --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cb352d58..9e3a0593 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@ # Shokofin -**Warning**: This plugin requires a version of Jellyfin after 10.7 (`>=10.7.0`) and a stable version of Shoko after 4.1.1 (`>=4.1.1`) to be installed to work properly. +A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/docs/) with [Shoko Server](https://shokoanime.com/downloads/shoko-server/). -A plugin to integrate your Shoko database with the Jellyfin media server. +## Read this before installing + +The plugin requires a version of Jellyfin greater or equal to **10.7.0** (`>=10.7.0`) and an ustable version of Shoko Server greater or equal to **4.1.1** (`>=4.1.1`) to be installed. It also requires that you have already set up and are using Shoko Server, and that the directories/folders you intend to use in Jellyfin are fully indexed (and optionally managed) by Shoko Server, otherwise the plugin won't be able to funciton properly — it won't be able to find metadata about any entries that are not indexed by Shoko Server since the metadata we want is not available. ## Breaking Changes ### 1.5.0 -If you're upgrading from an older version to version 1.5.0, then be sure to update the "Host" field in the plugin settings before you continue using the plugin. +If you're upgrading from an older version to version 1.5.0, then be sure to update the "Host" field in the plugin settings before you continue using the plugin. **Update: Starting with 1.7.0 you just need to reset the connection then log in again.** ## Install -There are multiple ways to install this plugin, but the recomended way is to use the official Jellyfin repository. +There are many ways to install the plugin, but the recomended way is to use the official Jellyfin repository. ### Official Repository From 739d7101b2b81a8aebc657ae2e7e5dc7164f7db9 Mon Sep 17 00:00:00 2001 From: revam Date: Fri, 21 Jan 2022 18:10:51 +0000 Subject: [PATCH 0317/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index 18e0f9e5..5f094aac 100644 --- a/manifest.json +++ b/manifest.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.1.0", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.1/shoko_1.7.1.0.zip", + "checksum": "c748f37e301aaa891da7e01842d02a87", + "timestamp": "2022-01-21T18:10:49Z" + }, { "version": "1.7.0.0", "changelog": "NA", From 99dbbd04c6a60c5495ba59d44d068cfa0d5fec39 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Sun, 23 Jan 2022 19:14:04 +0100 Subject: [PATCH 0318/1103] chore: fix spelling for task names --- Shokofin/Tasks/ExportUserDataTask.cs | 2 +- Shokofin/Tasks/ImportUserDataTask.cs | 2 +- Shokofin/Tasks/SyncUserDataTask.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/Tasks/ExportUserDataTask.cs b/Shokofin/Tasks/ExportUserDataTask.cs index ed8c180d..a4403fb1 100644 --- a/Shokofin/Tasks/ExportUserDataTask.cs +++ b/Shokofin/Tasks/ExportUserDataTask.cs @@ -51,7 +51,7 @@ public async Task Execute(CancellationToken cancellationToken, IProgress } /// - public string Name => "Export user-data"; + public string Name => "Export User Data"; /// public string Description => "Export the user-data stored in Jellyfin to Shoko. Will not import user-data from Shoko."; diff --git a/Shokofin/Tasks/ImportUserDataTask.cs b/Shokofin/Tasks/ImportUserDataTask.cs index 6a44d0fd..50861437 100644 --- a/Shokofin/Tasks/ImportUserDataTask.cs +++ b/Shokofin/Tasks/ImportUserDataTask.cs @@ -51,7 +51,7 @@ public async Task Execute(CancellationToken cancellationToken, IProgress } /// - public string Name => "Import user-data"; + public string Name => "Import User Data"; /// public string Description => "Import the user-data stored in Shoko to Jellyfin. Will not export user-data to Shoko."; diff --git a/Shokofin/Tasks/SyncUserDataTask.cs b/Shokofin/Tasks/SyncUserDataTask.cs index 21325915..0e49414a 100644 --- a/Shokofin/Tasks/SyncUserDataTask.cs +++ b/Shokofin/Tasks/SyncUserDataTask.cs @@ -51,7 +51,7 @@ public async Task Execute(CancellationToken cancellationToken, IProgress } /// - public string Name => "Sync user-data"; + public string Name => "Sync User Data"; /// public string Description => "Synchronize the user-data stored in Jellyfin with the user-data stored in Shoko. Imports or exports data as needed."; From c2f099a298ef92788c38d3f9089007d4680929b0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Sun, 23 Jan 2022 20:10:56 +0100 Subject: [PATCH 0319/1103] fix: fix refreshing outside a library scan for entries --- Shokofin/API/ShokoAPIManager.cs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 699a4255..9e1de559 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -1,4 +1,5 @@ using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -22,6 +23,8 @@ public class ShokoAPIManager private readonly ShokoAPIClient APIClient; + private readonly ILibraryManager LibraryManager; + private readonly List MediaFolderList = new List(); private readonly ConcurrentDictionary SeriesPathToIdDictionary = new ConcurrentDictionary(); @@ -40,10 +43,11 @@ public class ShokoAPIManager private readonly ConcurrentDictionary FileIdToEpisodeIdDictionary = new ConcurrentDictionary(); - public ShokoAPIManager(ILogger logger, ShokoAPIClient apiClient) + public ShokoAPIManager(ILogger logger, ShokoAPIClient apiClient, ILibraryManager libraryManager) { Logger = logger; APIClient = apiClient; + LibraryManager = libraryManager; } private static IMemoryCache DataCache = new MemoryCache(new MemoryCacheOptions() { @@ -77,9 +81,27 @@ public Folder FindMediaFolder(string path, Folder parent, Folder root) public string StripMediaFolder(string fullPath) { var mediaFolder = MediaFolderList.FirstOrDefault((folder) => fullPath.StartsWith(folder.Path)); - // If no root folder was found, then we _most likely_ already stripped it out beforehand. - if (mediaFolder == null || string.IsNullOrEmpty(mediaFolder?.Path)) + if (mediaFolder != null) { + return fullPath.Substring(mediaFolder.Path.Length); + } + // Try to get the media folder by loading the parent and navigating upwards till we reach the root. + var directoryPath = System.IO.Path.GetDirectoryName(fullPath); + if (string.IsNullOrEmpty(directoryPath)) { return fullPath; + } + mediaFolder = (LibraryManager.FindByPath(directoryPath, true) as Folder); + if (mediaFolder == null || string.IsNullOrEmpty(mediaFolder?.Path)) { + return fullPath; + } + // Look for the root folder for the current item. + var root = LibraryManager.RootFolder; + while (!mediaFolder.ParentId.Equals(root.Id)) { + if (mediaFolder.Parent == null) { + break; + } + mediaFolder = mediaFolder.Parent; + } + MediaFolderList.Add(mediaFolder); return fullPath.Substring(mediaFolder.Path.Length); } From 751eefa535736cdd7b5645a3f9a2ff6d5f93c362 Mon Sep 17 00:00:00 2001 From: revam Date: Sun, 23 Jan 2022 19:11:53 +0000 Subject: [PATCH 0320/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 8a3f5f34..d8e90f7d 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.1.2", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.1.2/shoko_1.7.1.2.zip", + "checksum": "a1d5f252aecc989fb8963e4c98dab334", + "timestamp": "2022-01-23T19:11:51Z" + }, { "version": "1.7.0.1", "changelog": "NA", From 2a000b5efaca0c5f5efeb40200874d08de80e613 Mon Sep 17 00:00:00 2001 From: Harshith Mohan Date: Mon, 24 Jan 2022 01:31:06 +0530 Subject: [PATCH 0321/1103] Refactor Find to FirstOrDefault --- Shokofin/API/ShokoAPIManager.cs | 2 +- Shokofin/Providers/ExtraMetadataProvider.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 9e1de559..a17af718 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -62,7 +62,7 @@ public ShokoAPIManager(ILogger logger, ShokoAPIClient apiClient public Folder FindMediaFolder(string path, Folder parent, Folder root) { - var mediaFolder = MediaFolderList.Find((folder) => path.StartsWith(folder.Path)); + var mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path)); // Look for the root folder for the current item. if (mediaFolder != null) { return mediaFolder; diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index 77638ca3..ad105000 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -495,7 +495,7 @@ private void UpdateEpisode(Episode episode, string episodeId) { Info.GroupInfo groupInfo = null; Info.SeriesInfo seriesInfo = ApiManager.GetSeriesInfoForEpisodeSync(episodeId); - Info.EpisodeInfo episodeInfo = seriesInfo.EpisodeList.Find(e => e.Id == episodeId); + Info.EpisodeInfo episodeInfo = seriesInfo.EpisodeList.FirstOrDefault(e => e.Id == episodeId); if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesInfo.Id, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); From 83b7250ee9e28a0855a16bbf07340765599801a4 Mon Sep 17 00:00:00 2001 From: harshithmohan Date: Sun, 23 Jan 2022 20:02:05 +0000 Subject: [PATCH 0322/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index d8e90f7d..12d1593f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.1.3", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.1.3/shoko_1.7.1.3.zip", + "checksum": "41daba9f409f259556f6c62e6b1f0090", + "timestamp": "2022-01-23T20:02:03Z" + }, { "version": "1.7.1.2", "changelog": "NA", From dfeefc013b0f763d31b68c4a5df0b1cd9e83f838 Mon Sep 17 00:00:00 2001 From: revam Date: Sun, 23 Jan 2022 20:53:31 +0000 Subject: [PATCH 0323/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index 5f094aac..c2bef7c3 100644 --- a/manifest.json +++ b/manifest.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.2.0", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.2/shoko_1.7.2.0.zip", + "checksum": "404397db0ecac4870857700de7437825", + "timestamp": "2022-01-23T20:53:29Z" + }, { "version": "1.7.1.0", "changelog": "NA", From ff0fffbe971076501c2468437255427ed595c2dc Mon Sep 17 00:00:00 2001 From: revam Date: Sun, 23 Jan 2022 20:55:02 +0000 Subject: [PATCH 0324/1103] Update repo manifest --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index c2bef7c3..ab9534d4 100644 --- a/manifest.json +++ b/manifest.json @@ -13,8 +13,8 @@ "changelog": "NA", "targetAbi": "10.7.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.2/shoko_1.7.2.0.zip", - "checksum": "404397db0ecac4870857700de7437825", - "timestamp": "2022-01-23T20:53:29Z" + "checksum": "38dd48745750756abbe6850d5527e694", + "timestamp": "2022-01-23T20:55:00Z" }, { "version": "1.7.1.0", From d697c36162aa21356a5c617cc505e045474e6002 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Sat, 12 Feb 2022 22:42:06 +0100 Subject: [PATCH 0325/1103] Add trace log points --- Shokofin/API/ShokoAPIClient.cs | 10 ++++++++-- Shokofin/API/ShokoAPIManager.cs | 3 +++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 29aff6bc..3f333a9c 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -50,6 +50,7 @@ private async Task GetAsync(string url, HttpMethod method, } try { + Logger.LogTrace("Trying to get {URL}", url); var remoteUrl = string.Concat(Plugin.Instance.Configuration.Host, url); // Because Shoko Server don't support HEAD requests, we spoof it instead. @@ -76,7 +77,9 @@ private async Task GetAsync(string url, HttpMethod method, using (var requestMessage = new HttpRequestMessage(method, remoteUrl)) { requestMessage.Content = (new StringContent("")); requestMessage.Headers.Add("apikey", apiKey); - return await _httpClient.SendAsync(requestMessage); + var response = await _httpClient.SendAsync(requestMessage); + Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); + return response; } } catch (HttpRequestException ex) @@ -109,6 +112,7 @@ private async Task PostAsync(string url, HttpMethod m } try { + Logger.LogTrace("Trying to get {URL}", url); var remoteUrl = string.Concat(Plugin.Instance.Configuration.Host, url); if (method == HttpMethod.Get) @@ -120,7 +124,9 @@ private async Task PostAsync(string url, HttpMethod m using (var requestMessage = new HttpRequestMessage(method, remoteUrl)) { requestMessage.Content = (new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json")); requestMessage.Headers.Add("apikey", apiKey); - return await _httpClient.SendAsync(requestMessage); + var response = await _httpClient.SendAsync(requestMessage); + Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); + return response; } } catch (HttpRequestException ex) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index a17af718..ef81ecfe 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -258,6 +258,7 @@ public async Task GetStudiosForSeries(string seriesId) var partialPath = StripMediaFolder(path); Logger.LogDebug("Looking for file matching {Path}", partialPath); var result = await APIClient.GetFileByPath(partialPath); + Logger.LogTrace("Found result with {Count} matches for {Path}", result?.Count ?? 0, partialPath); var file = result?.FirstOrDefault(); if (file == null) @@ -453,6 +454,7 @@ public async Task GetSeriesInfoByPath(string path) if (!SeriesPathToIdDictionary.TryGetValue(path, out seriesId)) { var result = await APIClient.GetSeriesPathEndsWith(partialPath); + Logger.LogTrace("Found result with {Count} matches for {Path}", result?.Count ?? 0, partialPath); seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); if (string.IsNullOrEmpty(seriesId)) @@ -671,6 +673,7 @@ public async Task GetGroupInfoByPath(string path, Ordering.GroupFilte else { var result = await APIClient.GetSeriesPathEndsWith(partialPath); + Logger.LogTrace("Found result with {Count} matches for {Path}", result?.Count ?? 0, partialPath); seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); if (string.IsNullOrEmpty(seriesId)) From 3c7cc044416d600b00e069f5bbcf3582d1124074 Mon Sep 17 00:00:00 2001 From: revam Date: Sat, 12 Feb 2022 21:42:48 +0000 Subject: [PATCH 0326/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 12d1593f..2d4c5aee 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.2.1", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.2.1/shoko_1.7.2.1.zip", + "checksum": "0be794cab597dab51bff1f33852bdfc3", + "timestamp": "2022-02-12T21:42:47Z" + }, { "version": "1.7.1.3", "changelog": "NA", From 6cb3155373667e5e8cbc4bec03b907e7e0cb98f3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Sat, 19 Feb 2022 18:17:07 +0100 Subject: [PATCH 0327/1103] feature: Add image width and height when supported --- Shokofin/API/Models/Image.cs | 4 ++++ Shokofin/Providers/ImageProvider.cs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index b5b9eb20..742c8229 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -16,6 +16,10 @@ public class Image public bool Disabled { get; set; } + public int? Width { get; set; } + + public int? Height { get; set; } + [JsonIgnore] public virtual string Path => $"/api/v3/Image/{Source}/{Type}/{ID}"; diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index fdf429af..56c00f41 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -125,6 +125,8 @@ private void AddImage(ref List list, ImageType imageType, API.M list.Add(new RemoteImageInfo { ProviderName = Plugin.MetadataProviderName, Type = imageType, + Width = image.Width, + Height = image.Height, Url = image.ToURLString(), }); } From 45f7cd9d02bc601825f345c853bbc8a29f8623f3 Mon Sep 17 00:00:00 2001 From: revam Date: Sat, 19 Feb 2022 17:21:33 +0000 Subject: [PATCH 0328/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 2d4c5aee..646f6d8d 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.2.2", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.2.2/shoko_1.7.2.2.zip", + "checksum": "66db4d4add3cba25da612532b5ee5acc", + "timestamp": "2022-02-19T17:21:32Z" + }, { "version": "1.7.2.1", "changelog": "NA", From fc133c12e63d974b650f47f30a028cc81be6cb6c Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Tue, 8 Mar 2022 17:16:10 +0100 Subject: [PATCH 0329/1103] fix: fix GetDescription in TextUtil --- Shokofin/Utils/TextUtil.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index 73b87d0e..6a0c8916 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -112,7 +112,7 @@ private static string GetDescription(string aniDbDescription, string otherDescri case TextSourceType.PreferAniDb: preferAniDb: overview = Text.SanitizeTextSummary(aniDbDescription); if (string.IsNullOrEmpty(overview)) - goto case TextSourceType.OnlyAniDb; + goto case TextSourceType.OnlyOther; break; case TextSourceType.OnlyAniDb: overview = Text.SanitizeTextSummary(aniDbDescription); @@ -145,7 +145,7 @@ private static string SanitizeTextSummary(string summary) if (config.SynopsisCleanMultiEmptyLines) summary = Regex.Replace(summary, @"\n{2,}", "\n", RegexOptions.Singleline); - return summary; + return summary.Trim(); } public static ( string, string ) GetEpisodeTitles(IEnumerable seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) From ce0cc85e26405a852669209643fa0d4d445607cf Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 8 Mar 2022 16:17:02 +0000 Subject: [PATCH 0330/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 646f6d8d..1af68cce 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.2.3", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.2.3/shoko_1.7.2.3.zip", + "checksum": "2cac755495cf473933d987fbaae83da1", + "timestamp": "2022-03-08T16:17:00Z" + }, { "version": "1.7.2.2", "changelog": "NA", From a80e53c9bde8db8bb59dc523a8d76c06411faf5e Mon Sep 17 00:00:00 2001 From: Marvel Renju <marvelrenju1@gmail.com> Date: Sat, 9 Apr 2022 15:27:20 +0100 Subject: [PATCH 0331/1103] Update to support jellyfin 10.8.0-beta1 --- Shokofin/API/ShokoAPIManager.cs | 8 ++++---- Shokofin/Providers/ExtraMetadataProvider.cs | 18 +++++++++--------- Shokofin/Shokofin.csproj | 4 ++-- Shokofin/Tasks/ExportUserDataTask.cs | 2 +- Shokofin/Tasks/ImportUserDataTask.cs | 2 +- Shokofin/Tasks/SyncUserDataTask.cs | 2 +- Shokofin/Web/WebController.cs | 2 +- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index ef81ecfe..603d5656 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -69,10 +69,10 @@ public Folder FindMediaFolder(string path, Folder parent, Folder root) } mediaFolder = parent; while (!mediaFolder.ParentId.Equals(root.Id)) { - if (mediaFolder.Parent == null) { + if (mediaFolder.GetParent() == null) { break; } - mediaFolder = mediaFolder.Parent; + mediaFolder = (Folder)mediaFolder.GetParent(); } MediaFolderList.Add(mediaFolder); return mediaFolder; @@ -96,10 +96,10 @@ public string StripMediaFolder(string fullPath) // Look for the root folder for the current item. var root = LibraryManager.RootFolder; while (!mediaFolder.ParentId.Equals(root.Id)) { - if (mediaFolder.Parent == null) { + if (mediaFolder.GetParent() == null) { break; } - mediaFolder = mediaFolder.Parent; + mediaFolder = (Folder)mediaFolder.GetParent(); } MediaFolderList.Add(mediaFolder); return fullPath.Substring(mediaFolder.Path.Length); diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index ad105000..c019359b 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -558,7 +558,7 @@ private void UpdateEpisode(Episode episode, string episodeId) private bool SeasonExists(string seriesPresentationUniqueKey, string seriesName, int seasonNumber) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { nameof (Season) }, + IncludeItemTypes = new [] { Enum.Parse<Jellyfin.Data.Enums.BaseItemKind>(nameof (Season)) }, IndexNumber = seasonNumber, SeriesPresentationUniqueKey = seriesPresentationUniqueKey, DtoOptions = new DtoOptions(true), @@ -603,7 +603,7 @@ private Season AddVirtualSeason(int seasonNumber, Series series) Logger.LogInformation("Adding virtual Season {SeasonNumber:00} to Series {SeriesName}.", seasonNumber, series.Name); - series.AddChild(season, CancellationToken.None); + series.AddChild(season); return season; } @@ -618,7 +618,7 @@ private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, int offset, int seas Logger.LogInformation("Adding virtual Season {SeasonNumber:00} to Series {SeriesName}. (Series={SeriesId})", seasonNumber, series.Name, seriesInfo.Id); - series.AddChild(season, CancellationToken.None); + series.AddChild(season); return season; } @@ -645,7 +645,7 @@ public void RemoveDuplicateSeasons(Series series, string seriesId) public void RemoveDuplicateSeasons(Season season, Series series, int seasonNumber, string seriesId) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { nameof (Season) }, + IncludeItemTypes = new [] { Enum.Parse<Jellyfin.Data.Enums.BaseItemKind>(nameof (Season)) }, ExcludeItemIds = new [] { season.Id }, IndexNumber = seasonNumber, DtoOptions = new DtoOptions(true), @@ -682,7 +682,7 @@ public void RemoveDuplicateSeasons(Season season, Series series, int seasonNumbe private bool EpisodeExists(string episodeId, string seriesId, string groupId) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { nameof (Episode) }, + IncludeItemTypes = new [] { Enum.Parse<Jellyfin.Data.Enums.BaseItemKind>(nameof (Episode)) }, HasAnyProviderId = { ["Shoko Episode"] = episodeId }, DtoOptions = new DtoOptions(true) }, true); @@ -705,7 +705,7 @@ private void AddVirtualEpisode(Info.GroupInfo groupInfo, Info.SeriesInfo seriesI Logger.LogInformation("Adding virtual Episode {EpisodeNumber:000} in Season {SeasonNumber:00} for Series {SeriesName}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episode.IndexNumber, season.Name, groupInfo?.Shoko.Name ?? seriesInfo.Shoko.Name, episodeInfo.Id, seriesInfo.Id, groupId); - season.AddChild(episode, CancellationToken.None); + season.AddChild(episode); } private void RemoveDuplicateEpisodes(Episode episode, string episodeId) @@ -714,7 +714,7 @@ private void RemoveDuplicateEpisodes(Episode episode, string episodeId) IsVirtualItem = true, ExcludeItemIds = new [] { episode.Id }, HasAnyProviderId = { ["Shoko Episode"] = episodeId }, - IncludeItemTypes = new [] { nameof (Episode) }, + IncludeItemTypes = new [] { Enum.Parse<Jellyfin.Data.Enums.BaseItemKind>(nameof (Episode)) }, GroupByPresentationUniqueKey = false, DtoOptions = new DtoOptions(true), }; @@ -792,7 +792,7 @@ private void AddExtras(Folder parent, Info.SeriesInfo seriesInfo) } if (needsUpdate) { parent.ExtraIds = parent.ExtraIds.Concat(extraIds).Distinct().ToArray(); - LibraryManager.UpdateItemAsync(parent, parent.Parent, ItemUpdateType.None, CancellationToken.None).ConfigureAwait(false); + LibraryManager.UpdateItemAsync(parent, parent.GetParent(), ItemUpdateType.None, CancellationToken.None).ConfigureAwait(false); } } @@ -800,7 +800,7 @@ public void RemoveExtras(Folder parent, string seriesId) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { IsVirtualItem = false, - IncludeItemTypes = new [] { nameof (Video) }, + IncludeItemTypes = new [] { Enum.Parse<Jellyfin.Data.Enums.BaseItemKind>(nameof(Video)) }, HasOwnerId = true, HasAnyProviderId = { ["Shoko Series"] = seriesId}, DtoOptions = new DtoOptions(true), diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj index 5329e77b..c413581c 100644 --- a/Shokofin/Shokofin.csproj +++ b/Shokofin/Shokofin.csproj @@ -1,11 +1,11 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net5.0</TargetFramework> + <TargetFramework>net6.0</TargetFramework> </PropertyGroup> <ItemGroup> - <PackageReference Include="Jellyfin.Controller" Version="10.7.0" /> + <PackageReference Include="Jellyfin.Controller" Version="10.8.0-beta1" /> <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> </ItemGroup> diff --git a/Shokofin/Tasks/ExportUserDataTask.cs b/Shokofin/Tasks/ExportUserDataTask.cs index a4403fb1..8357a48b 100644 --- a/Shokofin/Tasks/ExportUserDataTask.cs +++ b/Shokofin/Tasks/ExportUserDataTask.cs @@ -45,7 +45,7 @@ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() /// <param name="cancellationToken">The cancellation token.</param> /// <param name="progress">The progress.</param> /// <returns>Task.</returns> - public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress) + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { await _userSyncManager.ScanAndSync(SyncDirection.Export, progress, cancellationToken); } diff --git a/Shokofin/Tasks/ImportUserDataTask.cs b/Shokofin/Tasks/ImportUserDataTask.cs index 50861437..bec3c6df 100644 --- a/Shokofin/Tasks/ImportUserDataTask.cs +++ b/Shokofin/Tasks/ImportUserDataTask.cs @@ -45,7 +45,7 @@ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() /// <param name="cancellationToken">The cancellation token.</param> /// <param name="progress">The progress.</param> /// <returns>Task.</returns> - public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress) + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { await _userSyncManager.ScanAndSync(SyncDirection.Import, progress, cancellationToken); } diff --git a/Shokofin/Tasks/SyncUserDataTask.cs b/Shokofin/Tasks/SyncUserDataTask.cs index 0e49414a..b3f444d9 100644 --- a/Shokofin/Tasks/SyncUserDataTask.cs +++ b/Shokofin/Tasks/SyncUserDataTask.cs @@ -45,7 +45,7 @@ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() /// <param name="cancellationToken">The cancellation token.</param> /// <param name="progress">The progress.</param> /// <returns>Task.</returns> - public async Task Execute(CancellationToken cancellationToken, IProgress<double> progress) + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { await _userSyncManager.ScanAndSync(SyncDirection.Sync, progress, cancellationToken); } diff --git a/Shokofin/Web/WebController.cs b/Shokofin/Web/WebController.cs index cb789ce9..53271fd0 100644 --- a/Shokofin/Web/WebController.cs +++ b/Shokofin/Web/WebController.cs @@ -6,7 +6,7 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; -using MediaBrowser.Common.Json; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; using MediaBrowser.Model.Serialization; using Microsoft.AspNetCore.Http; From d2b21d5a75309148aed747ca526742ea07fe2bd6 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 21 Apr 2022 10:23:23 +0000 Subject: [PATCH 0332/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index ab9534d4..ec27fb61 100644 --- a/manifest.json +++ b/manifest.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.3.0", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3/shoko_1.7.3.0.zip", + "checksum": "4329184a94c68c621a4c944bee7d7f9d", + "timestamp": "2022-04-21T10:23:21Z" + }, { "version": "1.7.2.0", "changelog": "NA", From 8a7e270a112ac9a452dec62caf30b385862b5b1b Mon Sep 17 00:00:00 2001 From: Marvel Renju <marvelrenju1@gmail.com> Date: Thu, 21 Apr 2022 18:30:57 +0100 Subject: [PATCH 0333/1103] Replace instances of enum.parse with parsed type --- Shokofin/Providers/ExtraMetadataProvider.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index c019359b..32e7f0ac 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -558,7 +558,7 @@ private void UpdateEpisode(Episode episode, string episodeId) private bool SeasonExists(string seriesPresentationUniqueKey, string seriesName, int seasonNumber) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { Enum.Parse<Jellyfin.Data.Enums.BaseItemKind>(nameof (Season)) }, + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, IndexNumber = seasonNumber, SeriesPresentationUniqueKey = seriesPresentationUniqueKey, DtoOptions = new DtoOptions(true), @@ -645,7 +645,7 @@ public void RemoveDuplicateSeasons(Series series, string seriesId) public void RemoveDuplicateSeasons(Season season, Series series, int seasonNumber, string seriesId) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { Enum.Parse<Jellyfin.Data.Enums.BaseItemKind>(nameof (Season)) }, + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, ExcludeItemIds = new [] { season.Id }, IndexNumber = seasonNumber, DtoOptions = new DtoOptions(true), @@ -682,7 +682,7 @@ public void RemoveDuplicateSeasons(Season season, Series series, int seasonNumbe private bool EpisodeExists(string episodeId, string seriesId, string groupId) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { Enum.Parse<Jellyfin.Data.Enums.BaseItemKind>(nameof (Episode)) }, + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Episode }, HasAnyProviderId = { ["Shoko Episode"] = episodeId }, DtoOptions = new DtoOptions(true) }, true); @@ -714,7 +714,7 @@ private void RemoveDuplicateEpisodes(Episode episode, string episodeId) IsVirtualItem = true, ExcludeItemIds = new [] { episode.Id }, HasAnyProviderId = { ["Shoko Episode"] = episodeId }, - IncludeItemTypes = new [] { Enum.Parse<Jellyfin.Data.Enums.BaseItemKind>(nameof (Episode)) }, + IncludeItemTypes = new [] {Jellyfin.Data.Enums.BaseItemKind.Episode }, GroupByPresentationUniqueKey = false, DtoOptions = new DtoOptions(true), }; @@ -800,7 +800,7 @@ public void RemoveExtras(Folder parent, string seriesId) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { IsVirtualItem = false, - IncludeItemTypes = new [] { Enum.Parse<Jellyfin.Data.Enums.BaseItemKind>(nameof(Video)) }, + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Video }, HasOwnerId = true, HasAnyProviderId = { ["Shoko Series"] = seriesId}, DtoOptions = new DtoOptions(true), From 7b040cba2deb9726ef256ad5954be4000428f577 Mon Sep 17 00:00:00 2001 From: Marvel Renju <marvelrenju1@gmail.com> Date: Thu, 21 Apr 2022 18:39:32 +0100 Subject: [PATCH 0334/1103] Update dependencies to support 10.8.0-beta2 --- Shokofin/Shokofin.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj index c413581c..d3c02a87 100644 --- a/Shokofin/Shokofin.csproj +++ b/Shokofin/Shokofin.csproj @@ -5,7 +5,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Jellyfin.Controller" Version="10.8.0-beta1" /> + <PackageReference Include="Jellyfin.Controller" Version="10.8.0-beta2" /> <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> </ItemGroup> From 4e7199af0bb82007cbe02503dbed62cb30c43bbf Mon Sep 17 00:00:00 2001 From: Marvel Renju <marvelrenju1@gmail.com> Date: Thu, 21 Apr 2022 21:24:49 +0100 Subject: [PATCH 0335/1103] Update CI to use .NET 6 --- .github/workflows/release-daily.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 36e820d8..eba3fa75 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -22,7 +22,7 @@ jobs: - name: Setup dotnet uses: actions/setup-dotnet@v1 with: - dotnet-version: 5.0.x + dotnet-version: 6.0.x - name: Restore nuget packages run: dotnet restore Shokofin/Shokofin.csproj diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b13c465c..dcc618a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: - name: Setup dotnet uses: actions/setup-dotnet@v1 with: - dotnet-version: 5.0.x + dotnet-version: 6.0.x - name: Restore nuget packages run: dotnet restore Shokofin/Shokofin.csproj - name: Setup python From ff0b817acc6993dcc31ef7d5c4b3c9ff8a3acf57 Mon Sep 17 00:00:00 2001 From: Marvel Renju <marvelrenju1@gmail.com> Date: Thu, 21 Apr 2022 21:49:15 +0100 Subject: [PATCH 0336/1103] jprm update to .net6 --- build_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build_plugin.py b/build_plugin.py index cd13ae35..ed11a0c3 100644 --- a/build_plugin.py +++ b/build_plugin.py @@ -26,7 +26,7 @@ jellyfin_repo_url="https://github.com/ShokoAnime/Shokofin/releases/download" -zipfile=os.popen('jprm --verbosity=debug plugin build "." --output="%s" --version="%s" --dotnet-framework="net5.0"' % (artifact_dir, version)).read().strip() +zipfile=os.popen('jprm --verbosity=debug plugin build "." --output="%s" --version="%s" --dotnet-framework="net6.0"' % (artifact_dir, version)).read().strip() os.system('jprm repo add --url=%s %s %s' % (jellyfin_repo_url, jellyfin_repo_file, zipfile)) From 68bd6132698c690ddef35d36eda1b591ea57587e Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 21 Apr 2022 21:44:35 +0000 Subject: [PATCH 0337/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 1af68cce..d7a52345 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.3.1", + "changelog": "NA", + "targetAbi": "10.7.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.1/shoko_1.7.3.1.zip", + "checksum": "b79a3c0620ef15f51dda1a13d0c0592c", + "timestamp": "2022-04-21T21:44:29Z" + }, { "version": "1.7.2.3", "changelog": "NA", From c6f28a0905355635318c7129ba9dae3d974e1ed9 Mon Sep 17 00:00:00 2001 From: Mikal S <revam@users.noreply.github.com> Date: Thu, 21 Apr 2022 23:45:29 +0200 Subject: [PATCH 0338/1103] Update target abi --- build.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.yaml b/build.yaml index 40d6066a..61fc336f 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/master/LogoWide.png -targetAbi: "10.7.0.0" +targetAbi: "10.8.0.0" owner: "shokoanime" overview: "Manage your anime from Jellyfin using metadata from Shoko" description: > @@ -10,4 +10,4 @@ category: "Metadata" artifacts: - "Shokofin.dll" changelog: > - NA \ No newline at end of file + NA From 70bf06911261d4c32aff9f6d89c40cf46fb48beb Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 21 Apr 2022 21:46:09 +0000 Subject: [PATCH 0339/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index d7a52345..46f6cf6e 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.3.2", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.2/shoko_1.7.3.2.zip", + "checksum": "3f6a5e8a0c2555c735af519c25a78763", + "timestamp": "2022-04-21T21:46:07Z" + }, { "version": "1.7.3.1", "changelog": "NA", From fd710991ebf3c5a2c00f4c0e42645a94201cfcb0 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Mon, 25 Apr 2022 00:17:19 +0530 Subject: [PATCH 0340/1103] Stop shokofin from breaking itself on JF 10.7 --- manifest-unstable.json | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 46f6cf6e..54a9f0b2 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -16,14 +16,6 @@ "checksum": "3f6a5e8a0c2555c735af519c25a78763", "timestamp": "2022-04-21T21:46:07Z" }, - { - "version": "1.7.3.1", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.1/shoko_1.7.3.1.zip", - "checksum": "b79a3c0620ef15f51dda1a13d0c0592c", - "timestamp": "2022-04-21T21:44:29Z" - }, { "version": "1.7.2.3", "changelog": "NA", @@ -458,4 +450,4 @@ } ] } -] \ No newline at end of file +] From d5ab7622d603b4ed0180d91649a04ab324a3b5fc Mon Sep 17 00:00:00 2001 From: Ithiloneth <simon@moonsnow.se> Date: Thu, 5 May 2022 14:32:19 +0200 Subject: [PATCH 0341/1103] Rewrite of Library Settings section on grouping The instructions confused me. I have attempted to clean them up - please check that they make sense as per the operating procedure of the mentioned programs (ShokoServer/Desktop and ShokoFin). First PR; be gentle. --- 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 bc984949..e029c592 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -92,7 +92,7 @@ <h3>Metadata Settings</h3> <legend> <h3>Library Settings</h3> </legend> - <div class="fieldDescription verticalSection-extrabottompadding">"Automatically merge series that are spread across multiple folders" must be disabled in the <a href="#!/library.html">Library Settings</a> if the Show/Season grouping set to "Do not group Series into Seasons", otherwise it must be enabled. If you do not do this then the plugin will not work as expected.</div> + <div class="fieldDescription verticalSection-extrabottompadding">If you wish to set the Series/Grouping setting below to "Group series into Season based on Shoko's Groups", you must disable "Automatically merge series that are stored in separate folders" in the setting for all Libraries that depend on ShokoFin for their metadata.<br>On the other hand, if you do not want to have Series and Grouping be determined by Shoko, then you must enable the "Automatically merge series that are stored in separate folders" setting for all Libraries that use ShokoFin for their metadata.<br>See the library settings under each individual Library here:<a href="#!/library.html">Library Settings</a></div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="SeriesGrouping">Series/Season grouping:</label> <select is="emby-select" id="SeriesGrouping" name="SeriesGrouping" class="emby-select-withcolor emby-select"> @@ -271,4 +271,4 @@ <h3>Advanced Settings</h3> </form> </div> </div> -</div> \ No newline at end of file +</div> From 1630348f9307db979a38187dc06a13a642aa2d18 Mon Sep 17 00:00:00 2001 From: Ithiloneth <simon@moonsnow.se> Date: Thu, 5 May 2022 14:52:09 +0200 Subject: [PATCH 0342/1103] Error Correction and Language Touch Up I addressed changes as requested by revam. I changed the language of the core text. --- 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 e029c592..f50756a5 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -92,7 +92,7 @@ <h3>Metadata Settings</h3> <legend> <h3>Library Settings</h3> </legend> - <div class="fieldDescription verticalSection-extrabottompadding">If you wish to set the Series/Grouping setting below to "Group series into Season based on Shoko's Groups", you must disable "Automatically merge series that are stored in separate folders" in the setting for all Libraries that depend on ShokoFin for their metadata.<br>On the other hand, if you do not want to have Series and Grouping be determined by Shoko, then you must enable the "Automatically merge series that are stored in separate folders" setting for all Libraries that use ShokoFin for their metadata.<br>See the library settings under each individual Library here:<a href="#!/library.html">Library Settings</a></div> + <div class="fieldDescription verticalSection-extrabottompadding">If you wish to set the Series/Grouping setting below to "Don't group Series into Seasons", you must disable "Automatically merge series that are spread across multiple folders" in the settings for all Libraries that depend on Shokofin for their metadata.<br>On the other hand, if you want to have Series and Grouping be determined by Shoko, or TvDB/TMDB - you must enable the "Automatically merge series that are stored in separate folders" setting for all Libraries that use Shokofin for their metadata.<br>See the settings under each individual Library here:<a href="#!/library.html">Library Settings</a></div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="SeriesGrouping">Series/Season grouping:</label> <select is="emby-select" id="SeriesGrouping" name="SeriesGrouping" class="emby-select-withcolor emby-select"> From 88717d00779c0b9ed8699c7d0c3cfdddeb4da152 Mon Sep 17 00:00:00 2001 From: Ithiloneth <simon@moonsnow.se> Date: Thu, 5 May 2022 15:36:37 +0200 Subject: [PATCH 0343/1103] Word for word Made the second reference to the setting in the Library be word-for-word. --- 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 f50756a5..7680b0db 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -92,7 +92,7 @@ <h3>Metadata Settings</h3> <legend> <h3>Library Settings</h3> </legend> - <div class="fieldDescription verticalSection-extrabottompadding">If you wish to set the Series/Grouping setting below to "Don't group Series into Seasons", you must disable "Automatically merge series that are spread across multiple folders" in the settings for all Libraries that depend on Shokofin for their metadata.<br>On the other hand, if you want to have Series and Grouping be determined by Shoko, or TvDB/TMDB - you must enable the "Automatically merge series that are stored in separate folders" setting for all Libraries that use Shokofin for their metadata.<br>See the settings under each individual Library here:<a href="#!/library.html">Library Settings</a></div> + <div class="fieldDescription verticalSection-extrabottompadding">If you wish to set the Series/Grouping setting below to "Don't group Series into Seasons", you must disable "Automatically merge series that are spread across multiple folders" in the settings for all Libraries that depend on Shokofin for their metadata.<br>On the other hand, if you want to have Series and Grouping be determined by Shoko, or TvDB/TMDB - you must enable the "Automatically merge series that are spread across multiple folders" setting for all Libraries that use Shokofin for their metadata.<br>See the settings under each individual Library here:<a href="#!/library.html">Library Settings</a></div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="SeriesGrouping">Series/Season grouping:</label> <select is="emby-select" id="SeriesGrouping" name="SeriesGrouping" class="emby-select-withcolor emby-select"> From 658764307eecdaea47443e7ca714438c59e3f8dd Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 5 May 2022 13:40:37 +0000 Subject: [PATCH 0344/1103] Update unstable repo manifest --- manifest-unstable.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 54a9f0b2..8a5fecf3 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.3.3", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.3/shoko_1.7.3.3.zip", + "checksum": "82d69ddf7b3de713f81235a55c7a65a4", + "timestamp": "2022-05-05T13:40:35Z" + }, { "version": "1.7.3.2", "changelog": "NA\n", @@ -450,4 +458,4 @@ } ] } -] +] \ No newline at end of file From ae66c12e54535bbec1c0620d35aea42e67bbd799 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 26 Jun 2022 13:51:07 +0200 Subject: [PATCH 0345/1103] Minor fixes and fix the remaining errors for 10.8 --- README.md | 2 +- Shokofin/API/Models/File.cs | 2 +- Shokofin/API/Models/Group.cs | 12 +++++++--- Shokofin/API/Models/Image.cs | 4 ++-- Shokofin/API/Models/Series.cs | 6 ++++- Shokofin/API/ShokoAPIClient.cs | 5 +++-- Shokofin/API/ShokoAPIManager.cs | 2 +- Shokofin/Configuration/PluginConfiguration.cs | 18 +++++++-------- Shokofin/Configuration/UserConfiguration.cs | 10 ++++----- Shokofin/Configuration/configController.js | 2 +- Shokofin/Providers/ExtraMetadataProvider.cs | 22 +++++++++---------- Shokofin/Shokofin.csproj | 5 ++--- Shokofin/Sync/UserDataSyncManager.cs | 2 +- Shokofin/Utils/OrderingUtil.cs | 7 ++++++ Shokofin/Web/WebController.cs | 4 ++-- 15 files changed, 60 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 9e3a0593..99353513 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/docs/) with [Shok ## Read this before installing -The plugin requires a version of Jellyfin greater or equal to **10.7.0** (`>=10.7.0`) and an ustable version of Shoko Server greater or equal to **4.1.1** (`>=4.1.1`) to be installed. It also requires that you have already set up and are using Shoko Server, and that the directories/folders you intend to use in Jellyfin are fully indexed (and optionally managed) by Shoko Server, otherwise the plugin won't be able to funciton properly — it won't be able to find metadata about any entries that are not indexed by Shoko Server since the metadata we want is not available. +The plugin requires a version of Jellyfin greater or equal to **10.7.0** (`>=10.7.0`) and an unstable version of Shoko Server greater or equal to **4.1.1** (`>=4.1.1`) to be installed. It also requires that you have already set up and are using Shoko Server, and that the directories/folders you intend to use in Jellyfin are fully indexed (and optionally managed) by Shoko Server, otherwise the plugin won't be able to funciton properly — it won't be able to find metadata about any entries that are not indexed by Shoko Server since the metadata we want is not available. ## Breaking Changes diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index ca1049c7..68551c46 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -35,7 +35,7 @@ public class UserDataSummary { /// <summary> /// Number of ticks into the video to resume from. This is 0 if the video is not currently watched. /// </summary> - public long ResumePositionTicks { get; set; } + public long ResumePositionTicks { get; set; } } public class Location diff --git a/Shokofin/API/Models/Group.cs b/Shokofin/API/Models/Group.cs index 51e88a19..832917f8 100644 --- a/Shokofin/API/Models/Group.cs +++ b/Shokofin/API/Models/Group.cs @@ -3,14 +3,20 @@ namespace Shokofin.API.Models public class Group : BaseModel { public GroupIDs IDs { get; set; } - + + public string SortName { get; set; } + + public string Description { get; set; } + public bool HasCustomName { get; set; } - + public class GroupIDs : IDs { public int? DefaultSeries { get; set; } - + public int? ParentGroup { get; set; } + + public int TopLevelGroup { get; set; } } } } diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index 742c8229..71e21654 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -16,9 +16,9 @@ public class Image public bool Disabled { get; set; } - public int? Width { get; set; } + public int? Width { get; set; } - public int? Height { get; set; } + public int? Height { get; set; } [JsonIgnore] public virtual string Path diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index dd5c1a8b..55852236 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -78,11 +78,15 @@ public class Resource public class SeriesIDs : IDs { + public int? ParentGroup { get; set; } + + public int? TopLevelGroup { get; set; } + public int AniDB { get; set; } public List<int> TvDB { get; set; } = new List<int>(); - public List<int> MovieDB { get; set; } = new List<int>(); + public List<int> TMDB { get; set; } = new List<int>(); public List<int> MAL { get; set; } = new List<int>(); diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 3f333a9c..a898c09a 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -24,6 +24,7 @@ public class ShokoAPIClient public ShokoAPIClient(ILogger<ShokoAPIClient> logger) { _httpClient = (new HttpClient()); + _httpClient.Timeout = TimeSpan.FromMinutes(10); Logger = logger; } @@ -226,9 +227,9 @@ public Task<Series> GetSeriesFromEpisode(string id) return GetAsync<Series>($"/api/v3/Episode/{id}/Series"); } - public Task<List<Series>> GetSeriesInGroup(string id) + public Task<List<Series>> GetSeriesInGroup(string groupID, int filterID = 0) { - return GetAsync<List<Series>>($"/api/v3/Filter/0/Group/{id}/Series"); + return GetAsync<List<Series>>($"/api/v3/Filter/{filterID}/Group/{groupID}/Series?includeMissing=true&includeIgnored=false"); } public Task<Series.AniDB> GetSeriesAniDB(string id) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 603d5656..50ce3b03 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -131,7 +131,7 @@ public void Clear() private string GetImagePath(Image image) { - return image != null && APIClient.CheckImage(image.Path) ? image.ToURLString() : null; + return image != null ? image.ToURLString() : null; } private PersonInfo RoleToPersonInfo(Role role) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index d7dfdbe6..6a3e6002 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -42,33 +42,33 @@ public virtual string PrettyHost public bool SynopsisCleanMultiEmptyLines { get; set; } - public bool AddAniDBId { get; set; } + public bool AddAniDBId { get; set; } public bool AddOtherId { get; set; } public TextSourceType DescriptionSource { get; set; } - public SeriesAndBoxSetGroupType SeriesGrouping { get; set; } + public SeriesAndBoxSetGroupType SeriesGrouping { get; set; } - public OrderType SeasonOrdering { get; set; } + public OrderType SeasonOrdering { get; set; } public bool MarkSpecialsWhenGrouped { get; set; } - public SpecialOrderType SpecialsPlacement { get; set; } + public SpecialOrderType SpecialsPlacement { get; set; } public SeriesAndBoxSetGroupType BoxSetGrouping { get; set; } - public OrderType MovieOrdering { get; set; } + public OrderType MovieOrdering { get; set; } - public bool FilterOnLibraryTypes { get; set; } + public bool FilterOnLibraryTypes { get; set; } - public DisplayLanguageType TitleMainType { get; set; } + public DisplayLanguageType TitleMainType { get; set; } public DisplayLanguageType TitleAlternateType { get; set; } public UserConfiguration[] UserList { get; set; } - public string[] IgnoredFileExtensions { get; set; } + public string[] IgnoredFileExtensions { get; set; } public PluginConfiguration() { @@ -98,7 +98,7 @@ public PluginConfiguration() MovieOrdering = OrderType.Default; FilterOnLibraryTypes = false; UserList = Array.Empty<UserConfiguration>(); - IgnoredFileExtensions  = new [] { ".nfo", ".jpg", ".jpeg", ".png" }; + IgnoredFileExtensions = new [] { ".nfo", ".jpg", ".jpeg", ".png" }; } } } diff --git a/Shokofin/Configuration/UserConfiguration.cs b/Shokofin/Configuration/UserConfiguration.cs index 5bad20b4..fdc6bde3 100644 --- a/Shokofin/Configuration/UserConfiguration.cs +++ b/Shokofin/Configuration/UserConfiguration.cs @@ -17,20 +17,20 @@ public class UserConfiguration /// </summary> public bool EnableSynchronization { get; set; } - public bool SyncUserDataAfterPlayback { get; set; } + public bool SyncUserDataAfterPlayback { get; set; } - public bool SyncUserDataUnderPlayback { get; set; } + public bool SyncUserDataUnderPlayback { get; set; } - public bool SyncUserDataOnImport { get; set; } + public bool SyncUserDataOnImport { get; set; } /// <summary> /// The username of the linked user in Shoko. /// </summary> - public string Username { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; /// <summary> /// The API Token for authentication/authorization with Shoko Server. /// </summary> - public string Token { get; set; } = string.Empty; + public string Token { get; set; } = string.Empty; } } diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index d2609779..84eb561a 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -9,7 +9,7 @@ const Messages = { }; function filterIgnoreList(value) { - return Array.from(new Set(value.split(/[\s,]+/g).map(str =>  { str = str.trim().toLowerCase(); if (str[0] !== ".") str = "." + str; return str; }))); + return Array.from(new Set(value.split(/[\s,]+/g).map(str => { str = str.trim().toLowerCase(); if (str[0] !== ".") str = "." + str; return str; }))); } async function loadUserConfig(form, userId, config) { diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index 32e7f0ac..bbf84102 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -558,14 +558,14 @@ private void UpdateEpisode(Episode episode, string episodeId) private bool SeasonExists(string seriesPresentationUniqueKey, string seriesName, int seasonNumber) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, IndexNumber = seasonNumber, SeriesPresentationUniqueKey = seriesPresentationUniqueKey, DtoOptions = new DtoOptions(true), }, true); if (searchList.Count > 0) { - Logger.LogDebug("Season {SeasonName} for Series {SeriesName} was created in another concurrent thread, skipping.", searchList[0].Name, seriesName); + Logger.LogDebug("Season {SeasonName} for Series {SeriesName} was created in another concurrent thread, skipping.", searchList[0].Name, seriesName); return true; } @@ -645,8 +645,8 @@ public void RemoveDuplicateSeasons(Series series, string seriesId) public void RemoveDuplicateSeasons(Season season, Series series, int seasonNumber, string seriesId) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, - ExcludeItemIds = new [] { season.Id }, + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, + ExcludeItemIds = new [] { season.Id }, IndexNumber = seasonNumber, DtoOptions = new DtoOptions(true), }, true).Where(item => !item.IndexNumber.HasValue).ToList(); @@ -655,7 +655,7 @@ public void RemoveDuplicateSeasons(Season season, Series series, int seasonNumbe return; Logger.LogWarning("Removing {Count:00} duplicate seasons from Series {SeriesName} (Series={SeriesId})", searchList.Count, series.Name, seriesId); - var deleteOptions = new DeleteOptions { + var deleteOptions = new DeleteOptions { DeleteFileLocation = false, }; foreach (var item in searchList) @@ -683,12 +683,12 @@ private bool EpisodeExists(string episodeId, string seriesId, string groupId) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Episode }, - HasAnyProviderId = { ["Shoko Episode"] = episodeId }, - DtoOptions = new DtoOptions(true) + HasAnyProviderId = new Dictionary<string, string> { ["Shoko Episode"] = episodeId }, + DtoOptions = new DtoOptions(true), }, true); if (searchList.Count > 0) { - Logger.LogDebug("A virtual or physical episode entry already exists for Episode {EpisodeName}. Ignoreing. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", searchList[0].Name, episodeId, seriesId, groupId); + Logger.LogDebug("A virtual or physical episode entry already exists for Episode {EpisodeName}. Ignoreing. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", searchList[0].Name, episodeId, seriesId, groupId); return true; } return false; @@ -713,7 +713,7 @@ private void RemoveDuplicateEpisodes(Episode episode, string episodeId) var query = new InternalItemsQuery { IsVirtualItem = true, ExcludeItemIds = new [] { episode.Id }, - HasAnyProviderId = { ["Shoko Episode"] = episodeId }, + HasAnyProviderId = new Dictionary<string, string> { ["Shoko Episode"] = episodeId }, IncludeItemTypes = new [] {Jellyfin.Data.Enums.BaseItemKind.Episode }, GroupByPresentationUniqueKey = false, DtoOptions = new DtoOptions(true), @@ -802,7 +802,7 @@ public void RemoveExtras(Folder parent, string seriesId) IsVirtualItem = false, IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Video }, HasOwnerId = true, - HasAnyProviderId = { ["Shoko Series"] = seriesId}, + HasAnyProviderId = new Dictionary<string, string> { ["Shoko Series"] = seriesId}, DtoOptions = new DtoOptions(true), }, true); @@ -819,4 +819,4 @@ public void RemoveExtras(Folder parent, string seriesId) #endregion } -} \ No newline at end of file +} diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj index d3c02a87..577cb9d0 100644 --- a/Shokofin/Shokofin.csproj +++ b/Shokofin/Shokofin.csproj @@ -5,9 +5,8 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Jellyfin.Controller" Version="10.8.0-beta2" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> + <PackageReference Include="Jellyfin.Controller" Version="10.8.0" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> </ItemGroup> <ItemGroup> diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index b6e846d4..18671ba6 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -222,7 +222,7 @@ private void OnUserRatingSaved(object sender, UserDataSaveEventArgs e) var config = Plugin.Instance.Configuration; switch (e.Item) { case Episode: - case Movie: { + case Movie: { var video = e.Item as Video; if (!Lookup.TryGetEpisodeIdFor(video, out var episodeId)) return; diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index ced2da32..0de5ab05 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -384,6 +384,13 @@ public static int GetSeasonNumber(GroupInfo group, SeriesInfo series, EpisodeInf // Music videos if (title.Contains("music video", System.StringComparison.OrdinalIgnoreCase)) return ExtraType.Clip; + // Behind the Scenes + if (title.Contains("making of", System.StringComparison.CurrentCultureIgnoreCase)) + return ExtraType.BehindTheScenes; + if (title.Contains("music in", System.StringComparison.CurrentCultureIgnoreCase)) + return ExtraType.BehindTheScenes; + if (title.Contains("advance screening", System.StringComparison.CurrentCultureIgnoreCase)) + return ExtraType.BehindTheScenes; return null; } default: diff --git a/Shokofin/Web/WebController.cs b/Shokofin/Web/WebController.cs index 53271fd0..a76a3c56 100644 --- a/Shokofin/Web/WebController.cs +++ b/Shokofin/Web/WebController.cs @@ -63,7 +63,7 @@ public async Task<ActionResult<ApiKey>> PostAsync([FromBody] ApiLoginRequest bod } public class ApiLoginRequest { - public string username { get; set; } - public string password { get; set; } + public string username { get; set; } + public string password { get; set; } } } \ No newline at end of file From 56f4db9ae4001146e4aaa3dc77a181669102a40c Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 26 Jun 2022 11:52:44 +0000 Subject: [PATCH 0346/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 8a5fecf3..2f22f0e5 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.3.4", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.4/shoko_1.7.3.4.zip", + "checksum": "b79a09736992db8f6d5c35c5e93ffbe7", + "timestamp": "2022-06-26T11:52:43Z" + }, { "version": "1.7.3.3", "changelog": "NA\n", From b8cc432f1215202728811785ae070682c30d9868 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 27 Jun 2022 21:29:07 +0200 Subject: [PATCH 0347/1103] Re-add basic image validation. --- Shokofin/API/Models/Image.cs | 4 ++++ Shokofin/API/ShokoAPIManager.cs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index 71e21654..3dfd55e9 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -20,6 +20,10 @@ public class Image public int? Height { get; set; } + [JsonIgnore] + public virtual bool IsAvailable + => !string.IsNullOrEmpty(RelativeFilepath); + [JsonIgnore] public virtual string Path => $"/api/v3/Image/{Source}/{Type}/{ID}"; diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 50ce3b03..32f4d98b 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -131,7 +131,7 @@ public void Clear() private string GetImagePath(Image image) { - return image != null ? image.ToURLString() : null; + return image != null && image.IsAvailable ? image.ToURLString() : null; } private PersonInfo RoleToPersonInfo(Role role) From 3ba4611c20b2ed9812cb9bf9eb5285f25ca98df2 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 27 Jun 2022 19:42:08 +0000 Subject: [PATCH 0348/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 2f22f0e5..e667ec27 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.3.5", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.5/shoko_1.7.3.5.zip", + "checksum": "a3aeb8eb6a22cc5fa7e9b2a3c9e5c075", + "timestamp": "2022-06-27T19:42:06Z" + }, { "version": "1.7.3.4", "changelog": "NA\n", From 80267c0b41a4d637d4ee9b0b4d4f9bf77f789895 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 27 Jun 2022 21:48:56 +0200 Subject: [PATCH 0349/1103] Update README.md --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 99353513..3ba68fd4 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,14 @@ A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/docs/) with [Shok ## Read this before installing -The plugin requires a version of Jellyfin greater or equal to **10.7.0** (`>=10.7.0`) and an unstable version of Shoko Server greater or equal to **4.1.1** (`>=4.1.1`) to be installed. It also requires that you have already set up and are using Shoko Server, and that the directories/folders you intend to use in Jellyfin are fully indexed (and optionally managed) by Shoko Server, otherwise the plugin won't be able to funciton properly — it won't be able to find metadata about any entries that are not indexed by Shoko Server since the metadata we want is not available. +The plugin requires Jellyfin version 10.8.`x` and Shoko Server version **4.2.0** or greater to be installed. **It also requires that you have already set up and are using Shoko Server**, and that the directories/folders you intend to use in Jellyfin are **fully indexed** (and optionally managed) by Shoko Server, **otherwise the plugin won't be able to funciton properly** — meaning you won't be able to find metadata about any entries that are not indexed by Shoko Server with this plugin, since the metadata is not available. ## Breaking Changes +### 2.0.0 + +**Support for Jellyfin has landed, and support for Jellyfin 10.7.0 has ended**. + ### 1.5.0 If you're upgrading from an older version to version 1.5.0, then be sure to update the "Host" field in the plugin settings before you continue using the plugin. **Update: Starting with 1.7.0 you just need to reset the connection then log in again.** From e369b584765face38d280f77596283028682a194 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 27 Jun 2022 19:49:53 +0000 Subject: [PATCH 0350/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index e667ec27..08a4a147 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.3.6", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.6/shoko_1.7.3.6.zip", + "checksum": "bbda41e32829d521dc968ebd537118f4", + "timestamp": "2022-06-27T19:49:52Z" + }, { "version": "1.7.3.5", "changelog": "NA\n", From cada410dccfa06367c8b575ae777430e34b5a71b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 27 Jun 2022 21:50:16 +0200 Subject: [PATCH 0351/1103] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3ba68fd4..bd2e7cfe 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The plugin requires Jellyfin version 10.8.`x` and Shoko Server version **4.2.0** ### 2.0.0 -**Support for Jellyfin has landed, and support for Jellyfin 10.7.0 has ended**. +**Support for Jellyfin 10.8 has landed, and support for Jellyfin 10.7 has ended**. ### 1.5.0 From d4c4b51fd1cb47ccecac30b39af64d496a975d94 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 27 Jun 2022 19:50:58 +0000 Subject: [PATCH 0352/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 08a4a147..fde8ef4f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.3.7", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.7/shoko_1.7.3.7.zip", + "checksum": "30acbdf13ee211cda1b4e952ba33527d", + "timestamp": "2022-06-27T19:50:56Z" + }, { "version": "1.7.3.6", "changelog": "NA\n", From 3ce51a6a5c45b08f7e422c6038615c07323cccfd Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 27 Jun 2022 22:04:06 +0200 Subject: [PATCH 0353/1103] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bd2e7cfe..41af71ea 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/docs/) with [Shok ## Read this before installing -The plugin requires Jellyfin version 10.8.`x` and Shoko Server version **4.2.0** or greater to be installed. **It also requires that you have already set up and are using Shoko Server**, and that the directories/folders you intend to use in Jellyfin are **fully indexed** (and optionally managed) by Shoko Server, **otherwise the plugin won't be able to funciton properly** — meaning you won't be able to find metadata about any entries that are not indexed by Shoko Server with this plugin, since the metadata is not available. +The plugin requires Jellyfin version 10.8.`x` and Shoko Server version **4.1.2** or greater to be installed. **It also requires that you have already set up and are using Shoko Server**, and that the directories/folders you intend to use in Jellyfin are **fully indexed** (and optionally managed) by Shoko Server, **otherwise the plugin won't be able to funciton properly** — meaning you won't be able to find metadata about any entries that are not indexed by Shoko Server with this plugin, since the metadata is not available. ## Breaking Changes From 77a0a4911c017404917aedebdc87f3a1e0dd11cf Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 27 Jun 2022 20:04:44 +0000 Subject: [PATCH 0354/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index fde8ef4f..970871cf 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "1.7.3.8", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.8/shoko_1.7.3.8.zip", + "checksum": "426fa1895295d28a6a93ac38643316cb", + "timestamp": "2022-06-27T20:04:42Z" + }, { "version": "1.7.3.7", "changelog": "NA\n", From 00cf5fd9998db08611d5cd6fc18704a6d5746015 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 27 Jun 2022 22:07:25 +0000 Subject: [PATCH 0355/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index ec27fb61..e5a11117 100644 --- a/manifest.json +++ b/manifest.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.0.0", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.0/shoko_2.0.0.0.zip", + "checksum": "635f85542f567644bd8af2e48796e5e6", + "timestamp": "2022-06-27T22:07:22Z" + }, { "version": "1.7.3.0", "changelog": "NA", From cdf5c53c6fe36ad46eb08bae755fdfce068b93a8 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 2 Jul 2022 11:45:08 +0200 Subject: [PATCH 0356/1103] Fix playback position by converting it in the api client before it's sent to the api. --- Shokofin/API/ShokoAPIClient.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index a898c09a..a5fb610b 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -207,13 +207,15 @@ public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eve public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long progress, string apiKey) { - var response = await GetAsync($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={progress}", HttpMethod.Patch, apiKey); + var response = await GetAsync($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round((decimal)progress / 10000)}", HttpMethod.Patch, apiKey); return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); } public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long? progress, bool watched, string apiKey) { - var response = await GetAsync($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={progress ?? 0}&watched={watched}", HttpMethod.Patch, apiKey); + if (!progress.HasValue) + return await ScrobbleFile(fileId, episodeId, eventName, watched, apiKey); + var response = await GetAsync($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round((decimal)progress.Value / 10000)}&watched={watched}", HttpMethod.Patch, apiKey); return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); } From f33f535345739e42f96dce2832f8f84da669f929 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 2 Jul 2022 09:45:52 +0000 Subject: [PATCH 0357/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 970871cf..c3bcbd14 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.0.1", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.0.1/shoko_2.0.0.1.zip", + "checksum": "cfe8f16db569e0693b65cfa363635361", + "timestamp": "2022-07-02T09:45:51Z" + }, { "version": "1.7.3.8", "changelog": "NA\n", From 90f1d6ddfddcc5c6083279a130b6d9b2967f19d5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 2 Jul 2022 11:50:04 +0200 Subject: [PATCH 0358/1103] Add task for clearing the plugin cache --- Shokofin/Tasks/ClearPluginCacheTask.cs | 76 ++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 Shokofin/Tasks/ClearPluginCacheTask.cs diff --git a/Shokofin/Tasks/ClearPluginCacheTask.cs b/Shokofin/Tasks/ClearPluginCacheTask.cs new file mode 100644 index 00000000..099c4610 --- /dev/null +++ b/Shokofin/Tasks/ClearPluginCacheTask.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Tasks; +using MediaBrowser.Model.Globalization; +using Shokofin.Sync; +using Shokofin.API; + +namespace Shokofin.Tasks +{ + /// <summary> + /// Class ClearPluginCacheTask. + /// </summary> + public class ClearPluginCacheTask : IScheduledTask + { + /// <summary> + /// The _library manager. + /// </summary> + private readonly ShokoAPIManager APIManager; + + /// <summary> + /// Initializes a new instance of the <see cref="ClearPluginCacheTask" /> class. + /// </summary> + public ClearPluginCacheTask(ShokoAPIManager apiManager) + { + APIManager = apiManager; + } + + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new TaskTriggerInfo[0]; + } + + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + APIManager.Clear(); + return Task.CompletedTask; + } + + /// <inheritdoc /> + public string Name => "Clear Plugin Cache"; + + /// <inheritdoc /> + public string Description => "For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. Clear the plugin cache."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoClearPluginCache"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + } +} From f873154bbb434a2a56c59a79716fdb9b8b2471ba Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 2 Jul 2022 09:51:16 +0000 Subject: [PATCH 0359/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index c3bcbd14..f4319f9e 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.0.2", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.0.2/shoko_2.0.0.2.zip", + "checksum": "de0edec29d6986a39a00e3ae27d8a2e8", + "timestamp": "2022-07-02T09:51:14Z" + }, { "version": "2.0.0.1", "changelog": "NA\n", From 1fda254856b6c735160df2e50f6d360e8fcdeec9 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 2 Jul 2022 10:17:46 +0000 Subject: [PATCH 0360/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index e5a11117..cb2a6900 100644 --- a/manifest.json +++ b/manifest.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.0", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1/shoko_2.0.1.0.zip", + "checksum": "f6b1520a57381f9425935b10b4048398", + "timestamp": "2022-07-02T10:17:43Z" + }, { "version": "2.0.0.0", "changelog": "NA\n", From ba11e80709acb29dd6b6c95f4a90c21b255bcde1 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 26 Jul 2022 21:03:56 +0200 Subject: [PATCH 0361/1103] refactor: use a seperate user api key agent --- Shokofin/API/ShokoAPIClient.cs | 4 ++-- Shokofin/Configuration/configController.js | 7 ++++--- Shokofin/Web/WebController.cs | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index a5fb610b..044d6002 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -137,13 +137,13 @@ private async Task<HttpResponseMessage> PostAsync<Type>(string url, HttpMethod m } } - public async Task<ApiKey> GetApiKey(string username, string password) + public async Task<ApiKey> GetApiKey(string username, string password, bool forUser = false) { var postData = JsonSerializer.Serialize(new Dictionary<string, string> { {"user", username}, {"pass", password}, - {"device", "Shoko Jellyfin Plugin (Shokofin)"}, + {"device", forUser ? "Shoko Jellyfin Plugin (Shokofin) - User Key" : "Shoko Jellyfin Plugin (Shokofin)"}, }); var apiBaseUrl = Plugin.Instance.Configuration.Host; var response = await _httpClient.PostAsync($"{apiBaseUrl}/api/auth", new StringContent(postData, Encoding.UTF8, "application/json")); diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 84eb561a..77baf1b5 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -66,12 +66,13 @@ function toggleSyncUnderPlayback(form, checked) { } } -function getApiKey(username, password) { +function getApiKey(username, password, userKey = false) { return ApiClient.fetch({ dataType: "json", data: JSON.stringify({ username, password, + userKey, }), headers: { "Content-Type": "application/json", @@ -142,7 +143,7 @@ async function defaultSubmit(form) { const username = form.querySelector("#UserUsername").value; const password = form.querySelector("#UserPassword").value; if (!userConfig.Token) try { - const response = await getApiKey(username, password); + const response = await getApiKey(username, password, true); userConfig.Username = username; userConfig.Token = response.apikey; } @@ -306,7 +307,7 @@ async function syncUserSettings(form) { const username = form.querySelector("#UserUsername").value; const password = form.querySelector("#UserPassword").value; if (!userConfig.Token) try { - const response = await getApiKey(username, password); + const response = await getApiKey(username, password, true); userConfig.Username = username; userConfig.Token = response.apikey; } diff --git a/Shokofin/Web/WebController.cs b/Shokofin/Web/WebController.cs index a76a3c56..6d56301c 100644 --- a/Shokofin/Web/WebController.cs +++ b/Shokofin/Web/WebController.cs @@ -46,7 +46,7 @@ public async Task<ActionResult<ApiKey>> PostAsync([FromBody] ApiLoginRequest bod { try { Logger.LogDebug("Trying to create an API-key for user {Username}.", body.username); - var apiKey = await APIClient.GetApiKey(body.username, body.password).ConfigureAwait(false); + var apiKey = await APIClient.GetApiKey(body.username, body.password, body.userKey).ConfigureAwait(false); if (apiKey == null) { Logger.LogDebug("Failed to create an API-key for user {Username} — invalid credentials received.", body.username); return new StatusCodeResult(StatusCodes.Status401Unauthorized); @@ -65,5 +65,6 @@ public async Task<ActionResult<ApiKey>> PostAsync([FromBody] ApiLoginRequest bod public class ApiLoginRequest { public string username { get; set; } public string password { get; set; } + public bool userKey { get; set; } } } \ No newline at end of file From ccaafbb0793143962c73466dc36622f63a33d5f5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 26 Jul 2022 21:05:35 +0200 Subject: [PATCH 0362/1103] refactor: update file user stats model and endpoints --- Shokofin/API/Models/File.cs | 48 +++++++++++++++++++--------------- Shokofin/API/ShokoAPIClient.cs | 9 +++++-- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index 68551c46..f03a3b18 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -17,27 +17,6 @@ public class File public DateTime Created { get; set; } - /// <summary> - /// A summerised view of the user data for this file. - /// </summary> - public class UserDataSummary { - /// <summary> - /// The number of times this file have been watched. Doesn't include - /// active watch seesions. - /// </summary> - public int WatchedCount; - - /// <summary> - /// The last time this file was watched, if at all. - /// </summary> - public DateTime? LastWatchedAt; - - /// <summary> - /// Number of ticks into the video to resume from. This is 0 if the video is not currently watched. - /// </summary> - public long ResumePositionTicks { get; set; } - } - public class Location { public int ImportFolderID { get; set; } @@ -58,6 +37,33 @@ public class HashesType public string MD5 { get; set; } } + /// <summary> + /// User stats for the file. + /// </summary> + public class FileUserStats + { + /// <summary> + /// Where to resume the next playback. + /// </summary> + public TimeSpan? ResumePosition { get; set; } + + /// <summary> + /// Total number of times the file have been watched. + /// </summary> + public int WatchedCount { get; set; } + + /// <summary> + /// When the file was last watched. Will be null if the full is + /// currently marked as unwatched. + /// </summary> + public DateTime? LastWatchedAt { get; set; } + + /// <summary> + /// When the entry was last updated. + /// </summary> + public DateTime LastUpdatedAt { get; set; } + } + public class FileDetailed : File { public List<SeriesXRefs> SeriesIDs { get; set; } diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 044d6002..0cfaeabf 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -194,9 +194,14 @@ public Task<File> GetFile(string id) return GetAsync<List<File.FileDetailed>>($"/api/v3/File/PathEndsWith/{Uri.EscapeDataString(filename)}"); } - public Task<File.UserDataSummary> GetFileUserData(string fileId, string apiKey) + public Task<File.FileUserStats> GetFileUserStats(string fileId, string apiKey = null) { - return GetAsync<File.UserDataSummary>($"/api/v3/File/UserData"); + return GetAsync<File.FileUserStats>($"/api/v3/File/{fileId}/UserStats", apiKey); + } + + public Task<File.FileUserStats> PutFileUserStats(string fileId, File.FileUserStats userStats, string apiKey = null) + { + return PostAsync<File.FileUserStats, File.FileUserStats>($"/api/v3/File/{fileId}/UserStats", HttpMethod.Put, userStats, apiKey); } public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, bool watched, string apiKey) From 5e3089bebfdd94253683595edf28d8bb05b8c9c8 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 26 Jul 2022 22:15:24 +0200 Subject: [PATCH 0363/1103] feature: basic watch state sync --- Shokofin/Sync/SyncExtensions.cs | 43 ++++++++++++++ Shokofin/Sync/UserDataSyncManager.cs | 89 +++++++++++++++++++++------- 2 files changed, 111 insertions(+), 21 deletions(-) create mode 100644 Shokofin/Sync/SyncExtensions.cs diff --git a/Shokofin/Sync/SyncExtensions.cs b/Shokofin/Sync/SyncExtensions.cs new file mode 100644 index 00000000..f78a644e --- /dev/null +++ b/Shokofin/Sync/SyncExtensions.cs @@ -0,0 +1,43 @@ + +using System; +using MediaBrowser.Controller.Entities; +using Shokofin.API.Models; + +namespace Shokofin.Sync; + +public static class SyncExtensions +{ + public static File.FileUserStats ToFileUserStats(this UserItemData userData) + { + TimeSpan? resumePosition = new TimeSpan(userData.PlaybackPositionTicks); + if (Math.Floor(resumePosition.Value.TotalMilliseconds) == 0d) + resumePosition = null; + var lastUpdated = userData.LastPlayedDate ?? DateTime.Now; + return new File.FileUserStats + { + LastUpdatedAt = lastUpdated, + LastWatchedAt = userData.Played ? lastUpdated : null, + ResumePosition = resumePosition, + WatchedCount = userData.PlayCount, + }; + } + + public static UserItemData MergeWithFileUserStats(this UserItemData userData, File.FileUserStats userStats) + { + userData.Played = userStats.LastWatchedAt.HasValue; + userData.PlayCount = userStats.WatchedCount; + userData.PlaybackPositionTicks = userStats.ResumePosition?.Ticks ?? 0; + userData.LastPlayedDate = userStats.ResumePosition.HasValue ? userStats.LastUpdatedAt : userStats.LastWatchedAt ?? userStats.LastUpdatedAt; + return userData; + } + + public static UserItemData ToUserData(this File.FileUserStats userStats, Video video, Guid userId) + { + return new UserItemData + { + UserId = userId, + Key = video.GetUserDataKeys()[0], + LastPlayedDate = null, + }.MergeWithFileUserStats(userStats); + } +} \ No newline at end of file diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index 18671ba6..a1a09afc 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -282,7 +282,7 @@ public async Task ScanAndSync(SyncDirection direction, IProgress<double> progres continue; foreach (var userConfig in enabledUsers) { - await SyncVideo(video, userConfig, null, direction, fileId, episodeId).ConfigureAwait(false); + await SyncVideo(video, userConfig, direction, fileId, episodeId).ConfigureAwait(false); numComplete++; double percent = numComplete; @@ -311,7 +311,7 @@ public void OnItemAddedOrUpdated(object sender, ItemChangeEventArgs e) if (!userConfig.SyncUserDataOnImport) continue; - SyncVideo(video, userConfig, null, SyncDirection.Import, fileId, episodeId).ConfigureAwait(false); + SyncVideo(video, userConfig, SyncDirection.Import, fileId, episodeId).ConfigureAwait(false); } break; } @@ -407,26 +407,73 @@ private Task SyncVideo(Video video, UserConfiguration userConfig, UserItemData u return Task.CompletedTask; } - private Task SyncVideo(Video video, UserConfiguration userConfig, UserItemData userData, SyncDirection direction, string fileId, string episodeId) + private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDirection direction, string fileId, string episodeId) { - // Try to load the user-data if it was not provided - if (userData == null) - userData = UserDataManager.GetUserData(userConfig.UserId, video); - // Create some new user-data if none exists. - if (userData == null) - userData = new UserItemData { - UserId = userConfig.UserId, - Key = video.GetUserDataKeys()[0], - LastPlayedDate = null, - }; - - // var remoteUserData = await APIClient.GetFileUserData(fileId, userConfig.Token); - // if (remoteUserData == null) - // return; - - Logger.LogDebug("TODO; {SyncDirection} user data for video {VideoName}. (File={FileId},Episode={EpisodeId})", direction.ToString(), video.Name, fileId, episodeId); - - return Task.CompletedTask; + var localUserStats = UserDataManager.GetUserData(userConfig.UserId, video); + var remoteUserStats = await APIClient.GetFileUserStats(fileId, userConfig.Token); + Logger.LogInformation("{SyncDirection} user data for video {VideoName}. (File={FileId},Episode={EpisodeId},Local={HaveLocal},Remote={HaveRemote})", direction.ToString(), video.Name, fileId, episodeId, localUserStats != null, remoteUserStats != null); + switch (direction) + { + case SyncDirection.Export: + // Abort since there are no local stats to export. + if (localUserStats == null) + break; + // Export the local stats if there is no remote stats or if the local stats are newer. + if (remoteUserStats == null || localUserStats.LastPlayedDate.HasValue && localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) + { + remoteUserStats = await APIClient.PutFileUserStats(fileId, localUserStats.ToFileUserStats(), userConfig.Token); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, fileId, episodeId); + } + break; + case SyncDirection.Import: + // Abort since there are no remote stats to import. + if (remoteUserStats == null) + break; + // Create a new local stats entry if there is no local entry. + if (localUserStats == null) + { + UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats = remoteUserStats.ToUserData(video, userConfig.UserId), UserDataSaveReason.Import, CancellationToken.None); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, fileId, episodeId); + } + // Else merge the remote stats into the local stats entry. + else if (!localUserStats.LastPlayedDate.HasValue || remoteUserStats.LastUpdatedAt > localUserStats.LastPlayedDate.Value) + { + UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, fileId, episodeId); + } + break; + default: + case SyncDirection.Sync: { + // Export if there is local stats but no remote stats. + if (localUserStats == null && remoteUserStats != null) + goto case SyncDirection.Import; + // Try to import of there is no local stats ubt there are remote stats. + else if (remoteUserStats == null && localUserStats != null) + goto case SyncDirection.Export; + // Abort if there are no local or remote stats. + else if (remoteUserStats == null && localUserStats == null) + break; + // Try to sync if we're unable to read the last played timestamp. + if (!localUserStats.LastPlayedDate.HasValue) + goto case SyncDirection.Import; + // Abort if the stats are in sync. + if (localUserStats.LastPlayedDate.Value == remoteUserStats.LastUpdatedAt) + break; + // Export if the local state is fresher then the remote state. + if (localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) + { + remoteUserStats = await APIClient.PutFileUserStats(fileId, localUserStats.ToFileUserStats(), userConfig.Token); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, fileId, episodeId); + } + // Else import if the remote state is fresher then the local state. + else if (localUserStats.LastPlayedDate.Value < remoteUserStats.LastUpdatedAt) + { + UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, fileId, episodeId); + } + break; + } + } } } } From 40814c4d24ae285f75b29374faefd5052837aee4 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 26 Jul 2022 20:16:09 +0000 Subject: [PATCH 0364/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index f4319f9e..662dff15 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.1", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.1/shoko_2.0.1.1.zip", + "checksum": "a0af02cf4b4f4e5a8bf404dcd1c86be4", + "timestamp": "2022-07-26T20:16:07Z" + }, { "version": "2.0.0.2", "changelog": "NA\n", From b04ad347951180e603f7c36088dcbef4fc04bc41 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 26 Jul 2022 22:17:21 +0200 Subject: [PATCH 0365/1103] fix: reenable the setting for syncing on import. --- 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 7680b0db..057fe5c0 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -161,10 +161,10 @@ <h3>User Settings</h3> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> - <input disabled is="emby-checkbox" type="checkbox" id="SyncUserDataOnImport" /> + <input is="emby-checkbox" type="checkbox" id="SyncUserDataOnImport" /> <span>Sync watch-state on import</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this setting will import the watch-state for items from Shoko on import or refresh. Note: This feature is currently not fully implemented. Enabling this setting will only print debug messages in your console/log until the needed functionality is implemented in (the v3 API in) Shoko Server.</div> + <div class="fieldDescription checkboxFieldDescription">Enabling this setting will import the watch-state for items from Shoko on import or refresh.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> From 7d93b95189750758b2f7c32d6379ceea52c2adc5 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 26 Jul 2022 20:18:23 +0000 Subject: [PATCH 0366/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 662dff15..228f70e7 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.2", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.2/shoko_2.0.1.2.zip", + "checksum": "02c5cc61b7555aa6678c31756ff0370a", + "timestamp": "2022-07-26T20:18:21Z" + }, { "version": "2.0.1.1", "changelog": "NA\n", From 087407813c20c74c84739a76e2f3701ba3762075 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Jul 2022 20:16:23 +0200 Subject: [PATCH 0367/1103] fix: fix the user data sync --- Shokofin/API/Models/File.cs | 9 +++ Shokofin/API/ShokoAPIClient.cs | 4 +- Shokofin/Sync/UserDataSyncManager.cs | 107 +++++++++++++++++++++++---- 3 files changed, 103 insertions(+), 17 deletions(-) diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index f03a3b18..c04507c3 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -62,6 +62,15 @@ public class FileUserStats /// When the entry was last updated. /// </summary> public DateTime LastUpdatedAt { get; set; } + + /// <summary> + /// True if the <see cref="FileUserStats"/> object is considered empty. + /// </summary> + public virtual bool IsEmpty + { + get + => ResumePosition == null && WatchedCount == 0 && WatchedCount == 0; + } } public class FileDetailed : File diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 0cfaeabf..3bdc39d9 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -212,7 +212,7 @@ public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eve public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long progress, string apiKey) { - var response = await GetAsync($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round((decimal)progress / 10000)}", HttpMethod.Patch, apiKey); + var response = await GetAsync($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress).TotalMilliseconds)}", HttpMethod.Patch, apiKey); return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); } @@ -220,7 +220,7 @@ public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eve { if (!progress.HasValue) return await ScrobbleFile(fileId, episodeId, eventName, watched, apiKey); - var response = await GetAsync($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round((decimal)progress.Value / 10000)}&watched={watched}", HttpMethod.Patch, apiKey); + var response = await GetAsync($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress.Value).TotalMilliseconds)}&watched={watched}", HttpMethod.Patch, apiKey); return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); } diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index a1a09afc..75f88bea 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Configuration; +using FileUserStats = Shokofin.API.Models.File.FileUserStats; namespace Shokofin.Sync { @@ -131,7 +132,7 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) var itemId = e.Item.Id; var userData = e.UserData; var config = Plugin.Instance.Configuration; - bool success = false; + bool? success = false; switch (e.SaveReason) { // case UserDataSaveReason.PlaybackStart: // The progress event is sent at the same time, so this event is not needed. case UserDataSaveReason.PlaybackProgress: { @@ -191,19 +192,30 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) } Logger.LogInformation("Playback has ended. (File={FileId})", fileId); - success = await APIClient.ScrobbleFile(fileId, episodeId, "stop", userData.PlaybackPositionTicks, userData.Played, userConfig.Token).ConfigureAwait(false); + if (!userData.Played && userData.PlaybackPositionTicks > 0) + success = await APIClient.ScrobbleFile(fileId, episodeId, "stop", userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); + else + success = await APIClient.ScrobbleFile(fileId, episodeId, "stop", userData.PlaybackPositionTicks, userData.Played, userConfig.Token).ConfigureAwait(false); break; } case UserDataSaveReason.TogglePlayed: Logger.LogInformation("Scrobbled when toggled. (File={FileId})", fileId); - success = await APIClient.ScrobbleFile(fileId, episodeId, "user-interaction", userData.PlaybackPositionTicks, userData.Played, userConfig.Token).ConfigureAwait(false); + if (!userData.Played && userData.PlaybackPositionTicks > 0) + success = await APIClient.ScrobbleFile(fileId, episodeId, "user-interaction", userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); + else + success = await APIClient.ScrobbleFile(fileId, episodeId, "user-interaction", userData.PlaybackPositionTicks, userData.Played, userConfig.Token).ConfigureAwait(false); + break; + default: + success = null; break; } - if (success) { - Logger.LogInformation("Successfully synced watch state with Shoko. (File={FileId})", fileId); - } - else { - Logger.LogInformation("Failed to sync watch state with Shoko. (File={FileId})", fileId); + if (success.HasValue) { + if (success.Value) { + Logger.LogInformation("Successfully synced watch state with Shoko. (File={FileId})", fileId); + } + else { + Logger.LogInformation("Failed to sync watch state with Shoko. (File={FileId})", fileId); + } } } catch (Exception ex) { @@ -411,7 +423,11 @@ private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDire { var localUserStats = UserDataManager.GetUserData(userConfig.UserId, video); var remoteUserStats = await APIClient.GetFileUserStats(fileId, userConfig.Token); - Logger.LogInformation("{SyncDirection} user data for video {VideoName}. (File={FileId},Episode={EpisodeId},Local={HaveLocal},Remote={HaveRemote})", direction.ToString(), video.Name, fileId, episodeId, localUserStats != null, remoteUserStats != null); + bool isInSync = UserDataEqualsFileUserStats(localUserStats, remoteUserStats); + Logger.LogInformation("{SyncDirection} user data for video {VideoName}. (File={FileId},Episode={EpisodeId},Local={HaveLocal},Remote={HaveRemote},InSync={IsInSync})", direction.ToString(), video.Name, fileId, episodeId, localUserStats != null, remoteUserStats != null, isInSync); + if (isInSync) + return; + switch (direction) { case SyncDirection.Export: @@ -419,9 +435,18 @@ private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDire if (localUserStats == null) break; // Export the local stats if there is no remote stats or if the local stats are newer. - if (remoteUserStats == null || localUserStats.LastPlayedDate.HasValue && localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) + if (remoteUserStats == null) { - remoteUserStats = await APIClient.PutFileUserStats(fileId, localUserStats.ToFileUserStats(), userConfig.Token); + remoteUserStats = localUserStats.ToFileUserStats(); + // Don't sync if the local state is considered empty and there is no remote state. + if (remoteUserStats.IsEmpty) + break; + remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, fileId, episodeId); + } + else if (localUserStats.LastPlayedDate.HasValue && localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) { + remoteUserStats = localUserStats.ToFileUserStats(); + remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, fileId, episodeId); } break; @@ -436,7 +461,7 @@ private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDire Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, fileId, episodeId); } // Else merge the remote stats into the local stats entry. - else if (!localUserStats.LastPlayedDate.HasValue || remoteUserStats.LastUpdatedAt > localUserStats.LastPlayedDate.Value) + else if ((!localUserStats.LastPlayedDate.HasValue || remoteUserStats.LastUpdatedAt > localUserStats.LastPlayedDate.Value)) { UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, fileId, episodeId); @@ -447,22 +472,28 @@ private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDire // Export if there is local stats but no remote stats. if (localUserStats == null && remoteUserStats != null) goto case SyncDirection.Import; + // Try to import of there is no local stats ubt there are remote stats. else if (remoteUserStats == null && localUserStats != null) goto case SyncDirection.Export; + // Abort if there are no local or remote stats. else if (remoteUserStats == null && localUserStats == null) break; - // Try to sync if we're unable to read the last played timestamp. + + // Try to import if we're unable to read the last played timestamp. if (!localUserStats.LastPlayedDate.HasValue) goto case SyncDirection.Import; + // Abort if the stats are in sync. - if (localUserStats.LastPlayedDate.Value == remoteUserStats.LastUpdatedAt) + if (isInSync || localUserStats.LastPlayedDate.Value == remoteUserStats.LastUpdatedAt) break; + // Export if the local state is fresher then the remote state. if (localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) { - remoteUserStats = await APIClient.PutFileUserStats(fileId, localUserStats.ToFileUserStats(), userConfig.Token); + remoteUserStats = localUserStats.ToFileUserStats(); + remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, fileId, episodeId); } // Else import if the remote state is fresher then the local state. @@ -475,5 +506,51 @@ private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDire } } } + + /// <summary> + /// Checks if the local user data and the remote user stats are in sync. + /// </summary> + /// <param name="localUserData">The local user data</param> + /// <param name="remoteUserStats">The remote user stats.</param> + /// <returns>True if they are not in sync.</returns> + private static bool UserDataEqualsFileUserStats(UserItemData localUserData, FileUserStats remoteUserStats) + { + if (remoteUserStats == null && localUserData == null) + return true; + + if (localUserData == null) + return false; + + var localUserStats = localUserData.ToFileUserStats(); + if (remoteUserStats == null) + return localUserStats.IsEmpty; + + if (localUserStats.IsEmpty && remoteUserStats.IsEmpty) + return true; + + TimeSpan? resumePosition = new TimeSpan(localUserData.PlaybackPositionTicks); + if (Math.Floor(resumePosition.Value.TotalMilliseconds) == 0d) + resumePosition = null; + if (resumePosition != remoteUserStats.ResumePosition) + return false; + + if (localUserData.PlayCount != remoteUserStats.WatchedCount) + return false; + + var played = remoteUserStats.LastWatchedAt.HasValue; + if (localUserData.Played != played) + return false; + + var lastWatchedAt = localUserData.Played && !resumePosition.HasValue ? localUserData.LastPlayedDate : null; + if (lastWatchedAt.HasValue != remoteUserStats.LastWatchedAt.HasValue || lastWatchedAt.HasValue && lastWatchedAt != remoteUserStats.LastWatchedAt) + return false; + + var isUpdated = resumePosition.HasValue || localUserData.Played; + var lastUpdatedAt = isUpdated ? localUserData.LastPlayedDate : null; + if (isUpdated && (!lastUpdatedAt.HasValue || lastUpdatedAt.Value != remoteUserStats.LastUpdatedAt)) + return false; + + return true; + } } } From 3b8e10cf9a15349a9cca8842fb10848de5bc7da8 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 29 Jul 2022 18:17:28 +0000 Subject: [PATCH 0368/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 228f70e7..ed74993f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.3", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.3/shoko_2.0.1.3.zip", + "checksum": "2f6c82bd6bc6a63f1d94ebd59a4a9f0c", + "timestamp": "2022-07-29T18:17:26Z" + }, { "version": "2.0.1.2", "changelog": "NA\n", From af269b63df9f4c2629930f7b3896f2385b3f7588 Mon Sep 17 00:00:00 2001 From: Kerberos <57227244+krbrs@users.noreply.github.com> Date: Tue, 2 Aug 2022 13:41:47 +0200 Subject: [PATCH 0369/1103] Update configPage.html Edit misleading descriptions regarding sync options --- 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 057fe5c0..de1e87d9 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -155,14 +155,14 @@ <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</span> + <span>Enable synchronization features</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back watched status to Shoko under and after playback, and sync-back watch status from Shoko on import or refresh.</div> + <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> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SyncUserDataOnImport" /> - <span>Sync watch-state on import</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> From 1c441e80c7788fb7a8097fa28999a0f09ceae247 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 2 Aug 2022 11:47:00 +0000 Subject: [PATCH 0370/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index ed74993f..2bc0004e 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.4", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.4/shoko_2.0.1.4.zip", + "checksum": "82a6baab533cfa4b3ebc1dcafc202d54", + "timestamp": "2022-08-02T11:46:58Z" + }, { "version": "2.0.1.3", "changelog": "NA\n", From ecde668d9d1cac8d982515ff9d0ccda462329c28 Mon Sep 17 00:00:00 2001 From: Mikal S <revam@users.noreply.github.com> Date: Tue, 2 Aug 2022 14:40:00 +0200 Subject: [PATCH 0371/1103] fix: disable user sync settings then global toggle is disabled --- Shokofin/Configuration/configController.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 77baf1b5..41ca73a1 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -371,6 +371,13 @@ export default function (page) { loadUserConfig(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("#SyncUserDataAfterPlayback").addEventListener("change", function () { toggleSyncUnderPlayback(page, this.checked); }); From 1b518cbe6b4356a736b7b24b28d5ac01d209717d Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 2 Aug 2022 12:40:40 +0000 Subject: [PATCH 0372/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 2bc0004e..6dc5e652 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.5", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.5/shoko_2.0.1.5.zip", + "checksum": "47dc550823f89e4f14e5adf1570b3170", + "timestamp": "2022-08-02T12:40:38Z" + }, { "version": "2.0.1.4", "changelog": "NA\n", From dff887eaf3a72558c6dc8d52e61058cbdeae2547 Mon Sep 17 00:00:00 2001 From: Kerberos <57227244+krbrs@users.noreply.github.com> Date: Wed, 3 Aug 2022 09:52:35 +0200 Subject: [PATCH 0373/1103] Update ShokoAPIClient.cs Update API path for "Tag" filters --- 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 3bdc39d9..a30fbbf3 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -271,7 +271,7 @@ public Task<List<Series>> GetSeriesPathEndsWith(string dirname) public Task<List<Tag>> GetSeriesTags(string id, int filter = 0) { - return GetAsync<List<Tag>>($"/api/v3/Series/{id}/Tags/{filter}?excludeDescriptions=true"); + return GetAsync<List<Tag>>($"/api/v3/Series/{id}/Tags?filter={filter}&excludeDescriptions=true"); } public Task<Group> GetGroup(string id) From cba934c21fa075fe6df23707ad3059094ba5ba04 Mon Sep 17 00:00:00 2001 From: Kerberos <57227244+krbrs@users.noreply.github.com> Date: Wed, 3 Aug 2022 10:40:41 +0200 Subject: [PATCH 0374/1103] Make Genres great again Updated GetTagFilter() from int to ulong and changed the magic number for the genre filter --- Shokofin/API/ShokoAPIClient.cs | 2 +- Shokofin/API/ShokoAPIManager.cs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index a30fbbf3..73744566 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -269,7 +269,7 @@ public Task<List<Series>> GetSeriesPathEndsWith(string dirname) return GetAsync<List<Series>>($"/api/v3/Series/PathEndsWith/{Uri.EscapeDataString(dirname)}"); } - public Task<List<Tag>> GetSeriesTags(string id, int filter = 0) + public Task<List<Tag>> GetSeriesTags(string id, ulong filter = 0) { return GetAsync<List<Tag>>($"/api/v3/Series/{id}/Tags?filter={filter}&excludeDescriptions=true"); } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 32f4d98b..7c82f860 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -195,12 +195,12 @@ private async Task<string[]> GetTags(string seriesId) /// Get the tag filter /// </summary> /// <returns></returns> - private int GetTagFilter() + private ulong GetTagFilter() { var config = Plugin.Instance.Configuration; - var filter = 128; // We exclude genres by default + ulong filter = 128L; // We exclude genres by default - if (config.HideAniDbTags) filter = 129; + if (config.HideAniDbTags) filter = 129L; if (config.HideArtStyleTags) filter |= (filter << 1); if (config.HideSourceTags) filter |= (filter << 2); if (config.HideMiscTags) filter |= (filter << 3); @@ -214,8 +214,8 @@ private int GetTagFilter() public async Task<string[]> GetGenresForSeries(string seriesId) { - // The following magic number is the filter value to allow only genres in the returned list. - return (await APIClient.GetSeriesTags(seriesId, -2147483520))?.Select(SelectTagName).ToArray() ?? new string[0]; + // The following magic number is the filter value to allow only genres in the returned list.s + return (await APIClient.GetSeriesTags(seriesId, 2147483776))?.Select(SelectTagName).ToArray() ?? new string[0]; } private string SelectTagName(Tag tag) From ccfa586c8252b227c89dffef920fe1b7fbf141e8 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 3 Aug 2022 08:44:40 +0000 Subject: [PATCH 0375/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 6dc5e652..2165a6a7 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.6", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.6/shoko_2.0.1.6.zip", + "checksum": "17043404ca6c6b39d5a9a28dceb1dc62", + "timestamp": "2022-08-03T08:44:38Z" + }, { "version": "2.0.1.5", "changelog": "NA\n", From fe005276fab422e214bc8d07c6c126600d3f6355 Mon Sep 17 00:00:00 2001 From: Kerberos <57227244+krbrs@users.noreply.github.com> Date: Tue, 16 Aug 2022 14:20:53 +0200 Subject: [PATCH 0376/1103] Fix small Typo --- Shokofin/API/ShokoAPIManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 7c82f860..8ab83885 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -214,7 +214,7 @@ private ulong GetTagFilter() public async Task<string[]> GetGenresForSeries(string seriesId) { - // The following magic number is the filter value to allow only genres in the returned list.s + // The following magic number is the filter value to allow only genres in the returned list. return (await APIClient.GetSeriesTags(seriesId, 2147483776))?.Select(SelectTagName).ToArray() ?? new string[0]; } From f8ba9ba2ac5d39f70a26402dff5352ebed3c2ac0 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 19 Aug 2022 20:26:13 +0000 Subject: [PATCH 0377/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 2165a6a7..81f80043 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.7", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.7/shoko_2.0.1.7.zip", + "checksum": "b95218f9d0a37e0433e2035f2a579149", + "timestamp": "2022-08-19T20:26:11Z" + }, { "version": "2.0.1.6", "changelog": "NA\n", From 8c1774cf549cbc55549b7d3f6c57262caa8a0c21 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 7 Sep 2022 22:03:52 +0200 Subject: [PATCH 0378/1103] cleanup: parse role from sring enum --- Shokofin/API/Models/Role.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Shokofin/API/Models/Role.cs b/Shokofin/API/Models/Role.cs index a7f7e23d..12c90ee0 100644 --- a/Shokofin/API/Models/Role.cs +++ b/Shokofin/API/Models/Role.cs @@ -26,6 +26,7 @@ public class Person public Image Image { get; set; } } + [JsonConverter(typeof(JsonStringEnumConverter))] public enum CreatorRoleType { /// <summary> From 8738b78c7a65a704a25b21655d42ff6275c9271d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 7 Sep 2022 22:23:33 +0200 Subject: [PATCH 0379/1103] feature: add ratings for H content and a setting to optionally skip sync for said content. --- Shokofin/Configuration/UserConfiguration.cs | 2 ++ Shokofin/Configuration/configController.js | 9 ++++++--- Shokofin/Configuration/configPage.html | 7 +++++++ Shokofin/Providers/SeriesProvider.cs | 4 ++++ Shokofin/Sync/UserDataSyncManager.cs | 11 ++++++++++- 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Shokofin/Configuration/UserConfiguration.cs b/Shokofin/Configuration/UserConfiguration.cs index fdc6bde3..4705eac2 100644 --- a/Shokofin/Configuration/UserConfiguration.cs +++ b/Shokofin/Configuration/UserConfiguration.cs @@ -22,6 +22,8 @@ public class UserConfiguration public bool SyncUserDataUnderPlayback { get; set; } public bool SyncUserDataOnImport { get; set; } + + public bool SyncRestrictedVideos { get; set; } /// <summary> /// The username of the linked user in Shoko. diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 41ca73a1..4de68f6b 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -28,9 +28,10 @@ async function loadUserConfig(form, userId, config) { // Configure the elements within the user container form.querySelector("#UserEnableSynchronization").checked = userConfig.EnableSynchronization || false; - form.querySelector("#SyncUserDataOnImport").checked = userConfig.SyncUserDataOnImport; - form.querySelector("#SyncUserDataAfterPlayback").checked = userConfig.SyncUserDataAfterPlayback; - form.querySelector("#SyncUserDataUnderPlayback").checked = userConfig.SyncUserDataAfterPlayback && userConfig.SyncUserDataUnderPlayback; + form.querySelector("#SyncUserDataOnImport").checked = userConfig.SyncUserDataOnImport || false; + form.querySelector("#SyncUserDataAfterPlayback").checked = userConfig.SyncUserDataAfterPlayback || false; + form.querySelector("#SyncUserDataUnderPlayback").checked = userConfig.SyncUserDataAfterPlayback && userConfig.SyncUserDataUnderPlayback || false; + form.querySelector("#SyncRestrictedVideos").checked = userConfig.SyncRestrictedVideos || false; form.querySelector("#UserUsername").value = userConfig.Username || ""; // Synchronization settings form.querySelector("#UserPassword").value = ""; @@ -138,6 +139,7 @@ async function defaultSubmit(form) { userConfig.SyncUserDataOnImport = form.querySelector("#SyncUserDataOnImport").checked; userConfig.SyncUserDataAfterPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked; userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked && form.querySelector("#SyncUserDataUnderPlayback").checked; + 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; @@ -302,6 +304,7 @@ async function syncUserSettings(form) { userConfig.SyncUserDataOnImport = form.querySelector("#SyncUserDataOnImport").checked; userConfig.SyncUserDataAfterPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked; userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked && form.querySelector("#SyncUserDataUnderPlayback").checked; + 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; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index de1e87d9..a7e39caa 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -180,6 +180,13 @@ <h3>User Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back back the watch-state to Shoko during playback. "Sync watch-state after playback" must be enabled first to enable this setting.</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> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back 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:" /> <div class="fieldDescription">The username of the account to synchronize with the currently selected user.</div> diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 56d63f97..98407927 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -105,6 +105,8 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C Tags = series.Tags.ToArray(), Genres = series.Genres.ToArray(), Studios = series.Studios.ToArray(), + OfficialRating = series.AniDB.Restricted ? "XXX" : null, + CustomRating = series.AniDB.Restricted ? "XXX" : null, CommunityRating = series.AniDB.Rating.ToFloat(10), }; } @@ -153,6 +155,8 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in Tags = group.Tags.ToArray(), Genres = group.Genres.ToArray(), Studios = group.Studios.ToArray(), + OfficialRating = series.AniDB.Restricted ? "XXX" : null, + CustomRating = series.AniDB.Restricted ? "XXX" : null, CommunityRating = series.AniDB.Rating.ToFloat(10), }; AddProviderIds(result.Item, series.Id, group.Id, series.AniDB.ID.ToString()); diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index 75f88bea..4c56520f 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -125,7 +125,8 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) (e.Item is Movie || e.Item is Episode) && TryGetUserConfiguration(e.UserId, out var userConfig) && Lookup.TryGetFileIdFor(e.Item, out var fileId) && - Lookup.TryGetEpisodeIdFor(e.Item, out var episodeId) + Lookup.TryGetEpisodeIdFor(e.Item, out var episodeId) && + (userConfig.SyncRestrictedVideos || e.Item.CustomRating != "XXX") )) return; @@ -399,6 +400,10 @@ private Task SyncSeason(Season season, UserConfiguration userConfig, UserItemDat private Task SyncVideo(Video video, UserConfiguration userConfig, UserItemData userData, SyncDirection direction, string episodeId) { + if (!userConfig.SyncRestrictedVideos && video.CustomRating == "XXX") { + Logger.LogTrace("Skipped {SyncDirection} user data for video {VideoName}. (Episode={EpisodeId})", direction.ToString(), video.Name, episodeId); + return Task.CompletedTask; + } // Try to load the user-data if it was not provided if (userData == null) userData = UserDataManager.GetUserData(userConfig.UserId, video); @@ -421,6 +426,10 @@ private Task SyncVideo(Video video, UserConfiguration userConfig, UserItemData u private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDirection direction, string fileId, string episodeId) { + if (!userConfig.SyncRestrictedVideos && video.CustomRating == "XXX") { + Logger.LogTrace("Skipped {SyncDirection} user data for video {VideoName}. (File={FileId},Episode={EpisodeId})", direction.ToString(), video.Name, fileId, episodeId); + return; + } var localUserStats = UserDataManager.GetUserData(userConfig.UserId, video); var remoteUserStats = await APIClient.GetFileUserStats(fileId, userConfig.Token); bool isInSync = UserDataEqualsFileUserStats(localUserStats, remoteUserStats); From 373970ef4de485062c8bc887db1012704ef87016 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 7 Sep 2022 22:26:09 +0200 Subject: [PATCH 0380/1103] fix: fix start/end date for show entry when using shoko groups. --- Shokofin/Providers/SeriesProvider.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 98407927..acfd9ffe 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -141,6 +141,15 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in } var series = group.DefaultSeries; + var premiereDate = group.SeriesList + .Select(s => s.AniDB.AirDate) + .Where(s => s != null) + .OrderBy(s => s) + .FirstOrDefault(); + var endDate = group.SeriesList.Any(s => s.AniDB.EndDate == null) ? null : group.SeriesList + .Select(s => s.AniDB.AirDate) + .OrderBy(s => s) + .LastOrDefault(); var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, group.Shoko.Name, info.MetadataLanguage); Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, series.Id, group.Id); @@ -148,10 +157,10 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in Name = displayTitle, OriginalTitle = alternateTitle, Overview = Text.GetDescription(series), - PremiereDate = series.AniDB.AirDate, - EndDate = series.AniDB.EndDate, - ProductionYear = series.AniDB.AirDate?.Year, - Status = series.AniDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, + PremiereDate = premiereDate, + ProductionYear = premiereDate?.Year, + EndDate = endDate, + Status = endDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, Tags = group.Tags.ToArray(), Genres = group.Genres.ToArray(), Studios = group.Studios.ToArray(), From df9ab9ee13c4fbc4dd2c3af563f455131b278d7e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 7 Sep 2022 22:47:39 +0200 Subject: [PATCH 0381/1103] feature: add source genre --- Shokofin/API/ShokoAPIManager.cs | 35 ++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 8ab83885..076abf5d 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -215,7 +215,40 @@ private ulong GetTagFilter() public async Task<string[]> GetGenresForSeries(string seriesId) { // The following magic number is the filter value to allow only genres in the returned list. - return (await APIClient.GetSeriesTags(seriesId, 2147483776))?.Select(SelectTagName).ToArray() ?? new string[0]; + var set = (await APIClient.GetSeriesTags(seriesId, 2147483776))?.Select(SelectTagName).ToHashSet() ?? new(); + set.Add(await GetSourceGenre(seriesId)); + return set.ToArray(); + } + + private async Task<string> GetSourceGenre(string seriesId) + { + return (await APIClient.GetSeriesTags(seriesId, 2147483652))?.Select(SelectTagName).FirstOrDefault() switch { + "american derived" => "adapted from western media", + "cartoon" => "adapted from westen media", + "comic book" => "adapted from western media", + "4-koma" => "adapted from a manga", + "manga" => "adapted from a manga", + "4-koma manga" => "adapted from a manga", + "manhua" => "adapted from a manhua", + "manhwa" => "adapted from a manhwa", + "movie" => "adapted from a movie", + "novel" => "adapted from a light/web novel", + "rpg" => "adapted from a video game", + "action game" => "adapted from a video game", + "game" => "adapted from a video game", + "erotic game" => "adapted from an eroge", + "korean drama" => "adapted from a korean drama", + "television programme" => "adapted from a live-action show", + "visual novel" => "adapted from a visual novel", + "fan-made" => "fan-made", + "remake" => "remake", + "radio programme" => "original work", + "biographical film" => "original work", + "original work" => "original work", + "new" => "original work", + "ultra jump" => "original work", + _ => "original work", + }; } private string SelectTagName(Tag tag) From bd2ff5376244f3690d22a6f0fa8d9340a964e6e9 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 7 Sep 2022 20:51:57 +0000 Subject: [PATCH 0382/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 81f80043..5e5b8969 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.8", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.8/shoko_2.0.1.8.zip", + "checksum": "e5ddea633abe74a9b4ca577be51080a6", + "timestamp": "2022-09-07T20:51:54Z" + }, { "version": "2.0.1.7", "changelog": "NA\n", From 29c7ca0ab50bc45d7a46c96e858567924aacc1e1 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 7 Sep 2022 23:06:27 +0200 Subject: [PATCH 0383/1103] fix: convert source genre to TitleCase. --- Shokofin/API/ShokoAPIManager.cs | 52 ++++++++++++++++----------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 076abf5d..32536010 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -222,32 +222,32 @@ public async Task<string[]> GetGenresForSeries(string seriesId) private async Task<string> GetSourceGenre(string seriesId) { - return (await APIClient.GetSeriesTags(seriesId, 2147483652))?.Select(SelectTagName).FirstOrDefault() switch { - "american derived" => "adapted from western media", - "cartoon" => "adapted from westen media", - "comic book" => "adapted from western media", - "4-koma" => "adapted from a manga", - "manga" => "adapted from a manga", - "4-koma manga" => "adapted from a manga", - "manhua" => "adapted from a manhua", - "manhwa" => "adapted from a manhwa", - "movie" => "adapted from a movie", - "novel" => "adapted from a light/web novel", - "rpg" => "adapted from a video game", - "action game" => "adapted from a video game", - "game" => "adapted from a video game", - "erotic game" => "adapted from an eroge", - "korean drama" => "adapted from a korean drama", - "television programme" => "adapted from a live-action show", - "visual novel" => "adapted from a visual novel", - "fan-made" => "fan-made", - "remake" => "remake", - "radio programme" => "original work", - "biographical film" => "original work", - "original work" => "original work", - "new" => "original work", - "ultra jump" => "original work", - _ => "original work", + return(await APIClient.GetSeriesTags(seriesId, 2147483652))?.FirstOrDefault()?.Name?.ToLowerInvariant() switch { + "american derived" => "Adapted From Western Media", + "cartoon" => "Adapted From Western Media", + "comic book" => "Adapted From Western Media", + "4-koma" => "Adapted From A Manga", + "manga" => "Adapted From A Manga", + "4-koma manga" => "Adapted From A Manga", + "manhua" => "Adapted From A Manhua", + "manhwa" => "Adapted from a Manhwa", + "movie" => "Adapted From A Movie", + "novel" => "Adapted From A Light/Web Novel", + "rpg" => "Adapted From A Video Game", + "action game" => "Adapted From A Video Game", + "game" => "Adapted From A Video Game", + "erotic game" => "Adapted From An Eroge", + "korean drama" => "Adapted From A Korean Drama", + "television programme" => "Adapted From A Live-Action Show", + "visual novel" => "Adapted From A Visual Novel", + "fan-made" => "Fan-Made", + "remake" => "Remake", + "radio programme" => "Original Work", + "biographical film" => "Original Work", + "original work" => "Original Work", + "new" => "Original Work", + "ultra jump" => "Original Work", + _ => "Original Work", }; } From d27903a9c853dab105aa5c17925d531e791d6cfc Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 7 Sep 2022 21:07:05 +0000 Subject: [PATCH 0384/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 5e5b8969..9de5968b 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.9", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.9/shoko_2.0.1.9.zip", + "checksum": "cb4e25ea07e6cc98376eeebc4bc1d3a1", + "timestamp": "2022-09-07T21:07:03Z" + }, { "version": "2.0.1.8", "changelog": "NA\n", From 1f955702ce774603d4e29b1dd8187fc93b7f128c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 7 Sep 2022 23:16:45 +0200 Subject: [PATCH 0385/1103] fix: fix tag filters... hopefully. :fingers_crossed: --- Shokofin/API/ShokoAPIManager.cs | 15 ++++++++------- Shokofin/Configuration/PluginConfiguration.cs | 9 ++++++--- Shokofin/Configuration/configController.js | 9 ++++++--- Shokofin/Configuration/configPage.html | 18 ++++++++++++------ 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 32536010..ab0dcc12 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -198,13 +198,14 @@ private async Task<string[]> GetTags(string seriesId) private ulong GetTagFilter() { var config = Plugin.Instance.Configuration; - ulong filter = 128L; // We exclude genres by default - - if (config.HideAniDbTags) filter = 129L; - if (config.HideArtStyleTags) filter |= (filter << 1); - if (config.HideSourceTags) filter |= (filter << 2); - if (config.HideMiscTags) filter |= (filter << 3); - if (config.HidePlotTags) filter |= (filter << 4); + ulong filter = 132L; // We exclude genres and source by default + + if (config.HideAniDbTags) filter |= (1 << 0); + if (config.HideArtStyleTags) filter |= (1 << 1); + if (config.HideMiscTags) filter |= (1 << 3); + if (config.HidePlotTags) filter |= (1 << 4); + if (config.HideSettingTags) filter |= (1 << 5); + if (config.HideProgrammingTags) filter |= (1 << 6); return filter; } diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 6a3e6002..8218de82 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -26,13 +26,15 @@ public virtual string PrettyHost public bool HideArtStyleTags { get; set; } - public bool HideSourceTags { get; set; } - public bool HideMiscTags { get; set; } public bool HidePlotTags { get; set; } public bool HideAniDbTags { get; set; } + + public bool HideSettingTags { get; set; } + + public bool HideProgrammingTags { get; set; } public bool SynopsisCleanLinks { get; set; } @@ -77,10 +79,11 @@ public PluginConfiguration() Username = "Default"; ApiKey = ""; HideArtStyleTags = false; - HideSourceTags = false; HideMiscTags = false; HidePlotTags = true; HideAniDbTags = true; + HideSettingTags = false; + HideProgrammingTags = true; SynopsisCleanLinks = true; SynopsisCleanMiscLines = true; SynopsisRemoveSummary = true; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 4de68f6b..d88ba33a 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -113,10 +113,11 @@ async function defaultSubmit(form) { // Tag settings config.HideArtStyleTags = form.querySelector("#HideArtStyleTags").checked; - config.HideSourceTags = form.querySelector("#HideSourceTags").checked; config.HideMiscTags = form.querySelector("#HideMiscTags").checked; config.HidePlotTags = form.querySelector("#HidePlotTags").checked; config.HideAniDbTags = form.querySelector("#HideAniDbTags").checked; + config.HideSettingTags = form.querySelector("#HideSettingTags").checked; + config.HideProgrammingTags = form.querySelector("#HideProgrammingTags").checked; // Advanced settings config.PublicHost = publicHost; @@ -253,10 +254,11 @@ async function syncSettings(form) { // Tag settings config.HideArtStyleTags = form.querySelector("#HideArtStyleTags").checked; - config.HideSourceTags = form.querySelector("#HideSourceTags").checked; config.HideMiscTags = form.querySelector("#HideMiscTags").checked; config.HidePlotTags = form.querySelector("#HidePlotTags").checked; config.HideAniDbTags = form.querySelector("#HideAniDbTags").checked; + config.HideSettingTags = form.querySelector("#HideSettingTags").checked; + config.HideProgrammingTags = form.querySelector("#HideProgrammingTags").checked; // Advanced settings config.PublicHost = publicHost; @@ -415,10 +417,11 @@ export default function (page) { // Tag settings form.querySelector("#HideArtStyleTags").checked = config.HideArtStyleTags; - form.querySelector("#HideSourceTags").checked = config.HideSourceTags; form.querySelector("#HideMiscTags").checked = config.HideMiscTags; form.querySelector("#HidePlotTags").checked = config.HidePlotTags; form.querySelector("#HideAniDbTags").checked = config.HideAniDbTags; + form.querySelector("#HideSettingTags").checked = config.HideSettingTags; + form.querySelector("#HideProgrammingTags").checked = config.HideProgrammingTags; // Advanced settings form.querySelector("#PublicHost").value = config.PublicHost; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index a7e39caa..0848937a 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -216,12 +216,6 @@ <h3>Tag Settings</h3> <span>Hide art style related tags</span> </label> </div> - <div class="checkboxContainer"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="HideSourceTags" /> - <span>Hide source related tags</span> - </label> - </div> <div class="checkboxContainer"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="HideMiscTags" /> @@ -240,6 +234,18 @@ <h3>Tag Settings</h3> <span>Hide any miscellaneous tags</span> </label> </div> + <div class="checkboxContainer"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="HideSettingTags" /> + <span>Hide any tags related to the setting — a time or place in which the story occurs.</span> + </label> + </div> + <div class="checkboxContainer"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="HideProgrammingTags" /> + <span>Hide any tags that involve how or where it aired, or any awards it got</span> + </label> + </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> </button> From fe5e5eb6fc00c7592d70bfffdda94e077a014516 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 7 Sep 2022 21:17:49 +0000 Subject: [PATCH 0386/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 9de5968b..cacb9056 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.10", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.10/shoko_2.0.1.10.zip", + "checksum": "ecd0216e43e6f4d1e942b056e5c14b5c", + "timestamp": "2022-09-07T21:17:48Z" + }, { "version": "2.0.1.9", "changelog": "NA\n", From a14ea5585172932de3d68f69a90b2aea32ce8db7 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 22 Sep 2022 23:50:38 +0200 Subject: [PATCH 0387/1103] feature: add thumbnail url to images --- Shokofin/API/Models/Image.cs | 5 +++++ Shokofin/Providers/ImageProvider.cs | 1 + 2 files changed, 6 insertions(+) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index 3dfd55e9..afb62597 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -32,5 +32,10 @@ public string ToURLString() { return string.Concat(Plugin.Instance.Configuration.Host, Path); } + + public string ToPrettyURLString() + { + return string.Concat(Plugin.Instance.Configuration.PrettyHost, Path); + } } } \ No newline at end of file diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 56c00f41..395724a2 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -128,6 +128,7 @@ private void AddImage(ref List<RemoteImageInfo> list, ImageType imageType, API.M Width = image.Width, Height = image.Height, Url = image.ToURLString(), + ThumbnailUrl = image.ToPrettyURLString(), }); } From b2220990fd0d59df945e62f528e38cb396040fd2 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 22 Sep 2022 21:51:14 +0000 Subject: [PATCH 0388/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index cacb9056..59074132 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.11", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.11/shoko_2.0.1.11.zip", + "checksum": "3e34a066c08e2f0176c873819421484f", + "timestamp": "2022-09-22T21:51:13Z" + }, { "version": "2.0.1.10", "changelog": "NA\n", From 47a1d78fcca3ad317b9d732e8f750ded286f6675 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 26 Sep 2022 21:35:40 +0200 Subject: [PATCH 0389/1103] fix: throw if the api key is invalidated (or expires) --- Shokofin/API/ShokoAPIClient.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 73744566..d130225b 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -79,6 +79,8 @@ private async Task<HttpResponseMessage> GetAsync(string url, HttpMethod method, requestMessage.Content = (new StringContent("")); requestMessage.Headers.Add("apikey", apiKey); var response = await _httpClient.SendAsync(requestMessage); + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection in the plugin settings.", null, HttpStatusCode.Unauthorized); Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); return response; } @@ -126,6 +128,8 @@ private async Task<HttpResponseMessage> PostAsync<Type>(string url, HttpMethod m requestMessage.Content = (new StringContent(JsonSerializer.Serialize<Type>(body), Encoding.UTF8, "application/json")); requestMessage.Headers.Add("apikey", apiKey); var response = await _httpClient.SendAsync(requestMessage); + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection in the plugin settings.", null, HttpStatusCode.Unauthorized); Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); return response; } From b621cb033c35c3813db89e473ebeccc627b558e3 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 26 Sep 2022 19:36:16 +0000 Subject: [PATCH 0390/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 59074132..b9000cba 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.12", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.12/shoko_2.0.1.12.zip", + "checksum": "9d9f461f3c67ec520e9b86f15b24da13", + "timestamp": "2022-09-26T19:36:14Z" + }, { "version": "2.0.1.11", "changelog": "NA\n", From 7cc0f66f0bc703270cd1ac3e0bd3d8009a35add7 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Sat, 5 Nov 2022 00:58:04 +0530 Subject: [PATCH 0391/1103] Generate release notes for daily release [skip ci] --- .github/workflows/release-daily.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index eba3fa75..7255b4e5 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -51,6 +51,7 @@ jobs: tag_name: ${{ env.NEW_VERSION }} prerelease: true fail_on_unmatched_files: true + generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 442dd71f80ffb12952a40befd81001a844ff599f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 26 Nov 2022 21:23:26 +0100 Subject: [PATCH 0392/1103] fix: check for exact folder match while finding media folder, preventing "/a/bc" to be stripped if a media folder is "/a/b" --- Shokofin/API/ShokoAPIManager.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index ab0dcc12..48608b5d 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -14,6 +14,7 @@ using Shokofin.Utils; using CultureInfo = System.Globalization.CultureInfo; +using Path = System.IO.Path; namespace Shokofin.API { @@ -62,7 +63,7 @@ public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient public Folder FindMediaFolder(string path, Folder parent, Folder root) { - var mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path)); + var mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.PathSeparator)); // Look for the root folder for the current item. if (mediaFolder != null) { return mediaFolder; @@ -80,7 +81,7 @@ public Folder FindMediaFolder(string path, Folder parent, Folder root) public string StripMediaFolder(string fullPath) { - var mediaFolder = MediaFolderList.FirstOrDefault((folder) => fullPath.StartsWith(folder.Path)); + var mediaFolder = MediaFolderList.FirstOrDefault((folder) => fullPath.StartsWith(folder.Path + Path.PathSeparator)); if (mediaFolder != null) { return fullPath.Substring(mediaFolder.Path.Length); } From 1bd1d59e7e20ff9b388360289bb6b903aed7a307 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 26 Nov 2022 21:26:00 +0100 Subject: [PATCH 0393/1103] fix: ensure the preferred image is always first --- Shokofin/Providers/ImageProvider.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 395724a2..53401b62 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -11,6 +11,7 @@ using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; +using System.Linq; namespace Shokofin.Providers { @@ -110,17 +111,17 @@ private void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Info.Episod private void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Images images) { - foreach (var image in images?.Posters) + foreach (var image in images.Posters.OrderByDescending(image => image.Preferred)) AddImage(ref list, ImageType.Primary, image); - foreach (var image in images?.Fanarts) + foreach (var image in images.Fanarts.OrderByDescending(image => image.Preferred)) AddImage(ref list, ImageType.Backdrop, image); - foreach (var image in images?.Banners) + foreach (var image in images.Banners.OrderByDescending(image => image.Preferred)) AddImage(ref list, ImageType.Banner, image); } private void AddImage(ref List<RemoteImageInfo> list, ImageType imageType, API.Models.Image image) { - if (image == null || !ApiClient.CheckImage(image.Path)) + if (image == null || !image.IsAvailable) return; list.Add(new RemoteImageInfo { ProviderName = Plugin.MetadataProviderName, From 28b1609bea34ade95a10ce98a16fa5abe53a0159 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 26 Nov 2022 21:15:20 +0000 Subject: [PATCH 0394/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index b9000cba..3ad97ee9 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.13", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.13/shoko_2.0.1.13.zip", + "checksum": "6cdba349a116a8763762cabd3d4d7209", + "timestamp": "2022-11-26T21:15:18Z" + }, { "version": "2.0.1.12", "changelog": "NA\n", From 69ffdcf7ff5497117ee1988fd0feed8ad9786405 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 27 Nov 2022 18:27:09 +0100 Subject: [PATCH 0395/1103] fix: use the correct seperator character --- Shokofin/API/ShokoAPIManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 48608b5d..f39ee501 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -63,7 +63,7 @@ public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient public Folder FindMediaFolder(string path, Folder parent, Folder root) { - var mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.PathSeparator)); + var mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); // Look for the root folder for the current item. if (mediaFolder != null) { return mediaFolder; @@ -81,7 +81,7 @@ public Folder FindMediaFolder(string path, Folder parent, Folder root) public string StripMediaFolder(string fullPath) { - var mediaFolder = MediaFolderList.FirstOrDefault((folder) => fullPath.StartsWith(folder.Path + Path.PathSeparator)); + var mediaFolder = MediaFolderList.FirstOrDefault((folder) => fullPath.StartsWith(folder.Path + Path.DirectorySeparatorChar)); if (mediaFolder != null) { return fullPath.Substring(mediaFolder.Path.Length); } From 968140d8faaf561d77190dbf30a6cfa325d37161 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 27 Nov 2022 17:28:09 +0000 Subject: [PATCH 0396/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 3ad97ee9..124f7d70 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.14", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.14/shoko_2.0.1.14.zip", + "checksum": "b4f359ec464c46d05494d24304cb912d", + "timestamp": "2022-11-27T17:28:07Z" + }, { "version": "2.0.1.13", "changelog": "NA\n", From f0bc45503c60ab852b682f21016b76ea83690d85 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 27 Nov 2022 23:47:23 +0100 Subject: [PATCH 0397/1103] refactor: refactor the api to use flat namespaces + more - refactor the api related files to use flat namespaces and also enabled nullable checks on all the files. - (hopefully) fix files linked to multiple series causing the entries to be merged. - don't add "missing" episode entries when some files span multiple episodes. - try to operate more _safely_ and also complain louder if there is an error (so less suppressed errors) - separate tvdb and tmdb ids in the settings and tmdb series ids if available. --- Shokofin/API/Info/EpisodeInfo.cs | 27 +- Shokofin/API/Info/FileInfo.cs | 27 +- Shokofin/API/Info/GroupInfo.cs | 128 +- Shokofin/API/Info/SeriesInfo.cs | 261 +++- Shokofin/API/Models/ApiException.cs | 101 ++ Shokofin/API/Models/ApiKey.cs | 12 +- Shokofin/API/Models/BaseModel.cs | 11 - Shokofin/API/Models/Episode.cs | 224 +-- Shokofin/API/Models/File.cs | 250 ++-- Shokofin/API/Models/Group.cs | 61 +- Shokofin/API/Models/IDs.cs | 15 +- Shokofin/API/Models/Image.cs | 184 ++- Shokofin/API/Models/Images.cs | 21 +- Shokofin/API/Models/ListResult.cs | 24 + Shokofin/API/Models/Rating.cs | 49 +- Shokofin/API/Models/Relation.cs | 133 ++ Shokofin/API/Models/Role.cs | 177 ++- Shokofin/API/Models/Series.cs | 302 +++- Shokofin/API/Models/Sizes.cs | 26 - Shokofin/API/Models/Tag.cs | 45 +- Shokofin/API/Models/Title.cs | 56 +- Shokofin/API/Models/Vote.cs | 12 - Shokofin/API/ShokoAPIClient.cs | 462 +++--- Shokofin/API/ShokoAPIManager.cs | 1298 +++++++---------- Shokofin/Configuration/PluginConfiguration.cs | 10 +- Shokofin/Configuration/configController.js | 26 +- Shokofin/Configuration/configPage.html | 44 +- Shokofin/IdLookup.cs | 34 +- Shokofin/LibraryScanner.cs | 29 +- Shokofin/Providers/BoxSetProvider.cs | 4 +- Shokofin/Providers/EpisodeProvider.cs | 57 +- Shokofin/Providers/ExtraMetadataProvider.cs | 57 +- Shokofin/Providers/ImageProvider.cs | 6 +- Shokofin/Providers/MovieProvider.cs | 7 +- Shokofin/Providers/SeasonProvider.cs | 2 +- Shokofin/Providers/SeriesProvider.cs | 48 +- Shokofin/Sync/SyncExtensions.cs | 8 +- Shokofin/Sync/UserDataSyncManager.cs | 4 +- Shokofin/Utils/OrderingUtil.cs | 10 +- Shokofin/Utils/TextUtil.cs | 15 +- 40 files changed, 2563 insertions(+), 1704 deletions(-) create mode 100644 Shokofin/API/Models/ApiException.cs delete mode 100644 Shokofin/API/Models/BaseModel.cs create mode 100644 Shokofin/API/Models/ListResult.cs create mode 100644 Shokofin/API/Models/Relation.cs delete mode 100644 Shokofin/API/Models/Sizes.cs delete mode 100644 Shokofin/API/Models/Vote.cs diff --git a/Shokofin/API/Info/EpisodeInfo.cs b/Shokofin/API/Info/EpisodeInfo.cs index 3c0d1dfe..6b19d15b 100644 --- a/Shokofin/API/Info/EpisodeInfo.cs +++ b/Shokofin/API/Info/EpisodeInfo.cs @@ -1,17 +1,28 @@ +using System.Linq; using Shokofin.API.Models; +using Shokofin.Utils; -namespace Shokofin.API.Info +#nullable enable +namespace Shokofin.API.Info; + +public class EpisodeInfo { - public class EpisodeInfo - { - public string Id; + public string Id; - public MediaBrowser.Model.Entities.ExtraType? ExtraType; + public MediaBrowser.Model.Entities.ExtraType? ExtraType; - public Episode Shoko; + public Episode Shoko; - public Episode.AniDB AniDB; + public Episode.AniDB AniDB; - public Episode.TvDB TvDB; + public Episode.TvDB? TvDB; + + public EpisodeInfo(Episode episode) + { + Id = episode.IDs.Shoko.ToString(); + ExtraType = Ordering.GetExtraType(episode.AniDBEntity); + Shoko = episode; + AniDB = episode.AniDBEntity; + TvDB = episode.TvDBEntityList?.FirstOrDefault(); } } diff --git a/Shokofin/API/Info/FileInfo.cs b/Shokofin/API/Info/FileInfo.cs index 9360011c..7f04920c 100644 --- a/Shokofin/API/Info/FileInfo.cs +++ b/Shokofin/API/Info/FileInfo.cs @@ -1,13 +1,28 @@ +using System.Collections.Generic; +using System.Linq; using Shokofin.API.Models; -namespace Shokofin.API.Info +#nullable enable +namespace Shokofin.API.Info; + +public class FileInfo { - public class FileInfo - { - public string Id; + public string Id; + + public string SeriesId; - public File Shoko; + public MediaBrowser.Model.Entities.ExtraType? ExtraType; - public int ExtraEpisodesCount; + public File File; + + public List<EpisodeInfo> EpisodeList; + + public FileInfo(File file, List<EpisodeInfo> episodeList, string seriesId) + { + Id = file.Id.ToString(); + SeriesId = seriesId; + ExtraType = episodeList.FirstOrDefault(episode => episode.ExtraType != null)?.ExtraType; + File = file; + EpisodeList = episodeList; } } diff --git a/Shokofin/API/Info/GroupInfo.cs b/Shokofin/API/Info/GroupInfo.cs index 42aa9e54..ff196b53 100644 --- a/Shokofin/API/Info/GroupInfo.cs +++ b/Shokofin/API/Info/GroupInfo.cs @@ -1,36 +1,130 @@ using System.Collections.Generic; +using System.Linq; using Shokofin.API.Models; +using Shokofin.Utils; -namespace Shokofin.API.Info +#nullable enable +namespace Shokofin.API.Info; + +public class GroupInfo { - public class GroupInfo - { - public string Id; + public string Id; + + public Group Shoko; + + public string[] Tags; + + public string[] Genres; + + public string[] Studios; + + public List<SeriesInfo> SeriesList; + + public Dictionary<int, SeriesInfo> SeasonOrderDictionary; - public Group Shoko; + public Dictionary<SeriesInfo, int> SeasonNumberBaseDictionary; - public string[] Tags; + public SeriesInfo? DefaultSeries; - public string[] Genres; + public GroupInfo(Group group) + { + Id = group.IDs.Shoko.ToString(); + Shoko = group; + Tags = new string[0]; + Genres = new string[0]; + Studios = new string[0]; + SeriesList = new(); + SeasonNumberBaseDictionary = new(); + SeasonOrderDictionary = new(); + DefaultSeries = null; + } - public string[] Studios; + public GroupInfo(Group group, List<SeriesInfo> seriesList, Ordering.GroupFilterType filterByType) + { + var groupId = group.IDs.Shoko.ToString(); - public SeriesInfo GetSeriesInfoBySeasonNumber(int seasonNumber) { - if (seasonNumber == 0 || !(SeasonOrderDictionary.TryGetValue(seasonNumber, out var seriesInfo) && seriesInfo != null)) - return null; + // Order series list + var orderingType = filterByType == Ordering.GroupFilterType.Movies ? Plugin.Instance.Configuration.MovieOrdering : Plugin.Instance.Configuration.SeasonOrdering; + switch (orderingType) { + case Ordering.OrderType.Default: + break; + case Ordering.OrderType.ReleaseDate: + seriesList = seriesList.OrderBy(s => s?.AniDB?.AirDate ?? System.DateTime.MaxValue).ToList(); + break; + // Should not be selectable unless a user fiddles with DevTools in the browser to select the option. + case Ordering.OrderType.Chronological: + throw new System.Exception("Not implemented yet"); + } - return seriesInfo; + // Select the targeted id if a group spesify a default series. + int foundIndex = -1; + int targetId = group.IDs.MainSeries; + if (targetId != 0) + foundIndex = seriesList.FindIndex(s => s.Shoko.IDs.Shoko == targetId); + // Else select the default series as first-to-be-released. + else switch (orderingType) { + // The list is already sorted by release date, so just return the first index. + case Ordering.OrderType.ReleaseDate: + foundIndex = 0; + break; + // We don't know how Shoko may have sorted it, so just find the earliest series + case Ordering.OrderType.Default: + // We can't be sure that the the series in the list was _released_ chronologically, so find the earliest series, and use that as a base. + case Ordering.OrderType.Chronological: { + var earliestSeries = seriesList.Aggregate((cur, nxt) => (cur == null || (nxt.AniDB.AirDate ?? System.DateTime.MaxValue) < (cur.AniDB.AirDate ?? System.DateTime.MaxValue)) ? nxt : cur); + foundIndex = seriesList.FindIndex(s => s == earliestSeries); + break; + } } - public List<SeriesInfo> SeriesList; + // Throw if we can't get a base point for seasons. + if (foundIndex == -1) + throw new System.Exception("Unable to get a base-point for seasions withing the group"); - public Dictionary<int, SeriesInfo> SeasonOrderDictionary; + var seasonOrderDictionary = new Dictionary<int, SeriesInfo>(); + var seasonNumberBaseDictionary = new Dictionary<SeriesInfo, int>(); + var positiveSeasonNumber = 1; + var negativeSeasonNumber = -1; + foreach (var (seriesInfo, index) in seriesList.Select((s, i) => (s, i))) { + int seasonNumber; + var offset = 0; + if (seriesInfo.AlternateEpisodesList.Count > 0) + offset++; + if (seriesInfo.OthersList.Count > 0) + offset++; - public Dictionary<SeriesInfo, int> SeasonNumberBaseDictionary; + // Series before the default series get a negative season number + if (index < foundIndex) { + seasonNumber = negativeSeasonNumber; + negativeSeasonNumber -= offset + 1; + } + else { + seasonNumber = positiveSeasonNumber; + positiveSeasonNumber += offset + 1; + } + + seasonNumberBaseDictionary.Add(seriesInfo, seasonNumber); + seasonOrderDictionary.Add(seasonNumber, seriesInfo); + for (var i = 0; i < offset; i++) + seasonOrderDictionary.Add(seasonNumber + (index < foundIndex ? -(i + 1) : (i + 1)), seriesInfo); + } + + Id = groupId; + Shoko = group; + Tags = seriesList.SelectMany(s => s.Tags).Distinct().ToArray(); + Genres = seriesList.SelectMany(s => s.Genres).Distinct().ToArray(); + Studios = seriesList.SelectMany(s => s.Studios).Distinct().ToArray(); + SeriesList = seriesList; + SeasonNumberBaseDictionary = seasonNumberBaseDictionary; + SeasonOrderDictionary = seasonOrderDictionary; + DefaultSeries = seriesList[foundIndex]; + } - public SeriesInfo DefaultSeries; + public SeriesInfo? GetSeriesInfoBySeasonNumber(int seasonNumber) { + if (seasonNumber == 0 || !(SeasonOrderDictionary.TryGetValue(seasonNumber, out var seriesInfo) && seriesInfo != null)) + return null; - public int DefaultSeriesIndex; + return seriesInfo; } } diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index 568e2530..d02dbdf0 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -1,76 +1,203 @@ using System.Collections.Generic; +using System.Linq; using Shokofin.API.Models; using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo; +using PersonType = MediaBrowser.Model.Entities.PersonType; -namespace Shokofin.API.Info +#nullable enable +namespace Shokofin.API.Info; + +public class SeriesInfo { - public class SeriesInfo + public string Id; + + public Series Shoko; + + public Series.AniDBWithDate AniDB; + + public Series.TvDB? TvDB; + + public string[] Tags; + + public string[] Genres; + + public string[] Studios; + + public PersonInfo[] Staff; + + /// <summary> + /// All episodes (of all type) that belong to this series. + /// + /// Unordered. + /// </summary> + public List<EpisodeInfo> RawEpisodeList; + + /// <summary> + /// A pre-filtered list of normal episodes that belong to this series. + /// + /// Ordered by AniDb air-date. + /// </summary> + public List<EpisodeInfo> EpisodeList; + + /// <summary> + /// A pre-filtered list of "unknown" episodes that belong to this series. + /// + /// Ordered by AniDb air-date. + /// </summary> + public List<EpisodeInfo> AlternateEpisodesList; + + /// <summary> + /// A pre-filtered list of "other" episodes that belong to this series. + /// + /// Ordered by AniDb air-date. + /// </summary> + public List<EpisodeInfo> OthersList; + + /// <summary> + /// A pre-filtered list of "extra" videos that belong to this series. + /// + /// Ordered by AniDb air-date. + /// </summary> + public List<EpisodeInfo> ExtrasList; + + /// <summary> + /// A dictionary holding mappings for the previous normal episode for every special episode in a series. + /// </summary> + public Dictionary<EpisodeInfo, EpisodeInfo> SpesialsAnchors; + + /// <summary> + /// A pre-filtered list of special episodes without an ExtraType + /// attached. + /// + /// Ordered by AniDb episode number. + /// </summary> + public List<EpisodeInfo> SpecialsList; + + public SeriesInfo(Series series, List<EpisodeInfo> episodes, IEnumerable<Role> cast, string[] genres, string[] tags) + { + var seriesId = series.IDs.Shoko.ToString(); + var studios = cast + .Where(r => r.Type == CreatorRoleType.Studio) + .Select(r => r.Staff.Name) + .ToArray(); + var staff = cast + .Select(RoleToPersonInfo) + .OfType<PersonInfo>() + .ToArray(); + var specialsAnchorDictionary = new Dictionary<EpisodeInfo, EpisodeInfo>(); + var specialsList = new List<EpisodeInfo>(); + var episodesList = new List<EpisodeInfo>(); + var extrasList = new List<EpisodeInfo>(); + var altEpisodesList = new List<EpisodeInfo>(); + var othersList = new List<EpisodeInfo>(); + + // Iterate over the episodes once and store some values for later use. + int index = 0; + int lastNormalEpisode = 0; + foreach (var episode in episodes) { + switch (episode.AniDB.Type) { + case EpisodeType.Normal: + episodesList.Add(episode); + lastNormalEpisode = index; + break; + case EpisodeType.Other: + othersList.Add(episode); + break; + case EpisodeType.Unknown: + altEpisodesList.Add(episode); + break; + default: + if (episode.ExtraType != null) + extrasList.Add(episode); + 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; + } + break; + } + index++; + } + + // While the filtered specials list is ordered by episode number + specialsList = specialsList + .OrderBy(e => e.AniDB.EpisodeNumber) + .ToList(); + + Id = seriesId; + Shoko = series; + AniDB = series.AniDBEntity; + TvDB = series.TvDBEntityList.FirstOrDefault(); + Tags = tags; + Genres = genres; + Studios = studios; + Staff = staff; + RawEpisodeList = episodes; + EpisodeList = episodesList; + AlternateEpisodesList = altEpisodesList; + OthersList = othersList; + ExtrasList = extrasList; + SpesialsAnchors = specialsAnchorDictionary; + SpecialsList = specialsList; + } + + private string? GetImagePath(Image image) + { + return image != null && image.IsAvailable ? image.ToURLString() : null; + } + + private PersonInfo? RoleToPersonInfo(Role role) { - public string Id; - - public Series Shoko; - - public Series.AniDB AniDB; - - public string TvDBId; - - public Series.TvDB TvDB; - - public string[] Tags; - - public string[] Genres; - - public string[] Studios; - - public PersonInfo[] Staff; - - /// <summary> - /// All episodes (of all type) that belong to this series. - /// - /// Unordered. - /// </summary> - public List<EpisodeInfo> RawEpisodeList; - - /// <summary> - /// A pre-filtered list of normal episodes that belong to this series. - /// - /// Ordered by AniDb air-date. - /// </summary> - public List<EpisodeInfo> EpisodeList; - - /// <summary> - /// A pre-filtered list of "unknown" episodes that belong to this series. - /// - /// Ordered by AniDb air-date. - /// </summary> - public List<EpisodeInfo> AlternateEpisodesList; - - /// <summary> - /// A pre-filtered list of "other" episodes that belong to this series. - /// - /// Ordered by AniDb air-date. - /// </summary> - public List<EpisodeInfo> OthersList; - - /// <summary> - /// A pre-filtered list of "extra" videos that belong to this series. - /// - /// Ordered by AniDb air-date. - /// </summary> - public List<EpisodeInfo> ExtrasList; - - /// <summary> - /// A dictionary holding mappings for the previous normal episode for every special episode in a series. - /// </summary> - public Dictionary<EpisodeInfo, EpisodeInfo> SpesialsAnchors; - - /// <summary> - /// A pre-filtered list of special episodes without an ExtraType - /// attached. - /// - /// Ordered by AniDb episode number. - /// </summary> - public List<EpisodeInfo> SpecialsList; + switch (role.Type) { + default: + return null; + case CreatorRoleType.Director: + return new PersonInfo { + Type = PersonType.Director, + Name = role.Staff.Name, + Role = role.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }; + case CreatorRoleType.Producer: + return new PersonInfo { + Type = PersonType.Producer, + Name = role.Staff.Name, + Role = role.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }; + case CreatorRoleType.Music: + return new PersonInfo { + Type = PersonType.Lyricist, + Name = role.Staff.Name, + Role = role.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }; + case CreatorRoleType.SourceWork: + return new PersonInfo { + Type = PersonType.Writer, + Name = role.Staff.Name, + Role = role.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }; + case CreatorRoleType.SeriesComposer: + return new PersonInfo { + Type = PersonType.Composer, + Name = role.Staff.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }; + case CreatorRoleType.Seiyuu: + return new PersonInfo { + Type = PersonType.Actor, + Name = role.Staff.Name, + // The character will always be present if the role is a VA. + // We make it a conditional check since otherwise will the compiler complain. + Role = role.Character?.Name ?? "", + ImageUrl = GetImagePath(role.Staff.Image), + }; + } } } diff --git a/Shokofin/API/Models/ApiException.cs b/Shokofin/API/Models/ApiException.cs new file mode 100644 index 00000000..827695fb --- /dev/null +++ b/Shokofin/API/Models/ApiException.cs @@ -0,0 +1,101 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; + +#nullable enable +namespace Shokofin.API.Models; + +[Serializable] +public class ApiException : Exception +{ + + private record ValidationResponse + { + public Dictionary<string, string[]> errors = new(); + + public string title = ""; + + public HttpStatusCode status = HttpStatusCode.BadRequest; + } + + public readonly HttpStatusCode StatusCode; + + public readonly ApiExceptionType Type; + + public readonly RemoteApiException? Inner; + + public readonly Dictionary<string, string[]> ValidationErrors; + + public ApiException(HttpStatusCode statusCode, string source, string? message) : base(string.IsNullOrEmpty(message) ? source : $"{source}: {message}") + { + StatusCode = statusCode; + Type = ApiExceptionType.Simple; + ValidationErrors = new(); + } + + protected ApiException(HttpStatusCode statusCode, RemoteApiException inner) : base(inner.Message, inner) + { + StatusCode = statusCode; + Type = ApiExceptionType.RemoteException; + Inner = inner; + ValidationErrors = new(); + } + + 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(); + } + + public static ApiException FromResponse(HttpResponseMessage response) + { + var text = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + if (text.Length > 0 && text[0] == '{') { + var full = JsonSerializer.Deserialize<ValidationResponse>(text); + var title = full?.title; + var validationErrors = full?.errors; + return new ApiException(response.StatusCode, "ValidationError", title, validationErrors); + } + var index = text.IndexOf("HEADERS"); + if (index != -1) + { + var (firstLine, lines) = text.Substring(0, index).TrimEnd().Split('\n'); + var (name, splitMessage) = firstLine?.Split(':') ?? new string[] {}; + var message = string.Join(':', splitMessage).Trim(); + var stackTrace = string.Join('\n', lines); + return new ApiException(response.StatusCode, new RemoteApiException(name ?? "InternalServerException", message, stackTrace)); + } + return new ApiException(response.StatusCode, response.StatusCode.ToString() + "Exception", text.Split('\n').FirstOrDefault() ?? ""); + } + + public class RemoteApiException : Exception + { + public RemoteApiException(string source, string message, string stack) : base($"{source}: {message}") + { + Source = source; + StackTrace = stack; + } + + /// <inheritdoc/> + public override string StackTrace { get; } + } + + public enum ApiExceptionType + { + Simple = 0, + ValidationErrors = 1, + RemoteException = 2, + } +} + +public static class IListExtension { + public static void Deconstruct<T>(this IList<T> list, out T? first, out IList<T> rest) { + first = list.Count > 0 ? list[0] : default(T); // or throw + rest = list.Skip(1).ToList(); + } +} diff --git a/Shokofin/API/Models/ApiKey.cs b/Shokofin/API/Models/ApiKey.cs index a8fd9d8b..1f9edb6e 100644 --- a/Shokofin/API/Models/ApiKey.cs +++ b/Shokofin/API/Models/ApiKey.cs @@ -1,7 +1,7 @@ -namespace Shokofin.API.Models +# nullable enable +namespace Shokofin.API.Models; + +public class ApiKey { - public class ApiKey - { - public string apikey { get; set; } - } -} \ No newline at end of file + public string apikey { get; set; } = ""; +} diff --git a/Shokofin/API/Models/BaseModel.cs b/Shokofin/API/Models/BaseModel.cs deleted file mode 100644 index 6013a9bb..00000000 --- a/Shokofin/API/Models/BaseModel.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Shokofin.API.Models -{ - public abstract class BaseModel - { - public string Name { get; set; } - - public int Size { get; set; } - - public Sizes Sizes { get; set; } - } -} \ No newline at end of file diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index c70d2ddf..19876562 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -2,123 +2,163 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -namespace Shokofin.API.Models +#nullable enable +namespace Shokofin.API.Models; + +public class Episode { - public class Episode : BaseModel + /// <summary> + /// All identifiers related to the episode entry, e.g. the Shoko, AniDB, + /// TvDB, etc. + /// </summary> + public EpisodeIDs IDs { get; set; } = new(); + + public string Name { get; set; } = ""; + + /// <summary> + /// The duration of the episode. + /// </summary> + public TimeSpan Duration { get; set; } + + /// <summary> + /// Number of files + /// </summary> + /// <value></value> + public int Size { get; set; } + + /// <summary> + /// The <see cref="Episode.AniDB"/>, if <see cref="DataSource.AniDB"/> is + /// included in the data to add. + /// </summary> + [JsonPropertyName("AniDB")] + public AniDB AniDBEntity { get; set; } = new(); + + /// <summary> + /// The <see cref="Episode.TvDB"/> entries, if <see cref="DataSource.TvDB"/> + /// is included in the data to add. + /// </summary> + [JsonPropertyName("TvDB")] + public List<TvDB> TvDBEntityList { get; set; } = new(); + + public class AniDB { - public EpisodeIDs IDs { get; set; } - - public DateTime? Watched { get; set; } - - public class AniDB - { - public int ID { get; set; } + [JsonPropertyName("ID")] + public int Id { get; set; } - [JsonConverter(typeof(JsonStringEnumConverter))] - public EpisodeType Type { get; set; } - - public int EpisodeNumber { get; set; } + /// <summary> + /// The duration of the episode. + /// </summary> + public TimeSpan Duration { get; set; } - public DateTime? AirDate { get; set; } + [JsonConverter(typeof(JsonStringEnumConverter))] + public EpisodeType Type { get; set; } - public List<Title> Titles { get; set; } + public int EpisodeNumber { get; set; } - public string Description { get; set; } + public DateTime? AirDate { get; set; } - public Rating Rating { get; set; } - } + public List<Title> Titles { get; set; } = new(); - public class TvDB - { - public int ID { get; set; } + public string Description { get; set; } = ""; - public int Season { get; set; } + public Rating? Rating { get; set; } = new(); + } - public int Number { get; set; } + public class TvDB + { + [JsonPropertyName("ID")] + public int Id { get; set; } - public int AbsoluteNumber { get; set; } + [JsonPropertyName("Season")] + public int SeasonNumber { get; set; } - public string Title { get; set; } + [JsonPropertyName("Number")] + public int EpisodeNumber { get; set; } - public string Description { get; set; } + [JsonPropertyName("AbsoluteNumber")] + public int AbsoluteEpisodeNumber { get; set; } - public DateTime? AirDate { get; set; } + public string Title { get; set; } = ""; - public int? AirsAfterSeason { get; set; } + public string Description { get; set; } = ""; - public int? AirsBeforeSeason { get; set; } + public DateTime? AirDate { get; set; } - public int? AirsBeforeEpisode { get; set; } + public int? AirsAfterSeason { get; set; } - public Rating Rating { get; set; } + public int? AirsBeforeSeason { get; set; } - public Image Thumbnail { get; set; } - } + public int? AirsBeforeEpisode { get; set; } - public class EpisodeIDs : IDs - { - public int AniDB { get; set; } + public Rating? Rating { get; set; } - public List<int> TvDB { get; set; } = new List<int>(); - } + public Image Thumbnail { get; set; } = new(); } - - public enum EpisodeType + public class EpisodeIDs : IDs { - /// <summary> - /// The episode type is unknown. - /// </summary> - Unknown = 0, + public int AniDB { get; set; } - /// <summary> - /// A catch-all type for future extensions when a provider can't use a current episode type, but knows what the future type should be. - /// </summary> - Other = 1, - - /// <summary> - /// A normal episode. - /// </summary> - Normal = 2, - - /// <summary> - /// A special episode. - /// </summary> - Special = 3, - - /// <summary> - /// A trailer. - /// </summary> - Trailer = 4, - - /// <summary> - /// Either an opening-song, or an ending-song. - /// </summary> - ThemeSong = 5, - - /// <summary> - /// Intro, and/or opening-song. - /// </summary> - OpeningSong = 6, - - /// <summary> - /// Outro, end-roll, credits, and/or ending-song. - /// </summary> - EndingSong = 7, - - /// <summary> - /// AniDB parody type. Where else would this be useful? - /// </summary> - Parody = 8, + public List<int> TvDB { get; set; } = new(); + } +} - /// <summary> - /// A interview tied to the series. - /// </summary> - Interview = 9, - /// <summary> - /// A DVD or BD extra, e.g. BD-menu or deleted scenes. - /// </summary> - Extra = 10, - } +public enum EpisodeType +{ + /// <summary> + /// The episode type is unknown. + /// </summary> + Unknown = 0, + + /// <summary> + /// A catch-all type for future extensions when a provider can't use a current episode type, but knows what the future type should be. + /// </summary> + Other = 1, + + /// <summary> + /// A normal episode. + /// </summary> + Normal = 2, + + /// <summary> + /// A special episode. + /// </summary> + Special = 3, + + /// <summary> + /// A trailer. + /// </summary> + Trailer = 4, + + /// <summary> + /// Either an opening-song, or an ending-song. + /// </summary> + ThemeSong = 5, + + /// <summary> + /// Intro, and/or opening-song. + /// </summary> + OpeningSong = 6, + + /// <summary> + /// Outro, end-roll, credits, and/or ending-song. + /// </summary> + EndingSong = 7, + + /// <summary> + /// AniDB parody type. Where else would this be useful? + /// </summary> + Parody = 8, + + /// <summary> + /// A interview tied to the series. + /// </summary> + Interview = 9, + + /// <summary> + /// A DVD or BD extra, e.g. BD-menu or deleted scenes. + /// </summary> + Extra = 10, } + diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index c04507c3..021bd1da 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -1,97 +1,179 @@ using System; using System.Collections.Generic; +using System.Text.Json.Serialization; -namespace Shokofin.API.Models +#nullable enable +namespace Shokofin.API.Models; + +public class File { - public class File + /// <summary> + /// The id of the <see cref="File"/>. + /// </summary> + [JsonPropertyName("ID")] + public int Id { get; set; } + + /// <summary> + /// The Cross Reference Models for every episode this file belongs to, created in a reverse tree and + /// transformed back into a tree. Series -> Episode such that only episodes that this file is linked to are + /// shown. In many cases, this will have arrays of 1 item + /// </summary> + [JsonPropertyName("SeriesIDs")] + public List<CrossReference> CrossReferences { get; set; } = new(); + + /// <summary> + /// The calculated hashes from the <see cref="File"/>. + /// + /// Either will all hashes be filled or none. + /// </summary> + public HashMap Hashes { get; set; } = new(); + + /// <summary> + /// All the <see cref="Location"/>s this <see cref="File"/> is present at. + /// </summary> + public List<Location> Locations { get; set; } = new(); + + /// <summary> + /// Try to fit this file's resolution to something like 1080p, 480p, etc. + /// </summary> + public string Resolution { get; set; } = ""; + + /// <summary> + /// The duration of the file. + /// </summary> + public TimeSpan Duration { get; set; } + + [JsonPropertyName("Created")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("Updated")] + public DateTime LastUpdatedAt { get; set; } + + /// <summary> + /// The size of the file in bytes. + /// </summary> + public long Size { get; set; } + + /// <summary> + /// Metadata about the location where a file lies, including the import + /// folder it belogns to and the relative path from the base of the import + /// folder to where it lies. + /// </summary> + public class Location { - public int ID { get; set; } - - public long Size { get; set; } - - public HashesType Hashes { get; set; } - - public List<Location> Locations { get; set; } - - public string RoundedStandardResolution { get; set; } - - public DateTime Created { get; set; } - - public class Location - { - public int ImportFolderID { get; set; } - - public string RelativePath { get; set; } - - public bool Accessible { get; set; } - } - - public class HashesType - { - public string ED2K { get; set; } - - public string SHA1 { get; set; } - - public string CRC32 { get; set; } - - public string MD5 { get; set; } - } + /// <summary> + /// The id of the <see cref="ImportFolder"/> this <see cref="File"/> + /// resides in. + /// </summary> + /// <value></value> + [JsonPropertyName("ImportFolderID")] + public int ImportFolderId { get; set; } /// <summary> - /// User stats for the file. + /// The relative path from the base of the <see cref="ImportFolder"/> to + /// where the <see cref="File"/> lies. /// </summary> - public class FileUserStats - { - /// <summary> - /// Where to resume the next playback. - /// </summary> - public TimeSpan? ResumePosition { get; set; } - - /// <summary> - /// Total number of times the file have been watched. - /// </summary> - public int WatchedCount { get; set; } - - /// <summary> - /// When the file was last watched. Will be null if the full is - /// currently marked as unwatched. - /// </summary> - public DateTime? LastWatchedAt { get; set; } - - /// <summary> - /// When the entry was last updated. - /// </summary> - public DateTime LastUpdatedAt { get; set; } - - /// <summary> - /// True if the <see cref="FileUserStats"/> object is considered empty. - /// </summary> - public virtual bool IsEmpty - { - get - => ResumePosition == null && WatchedCount == 0 && WatchedCount == 0; - } - } + [JsonPropertyName("RelativePath")] + public string Path { get; set; } = ""; + + /// <summary> + /// True if the server can access the the <see cref="Location.Path"/> at + /// the moment of requesting the data. + /// </summary> + [JsonPropertyName("Accessible")] + public bool IsAccessible { get; set; } = false; + } + + /// <summary> + /// The calculated hashes of the file. Either will all hashes be filled or + /// none. + /// </summary> + public class HashMap + { + public string ED2K { get; set; } = ""; + + public string SHA1 { get; set; } = ""; + + public string CRC32 { get; set; } = ""; + + public string MD5 { get; set; } = ""; + } + + public class CrossReference + { + /// <summary> + /// The Series IDs + /// </summary> + [JsonPropertyName("SeriesID")] + public CrossReferenceIDs Series { get; set; } = new(); + + /// <summary> + /// The Episode IDs + /// </summary> + [JsonPropertyName("EpisodeIDs")] + public List<CrossReferenceIDs> Episodes { get; set; } = new(); + } + + public class CrossReferenceIDs : IDs + { + /// <summary> + /// Any AniDB ID linked to this object + /// </summary> + public int AniDB { get; set; } + + /// <summary> + /// Any TvDB IDs linked to this object + /// </summary> + public List<int> TvDB { get; set; } = new(); + } - public class FileDetailed : File + /// <summary> + /// User stats for the file. + /// </summary> + public class UserStats + { + /// <summary> + /// Where to resume the next playback. + /// </summary> + public TimeSpan? ResumePosition { get; set; } + + /// <summary> + /// Total number of times the file have been watched. + /// </summary> + public int WatchedCount { get; set; } + + /// <summary> + /// When the file was last watched. Will be null if the full is + /// currently marked as unwatched. + /// </summary> + public DateTime? LastWatchedAt { get; set; } + + /// <summary> + /// When the entry was last updated. + /// </summary> + public DateTime LastUpdatedAt { get; set; } + + /// <summary> + /// True if the <see cref="UserStats"/> object is considered empty. + /// </summary> + public virtual bool IsEmpty { - public List<SeriesXRefs> SeriesIDs { get; set; } - - public class FileIDs - { - public int AniDB { get; set; } - - public List<int> TvDB { get; set; } - - public int ID { get; set; } - } - - public class SeriesXRefs - { - public FileIDs SeriesID { get; set; } - - public List<FileIDs> EpisodeIDs { get; set; } - } + get => ResumePosition == null && WatchedCount == 0 && WatchedCount == 0; } } } + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FileSource +{ + Unknown = 0, + Other = 1, + TV = 2, + DVD = 3, + BluRay = 4, + Web = 5, + VHS = 6, + VCD = 7, + LaserDisc = 8, + Camera = 9 +} diff --git a/Shokofin/API/Models/Group.cs b/Shokofin/API/Models/Group.cs index 832917f8..889e64e0 100644 --- a/Shokofin/API/Models/Group.cs +++ b/Shokofin/API/Models/Group.cs @@ -1,22 +1,61 @@ -namespace Shokofin.API.Models +#nullable enable +namespace Shokofin.API.Models; + +public class Group { - public class Group : BaseModel + public string Name { get; set; } = ""; + + public int Size { get; set; } + + public GroupIDs IDs { get; set; } = new(); + + public string SortName { get; set; } = ""; + + public string Description { get; set; } = ""; + + public bool HasCustomName { get; set; } + + /// <summary> + /// Sizes object, has totals + /// </summary> + public GroupSizes Sizes { get; set; } = new(); + + public class GroupIDs : IDs { - public GroupIDs IDs { get; set; } + public int? DefaultSeries { get; set; } - public string SortName { get; set; } + public int MainSeries { get; set; } - public string Description { get; set; } + public int? ParentGroup { get; set; } - public bool HasCustomName { get; set; } + public int TopLevelGroup { get; set; } + } - public class GroupIDs : IDs - { - public int? DefaultSeries { get; set; } + /// <summary> + /// Downloaded, Watched, Total, etc + /// </summary> + public class GroupSizes : Series.SeriesSizes + { + /// <summary> + /// Number of direct sub-groups within the group. + /// /// </summary> + /// <value></value> + public int SubGroups { get; set; } - public int? ParentGroup { get; set; } + /// <summary> + /// Count of the different series types within the group. + /// </summary> + public SeriesTypeCounts SeriesTypes { get; set; } = new(); - public int TopLevelGroup { get; set; } + public class SeriesTypeCounts + { + public int Unknown; + public int Other; + public int TV; + public int TVSpecial; + public int Web; + public int Movie; + public int OVA; } } } diff --git a/Shokofin/API/Models/IDs.cs b/Shokofin/API/Models/IDs.cs index 6911e66a..031954e5 100644 --- a/Shokofin/API/Models/IDs.cs +++ b/Shokofin/API/Models/IDs.cs @@ -1,7 +1,10 @@ -namespace Shokofin.API.Models +using System.Text.Json.Serialization; + +#nullable enable +namespace Shokofin.API.Models; + +public class IDs { - public class IDs - { - public int ID { get; set; } - } -} \ No newline at end of file + [JsonPropertyName("ID")] + public int Shoko { get; set; } +} diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index afb62597..4aeb8f2c 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -1,41 +1,155 @@ using System.Text.Json.Serialization; -namespace Shokofin.API.Models +# nullable enable +namespace Shokofin.API.Models; + +public class Image { - public class Image + /// <summary> + /// AniDB, TvDB, TMDB, etc. + /// </summary> + public ImageSource Source { get; set; } = ImageSource.AniDB; + + /// <summary> + /// Poster, Banner, etc. + /// </summary> + 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. + /// </summary> + public string ID { get; set; } = ""; + + + /// <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"/>. + /// </summary> + [JsonPropertyName("Preferred")] + public bool IsDefault { get; set; } = false; + + /// <summary> + /// True if the image has been disabled. You must explicitly ask for these, for obvious reasons. + /// </summary> + [JsonPropertyName("Disabled")] + public bool IsDisabled { get; set; } = false; + + /// <summary> + /// Width of the image, if available. + /// </summary> + public int? Width { get; set; } + + /// <summary> + /// Height of the image, if available. + /// </summary> + public int? Height { get; set; } + + /// <summary> + /// The relative path from the image base directory if the image is present + /// on the server. + /// </summary> + [JsonPropertyName("RelativeFilepath")] + public string? LocalPath { get; set; } + + /// <summary> + /// True if the image is available. + /// </summary> + [JsonIgnore] + public virtual bool IsAvailable + => !string.IsNullOrEmpty(LocalPath); + + /// <summary> + /// The remote path to retrive the image. + /// </summary> + [JsonIgnore] + public virtual string Path + => $"/api/v3/Image/{Source.ToString()}/{Type.ToString()}/{ID}"; + + /// <summary> + /// Get an URL to download the image on the backend. + /// </summary> + /// <returns>The image URL</returns> + public string ToURLString() + { + return string.Concat(Plugin.Instance.Configuration.Host, Path); + } + + /// <summary> + /// Get an URL to display the image in the clients. + /// </summary> + /// <returns>The image URL</returns> + public string ToPrettyURLString() { - public string Source { get; set; } - - public string Type { get; set; } - - public string ID { get; set; } - - public string RelativeFilepath { get; set; } - - public bool Preferred { get; set; } - - public bool Disabled { get; set; } - - public int? Width { get; set; } - - public int? Height { get; set; } - - [JsonIgnore] - public virtual bool IsAvailable - => !string.IsNullOrEmpty(RelativeFilepath); - - [JsonIgnore] - public virtual string Path - => $"/api/v3/Image/{Source}/{Type}/{ID}"; - - public string ToURLString() - { - return string.Concat(Plugin.Instance.Configuration.Host, Path); - } - - public string ToPrettyURLString() - { - return string.Concat(Plugin.Instance.Configuration.PrettyHost, Path); - } + return string.Concat(Plugin.Instance.Configuration.PrettyHost, Path); } +} + +/// <summary> +/// Image source. +/// </summary> +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ImageSource +{ + /// <summary> + /// + /// </summary> + AniDB = 1, + + /// <summary> + /// + /// </summary> + TvDB = 2, + + /// <summary> + /// + /// </summary> + TMDB = 3, + + /// <summary> + /// + /// </summary> + Shoko = 100 +} + +/// <summary> +/// Image type. +/// </summary> +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ImageType +{ + /// <summary> + /// + /// </summary> + Poster = 1, + + /// <summary> + /// + /// </summary> + Banner = 2, + + /// <summary> + /// + /// </summary> + Thumb = 3, + + /// <summary> + /// + /// </summary> + Fanart = 4, + + /// <summary> + /// + /// </summary> + Character = 5, + + /// <summary> + /// + /// </summary> + Staff = 6, + + /// <summary> + /// Static resources are only valid if the <see cref="Image.Source"/> is set to <see cref="ImageSource.Shoko"/>. + /// </summary> + Static = 100 } \ No newline at end of file diff --git a/Shokofin/API/Models/Images.cs b/Shokofin/API/Models/Images.cs index 64590c48..4d7e41cb 100644 --- a/Shokofin/API/Models/Images.cs +++ b/Shokofin/API/Models/Images.cs @@ -1,14 +1,13 @@ using System.Collections.Generic; -namespace Shokofin.API.Models +#nullable enable +namespace Shokofin.API.Models; + +public class Images { - public class Images - { - public List<Image> Posters { get; set; } = new List<Image>(); - - public List<Image> Fanarts { get; set; } = new List<Image>(); - - public List<Image> Banners { get; set; } = new List<Image>(); - - } -} \ No newline at end of file + public List<Image> Posters { get; set; } = new List<Image>(); + + public List<Image> Fanarts { get; set; } = new List<Image>(); + + public List<Image> Banners { get; set; } = new List<Image>(); +} diff --git a/Shokofin/API/Models/ListResult.cs b/Shokofin/API/Models/ListResult.cs new file mode 100644 index 00000000..bfb7775d --- /dev/null +++ b/Shokofin/API/Models/ListResult.cs @@ -0,0 +1,24 @@ + +using System.Collections.Generic; + +#nullable enable +namespace Shokofin.API.Models; + +/// <summary> +/// A list with the total count of <typeparamref name="T"/> entries that +/// match the filter and a sliced or the full list of <typeparamref name="T"/> +/// entries. +/// </summary> +public class ListResult<T> +{ + /// <summary> + /// Total number of <typeparamref name="T"/> entries that matched the + /// applied filter. + /// </summary> + public int Total { get; set; } = 0; + + /// <summary> + /// A sliced page or the whole list of <typeparamref name="T"/> entries. + /// </summary> + public IReadOnlyList<T> List { get; set; } = new T[] {}; +} diff --git a/Shokofin/API/Models/Rating.cs b/Shokofin/API/Models/Rating.cs index 7226fb93..a6950826 100644 --- a/Shokofin/API/Models/Rating.cs +++ b/Shokofin/API/Models/Rating.cs @@ -1,20 +1,35 @@ -namespace Shokofin.API.Models +#nullable enable +namespace Shokofin.API.Models; + +public class Rating { - public class Rating - { - public decimal Value { get; set; } - - public int MaxValue { get; set; } - - public string Source { get; set; } - - public int Votes { get; set; } - - public string Type { get; set; } + /// <summary> + /// The rating value relative to the <see cref="Rating.MaxValue"/>. + /// </summary> + public decimal Value { get; set; } = 0; + + /// <summary> + /// Max value for the rating. + /// </summary> + public int MaxValue { get; set; } = 0; + + /// <summary> + /// AniDB, etc. + /// </summary> + public string Source { get; set; } = ""; - public float ToFloat(uint scale = 1) - { - return (float)((Value * scale) / MaxValue); - } + /// <summary> + /// number of votes + /// </summary> + public int? Votes { get; set; } + + /// <summary> + /// for temporary vs permanent, or any other situations that may arise later + /// </summary> + public string? Type { get; set; } + + public float ToFloat(uint scale = 1) + { + return (float)((Value * scale) / MaxValue); } -} \ No newline at end of file +} diff --git a/Shokofin/API/Models/Relation.cs b/Shokofin/API/Models/Relation.cs new file mode 100644 index 00000000..7c728ad5 --- /dev/null +++ b/Shokofin/API/Models/Relation.cs @@ -0,0 +1,133 @@ +using System.Text.Json.Serialization; + +#nullable enable +namespace Shokofin.API.Models; + +/// <summary> +/// Describes relations between two series entries. +/// </summary> +public class Relation +{ + /// <summary> + /// The IDs of the series. + /// </summary> + public RelationIDs IDs = new(); + + /// <summary> + /// The IDs of the related series. + /// </summary> + public RelationIDs RelatedIDs = new(); + + /// <summary> + /// The relation between <see cref="Relation.IDs"/> and <see cref="Relation.RelatedIDs"/>. + /// </summary> + public RelationType Type { get; set; } + + /// <summary> + /// AniDB, etc. + /// </summary> + public string Source { get; set; } = "Unknown"; + + /// <summary> + /// Relation IDs. + /// </summary> + public class RelationIDs + { + /// <summary> + /// The ID of the <see cref="Series"/> entry. + /// </summary> + public int? Shoko { get; set; } + + /// <summary> + /// The ID of the <see cref="Series.AniDB"/> entry. + /// </summary> + public int? AniDB { get; set; } + } +} + +/// <summary> +/// Explains how the main entry relates to the related entry. +/// </summary> +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum RelationType +{ + /// <summary> + /// The relation between the entries cannot be explained in simple terms. + /// </summary> + Other = 0, + + /// <summary> + /// The entries use the same setting, but follow different stories. + /// </summary> + SameSetting = 1, + + /// <summary> + /// The entries use the same base story, but is set in alternate settings. + /// </summary> + AlternativeSetting = 2, + + /// <summary> + /// The entries tell the same story in the same settings but are made at different times. + /// </summary> + AlternativeVersion = 3, + + /// <summary> + /// The entries tell different stories in different settings but otherwise shares some character(s). + /// </summary> + SharedCharacters = 4, + + /// <summary> + /// The first story either continues, or expands upon the story of the related entry. + /// </summary> + Prequel = 20, + + /// <summary> + /// The related entry is the main-story for the main entry, which is a side-story. + /// </summary> + MainStory = 21, + + /// <summary> + /// The related entry is a longer version of the summarized events in the main entry. + /// </summary> + FullStory = 22, + + /// <summary> + /// The related entry either continues, or expands upon the story of the main entry. + /// </summary> + Sequel = 40, + + /// <summary> + /// The related entry is a side-story for the main entry, which is the main-story. + /// </summary> + SideStory = 41, + + /// <summary> + /// The related entry summarizes the events of the story in the main entry. + /// </summary> + Summary = 42, +} + +/// <summary> +/// Extensions related to relations +/// </summary> +public static class RelationExtensions +{ + /// <summary> + /// Reverse the relation. + /// </summary> + /// <param name="type">The relation to reverse.</param> + /// <returns>The reversed relation.</returns> + public static RelationType Reverse(this RelationType type) + { + return type switch + { + RelationType.Prequel => RelationType.Sequel, + RelationType.Sequel => RelationType.Prequel, + RelationType.MainStory => RelationType.SideStory, + RelationType.SideStory => RelationType.MainStory, + RelationType.FullStory => RelationType.Summary, + RelationType.Summary => RelationType.FullStory, + _ => type + }; + } +} \ No newline at end of file diff --git a/Shokofin/API/Models/Role.cs b/Shokofin/API/Models/Role.cs index 12c90ee0..eb094f06 100644 --- a/Shokofin/API/Models/Role.cs +++ b/Shokofin/API/Models/Role.cs @@ -1,78 +1,109 @@ using System.Text.Json.Serialization; -namespace Shokofin.API.Models +#nullable enable +namespace Shokofin.API.Models; + +public class Role { - public class Role + /// <summary> + /// Extra info about the role. For example, role can be voice actor, while role_details is Main Character + /// </summary> + [JsonPropertyName("RoleDetails")] + public string Name { get; set; } = ""; + + /// <summary> + /// The role that the staff plays, cv, writer, director, etc + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + [JsonPropertyName("RoleName")] + public CreatorRoleType Type { get; set; } + + /// <summary> + /// Most will be Japanese. Once AniList is in, it will have multiple options + /// </summary> + public string? Language { get; set; } + + public Person Staff { get; set; } = new(); + + /// <summary> + /// The character played, the <see cref="Role.Type"/> is of type + /// <see cref="CreatorRoleType.Seiyuu"/>. + /// </summary> + public Person? Character { get; set; } + + public class Person { - public string Language { get; set; } - - public Person Staff { get; set; } - - public Person Character { get; set; } - - [JsonConverter(typeof(JsonStringEnumConverter))] - public CreatorRoleType RoleName { get; set; } - - public string RoleDetails { get; set; } - - public class Person - { - public string Name { get; set; } - - public string AlternateName { get; set; } - - public string Description { get; set; } - - public Image Image { get; set; } - } - - [JsonConverter(typeof(JsonStringEnumConverter))] - public enum CreatorRoleType - { - /// <summary> - /// Voice actor or voice actress. - /// </summary> - Seiyuu, - - /// <summary> - /// This can be anything involved in writing the show. - /// </summary> - Staff, - - /// <summary> - /// The studio responsible for publishing the show. - /// </summary> - Studio, - - /// <summary> - /// The main producer(s) for the show. - /// </summary> - Producer, - - /// <summary> - /// Direction. - /// </summary> - Director, - - /// <summary> - /// Series Composition. - /// </summary> - SeriesComposer, - - /// <summary> - /// Character Design. - /// </summary> - CharacterDesign, - - /// <summary> - /// Music composer. - /// </summary> - Music, - - /// <summary> - /// Responsible for the creation of the source work this show is detrived from. - /// </summary> - SourceWork, - } + /// <summary> + /// Main Name, romanized if needed + /// ex. Sawano Hiroyuki + /// </summary> + public string Name { get; set; } = ""; + + /// <summary> + /// Alternate Name, this can be any other name, whether kanji, an alias, etc + /// ex. 澤野弘之 + /// </summary> + public string? AlternateName { get; set; } + + /// <summary> + /// A description, bio, etc + /// ex. Sawano Hiroyuki was born September 12, 1980 in Tokyo, Japan. He is a composer and arranger. + /// </summary> + public string Description { get; set; } = ""; + + /// <summary> + /// Visual representation of the character or staff. Usually a profile + /// picture. + /// </summary> + public Image Image { get; set; } = new(); } -} \ No newline at end of file +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CreatorRoleType +{ + /// <summary> + /// Voice actor or voice actress. + /// </summary> + Seiyuu, + + /// <summary> + /// This can be anything involved in writing the show. + /// </summary> + Staff, + + /// <summary> + /// The studio responsible for publishing the show. + /// </summary> + Studio, + + /// <summary> + /// The main producer(s) for the show. + /// </summary> + Producer, + + /// <summary> + /// Direction. + /// </summary> + Director, + + /// <summary> + /// Series Composition. + /// </summary> + SeriesComposer, + + /// <summary> + /// Character Design. + /// </summary> + CharacterDesign, + + /// <summary> + /// Music composer. + /// </summary> + Music, + + /// <summary> + /// Responsible for the creation of the source work this show is detrived from. + /// </summary> + SourceWork, +} diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index 55852236..5af4c0f2 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -2,87 +2,186 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -namespace Shokofin.API.Models +#nullable enable +namespace Shokofin.API.Models; + +public class Series { - public class Series : BaseModel + public string Name { get; set; } = ""; + + public int Size { get; set; } + + /// <summary> + /// All identifiers related to the series entry, e.g. the Shoko, AniDB, + /// TvDB, etc. + /// </summary> + public SeriesIDs IDs { get; set; } = new(); + + /// <summary> + /// The default or random pictures for a series. This allows the client to + /// not need to get all images and pick one. + /// + /// There should always be a poster, but no promises on the rest. + /// </summary> + public Images Images { get; set; } = new(); + + /// <summary> + /// The user's rating, if any. + /// </summary> + public Rating? UserRating { get; set; } = new(); + + /// <summary> + /// The AniDB entry. + /// </summary> + [JsonPropertyName("AniDB")] + public AniDBWithDate AniDBEntity { get; set; } = new(); + + /// <summary> + /// The TvDB entries, if any. + /// </summary> + [JsonPropertyName("TvDB")] + public List<TvDB> TvDBEntityList { get; set; }= new(); + + public SeriesSizes Sizes { get; set; } = new(); + + /// <summary> + /// When the series entry was created during the process of the first file + /// being added to Shoko. + /// </summary> + [JsonPropertyName("Created")] + public DateTime CreatedAt { get; set; } + + /// <summary> + /// When the series entry was last updated. + /// </summary> + [JsonPropertyName("Updated")] + public DateTime LastUpdatedAt { get; set; } + + public class AniDB { - public SeriesIDs IDs { get; set; } - - public Images Images { get; set; } - - public Rating UserRating { get; set; } - - public List<Resource> Links { get; set; } - - public DateTime Created { get; set; } - - public DateTime Updated { get; set; } - - public class AniDB - { - public int ID { get; set; } + /// <summary> + /// AniDB Id + /// </summary> + [JsonPropertyName("ID")] + public int Id { get; set; } - [JsonConverter(typeof(JsonStringEnumConverter))] - public SeriesType Type { get; set; } + /// <summary> + /// <see cref="Series"/> Id if the series is available locally. + /// </summary> + [JsonPropertyName("ShokoID")] + public int? ShokoId { get; set; } - public string Title { get; set; } + /// <summary> + /// Series type. Series, OVA, Movie, etc + /// </summary> + public SeriesType Type { get; set; } - public bool Restricted { get; set; } + /// <summary> + /// Main Title, usually matches x-jat + /// </summary> + public string Title { get; set; } = ""; - public DateTime? AirDate { get; set; } + /// <summary> + /// There should always be at least one of these, the <see cref="Title"/>. May be omitted if needed. + /// </summary> + public List<Title>? Titles { get; set; } - public DateTime? EndDate { get; set; } + /// <summary> + /// Description. + /// </summary> + public string Description { get; set; } = ""; - public List<Title> Titles { get; set; } + /// <summary> + /// Restricted content. Mainly porn. + /// </summary> + public bool Restricted { get; set; } - public string Description { get; set; } + /// <summary> + /// The main or default poster. + /// </summary> + public Image Poster { get; set; } = new(); - public Image Poster { get; set; } + /// <summary> + /// Number of <see cref="EpisodeType.Normal"/> episodes contained within the series if it's known. + /// </summary> + public int? EpisodeCount { get; set; } - public Rating Rating { get; set; } + /// <summary> + /// The average rating for the anime. Only available on + /// </summary> + public Rating? Rating { get; set; } - } + /// <summary> + /// User approval rate for the similar submission. Only available for similar. + /// </summary> + public Rating? UserApproval { get; set; } - public class TvDB - { - public int ID { get; set; } + /// <summary> + /// Relation type. Only available for relations. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public RelationType? Relation { get; set; } + } - public DateTime? AirDate { get; set; } + public class AniDBWithDate : AniDB + { + /// <summary> + /// Description. + /// </summary> + public new string Description { get; set; } = ""; - public DateTime? EndDate { get; set; } + /// <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 string Title { get; set; } + /// <summary> + /// The average rating for the anime. Only available on + /// </summary> + public new Rating Rating { get; set; } = new(); - public string Description { get; set; } + /// <summary> + /// Number of <see cref="EpisodeType.Normal"/> episodes contained within the series if it's known. + /// </summary> + public new int EpisodeCount { get; set; } - public int? Season { get; set; } + /// <summary> + /// Air date (2013-02-27, shut up avael). Anything without an air date is going to be missing a lot of info. + /// </summary> + public DateTime? AirDate { get; set; } - public List<Image> Posters { get; set; } + /// <summary> + /// End date, can be omitted. Omitted means that it's still airing (2013-02-27) + /// </summary> + public DateTime? EndDate { get; set; } + } - public List<Image> Fanarts { get; set; } + public class TvDB + { + /// <summary> + /// TvDB Id. + /// </summary> + [JsonPropertyName("ID")] + public int Id { get; set; } - public List<Image> Banners { get; set; } + public DateTime? AirDate { get; set; } - public Rating Rating { get; set; } - } + public DateTime? EndDate { get; set; } - public class Resource - { - public string name { get; set; } + public string Title { get; set; } = ""; - public string url { get; set; } + public string Description { get; set; } = ""; - public Image image { get; set; } - } + public Rating Rating { get; set; } = new(); } public class SeriesIDs : IDs { - public int? ParentGroup { get; set; } + public int ParentGroup { get; set; } = 0; - public int? TopLevelGroup { get; set; } + public int TopLevelGroup { get; set; } = 0; - public int AniDB { get; set; } + public int AniDB { get; set; } = 0; public List<int> TvDB { get; set; } = new List<int>(); @@ -95,43 +194,92 @@ public class SeriesIDs : IDs public List<int> AniList { get; set; } = new List<int>(); } - public class SeriesSearchResult : Series - { - public string Match { get; set; } - - public double Distance { get; set; } - } - - [JsonConverter(typeof(JsonStringEnumConverter))] - public enum SeriesType + /// <summary> + /// Downloaded, Watched, Total, etc + /// </summary> + public class SeriesSizes { /// <summary> - /// The series type is unknown. - /// </summary> - Unknown, - /// <summary> - /// A catch-all type for future extensions when a provider can't use a current episode type, but knows what the future type should be. - /// </summary> - Other, - /// <summary> - /// Standard TV series. + /// Counts of each file source type available within the local colleciton /// </summary> - TV, + public FileSourceCounts FileSources { get; set; } = new(); + /// <summary> - /// TV special. + /// What is downloaded and available /// </summary> - TVSpecial, + public EpisodeTypeCounts Local { get; set; } = new(); + /// <summary> - /// Web series. + /// What is local and watched. /// </summary> - Web, + public EpisodeTypeCounts Watched { get; set; } = new(); + /// <summary> - /// All movies, regardless of source (e.g. web or theater) + /// Total count of each type /// </summary> - Movie, + public EpisodeTypeCounts Total { get; set; } = new(); + /// <summary> - /// Original Video Animations, AKA standalone releases that don't air on TV or the web. + /// Lists the count of each type of episode. /// </summary> - OVA, + public class EpisodeTypeCounts + { + public int Unknown { get; set; } + public int Episodes { get; set; } + public int Specials { get; set; } + public int Credits { get; set; } + public int Trailers { get; set; } + public int Parodies { get; set; } + public int Others { get; set; } + } + + public class FileSourceCounts + { + public int Unknown; + public int Other; + public int TV; + public int DVD; + public int BluRay; + public int Web; + public int VHS; + public int VCD; + public int LaserDisc; + public int Camera; + } } + } + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SeriesType +{ + /// <summary> + /// The series type is unknown. + /// </summary> + Unknown, + /// <summary> + /// A catch-all type for future extensions when a provider can't use a current episode type, but knows what the future type should be. + /// </summary> + Other, + /// <summary> + /// Standard TV series. + /// </summary> + TV, + /// <summary> + /// TV special. + /// </summary> + TVSpecial, + /// <summary> + /// Web series. + /// </summary> + Web, + /// <summary> + /// All movies, regardless of source (e.g. web or theater) + /// </summary> + Movie, + /// <summary> + /// Original Video Animations, AKA standalone releases that don't air on TV or the web. + /// </summary> + OVA, +} + diff --git a/Shokofin/API/Models/Sizes.cs b/Shokofin/API/Models/Sizes.cs deleted file mode 100644 index fbc4d66e..00000000 --- a/Shokofin/API/Models/Sizes.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Shokofin.API.Models -{ - public class Sizes - { - public EpisodeCounts Local { get; set; } - - public EpisodeCounts Watched { get; set; } - - public EpisodeCounts Total { get; set; } - - public class EpisodeCounts - { - public int Episodes { get; set; } - - public int Specials { get; set; } - - public int Credits { get; set; } - - public int Trailers { get; set; } - - public int Parodies { get; set; } - - public int Others { get; set; } - } - } -} \ No newline at end of file diff --git a/Shokofin/API/Models/Tag.cs b/Shokofin/API/Models/Tag.cs index ea4f6f9c..2a56a87a 100644 --- a/Shokofin/API/Models/Tag.cs +++ b/Shokofin/API/Models/Tag.cs @@ -1,11 +1,36 @@ -namespace Shokofin.API.Models +#nullable enable +using System.Text.Json.Serialization; + +#nullable enable +namespace Shokofin.API.Models; + +public class Tag { - public class Tag - { - public string Name { get; set; } - - public string Description { get; set; } - - public int Weight { get; set; } - } -} \ No newline at end of file + /// <summary> + /// Tag id. Relative to it's source for now. + /// </summary> + /// <value></value> + [JsonPropertyName("ID")] + public int Id { get; set; } + + /// <summary> + /// The tag itself + /// </summary> + public string Name { get; set; } = ""; + + /// <summary> + /// What does the tag mean/what's it for + /// </summary> + public string? Description { get; set; } + + /// <summary> + /// How relevant is it to the series + /// </summary> + public int? Weight { get; set; } + + /// <summary> + /// Source. Anidb, User, etc. + /// </summary> + /// <value></value> + public string Source { get; set; } = ""; +} diff --git a/Shokofin/API/Models/Title.cs b/Shokofin/API/Models/Title.cs index c6c8fd3d..4faa9028 100644 --- a/Shokofin/API/Models/Title.cs +++ b/Shokofin/API/Models/Title.cs @@ -1,13 +1,47 @@ -namespace Shokofin.API.Models +using System.Text.Json.Serialization; + +#nullable enable +namespace Shokofin.API.Models; + +public class Title { - public class Title - { - public string Name { get; set; } - - public string Language { get; set; } - - public string Type { get; set; } - - public string Source { get; set; } - } + /// <summary> + /// The title. + /// </summary> + [JsonPropertyName("Name")] + public string Value { get; set; } = ""; + + /// <summary> + /// 3-digit language code (x-jat, etc. are exceptions) + /// </summary> + [JsonPropertyName("Language")] + public string LanguageCode { get; set; } = "unk"; + /// <summary> + /// AniDB series type. Only available on series titles. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public TitleType? Type { get; set; } + + /// <summary> + /// True if this is the default title for the entry. + /// </summary> + [JsonPropertyName("Default")] + public bool IsDefault { get; set; } + + /// <summary> + /// AniDB, TvDB, AniList, etc. + /// </summary> + public string Source { get; set; } = "Unknown"; +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TitleType +{ + None = 0, + Main = 1, + Official = 2, + Short = 3, + Synonym = 4, + TitleCard = 5, + KanjiReading = 6, } \ No newline at end of file diff --git a/Shokofin/API/Models/Vote.cs b/Shokofin/API/Models/Vote.cs deleted file mode 100644 index c716c585..00000000 --- a/Shokofin/API/Models/Vote.cs +++ /dev/null @@ -1,12 +0,0 @@ -#nullable enable -namespace Shokofin.API.Models -{ - public class Vote - { - public decimal Value { get; set; } - - public int? MaxValue { get; set; } - - public string? Type { get; set; } - } -} \ No newline at end of file diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index d130225b..f8c6f2bf 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Net; using System.Net.Http; using System.Text; @@ -8,289 +7,268 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Shokofin.API.Models; -using File = Shokofin.API.Models.File; -namespace Shokofin.API -{ - /// <summary> - /// All API calls to Shoko needs to go through this gateway. - /// </summary> - public class ShokoAPIClient - { - private readonly HttpClient _httpClient; - - private readonly ILogger<ShokoAPIClient> Logger; +#nullable enable +namespace Shokofin.API; - public ShokoAPIClient(ILogger<ShokoAPIClient> logger) - { - _httpClient = (new HttpClient()); - _httpClient.Timeout = TimeSpan.FromMinutes(10); - Logger = logger; - } +/// <summary> +/// All API calls to Shoko needs to go through this gateway. +/// </summary> +public class ShokoAPIClient +{ + private readonly HttpClient _httpClient; - private Task<ReturnType> GetAsync<ReturnType>(string url, string apiKey = null) - => GetAsync<ReturnType>(url, HttpMethod.Get, apiKey); + private readonly ILogger<ShokoAPIClient> Logger; - private async Task<ReturnType> GetAsync<ReturnType>(string url, HttpMethod method, string apiKey = null) - { - var response = await GetAsync(url, method, apiKey); - var responseStream = response != null && response.StatusCode == HttpStatusCode.OK ? response.Content.ReadAsStreamAsync().Result : null; - return responseStream != null ? await JsonSerializer.DeserializeAsync<ReturnType>(responseStream) : default(ReturnType); - } + public ShokoAPIClient(ILogger<ShokoAPIClient> logger) + { + _httpClient = (new HttpClient()); + _httpClient.Timeout = TimeSpan.FromMinutes(10); + Logger = logger; + } - private async Task<HttpResponseMessage> GetAsync(string url, HttpMethod method, string apiKey = null) - { - // Use the default key if no key was provided. - if (apiKey == null) - apiKey = Plugin.Instance.Configuration.ApiKey; - - // Check if we have a key to use. - if (string.IsNullOrEmpty(apiKey)) { - _httpClient.DefaultRequestHeaders.Clear(); - throw new Exception("Unable to call the API before an connection is established to Shoko Server!"); - } + private Task<ReturnType> Get<ReturnType>(string url, string? apiKey = null) + => Get<ReturnType>(url, HttpMethod.Get, apiKey); - try { - Logger.LogTrace("Trying to get {URL}", url); - var remoteUrl = string.Concat(Plugin.Instance.Configuration.Host, url); + private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? apiKey = null) + { + var response = await Get(url, method, apiKey); + if (response.StatusCode != HttpStatusCode.OK) + throw ApiException.FromResponse(response); + var responseStream = await response.Content.ReadAsStreamAsync(); + var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream); + if (value == null) + throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); + return value; + } - // Because Shoko Server don't support HEAD requests, we spoof it instead. - if (method == HttpMethod.Head) { - var real = await _httpClient.GetAsync(remoteUrl, HttpCompletionOption.ResponseHeadersRead); - var fake = new HttpResponseMessage(real.StatusCode); - fake.ReasonPhrase = real.ReasonPhrase; - fake.RequestMessage = real.RequestMessage; + private async Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null) + { + // Use the default key if no key was provided. + if (apiKey == null) + apiKey = Plugin.Instance.Configuration.ApiKey; + + // Check if we have a key to use. + if (string.IsNullOrEmpty(apiKey)) { + _httpClient.DefaultRequestHeaders.Clear(); + throw new Exception("Unable to call the API before an connection is established to Shoko Server!"); + } + + try { + Logger.LogTrace("Trying to get {URL}", url); + var remoteUrl = string.Concat(Plugin.Instance.Configuration.Host, url); + + // Because Shoko Server don't support HEAD requests, we spoof it instead. + if (method == HttpMethod.Head) { + var real = await _httpClient.GetAsync(remoteUrl, HttpCompletionOption.ResponseHeadersRead); + var fake = new HttpResponseMessage(real.StatusCode); + fake.ReasonPhrase = real.ReasonPhrase; + fake.RequestMessage = real.RequestMessage; + if (fake.RequestMessage != null) fake.RequestMessage.Method = HttpMethod.Head; - fake.Version = real.Version; - fake.Content = (new StringContent(String.Empty)); - fake.Content.Headers.Clear(); - foreach (var pair in real.Content.Headers) { - fake.Content.Headers.Add(pair.Key, pair.Value); - } - fake.Headers.Clear(); - foreach (var pair in real.Headers) { - fake.Headers.Add(pair.Key, pair.Value); - } - real.Dispose(); - return fake; + fake.Version = real.Version; + fake.Content = (new StringContent(String.Empty)); + fake.Content.Headers.Clear(); + foreach (var pair in real.Content.Headers) { + fake.Content.Headers.Add(pair.Key, pair.Value); } - - using (var requestMessage = new HttpRequestMessage(method, remoteUrl)) { - requestMessage.Content = (new StringContent("")); - requestMessage.Headers.Add("apikey", apiKey); - var response = await _httpClient.SendAsync(requestMessage); - if (response.StatusCode == HttpStatusCode.Unauthorized) - throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection in the plugin settings.", null, HttpStatusCode.Unauthorized); - Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); - return response; + fake.Headers.Clear(); + foreach (var pair in real.Headers) { + fake.Headers.Add(pair.Key, pair.Value); } - } - catch (HttpRequestException ex) - { - Logger.LogWarning(ex, "Unable to connection to complete the request to Shoko."); - return null; - } - } - - private Task<ReturnType> PostAsync<Type, ReturnType>(string url, Type body, string apiKey = null) - => PostAsync<Type, ReturnType>(url, HttpMethod.Post, body, apiKey); - - private async Task<ReturnType> PostAsync<Type, ReturnType>(string url, HttpMethod method, Type body, string apiKey = null) - { - var response = await PostAsync<Type>(url, method, body, apiKey); - var responseStream = response != null && response.StatusCode == HttpStatusCode.OK ? response.Content.ReadAsStreamAsync().Result : null; - return responseStream != null ? await JsonSerializer.DeserializeAsync<ReturnType>(responseStream) : default(ReturnType); - } - - private async Task<HttpResponseMessage> PostAsync<Type>(string url, HttpMethod method, Type body, string apiKey = null) - { - // Use the default key if no key was provided. - if (apiKey == null) - apiKey = Plugin.Instance.Configuration.ApiKey; - - // Check if we have a key to use. - if (string.IsNullOrEmpty(apiKey)) { - _httpClient.DefaultRequestHeaders.Clear(); - throw new Exception("Unable to call the API before an connection is established to Shoko Server!"); + real.Dispose(); + return fake; } - try { - Logger.LogTrace("Trying to get {URL}", url); - var remoteUrl = string.Concat(Plugin.Instance.Configuration.Host, url); - - if (method == HttpMethod.Get) - throw new HttpRequestException("Get requests cannot contain a body."); - - if (method == HttpMethod.Head) - throw new HttpRequestException("Head requests cannot contain a body."); - - using (var requestMessage = new HttpRequestMessage(method, remoteUrl)) { - requestMessage.Content = (new StringContent(JsonSerializer.Serialize<Type>(body), Encoding.UTF8, "application/json")); - requestMessage.Headers.Add("apikey", apiKey); - var response = await _httpClient.SendAsync(requestMessage); - if (response.StatusCode == HttpStatusCode.Unauthorized) - throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection in the plugin settings.", null, HttpStatusCode.Unauthorized); - Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); - return response; - } - } - catch (HttpRequestException ex) - { - Logger.LogWarning(ex, "Unable to connection to complete the request to Shoko."); - return null; + using (var requestMessage = new HttpRequestMessage(method, remoteUrl)) { + requestMessage.Content = (new StringContent("")); + requestMessage.Headers.Add("apikey", apiKey); + var response = await _httpClient.SendAsync(requestMessage); + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection in the plugin settings.", null, HttpStatusCode.Unauthorized); + Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); + return response; } } - - public async Task<ApiKey> GetApiKey(string username, string password, bool forUser = false) + catch (HttpRequestException ex) { - var postData = JsonSerializer.Serialize(new Dictionary<string, string> - { - {"user", username}, - {"pass", password}, - {"device", forUser ? "Shoko Jellyfin Plugin (Shokofin) - User Key" : "Shoko Jellyfin Plugin (Shokofin)"}, - }); - var apiBaseUrl = Plugin.Instance.Configuration.Host; - var response = await _httpClient.PostAsync($"{apiBaseUrl}/api/auth", new StringContent(postData, Encoding.UTF8, "application/json")); - if (response.StatusCode == HttpStatusCode.OK) - return (await JsonSerializer.DeserializeAsync<ApiKey>(response.Content.ReadAsStreamAsync().Result)); - - return null; - } - - public bool CheckImage(string imagePath) - { - var response = GetAsync(imagePath, HttpMethod.Head).ConfigureAwait(false).GetAwaiter().GetResult(); - return response != null && response.StatusCode == HttpStatusCode.OK; + Logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); + throw; } + } - public Task<Episode> GetEpisode(string id) - { - return GetAsync<Episode>($"/api/v3/Episode/{id}"); - } + private Task<ReturnType> Post<Type, ReturnType>(string url, Type body, string? apiKey = null) + => Post<Type, ReturnType>(url, HttpMethod.Post, body, apiKey); - public Task<List<Episode>> GetEpisodesFromSeries(string seriesId) - { - return GetAsync<List<Episode>>($"/api/v3/Series/{seriesId}/Episode?includeMissing=true"); - } + private async Task<ReturnType> Post<Type, ReturnType>(string url, HttpMethod method, Type body, string? apiKey = null) + { + var response = await Post<Type>(url, method, body, apiKey); + if (response.StatusCode != HttpStatusCode.OK) + throw ApiException.FromResponse(response); + var responseStream = await response.Content.ReadAsStreamAsync(); + var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream); + if (value == null) + throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); + return value; + } - public Task<List<Episode>> GetEpisodeFromFile(string id) - { - return GetAsync<List<Episode>>($"/api/v3/File/{id}/Episode"); + private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method, Type body, string? apiKey = null) + { + // Use the default key if no key was provided. + if (apiKey == null) + apiKey = Plugin.Instance.Configuration.ApiKey; + + // Check if we have a key to use. + if (string.IsNullOrEmpty(apiKey)) { + _httpClient.DefaultRequestHeaders.Clear(); + throw new Exception("Unable to call the API before an connection is established to Shoko Server!"); + } + + try { + Logger.LogTrace("Trying to get {URL}", url); + var remoteUrl = string.Concat(Plugin.Instance.Configuration.Host, url); + + if (method == HttpMethod.Get) + throw new HttpRequestException("Get requests cannot contain a body."); + + if (method == HttpMethod.Head) + throw new HttpRequestException("Head requests cannot contain a body."); + + using (var requestMessage = new HttpRequestMessage(method, remoteUrl)) { + requestMessage.Content = (new StringContent(JsonSerializer.Serialize<Type>(body), Encoding.UTF8, "application/json")); + requestMessage.Headers.Add("apikey", apiKey); + var response = await _httpClient.SendAsync(requestMessage); + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection in the plugin settings.", null, HttpStatusCode.Unauthorized); + Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); + return response; + } } - - public Task<Episode.AniDB> GetEpisodeAniDb(string id) + catch (HttpRequestException ex) { - return GetAsync<Episode.AniDB>($"/api/v3/Episode/{id}/AniDB"); + Logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); + throw; } + } - public Task<List<Episode.TvDB>> GetEpisodeTvDb(string id) - { - return GetAsync<List<Episode.TvDB>>($"/api/v3/Episode/{id}/TvDB"); - } + public async Task<ApiKey?> GetApiKey(string username, string password, bool forUser = false) + { + var postData = JsonSerializer.Serialize(new Dictionary<string, string> + { + {"user", username}, + {"pass", password}, + {"device", forUser ? "Shoko Jellyfin Plugin (Shokofin) - User Key" : "Shoko Jellyfin Plugin (Shokofin)"}, + }); + var apiBaseUrl = Plugin.Instance.Configuration.Host; + var response = await _httpClient.PostAsync($"{apiBaseUrl}/api/auth", new StringContent(postData, Encoding.UTF8, "application/json")); + if (response.StatusCode == HttpStatusCode.OK) + return (await JsonSerializer.DeserializeAsync<ApiKey>(response.Content.ReadAsStreamAsync().Result)); + + return null; + } - public Task<File> GetFile(string id) - { - return GetAsync<File>($"/api/v3/File/{id}"); - } + public Task<Episode> GetEpisode(string id) + { + return Get<Episode>($"/api/v3/Episode/{id}?includeDataFrom=AniDB&includeDataFrom=TvDB"); + } - public Task<List<File.FileDetailed>> GetFileByPath(string filename) - { - return GetAsync<List<File.FileDetailed>>($"/api/v3/File/PathEndsWith/{Uri.EscapeDataString(filename)}"); - } + public Task<List<Episode>> GetEpisodesFromSeries(string seriesId) + { + return Get<List<Episode>>($"/api/v3/Series/{seriesId}/Episode?includeMissing=true&includeDataFrom=AniDB&includeDataFrom=TvDB"); + } - public Task<File.FileUserStats> GetFileUserStats(string fileId, string apiKey = null) - { - return GetAsync<File.FileUserStats>($"/api/v3/File/{fileId}/UserStats", apiKey); - } + public Task<File> GetFile(string id) + { + return Get<File>($"/api/v3/File/{id}?includeXRefs=true"); + } - public Task<File.FileUserStats> PutFileUserStats(string fileId, File.FileUserStats userStats, string apiKey = null) - { - return PostAsync<File.FileUserStats, File.FileUserStats>($"/api/v3/File/{fileId}/UserStats", HttpMethod.Put, userStats, apiKey); - } + public Task<List<File>> GetFileByPath(string filename) + { + return Get<List<File>>($"/api/v3/File/PathEndsWith/{Uri.EscapeDataString(filename)}"); + } - public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, bool watched, string apiKey) - { - var response = await GetAsync($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&watched={watched}", HttpMethod.Patch, apiKey); - return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); - } + public Task<List<File>> GetFilesForSeries(string seriesId) + { + return Get<List<File>>($"/api/v3/Series/{seriesId}/File?includeXRefs=true"); + } - public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long progress, string apiKey) - { - var response = await GetAsync($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress).TotalMilliseconds)}", HttpMethod.Patch, apiKey); - return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); - } + public Task<File.UserStats> GetFileUserStats(string fileId, string? apiKey = null) + { + return Get<File.UserStats>($"/api/v3/File/{fileId}/UserStats", apiKey); + } - public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long? progress, bool watched, string apiKey) - { - if (!progress.HasValue) - return await ScrobbleFile(fileId, episodeId, eventName, watched, apiKey); - var response = await GetAsync($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress.Value).TotalMilliseconds)}&watched={watched}", HttpMethod.Patch, apiKey); - return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); - } + public Task<File.UserStats> PutFileUserStats(string fileId, File.UserStats userStats, string? apiKey = null) + { + return Post<File.UserStats, File.UserStats>($"/api/v3/File/{fileId}/UserStats", HttpMethod.Put, userStats, apiKey); + } - public Task<Series> GetSeries(string id) - { - return GetAsync<Series>($"/api/v3/Series/{id}"); - } + public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, bool watched, string apiKey) + { + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&watched={watched}", HttpMethod.Patch, apiKey); + return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); + } - public Task<Series> GetSeriesFromEpisode(string id) - { - return GetAsync<Series>($"/api/v3/Episode/{id}/Series"); - } + public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long progress, string apiKey) + { + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress).TotalMilliseconds)}", HttpMethod.Patch, apiKey); + return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); + } - public Task<List<Series>> GetSeriesInGroup(string groupID, int filterID = 0) - { - return GetAsync<List<Series>>($"/api/v3/Filter/{filterID}/Group/{groupID}/Series?includeMissing=true&includeIgnored=false"); - } + public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long? progress, bool watched, string apiKey) + { + if (!progress.HasValue) + return await ScrobbleFile(fileId, episodeId, eventName, watched, apiKey); + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress.Value).TotalMilliseconds)}&watched={watched}", HttpMethod.Patch, apiKey); + return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); + } - public Task<Series.AniDB> GetSeriesAniDB(string id) - { - return GetAsync<Series.AniDB>($"/api/v3/Series/{id}/AniDB"); - } + public Task<Series> GetSeries(string id) + { + return Get<Series>($"/api/v3/Series/{id}?includeDataFrom=AniDB&includeDataFrom=TvDB"); + } - public Task<List<Series.TvDB>> GetSeriesTvDB(string id) - { - return GetAsync<List<Series.TvDB>>($"/api/v3/Series/{id}/TvDB"); - } + public Task<Series> GetSeriesFromEpisode(string id) + { + return Get<Series>($"/api/v3/Episode/{id}/Series?includeDataFrom=AniDB&includeDataFrom=TvDB"); + } - public Task<List<Role>> GetSeriesCast(string id) - { - return GetAsync<List<Role>>($"/api/v3/Series/{id}/Cast"); - } + public Task<List<Series>> GetSeriesInGroup(string groupID, int filterID = 0) + { + return Get<List<Series>>($"/api/v3/Filter/{filterID}/Group/{groupID}/Series?includeMissing=true&includeIgnored=false&includeDataFrom=AniDB&includeDataFrom=TvDB"); + } - public Task<List<Role>> GetSeriesCast(string id, Role.CreatorRoleType role) - { - return GetAsync<List<Role>>($"/api/v3/Series/{id}/Cast?roleType={role.ToString()}"); - } + public Task<List<Role>> GetSeriesCast(string id) + { + return Get<List<Role>>($"/api/v3/Series/{id}/Cast"); + } - public Task<Images> GetSeriesImages(string id) - { - return GetAsync<Images>($"/api/v3/Series/{id}/Images"); - } + public Task<Images> GetSeriesImages(string id) + { + return Get<Images>($"/api/v3/Series/{id}/Images"); + } - public Task<List<Series>> GetSeriesPathEndsWith(string dirname) - { - return GetAsync<List<Series>>($"/api/v3/Series/PathEndsWith/{Uri.EscapeDataString(dirname)}"); - } + public Task<List<Series>> GetSeriesPathEndsWith(string dirname) + { + return Get<List<Series>>($"/api/v3/Series/PathEndsWith/{Uri.EscapeDataString(dirname)}"); + } - public Task<List<Tag>> GetSeriesTags(string id, ulong filter = 0) - { - return GetAsync<List<Tag>>($"/api/v3/Series/{id}/Tags?filter={filter}&excludeDescriptions=true"); - } + public Task<List<Tag>> GetSeriesTags(string id, ulong filter = 0) + { + return Get<List<Tag>>($"/api/v3/Series/{id}/Tags?filter={filter}&excludeDescriptions=true"); + } - public Task<Group> GetGroup(string id) - { - return GetAsync<Group>($"/api/v3/Group/{id}"); - } + public Task<Group> GetGroup(string id) + { + return Get<Group>($"/api/v3/Group/{id}"); + } - public Task<Group> GetGroupFromSeries(string id) - { - return GetAsync<Group>($"/api/v3/Series/{id}/Group"); - } + public Task<Group> GetGroupFromSeries(string id) + { + return Get<Group>($"/api/v3/Series/{id}/Group"); + } - public Task<List<SeriesSearchResult>> SeriesSearch(string query) - { - return GetAsync<List<SeriesSearchResult>>($"/api/v3/Series/Search/{Uri.EscapeDataString(query)}"); - } + public Task<ListResult<Series.AniDB>> SeriesSearch(string query) + { + return Get<ListResult<Series.AniDB>>($"/api/v3/Series/AniDB/Search/{Uri.EscapeDataString(query)}?local=true&includeTitles=true&pageSize=0"); } } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index f39ee501..210027ac 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -14,910 +14,674 @@ using Shokofin.Utils; using CultureInfo = System.Globalization.CultureInfo; +using ItemLookupInfo = MediaBrowser.Controller.Providers.ItemLookupInfo; using Path = System.IO.Path; -namespace Shokofin.API +#nullable enable +namespace Shokofin.API; + +public class ShokoAPIManager { - public class ShokoAPIManager - { - private readonly ILogger<ShokoAPIManager> Logger; + private readonly ILogger<ShokoAPIManager> Logger; - private readonly ShokoAPIClient APIClient; + private readonly ShokoAPIClient APIClient; - private readonly ILibraryManager LibraryManager; + private readonly ILibraryManager LibraryManager; - private readonly List<Folder> MediaFolderList = new List<Folder>(); + private readonly List<Folder> MediaFolderList = new List<Folder>(); - private readonly ConcurrentDictionary<string, string> SeriesPathToIdDictionary = new ConcurrentDictionary<string, string>(); + private readonly ConcurrentDictionary<string, string> PathToSeriesIdDictionary = new(); - private readonly ConcurrentDictionary<string, string> SeriesIdToPathDictionary = new ConcurrentDictionary<string, string>(); + private readonly ConcurrentDictionary<string, List<string>> PathToEpisodeIdsDictionary = new(); - private readonly ConcurrentDictionary<string, string> SeriesIdToGroupIdDictionary = new ConcurrentDictionary<string, string>(); + private readonly ConcurrentDictionary<string, (string, string)> PathToFileIdAndSeriesIdDictionary = new(); - private readonly ConcurrentDictionary<string, string> EpisodePathToEpisodeIdDictionary = new ConcurrentDictionary<string, string>(); + private readonly ConcurrentDictionary<string, string> SeriesIdToPathDictionary = new(); - private readonly ConcurrentDictionary<string, string> EpisodeIdToEpisodePathDictionary = new ConcurrentDictionary<string, string>(); + private readonly ConcurrentDictionary<string, string> SeriesIdToGroupIdDictionary = new(); - private readonly ConcurrentDictionary<string, string> EpisodeIdToSeriesIdDictionary = new ConcurrentDictionary<string, string>(); + private readonly ConcurrentDictionary<string, string> EpisodeIdToEpisodePathDictionary = new(); - private readonly ConcurrentDictionary<string, (string, int, string, string)> FilePathToFileIdAndEpisodeCountDictionary = new ConcurrentDictionary<string, (string, int, string, string)>(); + private readonly ConcurrentDictionary<string, string> EpisodeIdToSeriesIdDictionary = new(); - private readonly ConcurrentDictionary<string, string> FileIdToEpisodeIdDictionary = new ConcurrentDictionary<string, string>(); + private readonly ConcurrentDictionary<string, List<string>> FileIdToEpisodeIdDictionary = new(); - public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient, ILibraryManager libraryManager) - { - Logger = logger; - APIClient = apiClient; - LibraryManager = libraryManager; - } + public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient, ILibraryManager libraryManager) + { + Logger = logger; + APIClient = apiClient; + LibraryManager = libraryManager; + } - private static IMemoryCache DataCache = new MemoryCache(new MemoryCacheOptions() { - ExpirationScanFrequency = ExpirationScanFrequency, - }); + private IMemoryCache DataCache = new MemoryCache(new MemoryCacheOptions() { + ExpirationScanFrequency = ExpirationScanFrequency, + }); - private static readonly System.TimeSpan ExpirationScanFrequency = new System.TimeSpan(0, 25, 0); + private static readonly System.TimeSpan ExpirationScanFrequency = new System.TimeSpan(0, 25, 0); - private static readonly System.TimeSpan DefaultTimeSpan = new System.TimeSpan(1, 0, 0); + private static readonly System.TimeSpan DefaultTimeSpan = new System.TimeSpan(1, 30, 0); - #region Ignore rule + #region Ignore rule - public Folder FindMediaFolder(string path, Folder parent, Folder root) - { - var mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); - // Look for the root folder for the current item. - if (mediaFolder != null) { - return mediaFolder; - } - mediaFolder = parent; - while (!mediaFolder.ParentId.Equals(root.Id)) { - if (mediaFolder.GetParent() == null) { - break; - } - mediaFolder = (Folder)mediaFolder.GetParent(); - } - MediaFolderList.Add(mediaFolder); + public Folder FindMediaFolder(string path) + { + Folder? mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); + if (mediaFolder == null) { + var parent = (Folder?)LibraryManager.FindByPath(Path.GetDirectoryName(path), true); + if (parent == null) + throw new Exception($"Unable to find parent of \"{path}\""); + mediaFolder = FindMediaFolder(path, parent, LibraryManager.RootFolder); + } + return mediaFolder; + } + + public Folder FindMediaFolder(string path, Folder parent, Folder root) + { + var mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); + // Look for the root folder for the current item. + if (mediaFolder != null) { return mediaFolder; } - - public string StripMediaFolder(string fullPath) - { - var mediaFolder = MediaFolderList.FirstOrDefault((folder) => fullPath.StartsWith(folder.Path + Path.DirectorySeparatorChar)); - if (mediaFolder != null) { - return fullPath.Substring(mediaFolder.Path.Length); + mediaFolder = parent; + while (!mediaFolder.ParentId.Equals(root.Id)) { + if (mediaFolder.GetParent() == null) { + break; } - // Try to get the media folder by loading the parent and navigating upwards till we reach the root. - var directoryPath = System.IO.Path.GetDirectoryName(fullPath); - if (string.IsNullOrEmpty(directoryPath)) { - return fullPath; - } - mediaFolder = (LibraryManager.FindByPath(directoryPath, true) as Folder); - if (mediaFolder == null || string.IsNullOrEmpty(mediaFolder?.Path)) { - return fullPath; - } - // Look for the root folder for the current item. - var root = LibraryManager.RootFolder; - while (!mediaFolder.ParentId.Equals(root.Id)) { - if (mediaFolder.GetParent() == null) { - break; - } - mediaFolder = (Folder)mediaFolder.GetParent(); - } - MediaFolderList.Add(mediaFolder); - return fullPath.Substring(mediaFolder.Path.Length); + mediaFolder = (Folder)mediaFolder.GetParent(); } + MediaFolderList.Add(mediaFolder); + return mediaFolder; + } - #endregion - #region Clear - - public void Clear() - { - Logger.LogDebug("Clearing data."); - DataCache.Dispose(); - MediaFolderList.Clear(); - FileIdToEpisodeIdDictionary.Clear(); - FilePathToFileIdAndEpisodeCountDictionary.Clear(); - EpisodeIdToSeriesIdDictionary.Clear(); - EpisodePathToEpisodeIdDictionary.Clear(); - EpisodeIdToEpisodePathDictionary.Clear(); - SeriesPathToIdDictionary.Clear(); - SeriesIdToPathDictionary.Clear(); - SeriesIdToGroupIdDictionary.Clear(); - DataCache = (new MemoryCache((new MemoryCacheOptions() { - ExpirationScanFrequency = ExpirationScanFrequency, - }))); + public string StripMediaFolder(string fullPath) + { + var mediaFolder = MediaFolderList.FirstOrDefault((folder) => fullPath.StartsWith(folder.Path + Path.DirectorySeparatorChar)); + if (mediaFolder != null) { + return fullPath.Substring(mediaFolder.Path.Length); } - - #endregion - #region People - - private string GetImagePath(Image image) - { - return image != null && image.IsAvailable ? image.ToURLString() : null; + // Try to get the media folder by loading the parent and navigating upwards till we reach the root. + var directoryPath = System.IO.Path.GetDirectoryName(fullPath); + if (string.IsNullOrEmpty(directoryPath)) { + return fullPath; } - - private PersonInfo RoleToPersonInfo(Role role) - { - switch (role.RoleName) { - default: - return null; - case Role.CreatorRoleType.Director: - return new PersonInfo { - Type = PersonType.Director, - Name = role.Staff.Name, - Role = role.RoleDetails, - ImageUrl = GetImagePath(role.Staff.Image), - }; - case Role.CreatorRoleType.Producer: - return new PersonInfo { - Type = PersonType.Producer, - Name = role.Staff.Name, - Role = role.RoleDetails, - ImageUrl = GetImagePath(role.Staff.Image), - }; - case Role.CreatorRoleType.Music: - return new PersonInfo { - Type = PersonType.Lyricist, - Name = role.Staff.Name, - Role = role.RoleDetails, - ImageUrl = GetImagePath(role.Staff.Image), - }; - case Role.CreatorRoleType.SourceWork: - return new PersonInfo { - Type = PersonType.Writer, - Name = role.Staff.Name, - Role = role.RoleDetails, - ImageUrl = GetImagePath(role.Staff.Image), - }; - case Role.CreatorRoleType.SeriesComposer: - return new PersonInfo { - Type = PersonType.Composer, - Name = role.Staff.Name, - ImageUrl = GetImagePath(role.Staff.Image), - }; - case Role.CreatorRoleType.Seiyuu: - return new PersonInfo { - Type = PersonType.Actor, - Name = role.Staff.Name, - Role = role.Character.Name, - ImageUrl = GetImagePath(role.Staff.Image), - }; - } + mediaFolder = (LibraryManager.FindByPath(directoryPath, true) as Folder); + if (mediaFolder == null || string.IsNullOrEmpty(mediaFolder?.Path)) { + return fullPath; } - - #endregion - #region Tags - - private async Task<string[]> GetTags(string seriesId) - { - return (await APIClient.GetSeriesTags(seriesId, GetTagFilter()))?.Select(SelectTagName).ToArray() ?? new string[0]; + // Look for the root folder for the current item. + var root = LibraryManager.RootFolder; + while (!mediaFolder.ParentId.Equals(root.Id)) { + if (mediaFolder.GetParent() == null) { + break; + } + mediaFolder = (Folder)mediaFolder.GetParent(); } + MediaFolderList.Add(mediaFolder); + return fullPath.Substring(mediaFolder.Path.Length); + } - /// <summary> - /// Get the tag filter - /// </summary> - /// <returns></returns> - private ulong GetTagFilter() - { - var config = Plugin.Instance.Configuration; - ulong filter = 132L; // We exclude genres and source by default + public bool IsInMixedLibrary(ItemLookupInfo info) + { + var mediaFolder = FindMediaFolder(info.Path); + var type = LibraryManager.GetInheritedContentType(mediaFolder); + return !string.IsNullOrEmpty(type) && type == "mixed"; + } - if (config.HideAniDbTags) filter |= (1 << 0); - if (config.HideArtStyleTags) filter |= (1 << 1); - if (config.HideMiscTags) filter |= (1 << 3); - if (config.HidePlotTags) filter |= (1 << 4); - if (config.HideSettingTags) filter |= (1 << 5); - if (config.HideProgrammingTags) filter |= (1 << 6); + #endregion + #region Clear - return filter; - } + public void Clear() + { + Logger.LogDebug("Clearing data."); + DataCache.Dispose(); + MediaFolderList.Clear(); + FileIdToEpisodeIdDictionary.Clear(); + PathToFileIdAndSeriesIdDictionary.Clear(); + EpisodeIdToSeriesIdDictionary.Clear(); + PathToEpisodeIdsDictionary.Clear(); + EpisodeIdToEpisodePathDictionary.Clear(); + PathToSeriesIdDictionary.Clear(); + SeriesIdToPathDictionary.Clear(); + SeriesIdToGroupIdDictionary.Clear(); + DataCache = (new MemoryCache((new MemoryCacheOptions() { + ExpirationScanFrequency = ExpirationScanFrequency, + }))); + } - #endregion - #region Genres + #endregion + #region Tags And Genres - public async Task<string[]> GetGenresForSeries(string seriesId) - { - // The following magic number is the filter value to allow only genres in the returned list. - var set = (await APIClient.GetSeriesTags(seriesId, 2147483776))?.Select(SelectTagName).ToHashSet() ?? new(); - set.Add(await GetSourceGenre(seriesId)); - return set.ToArray(); - } + private async Task<string[]> GetTagsForSeries(string seriesId) + { + return (await APIClient.GetSeriesTags(seriesId, GetTagFilter()))?.Select(SelectTagName).ToArray() ?? new string[0]; + } - private async Task<string> GetSourceGenre(string seriesId) - { - return(await APIClient.GetSeriesTags(seriesId, 2147483652))?.FirstOrDefault()?.Name?.ToLowerInvariant() switch { - "american derived" => "Adapted From Western Media", - "cartoon" => "Adapted From Western Media", - "comic book" => "Adapted From Western Media", - "4-koma" => "Adapted From A Manga", - "manga" => "Adapted From A Manga", - "4-koma manga" => "Adapted From A Manga", - "manhua" => "Adapted From A Manhua", - "manhwa" => "Adapted from a Manhwa", - "movie" => "Adapted From A Movie", - "novel" => "Adapted From A Light/Web Novel", - "rpg" => "Adapted From A Video Game", - "action game" => "Adapted From A Video Game", - "game" => "Adapted From A Video Game", - "erotic game" => "Adapted From An Eroge", - "korean drama" => "Adapted From A Korean Drama", - "television programme" => "Adapted From A Live-Action Show", - "visual novel" => "Adapted From A Visual Novel", - "fan-made" => "Fan-Made", - "remake" => "Remake", - "radio programme" => "Original Work", - "biographical film" => "Original Work", - "original work" => "Original Work", - "new" => "Original Work", - "ultra jump" => "Original Work", - _ => "Original Work", - }; - } + /// <summary> + /// Get the tag filter + /// </summary> + /// <returns></returns> + private ulong GetTagFilter() + { + var config = Plugin.Instance.Configuration; + ulong filter = 132L; // We exclude genres and source by default - private string SelectTagName(Tag tag) - { - return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tag.Name); - } + if (config.HideAniDbTags) filter |= (1 << 0); + if (config.HideArtStyleTags) filter |= (1 << 1); + if (config.HideMiscTags) filter |= (1 << 3); + if (config.HidePlotTags) filter |= (1 << 4); + if (config.HideSettingTags) filter |= (1 << 5); + if (config.HideProgrammingTags) filter |= (1 << 6); - #endregion - #region Studios + return filter; + } - public async Task<string[]> GetStudiosForSeries(string seriesId) - { - var cast = await APIClient.GetSeriesCast(seriesId, Role.CreatorRoleType.Studio); - // * NOTE: Shoko Server version <4.1.2 don't support filtered cast, nor other role types besides Role.CreatorRoleType.Seiyuu. - if (cast.Any(p => p.RoleName != Role.CreatorRoleType.Studio)) - return new string[0]; - return cast.Select(p => p.Staff.Name).ToArray(); - } + public async Task<string[]> GetGenresForSeries(string seriesId) + { + // The following magic number is the filter value to allow only genres in the returned list. + var genreSet = (await APIClient.GetSeriesTags(seriesId, 2147483776))?.Select(SelectTagName).ToHashSet() ?? new(); + var sourceGenre = await GetSourceGenre(seriesId); + genreSet.Add(sourceGenre); + return genreSet.ToArray(); + } - #endregion - #region File Info + private async Task<string> GetSourceGenre(string seriesId) + { + // The following magic number is the filter value to allow only the source type in the returned list. + return(await APIClient.GetSeriesTags(seriesId, 2147483652))?.FirstOrDefault()?.Name?.ToLowerInvariant() switch { + "american derived" => "Adapted From Western Media", + "cartoon" => "Adapted From Western Media", + "comic book" => "Adapted From Western Media", + "4-koma" => "Adapted From A Manga", + "manga" => "Adapted From A Manga", + "4-koma manga" => "Adapted From A Manga", + "manhua" => "Adapted From A Manhua", + "manhwa" => "Adapted from a Manhwa", + "movie" => "Adapted From A Movie", + "novel" => "Adapted From A Light/Web Novel", + "rpg" => "Adapted From A Video Game", + "action game" => "Adapted From A Video Game", + "game" => "Adapted From A Video Game", + "erotic game" => "Adapted From An Eroge", + "korean drama" => "Adapted From A Korean Drama", + "television programme" => "Adapted From A Live-Action Show", + "visual novel" => "Adapted From A Visual Novel", + "fan-made" => "Fan-Made", + "remake" => "Remake", + "radio programme" => "Radio Programme", + "biographical film" => "Original Work", + "original work" => "Original Work", + "new" => "Original Work", + "ultra jump" => "Original Work", + _ => "Original Work", + }; + } - public (FileInfo, EpisodeInfo, SeriesInfo, GroupInfo) GetFileInfoByPathSync(string path, Ordering.GroupFilterType? filterGroupByType) - { - if (FilePathToFileIdAndEpisodeCountDictionary.ContainsKey(path)) { - var (fileId, extraEpisodesCount, episodeId, seriesId) = FilePathToFileIdAndEpisodeCountDictionary[path]; - return (GetFileInfoSync(fileId, extraEpisodesCount), GetEpisodeInfoSync(episodeId), GetSeriesInfoSync(seriesId), filterGroupByType.HasValue ? GetGroupInfoForSeriesSync(seriesId, filterGroupByType.Value) : null); - } + private string SelectTagName(Tag tag) + { + return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tag.Name); + } - return GetFileInfoByPath(path, filterGroupByType).GetAwaiter().GetResult(); - } + #endregion + #region Path Set And Local Episode IDs - public async Task<(FileInfo, EpisodeInfo, SeriesInfo, GroupInfo)> GetFileInfoByPath(string path, Ordering.GroupFilterType? filterGroupByType) - { - if (FilePathToFileIdAndEpisodeCountDictionary.ContainsKey(path)) { - var (fI, eC, eI, sI) = FilePathToFileIdAndEpisodeCountDictionary[path]; - return (GetFileInfoSync(fI, eC), GetEpisodeInfoSync(eI), GetSeriesInfoSync(sI), filterGroupByType.HasValue ? GetGroupInfoForSeriesSync(sI, filterGroupByType.Value) : null); - } + public async Task<HashSet<string>> GetPathSetForSeries(string seriesId) + { + var (pathSet, _episodeIds) = await GetPathSetAndLocalEpisodeIdsForSeries(seriesId); + return pathSet; + } - var partialPath = StripMediaFolder(path); - Logger.LogDebug("Looking for file matching {Path}", partialPath); - var result = await APIClient.GetFileByPath(partialPath); - Logger.LogTrace("Found result with {Count} matches for {Path}", result?.Count ?? 0, partialPath); + public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) + { + var (_pathSet, episodeIds) = GetPathSetAndLocalEpisodeIdsForSeries(seriesId) + .GetAwaiter() + .GetResult(); + return episodeIds; + } - var file = result?.FirstOrDefault(); - if (file == null) - return (null, null, null, null); + // Set up both at the same time. + private async Task<(HashSet<string>, HashSet<string>)> GetPathSetAndLocalEpisodeIdsForSeries(string seriesId) + { + var key =$"series-path-set-and-episode-ids:${seriesId}"; + if (DataCache.TryGetValue<(HashSet<string>, HashSet<string>)>(key, out var cached)) + return cached; + var pathSet = new HashSet<string>(); + var episodeIds = new HashSet<string>(); + foreach (var file in await APIClient.GetFilesForSeries(seriesId)) { + if (file.CrossReferences.Count == 1) + foreach (var fileLocation in file.Locations) + pathSet.Add((Path.GetDirectoryName(fileLocation.Path) ?? "") + "/"); + var xref = file.CrossReferences.First(xref => xref.Series.Shoko.ToString() == seriesId); + foreach (var episodeXRef in xref.Episodes) + episodeIds.Add(episodeXRef.Shoko.ToString()); + } + DataCache.Set(key, (pathSet, episodeIds), DefaultTimeSpan); + return (pathSet, episodeIds); + } - var series = file?.SeriesIDs?.FirstOrDefault(); - var seriesId = series?.SeriesID.ID.ToString(); - var episodes = series?.EpisodeIDs?.FirstOrDefault(); - var episodeId = episodes?.ID.ToString(); - if (string.IsNullOrEmpty(seriesId) || string.IsNullOrEmpty(episodeId)) - return (null, null, null, null); + #endregion + #region File Info - GroupInfo groupInfo = null; + public async Task<(FileInfo?, SeriesInfo?, GroupInfo?)> GetFileInfoByPath(string path, Ordering.GroupFilterType? filterGroupByType) + { + // Use pointer for fast lookup. + if (PathToFileIdAndSeriesIdDictionary.ContainsKey(path)) { + var (fI, sI) = PathToFileIdAndSeriesIdDictionary[path]; + var fileInfo = await GetFileInfo(fI, sI); + var seriesInfo = await GetSeriesInfo(sI); + var groupInfo = filterGroupByType.HasValue ? await GetGroupInfoForSeries(sI, filterGroupByType.Value) : null; + return new(fileInfo, seriesInfo, groupInfo); + } + + // Strip the path and search for a match. + var partialPath = StripMediaFolder(path); + Logger.LogDebug("Looking for file matching {Path}", partialPath); + var result = await APIClient.GetFileByPath(partialPath); + Logger.LogTrace("Found result with {Count} matches for {Path}", result?.Count ?? 0, partialPath); + + // Check if we found a match. + var file = result?.FirstOrDefault(); + if (file == null || file.CrossReferences.Count == 0) + return (null, null, null); + + // Find the file locations matching the given path. + var fileId = file.Id.ToString(); + var fileLocations = file.Locations + .Where(location => location.Path.EndsWith(partialPath)) + .ToList(); + if (fileLocations.Count != 1) { + if (fileLocations.Count == 0) + throw new Exception($"I have no idea how this happened, but the path gave a file that doesn't have a mataching file location. See you in #support. (File={fileId})"); + + Logger.LogWarning("Multiple locations matched the path, picking the first location. (File={FileId})", fileId); + } + + // Find the correct series based on the path. + var selectedPath = (Path.GetDirectoryName(fileLocations.First().Path) ?? "") + "/"; + foreach (var seriesXRef in file.CrossReferences) { + var seriesId = seriesXRef.Series.Shoko.ToString(); + + // Check if the file is in the series folder. + var pathSet = await GetPathSetForSeries(seriesId); + if (!pathSet.Contains(selectedPath)) + continue; + + // Find the group info. + GroupInfo? groupInfo = null; if (filterGroupByType.HasValue) { groupInfo = await GetGroupInfoForSeries(seriesId, filterGroupByType.Value); if (groupInfo == null) - return (null, null, null, null); + return (null, null, null); } + // Find the series info. var seriesInfo = await GetSeriesInfo(seriesId); if (seriesInfo == null) - return (null, null, null, null); - - var episodeInfo = await GetEpisodeInfo(episodeId); - if (episodeInfo == null) - return (null, null, null, null); + return (null, null, null); - var fileId = file.ID.ToString(); - var episodeCount = series?.EpisodeIDs?.Count ?? 0; - var fileInfo = CreateFileInfo(file, fileId, episodeCount); + // Find the file info for the series. + var fileInfo = await CreateFileInfo(file, fileId, seriesId); // Add pointers for faster lookup. - EpisodePathToEpisodeIdDictionary.TryAdd(path, episodeId); - EpisodeIdToEpisodePathDictionary.TryAdd(episodeId, path); - FilePathToFileIdAndEpisodeCountDictionary.TryAdd(path, (fileId, episodeCount, episodeId, seriesId)); - return (fileInfo, episodeInfo, seriesInfo, groupInfo); - } + foreach (var episodeInfo in fileInfo.EpisodeList) + EpisodeIdToEpisodePathDictionary.TryAdd(episodeInfo.Id, path); - public FileInfo GetFileInfoSync(string fileId, int episodeCount = 0) - { - if (string.IsNullOrEmpty(fileId)) - return null; - - var cacheKey = $"file:{fileId}:{episodeCount}"; - FileInfo info = null; - if (DataCache.TryGetValue<FileInfo>(cacheKey, out info)) - return info; + // Add pointers for faster lookup. + PathToFileIdAndSeriesIdDictionary.TryAdd(path, (fileId, seriesId)); + PathToEpisodeIdsDictionary.TryAdd(path, fileInfo.EpisodeList.Select(episode => episode.Id).ToList()); - var file = APIClient.GetFile(fileId).GetAwaiter().GetResult(); - return CreateFileInfo(file, fileId, episodeCount); + // Return the result. + return new(fileInfo, seriesInfo, groupInfo); } - public async Task<FileInfo> GetFileInfo(string fileId, int episodeCount = 0) - { - if (string.IsNullOrEmpty(fileId)) - return null; + throw new Exception("Unable to find the series to use for the file."); + } - var cacheKey = $"file:{fileId}:{episodeCount}"; - FileInfo info = null; - if (DataCache.TryGetValue<FileInfo>(cacheKey, out info)) - return info; + public async Task<FileInfo?> GetFileInfo(string fileId, string seriesId) + { + if (string.IsNullOrEmpty(fileId) || string.IsNullOrEmpty(seriesId)) + return null; - var file = await APIClient.GetFile(fileId); - return CreateFileInfo(file, fileId, episodeCount); - } + var cacheKey = $"file:{fileId}:{seriesId}"; + FileInfo? info = null; + if (DataCache.TryGetValue<FileInfo>(cacheKey, out info)) + return info; - private FileInfo CreateFileInfo(File file, string fileId = null, int episodeCount = 0) - { - if (file == null) - return null; + var file = await APIClient.GetFile(fileId); + return await CreateFileInfo(file, fileId, seriesId); + } - if (string.IsNullOrEmpty(fileId)) - fileId = file.ID.ToString(); - - var cacheKey = $"file:{fileId}:{episodeCount}"; - FileInfo info = null; - if (DataCache.TryGetValue<FileInfo>(cacheKey, out info)) - return info; - - Logger.LogTrace("Creating info object for file. (File={FileId})", fileId); - info = new FileInfo - { - Id = fileId, - Shoko = file, - ExtraEpisodesCount = episodeCount - 1, - }; - DataCache.Set<FileInfo>(cacheKey, info, DefaultTimeSpan); + private async Task<FileInfo> CreateFileInfo(File file, string fileId, string seriesId) + { + var cacheKey = $"file:{fileId}:{seriesId}"; + FileInfo? info = null; + if (DataCache.TryGetValue<FileInfo>(cacheKey, out info)) return info; - } - public bool TryGetFileIdForPath(string path, out string fileId, out int episodeCount) - { - if (!string.IsNullOrEmpty(path) && FilePathToFileIdAndEpisodeCountDictionary.TryGetValue(path, out var pair)) { - fileId = pair.Item1; - episodeCount = pair.Item2; - return true; - } + var seriesXRef = file.CrossReferences.FirstOrDefault(xref => xref.Series.Shoko.ToString() == seriesId); + if (seriesXRef == null) + throw new Exception($"Unable to find any cross-references for the spesified series for the file. (File={fileId},Series={seriesId})"); - fileId = null; - episodeCount = 0; - return false; + // Find a list of the episode info for each episode linked to the file for the series. + var episodeList = new List<EpisodeInfo>(); + foreach (var episodeXRef in seriesXRef.Episodes) { + var episodeId = episodeXRef.Shoko.ToString(); + var episodeInfo = await GetEpisodeInfo(episodeId); + if (episodeInfo == null) + throw new Exception($"Unable to find episode cross-reference for the spesified series and episode for the file. (File={fileId},Episode={episodeId},Series={seriesId})"); + episodeList.Add(episodeInfo); } - #endregion - #region Episode Info + // Order the episodes. + episodeList = episodeList + .OrderBy(episode => episode.AniDB.Type) + .ThenBy(episode => episode.AniDB.EpisodeNumber) + .ToList(); - public EpisodeInfo GetEpisodeInfoSync(string episodeId) - { - if (string.IsNullOrEmpty(episodeId)) - return null; - if (DataCache.TryGetValue<EpisodeInfo>($"episode:{episodeId}", out var info)) - return info; - return GetEpisodeInfo(episodeId).GetAwaiter().GetResult(); - } + Logger.LogTrace("Creating info object for file. (File={FileId},Series={SeriesId})", fileId, seriesId); + info = new FileInfo(file, episodeList, seriesId); + DataCache.Set<FileInfo>(cacheKey, info, DefaultTimeSpan); + FileIdToEpisodeIdDictionary.TryAdd(fileId, episodeList.Select(episode => episode.Id).ToList()); + return info; + } - public async Task<EpisodeInfo> GetEpisodeInfo(string episodeId) - { - if (string.IsNullOrEmpty(episodeId)) - return null; - if (DataCache.TryGetValue<EpisodeInfo>($"episode:{episodeId}", out var info)) - return info; - var episode = await APIClient.GetEpisode(episodeId); - return await CreateEpisodeInfo(episode, episodeId); + public bool TryGetFileIdForPath(string path, out string? fileId) + { + if (!string.IsNullOrEmpty(path) && PathToFileIdAndSeriesIdDictionary.TryGetValue(path, out var pair)) { + fileId = pair.Item1; + return true; } - private async Task<EpisodeInfo> CreateEpisodeInfo(Episode episode, string episodeId = null) - { - if (episode == null) - return null; - if (string.IsNullOrEmpty(episodeId)) - episodeId = episode.IDs.ID.ToString(); - var cacheKey = $"episode:{episodeId}"; - EpisodeInfo info = null; - if (DataCache.TryGetValue<EpisodeInfo>(cacheKey, out info)) - return info; - Logger.LogTrace("Creating info object for episode {EpisodeName}. (Episode={EpisodeId})", episode.Name, episodeId); - var aniDB = (await APIClient.GetEpisodeAniDb(episodeId)); - info = new EpisodeInfo - { - Id = episodeId, - ExtraType = Ordering.GetExtraType(aniDB), - Shoko = episode, - AniDB = aniDB, - TvDB = ((await APIClient.GetEpisodeTvDb(episodeId))?.FirstOrDefault()), - }; - DataCache.Set<EpisodeInfo>(cacheKey, info, DefaultTimeSpan); + fileId = null; + return false; + } + + #endregion + #region Episode Info + + public async Task<EpisodeInfo?> GetEpisodeInfo(string episodeId) + { + if (string.IsNullOrEmpty(episodeId)) + return null; + if (DataCache.TryGetValue<EpisodeInfo>($"episode:{episodeId}", out var info)) return info; - } + var episode = await APIClient.GetEpisode(episodeId); + return CreateEpisodeInfo(episode, episodeId); + } - public bool TryGetEpisodeIdForPath(string path, out string episodeId) - { - if (string.IsNullOrEmpty(path)) { - episodeId = null; - return false; - } - return EpisodePathToEpisodeIdDictionary.TryGetValue(path, out episodeId); - } + private EpisodeInfo CreateEpisodeInfo(Episode episode, string episodeId) + { + if (string.IsNullOrEmpty(episodeId)) + episodeId = episode.IDs.Shoko.ToString(); + var cacheKey = $"episode:{episodeId}"; + EpisodeInfo? info = null; + if (DataCache.TryGetValue<EpisodeInfo>(cacheKey, out info)) + return info; + Logger.LogTrace("Creating info object for episode {EpisodeName}. (Episode={EpisodeId})", episode.Name, episodeId); + info = new EpisodeInfo(episode); + DataCache.Set<EpisodeInfo>(cacheKey, info, DefaultTimeSpan); + return info; + } - public bool TryGetEpisodePathForId(string episodeId, out string path) - { - if (string.IsNullOrEmpty(episodeId)) { - path = null; - return false; - } - return EpisodeIdToEpisodePathDictionary.TryGetValue(episodeId, out path); + public bool TryGetEpisodeIdForPath(string path, out string? episodeId) + { + if (string.IsNullOrEmpty(path)) { + episodeId = null; + return false; } + var result = PathToEpisodeIdsDictionary.TryGetValue(path, out var episodeIds); + episodeId = episodeIds?.FirstOrDefault(); + return result; + } - public bool TryGetSeriesIdForEpisodeId(string episodeId, out string seriesId) - { - return EpisodeIdToSeriesIdDictionary.TryGetValue(episodeId, out seriesId); + public bool TryGetEpisodeIdsForPath(string path, out List<string>? episodeIds) + { + if (string.IsNullOrEmpty(path)) { + episodeIds = null; + return false; } + return PathToEpisodeIdsDictionary.TryGetValue(path, out episodeIds); + } - #endregion - #region Series Info - - public SeriesInfo GetSeriesInfoByPathSync(string path) - { - if (SeriesPathToIdDictionary.ContainsKey(path)) - { - var seriesId = SeriesPathToIdDictionary[path]; - if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) - return info; - return GetSeriesInfo(seriesId).GetAwaiter().GetResult(); - } - return GetSeriesInfoByPath(path).GetAwaiter().GetResult(); + public bool TryGetEpisodeIdsForFileId(string fileId, out List<string>? episodeIds) + { + if (string.IsNullOrEmpty(fileId)) { + episodeIds = null; + return false; } + return FileIdToEpisodeIdDictionary.TryGetValue(fileId, out episodeIds); + } - public async Task<SeriesInfo> GetSeriesInfoByPath(string path) - { - var partialPath = StripMediaFolder(path); - Logger.LogDebug("Looking for series matching {Path}", partialPath); - string seriesId; - if (!SeriesPathToIdDictionary.TryGetValue(path, out seriesId)) - { - var result = await APIClient.GetSeriesPathEndsWith(partialPath); - Logger.LogTrace("Found result with {Count} matches for {Path}", result?.Count ?? 0, partialPath); - seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); - - if (string.IsNullOrEmpty(seriesId)) - return null; - - SeriesPathToIdDictionary[path] = seriesId; - SeriesIdToPathDictionary.TryAdd(seriesId, path); - } + public bool TryGetEpisodePathForId(string episodeId, out string? path) + { + if (string.IsNullOrEmpty(episodeId)) { + path = null; + return false; + } + return EpisodeIdToEpisodePathDictionary.TryGetValue(episodeId, out path); + } - if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) - return info; + public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) + { + return EpisodeIdToSeriesIdDictionary.TryGetValue(episodeId, out seriesId); + } - var series = await APIClient.GetSeries(seriesId); - return await CreateSeriesInfo(series, seriesId); - } + #endregion + #region Series Info - public async Task<SeriesInfo> GetSeriesInfoFromGroup(string groupId, int seasonNumber, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) - { - var groupInfo = await GetGroupInfo(groupId, filterByType); - if (groupInfo == null) - return null; - return groupInfo.GetSeriesInfoBySeasonNumber(seasonNumber); - } - public SeriesInfo GetSeriesInfoSync(string seriesId) + public async Task<SeriesInfo?> GetSeriesInfoByPath(string path) + { + var partialPath = StripMediaFolder(path); + Logger.LogDebug("Looking for series matching {Path}", partialPath); + string? seriesId; + if (!PathToSeriesIdDictionary.TryGetValue(path, out seriesId)) { - if (string.IsNullOrEmpty(seriesId)) - return null; - if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) - return info; - var series = APIClient.GetSeries(seriesId).GetAwaiter().GetResult(); - return CreateSeriesInfo(series, seriesId).GetAwaiter().GetResult(); - } + var result = await APIClient.GetSeriesPathEndsWith(partialPath); + Logger.LogTrace("Found result with {Count} matches for {Path}", result?.Count ?? 0, partialPath); + seriesId = result?.FirstOrDefault()?.IDs?.Shoko.ToString(); - public async Task<SeriesInfo> GetSeriesInfo(string seriesId) - { if (string.IsNullOrEmpty(seriesId)) return null; - if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) - return info; - var series = await APIClient.GetSeries(seriesId); - return await CreateSeriesInfo(series, seriesId); - } - - public SeriesInfo GetSeriesInfoForEpisodeSync(string episodeId) - { - if (EpisodeIdToSeriesIdDictionary.ContainsKey(episodeId)) { - var seriesId = EpisodeIdToSeriesIdDictionary[episodeId]; - if (DataCache.TryGetValue<SeriesInfo>($"series:{seriesId}", out var info)) - return info; - - return GetSeriesInfo(seriesId).GetAwaiter().GetResult(); - } - return GetSeriesInfoForEpisode(episodeId).GetAwaiter().GetResult(); + PathToSeriesIdDictionary[path] = seriesId; + SeriesIdToPathDictionary.TryAdd(seriesId, path); } - public async Task<SeriesInfo> GetSeriesInfoForEpisode(string episodeId) - { - string seriesId; - if (EpisodeIdToSeriesIdDictionary.ContainsKey(episodeId)) { - seriesId = EpisodeIdToSeriesIdDictionary[episodeId]; - } - else { - var series = await APIClient.GetSeriesFromEpisode(episodeId); - if (series == null) - return null; - seriesId = series.IDs.ID.ToString(); - } - - return await GetSeriesInfo(seriesId); - } + if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) + return info; - public bool TryGetSeriesIdForPath(string path, out string seriesId) - { - if (string.IsNullOrEmpty(path)) { - seriesId = null; - return false; - } - return SeriesPathToIdDictionary.TryGetValue(path, out seriesId); - } + var series = await APIClient.GetSeries(seriesId); + return await CreateSeriesInfo(series, seriesId); + } - public bool TryGetSeriesPathForId(string seriesId, out string path) - { - if (string.IsNullOrEmpty(seriesId)) { - path = null; - return false; - } - return SeriesIdToPathDictionary.TryGetValue(seriesId, out path); - } + public async Task<SeriesInfo?> GetSeriesInfo(string seriesId) + { + if (string.IsNullOrEmpty(seriesId)) + return null; + if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) + return info; + var series = await APIClient.GetSeries(seriesId); + return await CreateSeriesInfo(series, seriesId); + } - public bool TryGetGroupIdForSeriesId(string seriesId, out string groupId) - { - return SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out groupId); + public async Task<SeriesInfo?> GetSeriesInfoForEpisode(string episodeId) + { + string seriesId; + if (EpisodeIdToSeriesIdDictionary.ContainsKey(episodeId)) { + seriesId = EpisodeIdToSeriesIdDictionary[episodeId]; } - - private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId = null) - { + else { + var series = await APIClient.GetSeriesFromEpisode(episodeId); if (series == null) return null; - - if (string.IsNullOrEmpty(seriesId)) - seriesId = series.IDs.ID.ToString(); - - SeriesInfo info = null; - var cacheKey = $"series:{seriesId}"; - if (DataCache.TryGetValue<SeriesInfo>(cacheKey, out info)) - return info; - Logger.LogTrace("Creating info object for series {SeriesName}. (Series={SeriesId})", series.Name, seriesId); - - var aniDb = await APIClient.GetSeriesAniDB(seriesId); - var tvDbId = series.IDs.TvDB?.FirstOrDefault(); - var tags = await GetTags(seriesId); - var genres = await GetGenresForSeries(seriesId); - var cast = await APIClient.GetSeriesCast(seriesId); - - var studios = cast.Where(r => r.RoleName == Role.CreatorRoleType.Studio).Select(r => r.Staff.Name).ToArray(); - var staff = cast.Select(RoleToPersonInfo).OfType<PersonInfo>().ToArray(); - var specialsAnchorDictionary = new Dictionary<EpisodeInfo, EpisodeInfo>(); - var specialsList = new List<EpisodeInfo>(); - var episodesList = new List<EpisodeInfo>(); - var extrasList = new List<EpisodeInfo>(); - var altEpisodesList = new List<EpisodeInfo>(); - var othersList = new List<EpisodeInfo>(); - - // The episode list is ordered by air date - var allEpisodesList = APIClient.GetEpisodesFromSeries(seriesId) - .ContinueWith(task => Task.WhenAll(task.Result.Select(e => CreateEpisodeInfo(e)))) - .Unwrap() - .GetAwaiter() - .GetResult() - .Where(e => e != null && e.Shoko != null && e.AniDB != null) - .OrderBy(e => e.AniDB.AirDate) - .ToList(); - - // Iterate over the episodes once and store some values for later use. - for (int index = 0, lastNormalEpisode = 0; index < allEpisodesList.Count; index++) { - var episode = allEpisodesList[index]; - EpisodeIdToSeriesIdDictionary[episode.Id] = seriesId; - switch (episode.AniDB.Type) { - case EpisodeType.Normal: - episodesList.Add(episode); - lastNormalEpisode = index; - break; - case EpisodeType.Other: - othersList.Add(episode); - break; - case EpisodeType.Unknown: - altEpisodesList.Add(episode); - break; - default: - if (episode.ExtraType != null) - extrasList.Add(episode); - else if (episode.AniDB.Type == EpisodeType.Special) { - specialsList.Add(episode); - var previousEpisode = allEpisodesList - .GetRange(lastNormalEpisode, index - lastNormalEpisode) - .FirstOrDefault(e => e.AniDB.Type == EpisodeType.Normal); - if (previousEpisode != null) - specialsAnchorDictionary[episode] = previousEpisode; - } - break; - } - } - - // While the filtered specials list is ordered by episode number - specialsList = specialsList - .OrderBy(e => e.AniDB.EpisodeNumber) - .ToList(); - - info = new SeriesInfo { - Id = seriesId, - Shoko = series, - AniDB = aniDb, - TvDBId = tvDbId != 0 ? tvDbId.ToString() : null, - TvDB = tvDbId != 0 ? (await APIClient.GetSeriesTvDB(seriesId)).FirstOrDefault() : null, - Tags = tags, - Genres = genres, - Studios = studios, - Staff = staff, - RawEpisodeList = allEpisodesList, - EpisodeList = episodesList, - AlternateEpisodesList = altEpisodesList, - OthersList = othersList, - ExtrasList = extrasList, - SpesialsAnchors = specialsAnchorDictionary, - SpecialsList = specialsList, - }; - - DataCache.Set<SeriesInfo>(cacheKey, info, DefaultTimeSpan); - return info; + seriesId = series.IDs.Shoko.ToString(); } - #endregion - #region Group Info - - public GroupInfo GetGroupInfoByPathSync(string path, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) - { - return GetGroupInfoByPath(path, filterByType).GetAwaiter().GetResult(); - } - - public async Task<GroupInfo> GetGroupInfoByPath(string path, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) - { - var partialPath = StripMediaFolder(path); - Logger.LogDebug("Looking for group matching {Path}", partialPath); - - string seriesId; - if (SeriesPathToIdDictionary.TryGetValue(path, out seriesId)) - { - if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { - if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) - return info; - - return await GetGroupInfo(groupId, filterByType); - } - } - else - { - var result = await APIClient.GetSeriesPathEndsWith(partialPath); - Logger.LogTrace("Found result with {Count} matches for {Path}", result?.Count ?? 0, partialPath); - seriesId = result?.FirstOrDefault()?.IDs?.ID.ToString(); - - if (string.IsNullOrEmpty(seriesId)) - return null; + return await GetSeriesInfo(seriesId); + } - SeriesPathToIdDictionary[path] = seriesId; - SeriesIdToPathDictionary.TryAdd(seriesId, path); - } + private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId) + { + SeriesInfo? info = null; + var cacheKey = $"series:{seriesId}"; + if (DataCache.TryGetValue<SeriesInfo>(cacheKey, out info)) + return info; + Logger.LogTrace("Creating info object for series {SeriesName}. (Series={SeriesId})", series.Name, seriesId); + + var episodes = (await APIClient.GetEpisodesFromSeries(seriesId) ?? new()) + .Select(e => CreateEpisodeInfo(e, e.IDs.Shoko.ToString())) + .OrderBy(e => e.AniDB.AirDate) + .ToList(); + var cast = await APIClient.GetSeriesCast(seriesId); + var genres = await GetGenresForSeries(seriesId); + var tags = await GetTagsForSeries(seriesId); + + info = new SeriesInfo(series, episodes, cast, genres, tags); + + foreach (var episode in episodes) + EpisodeIdToSeriesIdDictionary[episode.Id] = seriesId; + DataCache.Set<SeriesInfo>(cacheKey, info, DefaultTimeSpan); + return info; + } - return await GetGroupInfoForSeries(seriesId, filterByType); + public bool TryGetSeriesIdForPath(string path, out string? seriesId) + { + if (string.IsNullOrEmpty(path)) { + seriesId = null; + return false; } + return PathToSeriesIdDictionary.TryGetValue(path, out seriesId); + } - public GroupInfo GetGroupInfoSync(string groupId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) - { - if (!string.IsNullOrEmpty(groupId) && DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) - return info; - - return GetGroupInfo(groupId, filterByType).GetAwaiter().GetResult(); + public bool TryGetSeriesPathForId(string seriesId, out string? path) + { + if (string.IsNullOrEmpty(seriesId)) { + path = null; + return false; } + return SeriesIdToPathDictionary.TryGetValue(seriesId, out path); + } - public async Task<GroupInfo> GetGroupInfo(string groupId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) - { - if (string.IsNullOrEmpty(groupId)) - return null; + public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) + { + return SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out groupId); + } - if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) - return info; + #endregion + #region Group Info - var group = await APIClient.GetGroup(groupId); - return await CreateGroupInfo(group, groupId, filterByType); - } + public async Task<GroupInfo?> GetGroupInfoByPath(string path, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + var partialPath = StripMediaFolder(path); + Logger.LogDebug("Looking for group matching {Path}", partialPath); - public GroupInfo GetGroupInfoForSeriesSync(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + string? seriesId; + if (PathToSeriesIdDictionary.TryGetValue(path, out seriesId)) { - if (string.IsNullOrEmpty(seriesId)) - return null; - if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) return info; - return GetGroupInfo(groupId, filterByType).GetAwaiter().GetResult(); + return await GetGroupInfo(groupId, filterByType); } - - return GetGroupInfoForSeries(seriesId, filterByType).GetAwaiter().GetResult(); } - - public async Task<GroupInfo> GetGroupInfoForSeries(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + else { + var result = await APIClient.GetSeriesPathEndsWith(partialPath); + Logger.LogTrace("Found result with {Count} matches for {Path}", result?.Count ?? 0, partialPath); + seriesId = result?.FirstOrDefault()?.IDs?.Shoko.ToString(); + if (string.IsNullOrEmpty(seriesId)) return null; - if (!SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { - var group = await APIClient.GetGroupFromSeries(seriesId); - if (group == null) - return null; + PathToSeriesIdDictionary[path] = seriesId; + SeriesIdToPathDictionary.TryAdd(seriesId, path); + } - groupId = group.IDs.ID.ToString(); - if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var groupInfo)) - return groupInfo; + return await GetGroupInfoForSeries(seriesId, filterByType); + } - return await CreateGroupInfo(group, groupId, filterByType); - } + public async Task<GroupInfo?> GetGroupInfo(string groupId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + if (string.IsNullOrEmpty(groupId)) + return null; - return await GetGroupInfo(groupId, filterByType); - } + if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) + return info; - private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Ordering.GroupFilterType filterByType) - { - if (group == null) - return null; + var group = await APIClient.GetGroup(groupId); + return await CreateGroupInfo(group, groupId, filterByType); + } - if (string.IsNullOrEmpty(groupId)) - groupId = group.IDs.ID.ToString(); + public async Task<GroupInfo?> GetGroupInfoForSeries(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + if (string.IsNullOrEmpty(seriesId)) + return null; - var cacheKey = $"group:{filterByType}:{groupId}"; - GroupInfo groupInfo = null; - if (DataCache.TryGetValue<GroupInfo>(cacheKey, out groupInfo)) - return groupInfo; - Logger.LogTrace("Creating info object for group {GroupName}. (Group={GroupId})", group.Name, groupId); - - var seriesList = (await APIClient.GetSeriesInGroup(groupId) - .ContinueWith(task => Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s)))) - .Unwrap()) - .Where(s => s != null) - .ToList(); - if (seriesList != null && seriesList.Count > 0) switch (filterByType) { - default: - break; - case Ordering.GroupFilterType.Movies: - seriesList = seriesList.Where(s => s.AniDB.Type == SeriesType.Movie).ToList(); - break; - case Ordering.GroupFilterType.Others: - seriesList = seriesList.Where(s => s.AniDB.Type != SeriesType.Movie).ToList(); - break; - } + if (!SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { + var group = await APIClient.GetGroupFromSeries(seriesId); + if (group == null) + return null; - // Return ealty if no series matched the filter or if the list was empty. - if (seriesList == null || seriesList.Count == 0) { - Logger.LogWarning("Creating an empty group info for filter {Filter}! (Group={GroupId})", filterByType.ToString(), groupId); - groupInfo = new GroupInfo { - Id = groupId, - Shoko = group, - Tags = new string[0], - Genres = new string[0], - Studios = new string[0], - SeriesList = (seriesList ?? new List<SeriesInfo>()), - SeasonNumberBaseDictionary = (new Dictionary<SeriesInfo, int>()), - SeasonOrderDictionary = (new Dictionary<int, SeriesInfo>()), - DefaultSeries = null, - DefaultSeriesIndex = -1, - }; - DataCache.Set<GroupInfo>(cacheKey, groupInfo, DefaultTimeSpan); + groupId = group.IDs.Shoko.ToString(); + if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var groupInfo)) return groupInfo; - } - // Order series list - var orderingType = filterByType == Ordering.GroupFilterType.Movies ? Plugin.Instance.Configuration.MovieOrdering : Plugin.Instance.Configuration.SeasonOrdering; - switch (orderingType) { - case Ordering.OrderType.Default: - break; - case Ordering.OrderType.ReleaseDate: - seriesList = seriesList.OrderBy(s => s?.AniDB?.AirDate ?? System.DateTime.MaxValue).ToList(); - break; - // Should not be selectable unless a user fiddles with DevTools in the browser to select the option. - case Ordering.OrderType.Chronological: - throw new System.Exception("Not implemented yet"); - } + return await CreateGroupInfo(group, groupId, filterByType); + } - // Select the targeted id if a group spesify a default series. - int foundIndex = -1; - int targetId = (group.IDs.DefaultSeries ?? 0); - if (targetId != 0) - foundIndex = seriesList.FindIndex(s => s.Shoko.IDs.ID == targetId); - // Else select the default series as first-to-be-released. - else switch (orderingType) { - // The list is already sorted by release date, so just return the first index. - case Ordering.OrderType.ReleaseDate: - foundIndex = 0; - break; - // We don't know how Shoko may have sorted it, so just find the earliest series - case Ordering.OrderType.Default: - // We can't be sure that the the series in the list was _released_ chronologically, so find the earliest series, and use that as a base. - case Ordering.OrderType.Chronological: { - var earliestSeries = seriesList.Aggregate((cur, nxt) => (cur == null || (nxt?.AniDB.AirDate ?? System.DateTime.MaxValue) < (cur.AniDB.AirDate ?? System.DateTime.MaxValue)) ? nxt : cur); - foundIndex = seriesList.FindIndex(s => s == earliestSeries); - break; - } - } + return await GetGroupInfo(groupId, filterByType); + } - // Throw if we can't get a base point for seasons. - if (foundIndex == -1) - throw new System.Exception("Unable to get a base-point for seasions withing the group"); - - var seasonOrderDictionary = new Dictionary<int, SeriesInfo>(); - var seasonNumberBaseDictionary = new Dictionary<SeriesInfo, int>(); - var positiveSeasonNumber = 1; - var negativeSeasonNumber = -1; - foreach (var (seriesInfo, index) in seriesList.Select((s, i) => (s, i))) { - int seasonNumber; - var offset = 0; - if (seriesInfo.AlternateEpisodesList.Count > 0) - offset++; - if (seriesInfo.OthersList.Count > 0) - offset++; - - // Series before the default series get a negative season number - if (index < foundIndex) { - seasonNumber = negativeSeasonNumber; - negativeSeasonNumber -= offset + 1; - } - else { - seasonNumber = positiveSeasonNumber; - positiveSeasonNumber += offset + 1; - } - - seasonNumberBaseDictionary.Add(seriesInfo, seasonNumber); - seasonOrderDictionary.Add(seasonNumber, seriesInfo); - for (var i = 0; i < offset; i++) - seasonOrderDictionary.Add(seasonNumber + (index < foundIndex ? -(i + 1) : (i + 1)), seriesInfo); - } + private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Ordering.GroupFilterType filterByType) + { + if (string.IsNullOrEmpty(groupId)) + groupId = group.IDs.Shoko.ToString(); - groupInfo = new GroupInfo { - Id = groupId, - Shoko = group, - Tags = seriesList.SelectMany(s => s.Tags).Distinct().ToArray(), - Genres = seriesList.SelectMany(s => s.Genres).Distinct().ToArray(), - Studios = seriesList.SelectMany(s => s.Studios).Distinct().ToArray(), - SeriesList = seriesList, - SeasonNumberBaseDictionary = seasonNumberBaseDictionary, - SeasonOrderDictionary = seasonOrderDictionary, - DefaultSeries = seriesList[foundIndex], - DefaultSeriesIndex = foundIndex, - }; - foreach (var series in seriesList) - SeriesIdToGroupIdDictionary[series.Id] = groupId; + var cacheKey = $"group:{filterByType}:{groupId}"; + GroupInfo? groupInfo = null; + if (DataCache.TryGetValue<GroupInfo>(cacheKey, out groupInfo)) + return groupInfo; + Logger.LogTrace("Creating info object for group {GroupName}. (Group={GroupId})", group.Name, groupId); + + var seriesList = (await APIClient.GetSeriesInGroup(groupId) + .ContinueWith(task => Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s, s.IDs.Shoko.ToString())))) + .Unwrap()) + .Where(s => s != null) + .ToList(); + if (seriesList.Count > 0) switch (filterByType) { + default: + break; + case Ordering.GroupFilterType.Movies: + seriesList = seriesList.Where(s => s.AniDB.Type == SeriesType.Movie).ToList(); + break; + case Ordering.GroupFilterType.Others: + seriesList = seriesList.Where(s => s.AniDB.Type != SeriesType.Movie).ToList(); + break; + } + + // Return early if no series matched the filter or if the list was empty. + if (seriesList.Count == 0) { + Logger.LogWarning("Creating an empty group info for filter {Filter}! (Group={GroupId})", filterByType.ToString(), groupId); + groupInfo = new GroupInfo(group); DataCache.Set<GroupInfo>(cacheKey, groupInfo, DefaultTimeSpan); return groupInfo; } - #endregion - #region Post Process Library Changes + groupInfo = new GroupInfo(group, seriesList, filterByType); + foreach (var series in seriesList) + SeriesIdToGroupIdDictionary[series.Id] = groupId; + DataCache.Set<GroupInfo>(cacheKey, groupInfo, DefaultTimeSpan); + return groupInfo; + } - public Task PostProcess(IProgress<double> progress, CancellationToken token) - { - Clear(); - return Task.CompletedTask; - } + #endregion + #region Post Process Library Changes - #endregion + public Task PostProcess(IProgress<double> progress, CancellationToken token) + { + Clear(); + return Task.CompletedTask; } + + #endregion } diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 8218de82..07a3e417 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -46,7 +46,11 @@ public virtual string PrettyHost public bool AddAniDBId { get; set; } - public bool AddOtherId { get; set; } + public bool AddTvDBId { get; set; } + + public bool AddTMDBId { get; set; } + + public bool MergeQuartSeasons { get; set; } public TextSourceType DescriptionSource { get; set; } @@ -89,7 +93,9 @@ public PluginConfiguration() SynopsisRemoveSummary = true; SynopsisCleanMultiEmptyLines = true; AddAniDBId = true; - AddOtherId = false; + AddTvDBId = true; + AddTMDBId = true; + MergeQuartSeasons = false; TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; DescriptionSource = TextSourceType.Default; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index d88ba33a..23741fb6 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -104,6 +104,11 @@ async function defaultSubmit(form) { config.SynopsisCleanMiscLines = form.querySelector("#MinimalAniDBDescriptions").checked; config.SynopsisRemoveSummary = form.querySelector("#MinimalAniDBDescriptions").checked; + // Provider settings + config.AddAniDBId = form.querySelector("#AddAniDBId").checked; + config.AddTvDBId = form.querySelector("#AddTvDBId").checked; + config.AddTMDBId = form.querySelector("#AddTMDBId").checked; + // Library settings config.SeriesGrouping = form.querySelector("#SeriesGrouping").value; config.BoxSetGrouping = form.querySelector("#BoxSetGrouping").value; @@ -123,8 +128,7 @@ async function defaultSubmit(form) { config.PublicHost = publicHost; config.IgnoredFileExtensions = ignoredFileExtensions; form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); - config.AddAniDBId = form.querySelector("#AddAniDBId").checked; - config.AddOtherId = form.querySelector("#AddOtherId").checked; + config.MergeQuartSeasons = form.querySelector("#MergeQuartSeasons").checked; // User settings const userId = form.querySelector("#UserSelector").value; @@ -245,6 +249,11 @@ async function syncSettings(form) { config.SynopsisCleanMiscLines = form.querySelector("#MinimalAniDBDescriptions").checked; config.SynopsisRemoveSummary = form.querySelector("#MinimalAniDBDescriptions").checked; + // Provider settings + config.AddAniDBId = form.querySelector("#AddAniDBId").checked; + config.AddTvDBId = form.querySelector("#AddTvDBId").checked; + config.AddTMDBId = form.querySelector("#AddTMDBId").checked; + // Library settings config.SeriesGrouping = form.querySelector("#SeriesGrouping").value; config.BoxSetGrouping = form.querySelector("#BoxSetGrouping").value; @@ -264,8 +273,7 @@ async function syncSettings(form) { config.PublicHost = publicHost; config.IgnoredFileExtensions = ignoredFileExtensions; form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); - config.AddAniDBId = form.querySelector("#AddAniDBId").checked; - config.AddOtherId = form.querySelector("#AddOtherId").checked; + config.MergeQuartSeasons = form.querySelector("#MergeQuartSeasons").checked; const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); Dashboard.processPluginConfigurationUpdateResult(result); @@ -343,6 +351,7 @@ export default function (page) { form.querySelector("#ConnectionSection").removeAttribute("hidden"); form.querySelector("#MetadataSection").removeAttribute("hidden"); form.querySelector("#MetadataSection").removeAttribute("hidden"); + form.querySelector("#ProviderSection").removeAttribute("hidden"); form.querySelector("#LibrarySection").removeAttribute("hidden"); form.querySelector("#UserSection").removeAttribute("hidden"); form.querySelector("#TagSection").removeAttribute("hidden"); @@ -356,6 +365,7 @@ export default function (page) { form.querySelector("#ConnectionSection").removeAttribute("hidden"); form.querySelector("#MetadataSection").setAttribute("hidden", ""); form.querySelector("#MetadataSection").setAttribute("hidden", ""); + form.querySelector("#ProviderSection").setAttribute("hidden", ""); form.querySelector("#LibrarySection").setAttribute("hidden", ""); form.querySelector("#UserSection").setAttribute("hidden", ""); form.querySelector("#TagSection").setAttribute("hidden", ""); @@ -405,6 +415,11 @@ export default function (page) { form.querySelector("#CleanupAniDBDescriptions").checked = config.SynopsisCleanMultiEmptyLines || config.SynopsisCleanLinks; form.querySelector("#MinimalAniDBDescriptions").checked = config.SynopsisRemoveSummary || config.SynopsisCleanMiscLines; + // Provider settings + form.querySelector("#AddAniDBId").checked = config.AddAniDBId; + form.querySelector("#AddTvDBId").checked = config.AddTvDBId; + form.querySelector("#AddTMDBId").checked = config.AddTMDBId; + // Library settings form.querySelector("#SeriesGrouping").value = config.SeriesGrouping; form.querySelector("#BoxSetGrouping").value = config.BoxSetGrouping; @@ -426,8 +441,7 @@ export default function (page) { // Advanced settings form.querySelector("#PublicHost").value = config.PublicHost; form.querySelector("#IgnoredFileExtensions").value = config.IgnoredFileExtensions.join(" "); - form.querySelector("#AddAniDBId").checked = config.AddAniDBId; - form.querySelector("#AddOtherId").checked = config.AddOtherId; + form.querySelector("#MergeQuartSeasons").checked = config.MergeQuartSeasons; if (!config.ApiKey) { Dashboard.alert(Messages.ConnectToShoko); diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 0848937a..0d9b269b 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -88,6 +88,36 @@ <h3>Metadata Settings</h3> <span>Save</span> </button> </fieldset> + <fieldset id="ProviderSection" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>Plugin Compatibility Settings</h3> + </legend> + <div class="fieldDescription verticalSection-extrabottompadding">We can optionally set some ids for interopobility with other plugins.</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="AddTvDBId" /> + <span>Add TvDB IDs</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this will add the TvDB 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> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> <fieldset id="LibrarySection" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Library Settings</h3> @@ -264,18 +294,12 @@ <h3>Advanced Settings</h3> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="AddAniDBId" /> - <span>Add AniDB ID to items</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="AddOtherId" /> - <span>Add TvDB/TMDB ID to items</span> + <input is="emby-checkbox" type="checkbox" id="MergeQuartSeasons" /> + <span>Automatically merge quart seasons</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this will add the TvDB/TMDB ID for all supported item types where an ID is available when using the default Series/Season grouping.</div> + <div class="fieldDescription checkboxFieldDescription">Enable at your own risk</div> </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> </button> diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index f987fac4..1af55bfb 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -78,6 +78,10 @@ public interface IIdLookup bool TryGetEpisodeIdFor(BaseItem item, out string episodeId); + bool TryGetEpisodeIdsFor(string path, out List<string> episodeIds); + + bool TryGetEpisodeIdsFor(BaseItem item, out List<string> episodeIds); + #endregion #region Episode Path @@ -153,7 +157,9 @@ public bool TryGetSeriesIdFor(Series series, out string seriesId) // Set the "Shoko Group" and "Shoko Series" provider ids for the series, since it haven't been set again. It doesn't matter if it's not saved to the database, since we only need it while running the following code. if (TryGetGroupIdFromSeriesId(seriesId, out var groupId)) { var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var groupInfo = ApiManager.GetGroupInfoSync(groupId, filterByType); + var groupInfo = ApiManager.GetGroupInfo(groupId, filterByType) + .GetAwaiter() + .GetResult(); seriesId = groupInfo.DefaultSeries.Id; SeriesProvider.AddProviderIds(series, seriesId, groupInfo.Id); @@ -201,7 +207,9 @@ public bool TryGetSeriesIdFor(BoxSet boxSet, out string seriesId) if (TryGetSeriesIdFor(boxSet.Path, out seriesId)) { if (TryGetGroupIdFromSeriesId(seriesId, out var groupId)) { var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var groupInfo = ApiManager.GetGroupInfoSync(groupId, filterByType); + var groupInfo = ApiManager.GetGroupInfo(groupId, filterByType) + .GetAwaiter() + .GetResult(); seriesId = groupInfo.DefaultSeries.Id; } return true; @@ -241,6 +249,26 @@ public bool TryGetEpisodeIdFor(BaseItem item, out string episodeId) return false; } + public bool TryGetEpisodeIdsFor(string path, out List<string> episodeIds) + { + return ApiManager.TryGetEpisodeIdsForPath(path, out episodeIds); + } + + public bool TryGetEpisodeIdsFor(BaseItem item, out List<string> episodeIds) + { + // This will account for virtual episodes and existing episodes + if (item.ProviderIds.TryGetValue("Shoko File", out var fileId) && ApiManager.TryGetEpisodeIdsForFileId(fileId, out episodeIds)) { + return true; + } + + // This will account for new episodes that haven't received their first metadata update yet. + if (TryGetEpisodeIdsFor(item.Path, out episodeIds)) { + return true; + } + + return false; + } + #endregion #region Episode Path @@ -257,7 +285,7 @@ public bool TryGetFileIdFor(BaseItem episode, out string fileId) if (episode.ProviderIds.TryGetValue("Shoko File", out fileId)) return true; - return ApiManager.TryGetFileIdForPath(episode.Path, out fileId, out var episodeCount); + return ApiManager.TryGetFileIdForPath(episode.Path, out fileId); } #endregion diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index 617f9a8c..ce33cb8a 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -69,7 +69,9 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base private bool ScanDirectory(string partialPath, string fullPath, string libraryType) { var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; - var series = ApiManager.GetSeriesInfoByPathSync(fullPath); + var series = ApiManager.GetSeriesInfoByPath(fullPath) + .GetAwaiter() + .GetResult(); // We warn here since we enabled the provider in our library, but we can't find a match for the given folder path. if (series == null) { @@ -80,8 +82,6 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy API.Info.GroupInfo group = null; // Filter library if we enabled the option. if (Plugin.Instance.Configuration.FilterOnLibraryTypes) switch (libraryType) { - default: - break; case "tvshows": if (series.AniDB.Type == SeriesType.Movie) { Logger.LogInformation("Library seperatation is enabled, ignoring series. (Series={SeriesId})", series.Id); @@ -90,7 +90,9 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy // If we're using series grouping, pre-load the group now to help reduce load times later. if (includeGroup) - group = ApiManager.GetGroupInfoForSeriesSync(series.Id, Ordering.GroupFilterType.Others); + group = ApiManager.GetGroupInfoForSeries(series.Id, Ordering.GroupFilterType.Others) + .GetAwaiter() + .GetResult(); break; case "movies": if (series.AniDB.Type != SeriesType.Movie) { @@ -100,12 +102,16 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy // If we're using series grouping, pre-load the group now to help reduce load times later. if (includeGroup) - group = ApiManager.GetGroupInfoForSeriesSync(series.Id, Ordering.GroupFilterType.Movies); + group = ApiManager.GetGroupInfoForSeries(series.Id, Ordering.GroupFilterType.Movies) + .GetAwaiter() + .GetResult(); break; } // If we're using series grouping, pre-load the group now to help reduce load times later. else if (includeGroup) - group = ApiManager.GetGroupInfoForSeriesSync(series.Id); + group = ApiManager.GetGroupInfoForSeries(series.Id) + .GetAwaiter() + .GetResult(); if (group != null) Logger.LogInformation("Found group {GroupName} (Series={SeriesId},Group={GroupId})", group.Shoko.Name, series.Id, group.Id); @@ -119,7 +125,9 @@ private bool ScanFile(string partialPath, string fullPath) { var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; var config = Plugin.Instance.Configuration; - var (file, episode, series, _group) = ApiManager.GetFileInfoByPathSync(fullPath, null); + var (file, series, _group) = ApiManager.GetFileInfoByPath(fullPath, null) + .GetAwaiter() + .GetResult(); // We warn here since we enabled the provider in our library, but we can't find a match for the given file path. if (file == null) { @@ -127,13 +135,14 @@ private bool ScanFile(string partialPath, string fullPath) return false; } - Logger.LogInformation("Found episode for {SeriesName} (Series={SeriesId},Episode={EpisodeId},File={FileId})", series.Shoko.Name, series.Id, episode.Id, file.Id); + Logger.LogInformation("Found {EpisodeCount} episode(s) for {SeriesName} (Series={SeriesId},File={FileId})", file.EpisodeList.Count, series.Shoko.Name, series.Id, file.Id); // We're going to post process this file later, but we don't want to include it in our library for now. - if (episode.ExtraType != null) { - Logger.LogInformation("Episode was assigned an extra type, ignoring episode. (Series={SeriesId},Episode={EpisodeId},File={FileId})", series.Id, episode.Id, file.Id); + if (file.ExtraType != null) { + Logger.LogInformation("File was assigned an extra type, ignoring file. (Series={SeriesId},File={FileId})", series.Id, file.Id); return true; } + return false; } } diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 0bf1e32e..08a0be93 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -76,7 +76,7 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, Ca }; result.Item.SetProviderId("Shoko Series", series.Id); if (Plugin.Instance.Configuration.AddAniDBId) - result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); + result.Item.SetProviderId("AniDB", series.AniDB.Id.ToString()); result.HasMetadata = true; @@ -115,7 +115,7 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in result.Item.SetProviderId("Shoko Series", series.Id); result.Item.SetProviderId("Shoko Group", group.Id); if (config.AddAniDBId) - result.Item.SetProviderId("AniDB", series.AniDB.ID.ToString()); + result.Item.SetProviderId("AniDB", series.AniDB.Id.ToString()); result.HasMetadata = true; diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 475e8fe9..caa0b437 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -47,7 +47,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell Info.EpisodeInfo episodeInfo = null; Info.SeriesInfo seriesInfo = null; Info.GroupInfo groupInfo = null; - if (info.IsMissingEpisode || info.Path == null) { + if (info.IsMissingEpisode || string.IsNullOrEmpty(info.Path)) { // We're unable to fetch the latest metadata for the virtual episode. if (!info.ProviderIds.TryGetValue("Shoko Episode", out var episodeId)) return result; @@ -63,7 +63,8 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell groupInfo = filterByType.HasValue ? (await ApiManager.GetGroupInfoForSeries(seriesInfo.Id, filterByType.Value)) : null; } else { - (fileInfo, episodeInfo, seriesInfo, groupInfo) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); + (fileInfo, seriesInfo, groupInfo) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); + episodeInfo = fileInfo?.EpisodeList.FirstOrDefault(); } // if the episode info is null then the series info and conditionally the group info is also null. @@ -72,18 +73,11 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell return result; } - var fileId = fileInfo?.Id ?? null; - result.Item = CreateMetadata(groupInfo, seriesInfo, episodeInfo, fileId, info.MetadataLanguage); - Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},Group={GroupId})", result.Item.Name, fileId, episodeInfo.Id, seriesInfo.Id, groupInfo?.Id ?? null); + result.Item = CreateMetadata(groupInfo, seriesInfo, episodeInfo, fileInfo, info.MetadataLanguage); + Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},Group={GroupId})", result.Item.Name, fileInfo?.Id, episodeInfo.Id, seriesInfo.Id, groupInfo?.Id); result.HasMetadata = true; - if (fileInfo != null) { - var episodeNumberEnd = episodeInfo.AniDB.EpisodeNumber + fileInfo.ExtraEpisodesCount; - if (episodeInfo.AniDB.EpisodeNumber != episodeNumberEnd) - result.Item.IndexNumberEnd = episodeNumberEnd; - } - return result; } catch (Exception e) { @@ -95,10 +89,10 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell public static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo series, Info.EpisodeInfo episode, Season season, System.Guid episodeId) => CreateMetadata(group, series, episode, null, season.GetPreferredMetadataLanguage(), season, episodeId); - public static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo series, Info.EpisodeInfo episode, string fileId, string metadataLanguage) - => CreateMetadata(group, series, episode, fileId, metadataLanguage, null, Guid.Empty); + public static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo series, Info.EpisodeInfo episode, Info.FileInfo file, string metadataLanguage) + => CreateMetadata(group, series, episode, file, metadataLanguage, null, Guid.Empty); - private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo series, Info.EpisodeInfo episode, string fileId, string metadataLanguage, Season season, System.Guid episodeId) + private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo series, Info.EpisodeInfo episode, Info.FileInfo file, string metadataLanguage, Season season, System.Guid episodeId) { var config = Plugin.Instance.Configuration; var mergeFriendly = config.SeriesGrouping == Ordering.GroupType.MergeFriendly && series.TvDB != null && episode.TvDB != null; @@ -172,6 +166,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, SeasonName = season.Name, DateLastSaved = DateTime.UtcNow, + RunTimeTicks = episode.AniDB.Duration.Ticks, }; result.PresentationUniqueKey = result.GetPresentationUniqueKey(); } @@ -189,8 +184,6 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri Overview = description, }; } - - result.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); } else { if (season != null) { @@ -212,7 +205,10 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri SeriesName = season.Series.Name, SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, SeasonName = season.Name, + OfficialRating = series.AniDB.Restricted ? "XXX" : null, + CustomRating = series.AniDB.Restricted ? "XXX" : null, DateLastSaved = DateTime.UtcNow, + RunTimeTicks = episode.AniDB.Duration.Ticks, }; result.PresentationUniqueKey = result.GetPresentationUniqueKey(); } @@ -227,23 +223,38 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri AirsBeforeSeasonNumber = airsBeforeSeasonNumber, PremiereDate = episode.AniDB.AirDate, Overview = description, + OfficialRating = series.AniDB.Restricted ? "XXX" : null, + CustomRating = series.AniDB.Restricted ? "XXX" : null, CommunityRating = episode.AniDB.Rating.ToFloat(10), }; } + } - if (config.SeriesGrouping == Ordering.GroupType.Default && config.AddOtherId && episode.TvDB != null) - result.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); + if (file != null) { + var episodeNumberEnd = episodeNumber + file.EpisodeList.Count - 1; + if (episode.AniDB.EpisodeNumber != episodeNumberEnd) + result.IndexNumberEnd = episodeNumberEnd; } - result.SetProviderId("Shoko Episode", episode.Id); - if (!string.IsNullOrEmpty(fileId)) - result.SetProviderId("Shoko File", fileId); - if (config.AddAniDBId) - result.SetProviderId("AniDB", episode.AniDB.ID.ToString()); + AddProviderIds(result, episodeId: episode.Id, fileId: file?.Id, anidbId: episode.AniDB.Id.ToString(), tvdbId: mergeFriendly || config.SeriesGrouping == Ordering.GroupType.Default ? episode.TvDB?.Id.ToString() : null); return result; } + private static void AddProviderIds(IHasProviderIds item, string episodeId, string fileId = null, string anidbId = null, string tvdbId = null, string tmdbId = null) + { + var config = Plugin.Instance.Configuration; + item.SetProviderId("Shoko Episode", episodeId); + if (!string.IsNullOrEmpty(fileId)) + item.SetProviderId("Shoko File", fileId); + if (config.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") + item.SetProviderId("AniDB", anidbId); + if (config.AddTvDBId && !string.IsNullOrEmpty(tvdbId) && tvdbId != "0") + item.SetProviderId(MetadataProvider.Tvdb, tvdbId); + if (Plugin.Instance.Configuration.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") + item.SetProviderId(MetadataProvider.Tvdb, tmdbId); + } + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) { // Isn't called from anywhere. If it is called, I don't know from where. diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index bbf84102..b5cb1c55 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -310,7 +310,9 @@ private void UpdateSeries(Series series, string seriesId) { // Provide metadata for a series using Shoko's Group feature if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { - var groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); + var groupInfo = ApiManager.GetGroupInfoForSeries(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) + .GetAwaiter() + .GetResult(); if (groupInfo == null) { Logger.LogWarning("Unable to find group info for series. (Series={SeriesID})", seriesId); return; @@ -326,6 +328,8 @@ private void UpdateSeries(Series series, string seriesId) // Handle specials when grouped. if (seasons.TryGetValue(0, out var zeroSeason)) { foreach (var seriesInfo in groupInfo.SeriesList) { + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesInfo.Id)) + episodeIds.Add(episodeId); foreach (var episodeInfo in seriesInfo.SpecialsList) { if (episodeIds.Contains(episodeInfo.Id)) continue; @@ -342,6 +346,8 @@ private void UpdateSeries(Series series, string seriesId) continue; var seriesInfo = pair.Value; + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesInfo.Id)) + episodeIds.Add(episodeId); foreach (var episodeInfo in seriesInfo.EpisodeList) { if (episodeIds.Contains(episodeInfo.Id)) continue; @@ -362,7 +368,9 @@ private void UpdateSeries(Series series, string seriesId) } // Provide metadata for other series else { - var seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); + var seriesInfo = ApiManager.GetSeriesInfo(seriesId) + .GetAwaiter() + .GetResult(); if (seriesInfo == null) { Logger.LogWarning("Unable to find series info. (Series={SeriesID})", seriesId); return; @@ -380,6 +388,8 @@ private void UpdateSeries(Series series, string seriesId) seasons.Add(seasonNumber, season); // Add missing episodes + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesInfo.Id)) + episodeIds.Add(episodeId); foreach (var episodeInfo in seriesInfo.RawEpisodeList) { if (episodeInfo.ExtraType != null) continue; @@ -407,7 +417,9 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de Info.SeriesInfo seriesInfo = null; // Provide metadata for a season using Shoko's Group feature if (seriesGrouping) { - groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); + groupInfo = ApiManager.GetGroupInfoForSeries(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) + .GetAwaiter() + .GetResult(); if (groupInfo == null) { Logger.LogWarning("Unable to find group info for series. (Series={SeriesId})", seriesId); return; @@ -433,7 +445,9 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de } // Provide metadata for other seasons else { - seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); + seriesInfo = ApiManager.GetSeriesInfo(seriesId) + .GetAwaiter() + .GetResult(); if (seriesInfo == null) { Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00}. (Series={SeriesId})", seasonNumber, seriesId); return; @@ -447,14 +461,20 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. var existingEpisodes = new HashSet<string>(); - foreach (var episode in season.Children.OfType<Episode>()) - if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) + foreach (var episode in season.Children.OfType<Episode>()) { + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + foreach (var episodeId in episodeIds) + existingEpisodes.Add(episodeId); + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) existingEpisodes.Add(episodeId); + } // Handle specials when grouped. if (seasonNumber == 0) { if (seriesGrouping) { foreach (var sI in groupInfo.SeriesList) { + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(sI.Id)) + existingEpisodes.Add(episodeId); foreach (var episodeInfo in sI.SpecialsList) { if (existingEpisodes.Contains(episodeInfo.Id)) continue; @@ -464,6 +484,8 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de } } else { + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesInfo.Id)) + existingEpisodes.Add(episodeId); foreach (var episodeInfo in seriesInfo.SpecialsList) { if (existingEpisodes.Contains(episodeInfo.Id)) continue; @@ -473,6 +495,8 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de } } else { + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesInfo.Id)) + existingEpisodes.Add(episodeId); foreach (var episodeInfo in seriesInfo.EpisodeList) { var episodeParentIndex = Ordering.GetSeasonNumber(groupInfo, seriesInfo, episodeInfo); if (episodeParentIndex != seasonNumber) @@ -494,12 +518,18 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de private void UpdateEpisode(Episode episode, string episodeId) { Info.GroupInfo groupInfo = null; - Info.SeriesInfo seriesInfo = ApiManager.GetSeriesInfoForEpisodeSync(episodeId); + Info.SeriesInfo seriesInfo = ApiManager.GetSeriesInfoForEpisode(episodeId) + .GetAwaiter() + .GetResult(); Info.EpisodeInfo episodeInfo = seriesInfo.EpisodeList.FirstOrDefault(e => e.Id == episodeId); if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) - groupInfo = ApiManager.GetGroupInfoForSeriesSync(seriesInfo.Id, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default); + groupInfo = ApiManager.GetGroupInfoForSeries(seriesInfo.Id, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) + .GetAwaiter() + .GetResult(); - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, episode.Season); + var episodeIds = ApiManager.GetLocalEpisodeIdsForSeries(seriesInfo.Id); + if (!episodeIds.Contains(episodeId)) + AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, episode.Season); } private (Dictionary<int, Season>, HashSet<string>) GetExistingSeasonsAndEpisodeIds(Series series) @@ -510,10 +540,17 @@ private void UpdateEpisode(Episode episode, string episodeId) case Season season: if (season.IndexNumber.HasValue) seasons.TryAdd(season.IndexNumber.Value, season); + // Add all known episode ids for the season. + if (Lookup.TryGetSeriesIdFor(season, out var seriesId)) + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesId)) + episodes.Add(episodeId); break; case Episode episode: // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + foreach (var episodeId in episodeIds) + episodes.Add(episodeId); + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) episodes.Add(episodeId); break; } diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 53401b62..e181f5d1 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -111,11 +111,11 @@ private void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Info.Episod private void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Images images) { - foreach (var image in images.Posters.OrderByDescending(image => image.Preferred)) + 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.Preferred)) + foreach (var image in images.Fanarts.OrderByDescending(image => image.IsDefault)) AddImage(ref list, ImageType.Backdrop, image); - foreach (var image in images.Banners.OrderByDescending(image => image.Preferred)) + foreach (var image in images.Banners.OrderByDescending(image => image.IsDefault)) AddImage(ref list, ImageType.Banner, image); } diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index d968f590..bb28690d 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -39,7 +39,8 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio var includeGroup = Plugin.Instance.Configuration.BoxSetGrouping == Ordering.GroupType.ShokoGroup; var config = Plugin.Instance.Configuration; Ordering.GroupFilterType? filterByType = config.BoxSetGrouping == Ordering.GroupType.ShokoGroup ? config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Movies : Ordering.GroupFilterType.Default : null; - var (file, episode, series, group) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); + var (file, series, group) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); + var episode = file?.EpisodeList.FirstOrDefault(); // if file is null then series and episode is also null. if (file == null) { @@ -71,9 +72,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio result.Item.SetProviderId("Shoko Episode", episode.Id); result.Item.SetProviderId("Shoko Series", series.Id); if (config.AddAniDBId) - result.Item.SetProviderId("AniDB", episode.AniDB.ID.ToString()); - if (config.BoxSetGrouping == Ordering.GroupType.MergeFriendly && episode.TvDB != null && config.BoxSetGrouping != Ordering.GroupType.ShokoGroup) - result.Item.SetProviderId(MetadataProvider.Tvdb, episode.TvDB.ID.ToString()); + result.Item.SetProviderId("AniDB", episode.AniDB.Id.ToString()); result.HasMetadata = true; diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index a6aea271..a128df65 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -269,7 +269,7 @@ public static Season CreateMetadata(Info.SeriesInfo seriesInfo, int seasonNumber season.ProviderIds.Add("Shoko Series", seriesInfo.Id); season.ProviderIds.Add("Shoko Season Offset", offset.ToString()); if (Plugin.Instance.Configuration.AddAniDBId) - season.ProviderIds.Add("AniDB", seriesInfo.AniDB.ID.ToString()); + season.ProviderIds.Add("AniDB", seriesInfo.AniDB.Id.ToString()); return season; } diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index acfd9ffe..f84503e1 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -110,7 +110,7 @@ private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, C CommunityRating = series.AniDB.Rating.ToFloat(10), }; } - AddProviderIds(result.Item, series.Id, null, series.AniDB.ID.ToString(), mergeFriendly ? series.TvDBId : null); + AddProviderIds(result.Item, seriesId: series.Id, anidbId: series.AniDB.Id.ToString(), tvdbId: mergeFriendly ? series.TvDB.Id.ToString() : null, tmdbId: series.Shoko.IDs.TMDB.FirstOrDefault().ToString()); result.HasMetadata = true; @@ -168,7 +168,7 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in CustomRating = series.AniDB.Restricted ? "XXX" : null, CommunityRating = series.AniDB.Rating.ToFloat(10), }; - AddProviderIds(result.Item, series.Id, group.Id, series.AniDB.ID.ToString()); + AddProviderIds(result.Item, seriesId: series.Id, groupId: group.Id, anidbId: series.AniDB.Id.ToString()); result.HasMetadata = true; @@ -179,42 +179,42 @@ private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo in return result; } - public static void AddProviderIds(Series series, string seriesId, string groupId = null, string aniDbId = null, string tvDbId = null) + public static void AddProviderIds(IHasProviderIds item, string seriesId, string groupId = null, string anidbId = null, string tvdbId = null, string tmdbId = null) { // NOTE: These next two lines will remain here till _someone_ fix the series merging for providers other then TvDB and ImDB in Jellyfin. - if (string.IsNullOrEmpty(tvDbId)) - series.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{seriesId}"); + // NOTE: #2 Will fix this once JF 10.9 is out, as it contains a change that will help in this situation. + if (string.IsNullOrEmpty(tvdbId)) + item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{seriesId}"); - series.SetProviderId("Shoko Series", seriesId); + item.SetProviderId("Shoko Series", seriesId); if (!string.IsNullOrEmpty(groupId)) - series.SetProviderId("Shoko Group", groupId); - if (Plugin.Instance.Configuration.AddAniDBId && !string.IsNullOrEmpty(aniDbId)) - series.SetProviderId("AniDB", aniDbId); - if (!string.IsNullOrEmpty(tvDbId)) - series.SetProviderId(MetadataProvider.Tvdb, tvDbId); + item.SetProviderId("Shoko Group", groupId); + if (Plugin.Instance.Configuration.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") + item.SetProviderId("AniDB", anidbId); + if (Plugin.Instance.Configuration.AddTvDBId &&!string.IsNullOrEmpty(tvdbId) && tvdbId != "0") + item.SetProviderId(MetadataProvider.Tvdb, tvdbId); + if (Plugin.Instance.Configuration.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") + item.SetProviderId(MetadataProvider.Tvdb, tmdbId); } + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo info, CancellationToken cancellationToken) { try { - var results = new List<RemoteSearchResult>(); - var searchResults = await ApiClient.SeriesSearch(info.Name).ContinueWith((e) => e.Result.ToList()); + var searchResults = await ApiClient.SeriesSearch(info.Name).ContinueWith((e) => e.Result.List); Logger.LogInformation($"Series search returned {searchResults.Count} results."); - - foreach (var series in searchResults) { - var seriesId = series.IDs.ID.ToString(); - var seriesInfo = ApiManager.GetSeriesInfoSync(seriesId); - var imageUrl = seriesInfo.AniDB.Poster != null && ApiClient.CheckImage(seriesInfo.AniDB.Poster.Path) ? seriesInfo.AniDB.Poster.ToURLString() : null; + return searchResults.Select(series => { + var seriesId = (series?.ShokoId ?? 0).ToString(); + var imageUrl = series?.Poster.IsAvailable ?? false ? series.Poster.ToPrettyURLString() : null; var parsedSeries = new RemoteSearchResult { - Name = Text.GetSeriesTitle(seriesInfo.AniDB.Titles, seriesInfo.Shoko.Name, info.MetadataLanguage), + Name = Text.GetSeriesTitle(series.Titles, series.Title, info.MetadataLanguage), SearchProviderName = Name, ImageUrl = imageUrl, + Overview = Text.SanitizeTextSummary(series.Description), }; - parsedSeries.SetProviderId("Shoko Series", seriesId); - results.Add(parsedSeries); - } - - return results; + AddProviderIds(parsedSeries, seriesId: seriesId, groupId: null, anidbId: series.Id.ToString(), tvdbId: null); + return parsedSeries; + }); } catch (Exception e) { Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); diff --git a/Shokofin/Sync/SyncExtensions.cs b/Shokofin/Sync/SyncExtensions.cs index f78a644e..24c958dc 100644 --- a/Shokofin/Sync/SyncExtensions.cs +++ b/Shokofin/Sync/SyncExtensions.cs @@ -7,13 +7,13 @@ namespace Shokofin.Sync; public static class SyncExtensions { - public static File.FileUserStats ToFileUserStats(this UserItemData userData) + public static File.UserStats ToFileUserStats(this UserItemData userData) { TimeSpan? resumePosition = new TimeSpan(userData.PlaybackPositionTicks); if (Math.Floor(resumePosition.Value.TotalMilliseconds) == 0d) resumePosition = null; var lastUpdated = userData.LastPlayedDate ?? DateTime.Now; - return new File.FileUserStats + return new File.UserStats { LastUpdatedAt = lastUpdated, LastWatchedAt = userData.Played ? lastUpdated : null, @@ -22,7 +22,7 @@ public static File.FileUserStats ToFileUserStats(this UserItemData userData) }; } - public static UserItemData MergeWithFileUserStats(this UserItemData userData, File.FileUserStats userStats) + public static UserItemData MergeWithFileUserStats(this UserItemData userData, File.UserStats userStats) { userData.Played = userStats.LastWatchedAt.HasValue; userData.PlayCount = userStats.WatchedCount; @@ -31,7 +31,7 @@ public static UserItemData MergeWithFileUserStats(this UserItemData userData, Fi return userData; } - public static UserItemData ToUserData(this File.FileUserStats userStats, Video video, Guid userId) + public static UserItemData ToUserData(this File.UserStats userStats, Video video, Guid userId) { return new UserItemData { diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index 4c56520f..8b8116a9 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -13,7 +13,7 @@ using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Configuration; -using FileUserStats = Shokofin.API.Models.File.FileUserStats; +using UserStats = Shokofin.API.Models.File.UserStats; namespace Shokofin.Sync { @@ -522,7 +522,7 @@ private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDire /// <param name="localUserData">The local user data</param> /// <param name="remoteUserStats">The remote user stats.</param> /// <returns>True if they are not in sync.</returns> - private static bool UserDataEqualsFileUserStats(UserItemData localUserData, FileUserStats remoteUserStats) + private static bool UserDataEqualsFileUserStats(UserItemData localUserData, UserStats remoteUserStats) { if (remoteUserStats == null && localUserData == null) return true; diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 0de5ab05..ebe303f0 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -173,7 +173,7 @@ public static int GetEpisodeNumber(GroupInfo group, SeriesInfo series, EpisodeIn } return episode.AniDB.EpisodeNumber; case GroupType.MergeFriendly: { - var episodeNumber = episode?.TvDB?.Number; + var episodeNumber = episode?.TvDB?.EpisodeNumber; if (episodeNumber.HasValue) return episodeNumber.Value; goto case GroupType.Default; @@ -224,7 +224,7 @@ public static (int?, int?, int?) GetSpecialPlacement(GroupInfo group, SeriesInfo // Abort if episode is not a TvDB special or AniDB special var allowOtherData = order == SpecialOrderType.InBetweenSeasonByOtherData || order == SpecialOrderType.InBetweenSeasonMixed; - if (allowOtherData ? !(episode?.TvDB?.Season == 0 || episode.AniDB.Type == EpisodeType.Special) : episode.AniDB.Type != EpisodeType.Special) + if (allowOtherData ? !(episode?.TvDB?.SeasonNumber == 0 || episode.AniDB.Type == EpisodeType.Special) : episode.AniDB.Type != EpisodeType.Special) return (null, null, null); int? episodeNumber = null; @@ -272,7 +272,7 @@ public static (int?, int?, int?) GetSpecialPlacement(GroupInfo group, SeriesInfo break; } - var nextEpisode = series.EpisodeList.FirstOrDefault(e => e.TvDB != null && e.TvDB.Season == seasonNumber && e.TvDB.Number == episodeNumber); + var nextEpisode = series.EpisodeList.FirstOrDefault(e => e.TvDB != null && e.TvDB.SeasonNumber == seasonNumber && e.TvDB.EpisodeNumber == episodeNumber); if (nextEpisode != null) { airsBeforeEpisodeNumber = GetEpisodeNumber(group, series, nextEpisode); airsBeforeSeasonNumber = seasonNumber; @@ -318,10 +318,10 @@ public static int GetSeasonNumber(GroupInfo group, SeriesInfo series, EpisodeInf case GroupType.MergeFriendly: { int? seasonNumber = null; if (episode.TvDB != null) { - if (episode.TvDB.Season == 0) + if (episode.TvDB.SeasonNumber == 0) seasonNumber = episode.TvDB.AirsAfterSeason ?? episode.TvDB.AirsBeforeSeason ?? 1; else - seasonNumber = episode.TvDB.Season; + seasonNumber = episode.TvDB.SeasonNumber; } if (!seasonNumber.HasValue) goto case GroupType.Default; diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index 6a0c8916..76b87fbd 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -129,8 +129,11 @@ private static string GetDescription(string aniDbDescription, string otherDescri /// </summary> /// <param name="summary">The raw AniDB summary</param> /// <returns>The sanitized AniDB summary</returns> - private static string SanitizeTextSummary(string summary) + public static string SanitizeTextSummary(string summary) { + if (string.IsNullOrWhiteSpace(summary)) + return ""; + var config = Plugin.Instance.Configuration; if (config.SynopsisCleanLinks) @@ -218,7 +221,7 @@ private static string __GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Ti return null; case DisplayTitleType.MainTitle: case DisplayTitleType.FullTitle: { - string title = (GetTitleByTypeAndLanguage(seriesTitles, "official", languageCandidates) ?? seriesTitle)?.Trim(); + string title = (GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, languageCandidates) ?? seriesTitle)?.Trim(); // Return series title. if (outputType == DisplayTitleType.MainTitle) return title; @@ -238,10 +241,10 @@ private static string __GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Ti } } - public static string GetTitleByTypeAndLanguage(IEnumerable<Title> titles, string type, params string[] langs) + public static string GetTitleByTypeAndLanguage(IEnumerable<Title> titles, TitleType type, params string[] langs) { if (titles != null) foreach (string lang in langs) { - string title = titles.FirstOrDefault(s => s.Language == lang && s.Type == type)?.Name; + string title = titles.FirstOrDefault(s => s.LanguageCode == lang && s.Type == type)?.Value; if (title != null) return title; } @@ -251,7 +254,7 @@ public static string GetTitleByTypeAndLanguage(IEnumerable<Title> titles, string public static string GetTitleByLanguages(IEnumerable<Title> titles, params string[] langs) { if (titles != null) foreach (string lang in langs) { - string title = titles.FirstOrDefault(s => lang.Equals(s.Language, System.StringComparison.OrdinalIgnoreCase))?.Name; + string title = titles.FirstOrDefault(s => lang.Equals(s.LanguageCode, System.StringComparison.OrdinalIgnoreCase))?.Value; if (title != null) return title; } @@ -264,7 +267,7 @@ public static string GetTitleByLanguages(IEnumerable<Title> titles, params strin /// <returns></returns> private static string[] GuessOriginLanguage(IEnumerable<Title> titles) { - string langCode = titles.FirstOrDefault(t => t?.Type == "main")?.Language.ToLower(); + string langCode = titles.FirstOrDefault(t => t?.Type == TitleType.Main)?.LanguageCode; // Guess the origin language based on the main title. switch (langCode) { case null: // fallback From 4e6a1b428baf3308c8c272a38d50ccbc8c21bad0 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 27 Nov 2022 23:34:02 +0000 Subject: [PATCH 0398/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 124f7d70..5d58292b 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.15", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.15/shoko_2.0.1.15.zip", + "checksum": "a24552a019c4d999edf94af113351ea7", + "timestamp": "2022-11-27T23:34:00Z" + }, { "version": "2.0.1.14", "changelog": "NA\n", From 4ba651efb9c1afc05808566202bee67c56cb9db0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 28 Nov 2022 01:22:29 +0100 Subject: [PATCH 0399/1103] misc: fix typos and misc. cleanup --- Shokofin/API/Info/GroupInfo.cs | 9 +++ Shokofin/API/ShokoAPIManager.cs | 129 +++++++++++++++++--------------- 2 files changed, 79 insertions(+), 59 deletions(-) diff --git a/Shokofin/API/Info/GroupInfo.cs b/Shokofin/API/Info/GroupInfo.cs index ff196b53..813de5d8 100644 --- a/Shokofin/API/Info/GroupInfo.cs +++ b/Shokofin/API/Info/GroupInfo.cs @@ -44,6 +44,15 @@ public GroupInfo(Group group, List<SeriesInfo> seriesList, Ordering.GroupFilterT { var groupId = group.IDs.Shoko.ToString(); + if (seriesList.Count > 0) switch (filterByType) { + case Ordering.GroupFilterType.Movies: + seriesList = seriesList.Where(s => s.AniDB.Type == SeriesType.Movie).ToList(); + break; + case Ordering.GroupFilterType.Others: + seriesList = seriesList.Where(s => s.AniDB.Type != SeriesType.Movie).ToList(); + break; + } + // Order series list var orderingType = filterByType == Ordering.GroupFilterType.Movies ? Plugin.Instance.Configuration.MovieOrdering : Plugin.Instance.Configuration.SeasonOrdering; switch (orderingType) { diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 210027ac..1049ea07 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -65,13 +65,15 @@ public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient public Folder FindMediaFolder(string path) { - Folder? mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); + var mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); if (mediaFolder == null) { var parent = (Folder?)LibraryManager.FindByPath(Path.GetDirectoryName(path), true); if (parent == null) - throw new Exception($"Unable to find parent of \"{path}\""); + throw new Exception($"Unable to find parent folder for \"{path}\""); + mediaFolder = FindMediaFolder(path, parent, LibraryManager.RootFolder); } + return mediaFolder; } @@ -82,6 +84,7 @@ public Folder FindMediaFolder(string path, Folder parent, Folder root) if (mediaFolder != null) { return mediaFolder; } + mediaFolder = parent; while (!mediaFolder.ParentId.Equals(root.Id)) { if (mediaFolder.GetParent() == null) { @@ -89,6 +92,7 @@ public Folder FindMediaFolder(string path, Folder parent, Folder root) } mediaFolder = (Folder)mediaFolder.GetParent(); } + MediaFolderList.Add(mediaFolder); return mediaFolder; } @@ -99,15 +103,18 @@ public string StripMediaFolder(string fullPath) if (mediaFolder != null) { return fullPath.Substring(mediaFolder.Path.Length); } + // Try to get the media folder by loading the parent and navigating upwards till we reach the root. var directoryPath = System.IO.Path.GetDirectoryName(fullPath); if (string.IsNullOrEmpty(directoryPath)) { return fullPath; } + mediaFolder = (LibraryManager.FindByPath(directoryPath, true) as Folder); if (mediaFolder == null || string.IsNullOrEmpty(mediaFolder?.Path)) { return fullPath; } + // Look for the root folder for the current item. var root = LibraryManager.RootFolder; while (!mediaFolder.ParentId.Equals(root.Id)) { @@ -116,6 +123,7 @@ public string StripMediaFolder(string fullPath) } mediaFolder = (Folder)mediaFolder.GetParent(); } + MediaFolderList.Add(mediaFolder); return fullPath.Substring(mediaFolder.Path.Length); } @@ -244,6 +252,7 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) var key =$"series-path-set-and-episode-ids:${seriesId}"; if (DataCache.TryGetValue<(HashSet<string>, HashSet<string>)>(key, out var cached)) return cached; + var pathSet = new HashSet<string>(); var episodeIds = new HashSet<string>(); foreach (var file in await APIClient.GetFilesForSeries(seriesId)) { @@ -254,6 +263,7 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) foreach (var episodeXRef in xref.Episodes) episodeIds.Add(episodeXRef.Shoko.ToString()); } + DataCache.Set(key, (pathSet, episodeIds), DefaultTimeSpan); return (pathSet, episodeIds); } @@ -290,7 +300,7 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) .ToList(); if (fileLocations.Count != 1) { if (fileLocations.Count == 0) - throw new Exception($"I have no idea how this happened, but the path gave a file that doesn't have a mataching file location. See you in #support. (File={fileId})"); + throw new Exception($"I have no idea how this happened, but the path gave a file that doesn't have a matching file location. See you in #support. (File={fileId})"); Logger.LogWarning("Multiple locations matched the path, picking the first location. (File={FileId})", fileId); } @@ -333,7 +343,7 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) return new(fileInfo, seriesInfo, groupInfo); } - throw new Exception("Unable to find the series to use for the file."); + throw new Exception($"Unable to find the series to use for the file. (File={fileId})"); } public async Task<FileInfo?> GetFileInfo(string fileId, string seriesId) @@ -342,9 +352,8 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) return null; var cacheKey = $"file:{fileId}:{seriesId}"; - FileInfo? info = null; - if (DataCache.TryGetValue<FileInfo>(cacheKey, out info)) - return info; + if (DataCache.TryGetValue<FileInfo>(cacheKey, out var fileInfo)) + return fileInfo; var file = await APIClient.GetFile(fileId); return await CreateFileInfo(file, fileId, seriesId); @@ -353,10 +362,12 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) private async Task<FileInfo> CreateFileInfo(File file, string fileId, string seriesId) { var cacheKey = $"file:{fileId}:{seriesId}"; - FileInfo? info = null; - if (DataCache.TryGetValue<FileInfo>(cacheKey, out info)) - return info; + if (DataCache.TryGetValue<FileInfo>(cacheKey, out var fileInfo)) + return fileInfo; + Logger.LogTrace("Creating info object for file. (File={FileId},Series={SeriesId})", fileId, seriesId); + + // Find the cross-references for the selected series. var seriesXRef = file.CrossReferences.FirstOrDefault(xref => xref.Series.Shoko.ToString() == seriesId); if (seriesXRef == null) throw new Exception($"Unable to find any cross-references for the spesified series for the file. (File={fileId},Series={seriesId})"); @@ -377,11 +388,11 @@ private async Task<FileInfo> CreateFileInfo(File file, string fileId, string ser .ThenBy(episode => episode.AniDB.EpisodeNumber) .ToList(); - Logger.LogTrace("Creating info object for file. (File={FileId},Series={SeriesId})", fileId, seriesId); - info = new FileInfo(file, episodeList, seriesId); - DataCache.Set<FileInfo>(cacheKey, info, DefaultTimeSpan); + fileInfo = new FileInfo(file, episodeList, seriesId); + + DataCache.Set<FileInfo>(cacheKey, fileInfo, DefaultTimeSpan); FileIdToEpisodeIdDictionary.TryAdd(fileId, episodeList.Select(episode => episode.Id).ToList()); - return info; + return fileInfo; } public bool TryGetFileIdForPath(string path, out string? fileId) @@ -402,24 +413,27 @@ public bool TryGetFileIdForPath(string path, out string? fileId) { if (string.IsNullOrEmpty(episodeId)) return null; - if (DataCache.TryGetValue<EpisodeInfo>($"episode:{episodeId}", out var info)) - return info; + + var key = $"episode:{episodeId}"; + if (DataCache.TryGetValue<EpisodeInfo>(key, out var episodeInfo)) + return episodeInfo; + var episode = await APIClient.GetEpisode(episodeId); return CreateEpisodeInfo(episode, episodeId); } private EpisodeInfo CreateEpisodeInfo(Episode episode, string episodeId) { - if (string.IsNullOrEmpty(episodeId)) - episodeId = episode.IDs.Shoko.ToString(); var cacheKey = $"episode:{episodeId}"; - EpisodeInfo? info = null; - if (DataCache.TryGetValue<EpisodeInfo>(cacheKey, out info)) - return info; + if (DataCache.TryGetValue<EpisodeInfo>(cacheKey, out var episodeInfo)) + return episodeInfo; + Logger.LogTrace("Creating info object for episode {EpisodeName}. (Episode={EpisodeId})", episode.Name, episodeId); - info = new EpisodeInfo(episode); - DataCache.Set<EpisodeInfo>(cacheKey, info, DefaultTimeSpan); - return info; + + episodeInfo = new EpisodeInfo(episode); + + DataCache.Set<EpisodeInfo>(cacheKey, episodeInfo, DefaultTimeSpan); + return episodeInfo; } public bool TryGetEpisodeIdForPath(string path, out string? episodeId) @@ -462,6 +476,10 @@ public bool TryGetEpisodePathForId(string episodeId, out string? path) public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) { + if (string.IsNullOrEmpty(episodeId)) { + seriesId = null; + return false; + } return EpisodeIdToSeriesIdDictionary.TryGetValue(episodeId, out seriesId); } @@ -472,9 +490,7 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) { var partialPath = StripMediaFolder(path); Logger.LogDebug("Looking for series matching {Path}", partialPath); - string? seriesId; - if (!PathToSeriesIdDictionary.TryGetValue(path, out seriesId)) - { + if (!PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) { var result = await APIClient.GetSeriesPathEndsWith(partialPath); Logger.LogTrace("Found result with {Count} matches for {Path}", result?.Count ?? 0, partialPath); seriesId = result?.FirstOrDefault()?.IDs?.Shoko.ToString(); @@ -486,8 +502,11 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) SeriesIdToPathDictionary.TryAdd(seriesId, path); } - if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) + var key = $"series:{seriesId}"; + if (DataCache.TryGetValue<SeriesInfo>(key, out var info)) { + Logger.LogTrace("Reusing info object for series {SeriesName}. (Series={SeriesId})", info.Shoko.Name, seriesId); return info; + } var series = await APIClient.GetSeries(seriesId); return await CreateSeriesInfo(series, seriesId); @@ -497,7 +516,9 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) { if (string.IsNullOrEmpty(seriesId)) return null; - if (DataCache.TryGetValue<SeriesInfo>( $"series:{seriesId}", out var info)) + + var key = $"series:{seriesId}"; + if (DataCache.TryGetValue<SeriesInfo>(key, out var info)) return info; var series = await APIClient.GetSeries(seriesId); return await CreateSeriesInfo(series, seriesId); @@ -505,11 +526,7 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) public async Task<SeriesInfo?> GetSeriesInfoForEpisode(string episodeId) { - string seriesId; - if (EpisodeIdToSeriesIdDictionary.ContainsKey(episodeId)) { - seriesId = EpisodeIdToSeriesIdDictionary[episodeId]; - } - else { + if (!EpisodeIdToSeriesIdDictionary.TryGetValue(episodeId, out var seriesId)) { var series = await APIClient.GetSeriesFromEpisode(episodeId); if (series == null) return null; @@ -521,10 +538,10 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId) { - SeriesInfo? info = null; var cacheKey = $"series:{seriesId}"; - if (DataCache.TryGetValue<SeriesInfo>(cacheKey, out info)) - return info; + if (DataCache.TryGetValue<SeriesInfo>(cacheKey, out var seriesInfo)) + return seriesInfo; + Logger.LogTrace("Creating info object for series {SeriesName}. (Series={SeriesId})", series.Name, seriesId); var episodes = (await APIClient.GetEpisodesFromSeries(seriesId) ?? new()) @@ -535,12 +552,12 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId) var genres = await GetGenresForSeries(seriesId); var tags = await GetTagsForSeries(seriesId); - info = new SeriesInfo(series, episodes, cast, genres, tags); + seriesInfo = new SeriesInfo(series, episodes, cast, genres, tags); foreach (var episode in episodes) EpisodeIdToSeriesIdDictionary[episode.Id] = seriesId; - DataCache.Set<SeriesInfo>(cacheKey, info, DefaultTimeSpan); - return info; + DataCache.Set<SeriesInfo>(cacheKey, seriesInfo, DefaultTimeSpan); + return seriesInfo; } public bool TryGetSeriesIdForPath(string path, out string? seriesId) @@ -563,6 +580,10 @@ public bool TryGetSeriesPathForId(string seriesId, out string? path) public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) { + if (string.IsNullOrEmpty(seriesId)) { + groupId = null; + return false; + } return SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out groupId); } @@ -574,12 +595,12 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) var partialPath = StripMediaFolder(path); Logger.LogDebug("Looking for group matching {Path}", partialPath); - string? seriesId; - if (PathToSeriesIdDictionary.TryGetValue(path, out seriesId)) - { + if (PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) { if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { - if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) + if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) { + Logger.LogTrace("Reusing info object for group {GroupName}. (Group={GroupId})", info.Shoko.Name, seriesId); return info; + } return await GetGroupInfo(groupId, filterByType); } @@ -634,13 +655,10 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Ordering.GroupFilterType filterByType) { - if (string.IsNullOrEmpty(groupId)) - groupId = group.IDs.Shoko.ToString(); - var cacheKey = $"group:{filterByType}:{groupId}"; - GroupInfo? groupInfo = null; - if (DataCache.TryGetValue<GroupInfo>(cacheKey, out groupInfo)) + if (DataCache.TryGetValue<GroupInfo>(cacheKey, out var groupInfo)) return groupInfo; + Logger.LogTrace("Creating info object for group {GroupName}. (Group={GroupId})", group.Name, groupId); var seriesList = (await APIClient.GetSeriesInGroup(groupId) @@ -648,26 +666,19 @@ private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Order .Unwrap()) .Where(s => s != null) .ToList(); - if (seriesList.Count > 0) switch (filterByType) { - default: - break; - case Ordering.GroupFilterType.Movies: - seriesList = seriesList.Where(s => s.AniDB.Type == SeriesType.Movie).ToList(); - break; - case Ordering.GroupFilterType.Others: - seriesList = seriesList.Where(s => s.AniDB.Type != SeriesType.Movie).ToList(); - break; - } // Return early if no series matched the filter or if the list was empty. if (seriesList.Count == 0) { Logger.LogWarning("Creating an empty group info for filter {Filter}! (Group={GroupId})", filterByType.ToString(), groupId); + groupInfo = new GroupInfo(group); + DataCache.Set<GroupInfo>(cacheKey, groupInfo, DefaultTimeSpan); return groupInfo; } groupInfo = new GroupInfo(group, seriesList, filterByType); + foreach (var series in seriesList) SeriesIdToGroupIdDictionary[series.Id] = groupId; DataCache.Set<GroupInfo>(cacheKey, groupInfo, DefaultTimeSpan); From eebbabeed875946f213370b7b643a505eae39061 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 28 Nov 2022 00:23:09 +0000 Subject: [PATCH 0400/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 5d58292b..54963a18 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.16", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.16/shoko_2.0.1.16.zip", + "checksum": "00bfca9599ce94812d9da884baf9922a", + "timestamp": "2022-11-28T00:23:07Z" + }, { "version": "2.0.1.15", "changelog": "NA\n", From c69af0e60ec2cf35429147db215b650d60108c60 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 28 Nov 2022 02:23:38 +0100 Subject: [PATCH 0401/1103] misc: more cleanup --- Shokofin/API/ShokoAPIManager.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 1049ea07..a00c2cc6 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -28,7 +28,7 @@ public class ShokoAPIManager private readonly ILibraryManager LibraryManager; - private readonly List<Folder> MediaFolderList = new List<Folder>(); + private readonly List<Folder> MediaFolderList = new(); private readonly ConcurrentDictionary<string, string> PathToSeriesIdDictionary = new(); @@ -142,15 +142,15 @@ public void Clear() { Logger.LogDebug("Clearing data."); DataCache.Dispose(); - MediaFolderList.Clear(); - FileIdToEpisodeIdDictionary.Clear(); - PathToFileIdAndSeriesIdDictionary.Clear(); + EpisodeIdToEpisodePathDictionary.Clear(); EpisodeIdToSeriesIdDictionary.Clear(); + FileIdToEpisodeIdDictionary.Clear(); + MediaFolderList.Clear(); PathToEpisodeIdsDictionary.Clear(); - EpisodeIdToEpisodePathDictionary.Clear(); + PathToFileIdAndSeriesIdDictionary.Clear(); PathToSeriesIdDictionary.Clear(); - SeriesIdToPathDictionary.Clear(); SeriesIdToGroupIdDictionary.Clear(); + SeriesIdToPathDictionary.Clear(); DataCache = (new MemoryCache((new MemoryCacheOptions() { ExpirationScanFrequency = ExpirationScanFrequency, }))); From b6fce5a089f84e1e48c6eeb2d412bc5a8df8e46e Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 28 Nov 2022 01:24:18 +0000 Subject: [PATCH 0402/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 54963a18..9361ab5b 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.17", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.17/shoko_2.0.1.17.zip", + "checksum": "b259a603bfccfe0e2c811e4607300c5d", + "timestamp": "2022-11-28T01:24:16Z" + }, { "version": "2.0.1.16", "changelog": "NA\n", From f7ac1259f18bf7e83acf8877e70ba171d55f0f32 Mon Sep 17 00:00:00 2001 From: Kerberos <57227244+krbrs@users.noreply.github.com> Date: Mon, 28 Nov 2022 14:37:01 +0100 Subject: [PATCH 0403/1103] Fixed some typos --- Shokofin/LibraryScanner.cs | 4 ++-- Shokofin/Providers/SeasonProvider.cs | 2 +- Shokofin/Sync/UserDataSyncManager.cs | 10 +++++----- build.yaml | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index ce33cb8a..4e12cfb2 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -84,7 +84,7 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy if (Plugin.Instance.Configuration.FilterOnLibraryTypes) switch (libraryType) { case "tvshows": if (series.AniDB.Type == SeriesType.Movie) { - Logger.LogInformation("Library seperatation is enabled, ignoring series. (Series={SeriesId})", series.Id); + Logger.LogInformation("Library separation is enabled, ignoring series. (Series={SeriesId})", series.Id); return true; } @@ -96,7 +96,7 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy break; case "movies": if (series.AniDB.Type != SeriesType.Movie) { - Logger.LogInformation("Library seperatation is enabled, ignoring series. (Series={SeriesId})", series.Id); + Logger.LogInformation("Library separation is enabled, ignoring series. (Series={SeriesId})", series.Id); return true; } diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index a128df65..34e04531 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -84,7 +84,7 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in int offset = 0; int seasonNumber = 1; API.Info.SeriesInfo series; - // All previsouly known seasons + // All previously known seasons if (info.ProviderIds.TryGetValue("Shoko Series", out var seriesId) && info.ProviderIds.TryGetValue("Shoko Season Offset", out var offsetText) && int.TryParse(offsetText, out offset)) { series = await ApiManager.GetSeriesInfo(seriesId); diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index 8b8116a9..fb4ef204 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -65,7 +65,7 @@ private bool TryGetUserConfiguration(Guid userId, out UserConfiguration config) #region Export/Scrobble - internal class SeesionMetadata { + internal class SessionMetadata { public Guid ItemId; public string FileId; public SessionInfo Session; @@ -73,12 +73,12 @@ internal class SeesionMetadata { public bool SentPaused; } - private readonly ConcurrentDictionary<Guid, SeesionMetadata> ActiveSessions = new ConcurrentDictionary<Guid, SeesionMetadata>(); + private readonly ConcurrentDictionary<Guid, SessionMetadata> ActiveSessions = new ConcurrentDictionary<Guid, SessionMetadata>(); public void OnSessionStarted(object sender, SessionEventArgs e) { if (TryGetUserConfiguration(e.SessionInfo.UserId, out var userConfig) && userConfig.SyncUserDataUnderPlayback) { - var sessionMetadata = new SeesionMetadata { + var sessionMetadata = new SessionMetadata { ItemId = Guid.Empty, Session = e.SessionInfo, FileId = null, @@ -89,7 +89,7 @@ public void OnSessionStarted(object sender, SessionEventArgs e) } foreach (var user in e.SessionInfo.AdditionalUsers) { if (TryGetUserConfiguration(e.SessionInfo.UserId, out userConfig) && userConfig.SyncUserDataUnderPlayback) { - var sessionMetadata = new SeesionMetadata { + var sessionMetadata = new SessionMetadata { ItemId = Guid.Empty, Session = e.SessionInfo, FileId = null, @@ -225,7 +225,7 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) } } - // Updates to favotite state and/or user data. + // Updates to favorite state and/or user data. private void OnUserRatingSaved(object sender, UserDataSaveEventArgs e) { if (!TryGetUserConfiguration(e.UserId, out var userConfig)) diff --git a/build.yaml b/build.yaml index 61fc336f..3093c286 100644 --- a/build.yaml +++ b/build.yaml @@ -5,7 +5,7 @@ targetAbi: "10.8.0.0" owner: "shokoanime" overview: "Manage your anime from Jellyfin using metadata from Shoko" description: > - A plugin to provide metadata from Shoko Server for your locally organised anime library in Jellyfin. + A plugin to provide metadata from Shoko Server for your locally organized anime library in Jellyfin. category: "Metadata" artifacts: - "Shokofin.dll" From a01247206b7ac55ee4173998e917b3d8d5a6701c Mon Sep 17 00:00:00 2001 From: Kerberos <57227244+krbrs@users.noreply.github.com> Date: Mon, 28 Nov 2022 14:37:57 +0100 Subject: [PATCH 0404/1103] More typos --- Shokofin/API/Info/GroupInfo.cs | 4 ++-- Shokofin/API/Info/SeriesInfo.cs | 4 ++-- Shokofin/API/ShokoAPIManager.cs | 4 ++-- Shokofin/IdLookup.cs | 8 ++++---- Shokofin/Providers/ExtraMetadataProvider.cs | 4 ++-- Shokofin/Utils/OrderingUtil.cs | 6 +++--- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Shokofin/API/Info/GroupInfo.cs b/Shokofin/API/Info/GroupInfo.cs index 813de5d8..d906bc2e 100644 --- a/Shokofin/API/Info/GroupInfo.cs +++ b/Shokofin/API/Info/GroupInfo.cs @@ -66,7 +66,7 @@ public GroupInfo(Group group, List<SeriesInfo> seriesList, Ordering.GroupFilterT throw new System.Exception("Not implemented yet"); } - // Select the targeted id if a group spesify a default series. + // Select the targeted id if a group specify a default series. int foundIndex = -1; int targetId = group.IDs.MainSeries; if (targetId != 0) @@ -89,7 +89,7 @@ public GroupInfo(Group group, List<SeriesInfo> seriesList, Ordering.GroupFilterT // Throw if we can't get a base point for seasons. if (foundIndex == -1) - throw new System.Exception("Unable to get a base-point for seasions withing the group"); + throw new System.Exception("Unable to get a base-point for seasons within the group"); var seasonOrderDictionary = new Dictionary<int, SeriesInfo>(); var seasonNumberBaseDictionary = new Dictionary<SeriesInfo, int>(); diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index d02dbdf0..3068aced 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -64,7 +64,7 @@ public class SeriesInfo /// <summary> /// A dictionary holding mappings for the previous normal episode for every special episode in a series. /// </summary> - public Dictionary<EpisodeInfo, EpisodeInfo> SpesialsAnchors; + public Dictionary<EpisodeInfo, EpisodeInfo> SpecialsAnchors; /// <summary> /// A pre-filtered list of special episodes without an ExtraType @@ -141,7 +141,7 @@ public SeriesInfo(Series series, List<EpisodeInfo> episodes, IEnumerable<Role> c AlternateEpisodesList = altEpisodesList; OthersList = othersList; ExtrasList = extrasList; - SpesialsAnchors = specialsAnchorDictionary; + SpecialsAnchors = specialsAnchorDictionary; SpecialsList = specialsList; } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index a00c2cc6..d73e6840 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -370,7 +370,7 @@ private async Task<FileInfo> CreateFileInfo(File file, string fileId, string ser // Find the cross-references for the selected series. var seriesXRef = file.CrossReferences.FirstOrDefault(xref => xref.Series.Shoko.ToString() == seriesId); if (seriesXRef == null) - throw new Exception($"Unable to find any cross-references for the spesified series for the file. (File={fileId},Series={seriesId})"); + throw new Exception($"Unable to find any cross-references for the specified series for the file. (File={fileId},Series={seriesId})"); // Find a list of the episode info for each episode linked to the file for the series. var episodeList = new List<EpisodeInfo>(); @@ -378,7 +378,7 @@ private async Task<FileInfo> CreateFileInfo(File file, string fileId, string ser var episodeId = episodeXRef.Shoko.ToString(); var episodeInfo = await GetEpisodeInfo(episodeId); if (episodeInfo == null) - throw new Exception($"Unable to find episode cross-reference for the spesified series and episode for the file. (File={fileId},Episode={episodeId},Series={seriesId})"); + throw new Exception($"Unable to find episode cross-reference for the specified series and episode for the file. (File={fileId},Episode={episodeId},Series={seriesId})"); episodeList.Add(episodeInfo); } diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index 1af55bfb..200a20d4 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -39,7 +39,7 @@ public interface IIdLookup /// </summary> /// <param name="series">The <see cref="MediaBrowser.Controller.Entities.TV.Series" /> to check for.</param> /// <param name="seriesId">The variable to put the id in.</param> - /// <returns>True if it successfully retrived the id for the <see cref="MediaBrowser.Controller.Entities.TV.Series" />.</returns> + /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Series" />.</returns> bool TryGetSeriesIdFor(Series series, out string seriesId); /// <summary> @@ -47,7 +47,7 @@ public interface IIdLookup /// </summary> /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> /// <param name="seriesId">The variable to put the id in.</param> - /// <returns>True if it successfully retrived the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> + /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> bool TryGetSeriesIdFor(Season season, out string seriesId); /// <summary> @@ -55,7 +55,7 @@ public interface IIdLookup /// </summary> /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> /// <param name="seriesId">The variable to put the id in.</param> - /// <returns>True if it successfully retrived the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> + /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> bool TryGetSeriesIdFor(BoxSet boxSet, out string seriesId); /// <summary> @@ -63,7 +63,7 @@ public interface IIdLookup /// </summary> /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> /// <param name="seriesId">The variable to put the id in.</param> - /// <returns>True if it successfully retrived the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> + /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> bool TryGetSeriesIdFor(Movie movie, out string seriesId); #endregion diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index b5cb1c55..b24e2dd8 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -725,7 +725,7 @@ private bool EpisodeExists(string episodeId, string seriesId, string groupId) }, true); if (searchList.Count > 0) { - Logger.LogDebug("A virtual or physical episode entry already exists for Episode {EpisodeName}. Ignoreing. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", searchList[0].Name, episodeId, seriesId, groupId); + Logger.LogDebug("A virtual or physical episode entry already exists for Episode {EpisodeName}. Ignoring. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", searchList[0].Name, episodeId, seriesId, groupId); return true; } return false; @@ -809,7 +809,7 @@ private void AddExtras(Folder parent, Info.SeriesInfo seriesInfo) } } else { - Logger.LogInformation("Addding {ExtraType} {VideoName} to parent {ParentName} (Series={SeriesId})", episodeInfo.ExtraType, episodeInfo.Shoko.Name, parent.Name, seriesInfo.Id); + Logger.LogInformation("Adding {ExtraType} {VideoName} to parent {ParentName} (Series={SeriesId})", episodeInfo.ExtraType, episodeInfo.Shoko.Name, parent.Name, seriesInfo.Id); video = new Video { Id = LibraryManager.GetNewItemId($"{parent.Id} {episodeInfo.ExtraType} {episodeInfo.Id}", typeof (Video)), Name = episodeInfo.Shoko.Name, diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index ebe303f0..4561ed41 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -30,7 +30,7 @@ public enum GroupType MergeFriendly = 1, /// <summary> - /// Group seris based on Shoko's default group filter. + /// Group series based on Shoko's default group filter. /// </summary> ShokoGroup = 2, @@ -96,7 +96,7 @@ public enum SpecialOrderType { /// <summary> /// Get index number for a movie in a box-set. /// </summary> - /// <returns>Absoute index.</returns> + /// <returns>Absolute index.</returns> public static int GetMovieIndexNumber(GroupInfo group, SeriesInfo series, EpisodeInfo episode) { switch (Plugin.Instance.Configuration.BoxSetGrouping) { @@ -240,7 +240,7 @@ public static (int?, int?, int?) GetSpecialPlacement(GroupInfo group, SeriesInfo byAirdate: // Reset the order if we come from `SpecialOrderType.InBetweenSeasonMixed`. episodeNumber = null; - if (series.SpesialsAnchors.TryGetValue(episode, out var previousEpisode)) + if (series.SpecialsAnchors.TryGetValue(episode, out var previousEpisode)) episodeNumber = GetEpisodeNumber(group, series, previousEpisode); if (episodeNumber.HasValue && episodeNumber.Value < series.EpisodeList.Count) { From 7977b9bce61ff11ee622bb74800abc66dcf8982f Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 28 Nov 2022 14:09:06 +0000 Subject: [PATCH 0405/1103] Update unstable repo manifest --- manifest-unstable.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 9361ab5b..eeaa6f94 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -2,12 +2,20 @@ { "guid": "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", "name": "Shoko", - "description": "A plugin to provide metadata from Shoko Server for your locally organised anime library in Jellyfin.\n", + "description": "A plugin to provide metadata from Shoko Server for your locally organized anime library in Jellyfin.\n", "overview": "Manage your anime from Jellyfin using metadata from Shoko", "owner": "shokoanime", "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.18", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.18/shoko_2.0.1.18.zip", + "checksum": "a0714d937ad7fcf24a20465663e8c219", + "timestamp": "2022-11-28T14:09:03Z" + }, { "version": "2.0.1.17", "changelog": "NA\n", From 3ce0ceb0d1fcf6af12243dbed5d5be3439ac42ac Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 28 Nov 2022 22:00:29 +0100 Subject: [PATCH 0406/1103] fix: fix file locations not matching --- Shokofin/API/Models/File.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index 021bd1da..db3ae0cb 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -74,7 +74,14 @@ public class Location /// where the <see cref="File"/> lies. /// </summary> [JsonPropertyName("RelativePath")] - public string Path { get; set; } = ""; + public string RelativePath { get; set; } = ""; + + /// <summary> + /// The relative path from the base of the <see cref="ImportFolder"/> to + /// where the <see cref="File"/> lies, with a leading slash applied at + /// the start. + /// </summary> + public string Path => "/" + RelativePath; /// <summary> /// True if the server can access the the <see cref="Location.Path"/> at From 31b0109c73cb98d2c87b8c1460909cae01f54306 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 28 Nov 2022 21:01:30 +0000 Subject: [PATCH 0407/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index eeaa6f94..7370441b 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.19", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.19/shoko_2.0.1.19.zip", + "checksum": "350b736361b2888dd667cf3c8301d44e", + "timestamp": "2022-11-28T21:01:27Z" + }, { "version": "2.0.1.18", "changelog": "NA\n", From 188fa39e74927521d6a2780263c30b8ded93e063 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 28 Nov 2022 22:02:39 +0100 Subject: [PATCH 0408/1103] misc: remove unneeded property name mapping --- Shokofin/API/Models/File.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index db3ae0cb..f090e3a9 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -73,7 +73,6 @@ public class Location /// The relative path from the base of the <see cref="ImportFolder"/> to /// where the <see cref="File"/> lies. /// </summary> - [JsonPropertyName("RelativePath")] public string RelativePath { get; set; } = ""; /// <summary> From 5e260094cca0f4da1e38c641fcec5e73d6cbc981 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 28 Nov 2022 21:03:17 +0000 Subject: [PATCH 0409/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 7370441b..2cb1bf44 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.20", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.20/shoko_2.0.1.20.zip", + "checksum": "db1ccd70db78eddb1ff16a2757f82f34", + "timestamp": "2022-11-28T21:03:16Z" + }, { "version": "2.0.1.19", "changelog": "NA\n", From 76cffd4dd362e06014153acf675b317249bc23b2 Mon Sep 17 00:00:00 2001 From: Kerberos <57227244+krbrs@users.noreply.github.com> Date: Tue, 29 Nov 2022 12:59:10 +0100 Subject: [PATCH 0410/1103] Tried implementing IgnoredFolders filter to ignore folders present on e.g. NAS where you can't put an ignore file in and which you can't delete like snapshot directories. Also fixed spelling where found --- README.md | 4 ++-- Shokofin/API/Models/File.cs | 2 +- Shokofin/API/Models/Image.cs | 2 +- Shokofin/API/Models/Role.cs | 2 +- Shokofin/API/Models/Series.cs | 2 +- Shokofin/API/Models/Tag.cs | 2 +- Shokofin/Configuration/PluginConfiguration.cs | 3 +++ Shokofin/Configuration/configController.js | 24 ++++++++++++------- Shokofin/Configuration/configPage.html | 10 +++++--- Shokofin/LibraryScanner.cs | 5 ++++ Shokofin/Plugin.cs | 4 ++++ 11 files changed, 42 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 41af71ea..4c2e49dd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/docs/) with [Shok ## Read this before installing -The plugin requires Jellyfin version 10.8.`x` and Shoko Server version **4.1.2** or greater to be installed. **It also requires that you have already set up and are using Shoko Server**, and that the directories/folders you intend to use in Jellyfin are **fully indexed** (and optionally managed) by Shoko Server, **otherwise the plugin won't be able to funciton properly** — meaning you won't be able to find metadata about any entries that are not indexed by Shoko Server with this plugin, since the metadata is not available. +The plugin requires Jellyfin version 10.8.`x` and Shoko Server version **4.1.2** or greater to be installed. **It also requires that you have already set up and are using Shoko Server**, and that the directories/folders you intend to use in Jellyfin are **fully indexed** (and optionally managed) by Shoko Server, **otherwise the plugin won't be able to function properly** — meaning you won't be able to find metadata about any entries that are not indexed by Shoko Server with this plugin, since the metadata is not available. ## Breaking Changes @@ -18,7 +18,7 @@ If you're upgrading from an older version to version 1.5.0, then be sure to upda ## Install -There are many ways to install the plugin, but the recomended way is to use the official Jellyfin repository. +There are many ways to install the plugin, but the recommended way is to use the official Jellyfin repository. ### Official Repository diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index f090e3a9..737459a6 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -56,7 +56,7 @@ public class File /// <summary> /// Metadata about the location where a file lies, including the import - /// folder it belogns to and the relative path from the base of the import + /// folder it belongs to and the relative path from the base of the import /// folder to where it lies. /// </summary> public class Location diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index 4aeb8f2c..bb6f9a9b 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -60,7 +60,7 @@ public virtual bool IsAvailable => !string.IsNullOrEmpty(LocalPath); /// <summary> - /// The remote path to retrive the image. + /// The remote path to retrieve the image. /// </summary> [JsonIgnore] public virtual string Path diff --git a/Shokofin/API/Models/Role.cs b/Shokofin/API/Models/Role.cs index eb094f06..191cc687 100644 --- a/Shokofin/API/Models/Role.cs +++ b/Shokofin/API/Models/Role.cs @@ -103,7 +103,7 @@ public enum CreatorRoleType Music, /// <summary> - /// Responsible for the creation of the source work this show is detrived from. + /// Responsible for the creation of the source work this show is derived from. /// </summary> SourceWork, } diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index 5af4c0f2..4b7950a4 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -200,7 +200,7 @@ public class SeriesIDs : IDs public class SeriesSizes { /// <summary> - /// Counts of each file source type available within the local colleciton + /// Counts of each file source type available within the local collection /// </summary> public FileSourceCounts FileSources { get; set; } = new(); diff --git a/Shokofin/API/Models/Tag.cs b/Shokofin/API/Models/Tag.cs index 2a56a87a..54442184 100644 --- a/Shokofin/API/Models/Tag.cs +++ b/Shokofin/API/Models/Tag.cs @@ -29,7 +29,7 @@ public class Tag public int? Weight { get; set; } /// <summary> - /// Source. Anidb, User, etc. + /// Source. AniDB, User, etc. /// </summary> /// <value></value> public string Source { get; set; } = ""; diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 07a3e417..2272d59d 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -76,6 +76,8 @@ public virtual string PrettyHost public string[] IgnoredFileExtensions { get; set; } + public string[] IgnoredFolders { get; set; } + public PluginConfiguration() { Host = "http://127.0.0.1:8111"; @@ -108,6 +110,7 @@ public PluginConfiguration() FilterOnLibraryTypes = false; UserList = Array.Empty<UserConfiguration>(); IgnoredFileExtensions = new [] { ".nfo", ".jpg", ".jpeg", ".png" }; + IgnoredFolders = new [] { ".streams", "@Recently-Snapshot" }; } } } diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 23741fb6..9d7752e6 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -4,7 +4,7 @@ const PluginConfig = { const Messages = { ConnectToShoko: "Please establish a connection to a running instance of Shoko Server before you continue.", - InvalidCredentials: "An error occured while trying to authenticating the user using the provided credentials.", + 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.", }; @@ -94,6 +94,7 @@ async function defaultSubmit(form) { form.querySelector("#PublicHost").value = publicHost; } const ignoredFileExtensions = filterIgnoreList(form.querySelector("#IgnoredFileExtensions").value); + const ignoredFolders = filterIgnoreList(from.querySelector("#IgnoredFolders").value); // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; @@ -128,6 +129,8 @@ async function defaultSubmit(form) { config.PublicHost = publicHost; config.IgnoredFileExtensions = ignoredFileExtensions; form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); + config.IgnoredFolders = ignoredFolders; + form.querySelector("#IgnoredFolders").value = ignoredFolders.join(" "); config.MergeQuartSeasons = form.querySelector("#MergeQuartSeasons").checked; // User settings @@ -240,6 +243,8 @@ async function syncSettings(form) { } const ignoredFileExtensions = filterIgnoreList(form.querySelector("#IgnoredFileExtensions").value); + const ignoredFolders = filterIgnoreList(form.querySelector("#IgnoredFolders").value); + // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; config.TitleAlternateType = form.querySelector("#TitleAlternateType").value; @@ -273,6 +278,8 @@ async function syncSettings(form) { config.PublicHost = publicHost; config.IgnoredFileExtensions = ignoredFileExtensions; form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); + config.IgnoredFolders = ignoredFolders; + form.querySelector("#IgnoredFolders").value = ignoredFolders.join(" "); config.MergeQuartSeasons = form.querySelector("#MergeQuartSeasons").checked; const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); @@ -341,7 +348,7 @@ export default function (page) { const form = page.querySelector("#ShokoConfigForm"); const userSelector = form.querySelector("#UserSelector"); // Refresh the view after we changed the settings, so the view reflect the new settings. - const refershSettings = (config) => { + const refreshSettings = (config) => { if (config.ApiKey) { form.querySelector("#Host").setAttribute("disabled", ""); form.querySelector("#Username").setAttribute("disabled", ""); @@ -441,13 +448,14 @@ export default function (page) { // Advanced settings form.querySelector("#PublicHost").value = config.PublicHost; form.querySelector("#IgnoredFileExtensions").value = config.IgnoredFileExtensions.join(" "); + form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); form.querySelector("#MergeQuartSeasons").checked = config.MergeQuartSeasons; if (!config.ApiKey) { Dashboard.alert(Messages.ConnectToShoko); } - refershSettings(config); + refreshSettings(config); } catch (err) { Dashboard.alert(Messages.UnableToRender); @@ -463,22 +471,22 @@ export default function (page) { default: case "all-settings": Dashboard.showLoadingMsg(); - defaultSubmit(form).then(refershSettings).catch(onError); + defaultSubmit(form).then(refreshSettings).catch(onError); break; case "settings": Dashboard.showLoadingMsg(); - syncSettings(form).then(refershSettings).catch(onError); + syncSettings(form).then(refreshSettings).catch(onError); break; case "reset-connection": Dashboard.showLoadingMsg(); - resetConnectionSettings(form).then(refershSettings).catch(onError); + resetConnectionSettings(form).then(refreshSettings).catch(onError); break; case "unlink-user": - unlinkUser(form).then(refershSettings).catch(onError); + unlinkUser(form).then(refreshSettings).catch(onError); break; case "user-settings": Dashboard.showLoadingMsg(); - syncUserSettings(form).then(refershSettings).catch(onError); + syncUserSettings(form).then(refreshSettings).catch(onError); break; } return false; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 0d9b269b..9d9b0282 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -92,7 +92,7 @@ <h3>Metadata Settings</h3> <legend> <h3>Plugin Compatibility Settings</h3> </legend> - <div class="fieldDescription verticalSection-extrabottompadding">We can optionally set some ids for interopobility with other plugins.</div> + <div class="fieldDescription verticalSection-extrabottompadding">We can optionally set some ids for interoperability with other plugins.</div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="AddAniDBId" /> @@ -144,7 +144,7 @@ <h3>Library Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="FilterOnLibraryTypes" /> - <span>Enable library seperation</span> + <span>Enable library separation</span> </label> <div class="fieldDescription checkboxFieldDescription">This setting can be used to have one shared root folder on your disk for two libraries in Shoko — one library for movies and one for shows. Enabling this will cause the plugin to actively filter out movies from the show library and everything but movies from the movies library. Also, if you've selected to use Shoko's Group feature to create Series/Seasons then it will also exclude the Movies from within the series — i.e. the "season" for the movie won't appear  — even if they share a group in Shoko.</div> </div> @@ -290,7 +290,11 @@ <h3>Advanced Settings</h3> </div> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="text" id="IgnoredFileExtensions" label="Ignored file extensions:" /> - <div class="fieldDescription">A space seperated list of file extensions which will be ignored during the library scan.</div> + <div class="fieldDescription">A space separated list of file extensions which will be ignored during the library scan.</div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <input is="emby-input" type="text" id="IgnoredFolders" label="Ignored folder names:" /> + <div class="fieldDescription">A coma separated list of folder names which will be ignored during the library scan.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index 4e12cfb2..9d7cd3f5 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -51,6 +51,11 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base return false; } + if (!fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(fileInfo.FullName.ToLowerInvariant())) { + Logger.LogDebug("Skipped excluded folder at path {Path}", fileInfo.FullName); + return false; + } + var fullPath = fileInfo.FullName; var mediaFolder = ApiManager.FindMediaFolder(fullPath, parent as Folder, root); var partialPath = fullPath.Substring(mediaFolder.Path.Length); diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 01dc3f1a..ecbce475 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -22,6 +22,7 @@ public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) Instance = this; ConfigurationChanged += OnConfigChanged; IgnoredFileExtensions = this.Configuration.IgnoredFileExtensions.ToHashSet(); + IgnoredFolders = this.Configuration.IgnoredFolders.ToHashSet(); } public void OnConfigChanged(object sender, BasePluginConfiguration e) @@ -29,10 +30,13 @@ public void OnConfigChanged(object sender, BasePluginConfiguration e) if (!(e is PluginConfiguration config)) return; IgnoredFileExtensions = config.IgnoredFileExtensions.ToHashSet(); + IgnoredFolders = config.IgnoredFolders.ToHashSet(); } public HashSet<string> IgnoredFileExtensions; + public HashSet<string> IgnoredFolders; + public static Plugin Instance { get; private set; } public IEnumerable<PluginPageInfo> GetPages() From cae76840a31f162ad5078111e11508e3852fb6e8 Mon Sep 17 00:00:00 2001 From: Kerberos <57227244+krbrs@users.noreply.github.com> Date: Tue, 29 Nov 2022 13:31:38 +0100 Subject: [PATCH 0411/1103] fix typo in join() as coma separated --- Shokofin/Configuration/configController.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 9d7752e6..28aadbac 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -130,7 +130,7 @@ async function defaultSubmit(form) { config.IgnoredFileExtensions = ignoredFileExtensions; form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); config.IgnoredFolders = ignoredFolders; - form.querySelector("#IgnoredFolders").value = ignoredFolders.join(" "); + form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); config.MergeQuartSeasons = form.querySelector("#MergeQuartSeasons").checked; // User settings @@ -279,7 +279,7 @@ async function syncSettings(form) { config.IgnoredFileExtensions = ignoredFileExtensions; form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); config.IgnoredFolders = ignoredFolders; - form.querySelector("#IgnoredFolders").value = ignoredFolders.join(" "); + form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); config.MergeQuartSeasons = form.querySelector("#MergeQuartSeasons").checked; const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); From 7f5e1b954dc43beca9964dd457ed54a1b652c451 Mon Sep 17 00:00:00 2001 From: Kerberos <57227244+krbrs@users.noreply.github.com> Date: Tue, 29 Nov 2022 17:30:57 +0100 Subject: [PATCH 0412/1103] requested adjustments --- Shokofin/Configuration/configController.js | 67 +++++++++++++++++++--- Shokofin/LibraryScanner.cs | 10 ++-- 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 28aadbac..b8b2784c 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -8,8 +8,62 @@ const Messages = { UnableToRender: "There was an error loading the page, please refresh once to see if that will fix it.", }; -function filterIgnoreList(value) { - return Array.from(new Set(value.split(/[\s,]+/g).map(str => { str = str.trim().toLowerCase(); if (str[0] !== ".") str = "." + str; return str; }))); +/** + * 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 filterIgnoredExtensions(value) { + // We convert to a set to filter out duplicate values. + const filteredSet = new Set( + value + // Split the values at every space, tab, comma. + .split(/[\s,]+/g) + // Sanitize inputs. + .map(str => { + // Trim the start and end and convert to lower-case. + str = str.trim().toLowerCase(); + + // Add a dot if it's missing. + if (str[0] !== ".") + str = "." + str; + + return str; + }), + ); + + // Filter out empty values. + if (filteredSet.has("")) + filteredSet.delete(""); + + // 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 {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 => { + // Trim the start and end and convert to lower-case. + str = str.trim().toLowerCase(); + return str; + }); + + // Filter out empty values. + if (filteredSet.has("")) + filteredSet.delete(""); + + // Convert it back into an array. + return Array.from(filteredSet); } async function loadUserConfig(form, userId, config) { @@ -93,8 +147,8 @@ async function defaultSubmit(form) { publicHost = publicHost.slice(0, -1); form.querySelector("#PublicHost").value = publicHost; } - const ignoredFileExtensions = filterIgnoreList(form.querySelector("#IgnoredFileExtensions").value); - const ignoredFolders = filterIgnoreList(from.querySelector("#IgnoredFolders").value); + const ignoredFileExtensions = filterIgnoredExtensions(form.querySelector("#IgnoredFileExtensions").value); + const ignoredFolders = filterIgnoredFolders(from.querySelector("#IgnoredFolders").value); // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; @@ -241,9 +295,8 @@ async function syncSettings(form) { publicHost = publicHost.slice(0, -1); form.querySelector("#PublicHost").value = publicHost; } - const ignoredFileExtensions = filterIgnoreList(form.querySelector("#IgnoredFileExtensions").value); - - const ignoredFolders = filterIgnoreList(form.querySelector("#IgnoredFolders").value); + const ignoredFileExtensions = filterIgnoredExtensions(form.querySelector("#IgnoredFileExtensions").value); + const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index 9d7cd3f5..3413c7bf 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -6,6 +6,8 @@ using Shokofin.API.Models; using Shokofin.Utils; +using Path = System.IO.Path; + namespace Shokofin { public class LibraryScanner : IResolverIgnoreRule @@ -46,13 +48,13 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base if (!Lookup.IsEnabledForItem(parent)) return false; - if (!fileInfo.IsDirectory && Plugin.Instance.IgnoredFileExtensions.Contains(fileInfo.Extension.ToLowerInvariant())) { - Logger.LogDebug("Skipped excluded file at path {Path}", fileInfo.FullName); + if (fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(Path.GetFileName(fileInfo.FullName).ToLowerInvariant())) { + Logger.LogDebug("Skipped excluded folder at path {Path}", fileInfo.FullName); return false; } - if (!fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(fileInfo.FullName.ToLowerInvariant())) { - Logger.LogDebug("Skipped excluded folder at path {Path}", fileInfo.FullName); + if (!fileInfo.IsDirectory && Plugin.Instance.IgnoredFileExtensions.Contains(fileInfo.Extension.ToLowerInvariant())) { + Logger.LogDebug("Skipped excluded file at path {Path}", fileInfo.FullName); return false; } From b5279278cc5c4179425d5c321949c917eb37bbdd Mon Sep 17 00:00:00 2001 From: Mikal S <revam@users.noreply.github.com> Date: Tue, 29 Nov 2022 17:48:19 +0100 Subject: [PATCH 0413/1103] ignore directories and all sub-entries --- Shokofin/LibraryScanner.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index 3413c7bf..782a0e06 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -50,7 +50,7 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base if (fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(Path.GetFileName(fileInfo.FullName).ToLowerInvariant())) { Logger.LogDebug("Skipped excluded folder at path {Path}", fileInfo.FullName); - return false; + return true; } if (!fileInfo.IsDirectory && Plugin.Instance.IgnoredFileExtensions.Contains(fileInfo.Extension.ToLowerInvariant())) { From 3498efbfc61301394b1fa1501ec93e56af3bf7e8 Mon Sep 17 00:00:00 2001 From: Mikal S <revam@users.noreply.github.com> Date: Tue, 29 Nov 2022 17:49:46 +0100 Subject: [PATCH 0414/1103] update debug log statement --- Shokofin/LibraryScanner.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index 782a0e06..a09f428c 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -49,7 +49,7 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base return false; if (fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(Path.GetFileName(fileInfo.FullName).ToLowerInvariant())) { - Logger.LogDebug("Skipped excluded folder at path {Path}", fileInfo.FullName); + Logger.LogDebug("Excluded folder at path {Path}", fileInfo.FullName); return true; } From 85ea20f70076f3c040d6aab839866ea165538a4b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 29 Nov 2022 16:51:23 +0000 Subject: [PATCH 0415/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 2cb1bf44..2ace689e 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.21", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.21/shoko_2.0.1.21.zip", + "checksum": "83f9bf24a84e9fba758f8635115f7520", + "timestamp": "2022-11-29T16:51:20Z" + }, { "version": "2.0.1.20", "changelog": "NA\n", From cf397a205ba8cea7700434e6803ed1113d3e4bb4 Mon Sep 17 00:00:00 2001 From: Kerberos <57227244+krbrs@users.noreply.github.com> Date: Wed, 30 Nov 2022 10:36:24 +0100 Subject: [PATCH 0416/1103] fix split char for foldernames --- Shokofin/Configuration/configController.js | 2 +- Shokofin/Configuration/configPage.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index b8b2784c..0143ffa1 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -50,7 +50,7 @@ const Messages = { const filteredSet = new Set( value // Split the values at every comma. - .split(".")) + .split(",")) // Sanitize inputs. .map(str => { // Trim the start and end and convert to lower-case. diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 9d9b0282..0d3a7703 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -294,7 +294,7 @@ <h3>Advanced Settings</h3> </div> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="text" id="IgnoredFolders" label="Ignored folder names:" /> - <div class="fieldDescription">A coma separated list of folder names which will be ignored during the library scan.</div> + <div class="fieldDescription">A comma separated list of folder names which will be ignored during the library scan.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> From 89d4774af7c0da13623462152708e4d13c82ab09 Mon Sep 17 00:00:00 2001 From: Kerberos <57227244+krbrs@users.noreply.github.com> Date: Wed, 30 Nov 2022 10:42:45 +0100 Subject: [PATCH 0417/1103] Update configController.js --- Shokofin/Configuration/configController.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 0143ffa1..41e0dfff 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -50,13 +50,14 @@ const Messages = { const filteredSet = new Set( value // Split the values at every comma. - .split(",")) + .split(",") // Sanitize inputs. .map(str => { // Trim the start and end and convert to lower-case. str = str.trim().toLowerCase(); return str; - }); + }), + ); // Filter out empty values. if (filteredSet.has("")) From b5e030fce219b6dc8403d3b76c1a6654d01d38d1 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 30 Nov 2022 11:54:38 +0000 Subject: [PATCH 0418/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 2ace689e..caf7d25e 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.22", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.22/shoko_2.0.1.22.zip", + "checksum": "02e3bf819a4962128a18264a032975f6", + "timestamp": "2022-11-30T11:54:35Z" + }, { "version": "2.0.1.21", "changelog": "NA\n", From b548e6932d1c7400bd09a456d3095c620cf13163 Mon Sep 17 00:00:00 2001 From: Kerberos <57227244+krbrs@users.noreply.github.com> Date: Wed, 30 Nov 2022 15:14:24 +0100 Subject: [PATCH 0419/1103] lowercase ignoredFolders + syno and win exclusions --- 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 2272d59d..a7da5f79 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -110,7 +110,7 @@ public PluginConfiguration() FilterOnLibraryTypes = false; UserList = Array.Empty<UserConfiguration>(); IgnoredFileExtensions = new [] { ".nfo", ".jpg", ".jpeg", ".png" }; - IgnoredFolders = new [] { ".streams", "@Recently-Snapshot" }; + IgnoredFolders = new [] { ".streams", "@recently-snapshot", "$recycle.bin", ".recycle.bin", "#recycle" }; } } } From bee7751eabffe03ca9c1e73a1be208c68db58b5e Mon Sep 17 00:00:00 2001 From: krbrs <57227244+krbrs@users.noreply.github.com> Date: Thu, 1 Dec 2022 22:25:07 +0100 Subject: [PATCH 0420/1103] Update PluginConfiguration.cs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed recycle ♻️ folders present in the core of jellyfin from shokofin ignore folders list --- 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 a7da5f79..6c44f7c3 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -110,7 +110,7 @@ public PluginConfiguration() FilterOnLibraryTypes = false; UserList = Array.Empty<UserConfiguration>(); IgnoredFileExtensions = new [] { ".nfo", ".jpg", ".jpeg", ".png" }; - IgnoredFolders = new [] { ".streams", "@recently-snapshot", "$recycle.bin", ".recycle.bin", "#recycle" }; + IgnoredFolders = new [] { ".streams", "@recently-snapshot" }; } } } From 05bcabafc67c7a79ba286face8f7ed499bcc7f5b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 2 Dec 2022 02:34:01 +0000 Subject: [PATCH 0421/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index caf7d25e..12ef079b 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.23", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.23/shoko_2.0.1.23.zip", + "checksum": "8b9eb1496bd4963e48f5bd721e031210", + "timestamp": "2022-12-02T02:33:59Z" + }, { "version": "2.0.1.22", "changelog": "NA\n", From 79791148c0a35dc0f851648bdd20125e6b337b4d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 20 Dec 2022 18:09:34 +0100 Subject: [PATCH 0422/1103] fix: fix get user file stats since it can return 404 if there is no user file stats for the user. --- Shokofin/API/ShokoAPIClient.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index f8c6f2bf..e589eb8c 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -192,9 +192,19 @@ public Task<List<File>> GetFilesForSeries(string seriesId) return Get<List<File>>($"/api/v3/Series/{seriesId}/File?includeXRefs=true"); } - public Task<File.UserStats> GetFileUserStats(string fileId, string? apiKey = null) + public async Task<File.UserStats?> GetFileUserStats(string fileId, string? apiKey = null) { - return Get<File.UserStats>($"/api/v3/File/{fileId}/UserStats", apiKey); + try + { + return await Get<File.UserStats>($"/api/v3/File/{fileId}/UserStats", apiKey); + } + catch (ApiException e) + { + // File user stats were not found. + if (e.StatusCode == HttpStatusCode.NotFound && e.Message.Contains("FileUserStats")) + return null; + throw; + } } public Task<File.UserStats> PutFileUserStats(string fileId, File.UserStats userStats, string? apiKey = null) From ee8d5ff9225c7b901710ab4697d153c8e8092775 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 20 Dec 2022 17:10:17 +0000 Subject: [PATCH 0423/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 12ef079b..b48b13fe 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.24", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.24/shoko_2.0.1.24.zip", + "checksum": "8c31376a8aaa09fdb6169d15ea93affa", + "timestamp": "2022-12-20T17:10:15Z" + }, { "version": "2.0.1.23", "changelog": "NA\n", From 55348a78df4df74a636892293f82aabca9af2049 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 26 Dec 2022 18:50:44 +0100 Subject: [PATCH 0424/1103] fix: fix getting series/group info for paths also be more verbose when reusing series and groups from the cache. --- Shokofin/API/ShokoAPIManager.cs | 112 +++++++++++++++++++++----------- 1 file changed, 75 insertions(+), 37 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index d73e6840..8db453ee 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -232,12 +232,23 @@ private string SelectTagName(Tag tag) #endregion #region Path Set And Local Episode IDs + /// <summary> + /// Get a set of paths that are unique to the series and don't belong to + /// any other series. + /// </summary> + /// <param name="seriesId">Shoko series id.</param> + /// <returns>Unique path set for the series</returns> public async Task<HashSet<string>> GetPathSetForSeries(string seriesId) { var (pathSet, _episodeIds) = await GetPathSetAndLocalEpisodeIdsForSeries(seriesId); return pathSet; } + /// <summary> + /// Get a set of local episode ids for the series. + /// </summary> + /// <param name="seriesId">Shoko series id.</param> + /// <returns>Local episode ids for the series</returns> public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) { var (_pathSet, episodeIds) = GetPathSetAndLocalEpisodeIdsForSeries(seriesId) @@ -488,24 +499,14 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) public async Task<SeriesInfo?> GetSeriesInfoByPath(string path) { - var partialPath = StripMediaFolder(path); - Logger.LogDebug("Looking for series matching {Path}", partialPath); - if (!PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) { - var result = await APIClient.GetSeriesPathEndsWith(partialPath); - Logger.LogTrace("Found result with {Count} matches for {Path}", result?.Count ?? 0, partialPath); - seriesId = result?.FirstOrDefault()?.IDs?.Shoko.ToString(); - - if (string.IsNullOrEmpty(seriesId)) - return null; - - PathToSeriesIdDictionary[path] = seriesId; - SeriesIdToPathDictionary.TryAdd(seriesId, path); - } + var seriesId = await GetSeriesIdForPath(path); + if (string.IsNullOrEmpty(seriesId)) + return null; var key = $"series:{seriesId}"; - if (DataCache.TryGetValue<SeriesInfo>(key, out var info)) { - Logger.LogTrace("Reusing info object for series {SeriesName}. (Series={SeriesId})", info.Shoko.Name, seriesId); - return info; + if (DataCache.TryGetValue<SeriesInfo>(key, out var seriesInfo)) { + Logger.LogTrace("Reusing info object for series {SeriesName}. (Series={SeriesId})", seriesInfo.Shoko.Name, seriesId); + return seriesInfo; } var series = await APIClient.GetSeries(seriesId); @@ -517,9 +518,12 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) if (string.IsNullOrEmpty(seriesId)) return null; - var key = $"series:{seriesId}"; - if (DataCache.TryGetValue<SeriesInfo>(key, out var info)) - return info; + var cachedKey = $"series:{seriesId}"; + if (DataCache.TryGetValue<SeriesInfo>(cachedKey, out var seriesInfo)) { + Logger.LogTrace("Reusing info object for series {SeriesName}. (Series={SeriesId})", seriesInfo.Shoko.Name, seriesId); + return seriesInfo; + } + var series = await APIClient.GetSeries(seriesId); return await CreateSeriesInfo(series, seriesId); } @@ -539,8 +543,10 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId) { var cacheKey = $"series:{seriesId}"; - if (DataCache.TryGetValue<SeriesInfo>(cacheKey, out var seriesInfo)) + if (DataCache.TryGetValue<SeriesInfo>(cacheKey, out var seriesInfo)) { + Logger.LogTrace("Reusing info object for series {SeriesName}. (Series={SeriesId})", seriesInfo.Shoko.Name, seriesId); return seriesInfo; + } Logger.LogTrace("Creating info object for series {SeriesName}. (Series={SeriesId})", series.Name, seriesId); @@ -587,19 +593,51 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) return SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out groupId); } + private async Task<string?> GetSeriesIdForPath(string path) + { + // Reuse cached value. + if (PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) + return seriesId; + + var partialPath = StripMediaFolder(path); + Logger.LogDebug("Looking for series matching {Path}", partialPath); + var result = await APIClient.GetSeriesPathEndsWith(partialPath); + Logger.LogTrace("Found result with {Count} matches for {Path}", result.Count, partialPath); + + // Retrun the first match where the series unique paths partially match + // the input path. + foreach (var series in result) + { + seriesId = series.IDs.Shoko.ToString(); + var pathSet = await GetPathSetForSeries(seriesId); + foreach (var uniquePath in pathSet) + { + // Remove the trailing slash before matching. + if (!uniquePath[..^1].EndsWith(partialPath)) + continue; + + PathToSeriesIdDictionary[path] = seriesId; + SeriesIdToPathDictionary.TryAdd(seriesId, path); + + return seriesId; + } + } + + // In the edge case for series with only files with multiple + // cross-refereces we just return the first match. + return result.FirstOrDefault()?.IDs.Shoko.ToString(); + } + #endregion #region Group Info public async Task<GroupInfo?> GetGroupInfoByPath(string path, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) { - var partialPath = StripMediaFolder(path); - Logger.LogDebug("Looking for group matching {Path}", partialPath); - if (PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) { if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { - if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) { - Logger.LogTrace("Reusing info object for group {GroupName}. (Group={GroupId})", info.Shoko.Name, seriesId); - return info; + if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var groupInfo)) { + Logger.LogTrace("Reusing info object for group {GroupName}. (Series={seriesId},Group={GroupId})", groupInfo.Shoko.Name, seriesId, groupId); + return groupInfo; } return await GetGroupInfo(groupId, filterByType); @@ -607,15 +645,9 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) } else { - var result = await APIClient.GetSeriesPathEndsWith(partialPath); - Logger.LogTrace("Found result with {Count} matches for {Path}", result?.Count ?? 0, partialPath); - seriesId = result?.FirstOrDefault()?.IDs?.Shoko.ToString(); - + seriesId = await GetSeriesIdForPath(path); if (string.IsNullOrEmpty(seriesId)) return null; - - PathToSeriesIdDictionary[path] = seriesId; - SeriesIdToPathDictionary.TryAdd(seriesId, path); } return await GetGroupInfoForSeries(seriesId, filterByType); @@ -626,8 +658,10 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) if (string.IsNullOrEmpty(groupId)) return null; - if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var info)) - return info; + if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var groupInfo)) { + Logger.LogTrace("Reusing info object for group {GroupName}. (Group={GroupId})", groupInfo.Shoko.Name, groupId); + return groupInfo; + } var group = await APIClient.GetGroup(groupId); return await CreateGroupInfo(group, groupId, filterByType); @@ -644,8 +678,10 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) return null; groupId = group.IDs.Shoko.ToString(); - if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var groupInfo)) + if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var groupInfo)) { + Logger.LogTrace("Reusing info object for group {GroupName}. (Series={SeriesId},Group={GroupId})", groupInfo.Shoko.Name, seriesId, groupId); return groupInfo; + } return await CreateGroupInfo(group, groupId, filterByType); } @@ -656,8 +692,10 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Ordering.GroupFilterType filterByType) { var cacheKey = $"group:{filterByType}:{groupId}"; - if (DataCache.TryGetValue<GroupInfo>(cacheKey, out var groupInfo)) + if (DataCache.TryGetValue<GroupInfo>(cacheKey, out var groupInfo)) { + Logger.LogTrace("Reusing info object for group {GroupName}. (Group={GroupId})", groupInfo.Shoko.Name, groupId); return groupInfo; + } Logger.LogTrace("Creating info object for group {GroupName}. (Group={GroupId})", group.Name, groupId); From 67f5e68d53d03b0a85ffb753ce5c3d230f05589b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 26 Dec 2022 17:51:27 +0000 Subject: [PATCH 0425/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index b48b13fe..8993ada4 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.25", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.25/shoko_2.0.1.25.zip", + "checksum": "bfd927a165806263641a53ffe736774b", + "timestamp": "2022-12-26T17:51:25Z" + }, { "version": "2.0.1.24", "changelog": "NA\n", From edfc129e996dbcc960ab14b4085220bb7f2d2c21 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 7 Jan 2023 12:28:00 +0100 Subject: [PATCH 0426/1103] fix: Presumably fix TvDB Specials placement --- Shokofin/Utils/OrderingUtil.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 4561ed41..edcadb94 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -256,14 +256,13 @@ public static (int?, int?, int?) GetSpecialPlacement(GroupInfo group, SeriesInfo // We need to have TvDB/TMDB data in the first place to do this method. if (episode.TvDB == null) { if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirdate; - airsAfterSeasonNumber = seasonNumber; break; } episodeNumber = episode.TvDB.AirsBeforeEpisode; if (!episodeNumber.HasValue) { - if (episode.TvDB.AirsAfterSeason.HasValue) { - airsAfterSeasonNumber = seasonNumber; + if (episode.TvDB.AirsBeforeSeason.HasValue) { + airsBeforeSeasonNumber = seasonNumber; break; } @@ -280,7 +279,6 @@ public static (int?, int?, int?) GetSpecialPlacement(GroupInfo group, SeriesInfo } if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirdate; - airsAfterSeasonNumber = seasonNumber; break; } From c314f47ec59b0b5488ff02c4a9bef39939e6a0eb Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 7 Jan 2023 11:28:41 +0000 Subject: [PATCH 0427/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 8993ada4..d7d2f8b2 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.26", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.26/shoko_2.0.1.26.zip", + "checksum": "29450ae8a17a6a6f5517727e03ef8542", + "timestamp": "2023-01-07T11:28:40Z" + }, { "version": "2.0.1.25", "changelog": "NA\n", From a384bd7cebacc587f69eaa19c3732484eff9ab37 Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Mon, 9 Jan 2023 22:33:31 +0000 Subject: [PATCH 0428/1103] Use operation system agnostic path separators --- Shokofin/API/Models/File.cs | 2 +- Shokofin/API/ShokoAPIManager.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index 737459a6..b17288f3 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -80,7 +80,7 @@ public class Location /// where the <see cref="File"/> lies, with a leading slash applied at /// the start. /// </summary> - public string Path => "/" + RelativePath; + public string Path => System.IO.Path.DirectorySeparatorChar + RelativePath; /// <summary> /// True if the server can access the the <see cref="Location.Path"/> at diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 8db453ee..edd18ee0 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -269,7 +269,7 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) foreach (var file in await APIClient.GetFilesForSeries(seriesId)) { if (file.CrossReferences.Count == 1) foreach (var fileLocation in file.Locations) - pathSet.Add((Path.GetDirectoryName(fileLocation.Path) ?? "") + "/"); + pathSet.Add((Path.GetDirectoryName(fileLocation.Path) ?? "") + Path.DirectorySeparatorChar); var xref = file.CrossReferences.First(xref => xref.Series.Shoko.ToString() == seriesId); foreach (var episodeXRef in xref.Episodes) episodeIds.Add(episodeXRef.Shoko.ToString()); @@ -317,7 +317,7 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) } // Find the correct series based on the path. - var selectedPath = (Path.GetDirectoryName(fileLocations.First().Path) ?? "") + "/"; + var selectedPath = (Path.GetDirectoryName(fileLocations.First().Path) ?? "") + Path.DirectorySeparatorChar; foreach (var seriesXRef in file.CrossReferences) { var seriesId = seriesXRef.Series.Shoko.ToString(); From a566b75f729ff996fab99551fdc3f908c93de305 Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Mon, 9 Jan 2023 23:42:05 +0000 Subject: [PATCH 0429/1103] Update Shokofin/API/Models/File.cs As per request - https://github.com/ShokoAnime/Shokofin/pull/34#discussion_r1065204420 Co-authored-by: Mikal S. <revam@users.noreply.github.com> --- Shokofin/API/Models/File.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index b17288f3..ef1feffd 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -80,7 +80,19 @@ public class Location /// where the <see cref="File"/> lies, with a leading slash applied at /// the start. /// </summary> - public string Path => System.IO.Path.DirectorySeparatorChar + RelativePath; + public string Path => + __path != null ? ( + __path + ) : ( + __path = System.IO.Path.DirectorySeparatorChar + RelativePath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar) + ); + + /// <summary> + /// Cached path for later re-use. + /// </summary> + private string? __path { get; set; } /// <summary> /// True if the server can access the the <see cref="Location.Path"/> at From c1a03e18342012d2a38cafd49731af153347768c Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 9 Jan 2023 23:48:36 +0000 Subject: [PATCH 0430/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index d7d2f8b2..0d6a2aa5 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.27", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.27/shoko_2.0.1.27.zip", + "checksum": "dbd00c11e9fa31a2e60f00b049afa4f1", + "timestamp": "2023-01-09T23:48:34Z" + }, { "version": "2.0.1.26", "changelog": "NA\n", From e28312072783b8bd11b945f671a4267eb52ffb4f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 10 Jan 2023 02:29:24 +0100 Subject: [PATCH 0431/1103] feature: display all titles for multi-episode files --- Shokofin/Configuration/PluginConfiguration.cs | 9 +++-- Shokofin/Configuration/configController.js | 3 ++ Shokofin/Configuration/configPage.html | 7 ++++ Shokofin/Providers/EpisodeProvider.cs | 33 ++++++++++++++++--- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 6c44f7c3..29389792 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -17,7 +17,7 @@ public class PluginConfiguration : BasePluginConfiguration public string PublicHost { get; set; } [JsonIgnore] - public virtual string PrettyHost + public virtual string PrettyHost => string.IsNullOrEmpty(PublicHost) ? Host : PublicHost; public string Username { get; set; } @@ -31,11 +31,13 @@ public virtual string PrettyHost public bool HidePlotTags { get; set; } public bool HideAniDbTags { get; set; } - + public bool HideSettingTags { get; set; } - + public bool HideProgrammingTags { get; set; } + public bool TitleAddForMultipleEpisodes { get; set; } + public bool SynopsisCleanLinks { get; set; } public bool SynopsisCleanMiscLines { get; set; } @@ -90,6 +92,7 @@ public PluginConfiguration() HideAniDbTags = true; HideSettingTags = false; HideProgrammingTags = true; + TitleAddForMultipleEpisodes = true; SynopsisCleanLinks = true; SynopsisCleanMiscLines = true; SynopsisRemoveSummary = true; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 41e0dfff..762affb1 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -154,6 +154,7 @@ async function defaultSubmit(form) { // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; config.TitleAlternateType = form.querySelector("#TitleAlternateType").value; + config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; config.DescriptionSource = form.querySelector("#DescriptionSource").value; config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; @@ -302,6 +303,7 @@ async function syncSettings(form) { // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; config.TitleAlternateType = form.querySelector("#TitleAlternateType").value; + config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; config.DescriptionSource = form.querySelector("#DescriptionSource").value; config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; @@ -472,6 +474,7 @@ export default function (page) { // Metadata settings form.querySelector("#TitleMainType").value = config.TitleMainType; form.querySelector("#TitleAlternateType").value = config.TitleAlternateType; + form.querySelector("#TitleAddForMultipleEpisodes").checked = config.TitleAddForMultipleEpisodes || true; form.querySelector("#DescriptionSource").value = config.DescriptionSource; form.querySelector("#CleanupAniDBDescriptions").checked = config.SynopsisCleanMultiEmptyLines || config.SynopsisCleanLinks; form.querySelector("#MinimalAniDBDescriptions").checked = config.SynopsisRemoveSummary || config.SynopsisCleanMiscLines; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 0d3a7703..e30ec1ca 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -59,6 +59,13 @@ <h3>Metadata Settings</h3> </select> <div class="fieldDescription selectFieldDescription">How to select the alternate title for each item. The plugin will fallback to the default title if no title can be found in the target language.</div> </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="TitleAddForMultipleEpisodes" /> + <span>Add all titles for multi-episode entries.</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Will add the title for every episode in multi-episode entries to the episode title.</div> + </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="DescriptionSource">Description source:</label> <select is="emby-select" id="DescriptionSource" name="DescriptionSource" class="emby-select-withcolor emby-select"> diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index caa0b437..f87b9c54 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -99,12 +99,37 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri string displayTitle, alternateTitle; string defaultEpisodeTitle = mergeFriendly ? episode.TvDB.Title : episode.Shoko.Name; - if (series.AniDB.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) { - string defaultSeriesTitle = mergeFriendly ? series.TvDB.Title : series.Shoko.Name; - ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); + if (config.TitleAddForMultipleEpisodes && file != null && file.EpisodeList.Count > 1) { + var displayTitles = new List<string>(file.EpisodeList.Count); + var alternateTitles = new List<string>(file.EpisodeList.Count); + for (var index = 0; index > file.EpisodeList.Count; index++) + { + var episodeInfo = file.EpisodeList[index]; + if (series.AniDB.Type == SeriesType.Movie && (episodeInfo.AniDB.Type == EpisodeType.Normal || episodeInfo.AniDB.Type == EpisodeType.Special)) { + string defaultSeriesTitle = mergeFriendly ? series.TvDB.Title : series.Shoko.Name; + var ( dTitle, aTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episodeInfo.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); + displayTitles[index] = dTitle; + alternateTitles[index] = aTitle; + } + else { + var ( dTitle, aTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episodeInfo.AniDB.Titles, defaultEpisodeTitle, metadataLanguage); + displayTitles[index] = dTitle; + alternateTitles[index] = aTitle; + } + } + // TODO: Get a language spesific seperator, and/or create language spesific contatination rules (e.g. use "and" for the last title in English, etc.) + displayTitle = string.Join(", ", displayTitles.Where(title => !string.IsNullOrWhiteSpace(title))); + alternateTitle = string.Join(", ", alternateTitles.Where(title => !string.IsNullOrWhiteSpace(title))); } else { - ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultEpisodeTitle, metadataLanguage); + if (series.AniDB.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) { + string defaultSeriesTitle = mergeFriendly ? series.TvDB.Title : series.Shoko.Name; + ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); + } + else { + ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultEpisodeTitle, metadataLanguage); + } + } var episodeNumber = Ordering.GetEpisodeNumber(group, series, episode); From 63c8add68b1942626fb09a5b16ad2679d7e78a6e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 10 Jan 2023 02:35:38 +0100 Subject: [PATCH 0432/1103] feature: tweak video scrobbling --- Shokofin/Configuration/UserConfiguration.cs | 29 ++++++++++++++++++++- Shokofin/Configuration/configController.js | 26 +++++------------- Shokofin/Configuration/configPage.html | 11 ++++++-- Shokofin/Sync/UserDataSyncManager.cs | 10 ++++++- 4 files changed, 53 insertions(+), 23 deletions(-) diff --git a/Shokofin/Configuration/UserConfiguration.cs b/Shokofin/Configuration/UserConfiguration.cs index 4705eac2..d915755d 100644 --- a/Shokofin/Configuration/UserConfiguration.cs +++ b/Shokofin/Configuration/UserConfiguration.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel.DataAnnotations; namespace Shokofin.Configuration { @@ -17,12 +18,38 @@ public class UserConfiguration /// </summary> public bool EnableSynchronization { get; set; } + /// <summary> + /// Enable the stop event for syncing after video playback. + /// </summary> public bool SyncUserDataAfterPlayback { get; set; } + /// <summary> + /// Enable the play/pause/resume(/stop) events for syncing under/during + /// video playback. + /// </summary> public bool SyncUserDataUnderPlayback { get; set; } + /// <summary> + /// Enable the scrobble event for live syncing under/during video + /// playback. + /// </summary> + public bool SyncUserDataUnderPlaybackLive { get; set; } + + /// <summary> + /// Number of ticks to skip (1 tick is 10 seconds) before scrobbling to + /// shoko. + /// </summary> + [Range(1, 250)] + public byte SyncUserDataUnderPlaybackAtEveryXTicks { get; set; } = 6; + + /// <summary> + /// Enable syncing user data when an item have been added/updated. + /// </summary> public bool SyncUserDataOnImport { get; set; } - + + /// <summary> + /// Enabling user data sync. for restricted videos (H). + /// </summary> public bool SyncRestrictedVideos { get; set; } /// <summary> diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 762affb1..67fb19c7 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -109,19 +109,6 @@ async function loadUserConfig(form, userId, config) { Dashboard.hideLoadingMsg(); } -function toggleSyncUnderPlayback(form, checked) { - const elem = form.querySelector("#SyncUserDataUnderPlayback"); - if (checked) { - elem.removeAttribute("disabled"); - elem.classList.remove("disabled"); - } - else { - elem.setAttribute("disabled", ""); - elem.classList.add("disabled"); - elem.checked = false; - } -} - function getApiKey(username, password, userKey = false) { return ApiClient.fetch({ dataType: "json", @@ -202,7 +189,9 @@ async function defaultSubmit(form) { userConfig.EnableSynchronization = form.querySelector("#UserEnableSynchronization").checked; userConfig.SyncUserDataOnImport = form.querySelector("#SyncUserDataOnImport").checked; userConfig.SyncUserDataAfterPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked; - userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked && form.querySelector("#SyncUserDataUnderPlayback").checked; + userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataUnderPlayback").checked; + userConfig.SyncUserDataUnderPlaybackLive = form.querySelector("#SyncUserDataUnderPlaybackLive").checked; + userConfig.SyncUserDataUnderPlaybackAtEveryXTicks = 6; userConfig.SyncRestrictedVideos = form.querySelector("#SyncRestrictedVideos").checked; // Only try to save a new token if a token is not already present. @@ -376,7 +365,9 @@ async function syncUserSettings(form) { userConfig.EnableSynchronization = form.querySelector("#UserEnableSynchronization").checked; userConfig.SyncUserDataOnImport = form.querySelector("#SyncUserDataOnImport").checked; userConfig.SyncUserDataAfterPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked; - userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked && form.querySelector("#SyncUserDataUnderPlayback").checked; + userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataUnderPlayback").checked; + userConfig.SyncUserDataUnderPlaybackLive = form.querySelector("#SyncUserDataUnderPlaybackLive").checked; + userConfig.SyncUserDataUnderPlaybackAtEveryXTicks = 6; userConfig.SyncRestrictedVideos = form.querySelector("#SyncRestrictedVideos").checked; // Only try to save a new token if a token is not already present. @@ -454,10 +445,7 @@ export default function (page) { form.querySelector("#SyncUserDataOnImport").disabled = disabled; form.querySelector("#SyncUserDataAfterPlayback").disabled = disabled; form.querySelector("#SyncUserDataUnderPlayback").disabled = disabled; - }); - - form.querySelector("#SyncUserDataAfterPlayback").addEventListener("change", function () { - toggleSyncUnderPlayback(page, this.checked); + form.querySelector("#SyncUserDataUnderPlaybackLive").disabled = disabled; }); page.addEventListener("viewshow", async function () { diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index e30ec1ca..20844c3b 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -213,9 +213,16 @@ <h3>User Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SyncUserDataUnderPlayback" /> - <span>Sync watch-state during playback</span> + <span>Sync watch-state events during playback</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back back the watch-state to Shoko during playback. "Sync watch-state after playback" must be enabled first to enable this setting.</div> + <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back back the watch-state to Shoko on every play/pause/resume/stop events during 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> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back back the watch-state to Shoko live during playback.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index fb4ef204..4078a5d5 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -70,6 +70,7 @@ internal class SessionMetadata { public string FileId; public SessionInfo Session; public long Ticks; + public byte ScrobbleTicks; public bool SentPaused; } @@ -146,6 +147,7 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) sessionMetadata.ItemId = e.Item.Id; sessionMetadata.FileId = fileId; sessionMetadata.Ticks = userData.PlaybackPositionTicks; + sessionMetadata.ScrobbleTicks = 0; sessionMetadata.SentPaused = false; Logger.LogInformation("Playback has started. (File={FileId})", fileId); @@ -166,6 +168,7 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) // The playback was resumed. else if (sessionMetadata.SentPaused) { sessionMetadata.Ticks = ticks; + sessionMetadata.ScrobbleTicks = 0; sessionMetadata.SentPaused = false; Logger.LogInformation("Playback was resumed. (File={FileId})", fileId); @@ -174,21 +177,26 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) // Scrobble. else { sessionMetadata.Ticks = ticks; + if (++sessionMetadata.ScrobbleTicks < userConfig.SyncUserDataUnderPlaybackAtEveryXTicks || + !userConfig.SyncUserDataUnderPlaybackLive) + return; Logger.LogInformation("Scrobbled during playback. (File={FileId})", fileId); + sessionMetadata.ScrobbleTicks = 0; success = await APIClient.ScrobbleFile(fileId, episodeId, "scrobble", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); } } break; } case UserDataSaveReason.PlaybackFinished: { - if (!userConfig.SyncUserDataAfterPlayback) + if (!(userConfig.SyncUserDataAfterPlayback || userConfig.SyncUserDataUnderPlayback)) return; if (ActiveSessions.TryGetValue(e.UserId, out var sessionMetadata) && sessionMetadata.ItemId == e.Item.Id) { sessionMetadata.ItemId = Guid.Empty; sessionMetadata.FileId = null; sessionMetadata.Ticks = 0; + sessionMetadata.ScrobbleTicks = 0; sessionMetadata.SentPaused = false; } From 5a58d73a713e0413bffe4d2899d1eddcec7cf141 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 10 Jan 2023 01:36:32 +0000 Subject: [PATCH 0433/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 0d6a2aa5..31d356ef 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.28", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.28/shoko_2.0.1.28.zip", + "checksum": "4f4140a3ad1fba0f735eb13a5965df13", + "timestamp": "2023-01-10T01:36:30Z" + }, { "version": "2.0.1.27", "changelog": "NA\n", From 66e44f919eb7e490daafe0742b899afdda809419 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 10 Jan 2023 19:27:51 +0100 Subject: [PATCH 0434/1103] fix: fix multi-epsiode entries and add descriptions --- Shokofin/Providers/EpisodeProvider.cs | 35 ++++---- Shokofin/Utils/TextUtil.cs | 124 ++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 18 deletions(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index f87b9c54..606845f8 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -95,46 +95,45 @@ public static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo serie private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo series, Info.EpisodeInfo episode, Info.FileInfo file, string metadataLanguage, Season season, System.Guid episodeId) { var config = Plugin.Instance.Configuration; - var mergeFriendly = config.SeriesGrouping == Ordering.GroupType.MergeFriendly && series.TvDB != null && episode.TvDB != null; + var maybeMergeFriendly = config.SeriesGrouping == Ordering.GroupType.MergeFriendly && series.TvDB != null; - string displayTitle, alternateTitle; - string defaultEpisodeTitle = mergeFriendly ? episode.TvDB.Title : episode.Shoko.Name; + string displayTitle, alternateTitle, description; if (config.TitleAddForMultipleEpisodes && file != null && file.EpisodeList.Count > 1) { var displayTitles = new List<string>(file.EpisodeList.Count); var alternateTitles = new List<string>(file.EpisodeList.Count); - for (var index = 0; index > file.EpisodeList.Count; index++) + foreach (var episodeInfo in file.EpisodeList) { - var episodeInfo = file.EpisodeList[index]; + string defaultEpisodeTitle = maybeMergeFriendly && episodeInfo.TvDB != null ? episodeInfo.TvDB.Title : episodeInfo.Shoko.Name; if (series.AniDB.Type == SeriesType.Movie && (episodeInfo.AniDB.Type == EpisodeType.Normal || episodeInfo.AniDB.Type == EpisodeType.Special)) { - string defaultSeriesTitle = mergeFriendly ? series.TvDB.Title : series.Shoko.Name; + string defaultSeriesTitle = maybeMergeFriendly ? series.TvDB.Title : series.Shoko.Name; var ( dTitle, aTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episodeInfo.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); - displayTitles[index] = dTitle; - alternateTitles[index] = aTitle; + displayTitles.Add(dTitle); + alternateTitles.Add(aTitle); } else { var ( dTitle, aTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episodeInfo.AniDB.Titles, defaultEpisodeTitle, metadataLanguage); - displayTitles[index] = dTitle; - alternateTitles[index] = aTitle; + displayTitles.Add(dTitle); + alternateTitles.Add(aTitle); } } - // TODO: Get a language spesific seperator, and/or create language spesific contatination rules (e.g. use "and" for the last title in English, etc.) - displayTitle = string.Join(", ", displayTitles.Where(title => !string.IsNullOrWhiteSpace(title))); - alternateTitle = string.Join(", ", alternateTitles.Where(title => !string.IsNullOrWhiteSpace(title))); + displayTitle = Text.JoinText(displayTitles); + alternateTitle = Text.JoinText(alternateTitles); + description = Text.GetDescription(file.EpisodeList); } else { + string defaultEpisodeTitle = maybeMergeFriendly && episode.TvDB != null ? episode.TvDB.Title : episode.Shoko.Name; if (series.AniDB.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) { - string defaultSeriesTitle = mergeFriendly ? series.TvDB.Title : series.Shoko.Name; + string defaultSeriesTitle = maybeMergeFriendly ? series.TvDB.Title : series.Shoko.Name; ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); } else { ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultEpisodeTitle, metadataLanguage); } - + description = Text.GetDescription(episode); } var episodeNumber = Ordering.GetEpisodeNumber(group, series, episode); var seasonNumber = Ordering.GetSeasonNumber(group, series, episode); - var description = Text.GetDescription(episode); if (config.MarkSpecialsWhenGrouped) switch (episode.AniDB.Type) { case EpisodeType.Unknown: @@ -170,7 +169,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri Episode result; var (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber) = Ordering.GetSpecialPlacement(group, series, episode); - if (mergeFriendly) { + if (maybeMergeFriendly && episode.TvDB != null) { if (season != null) { result = new Episode { Name = displayTitle, @@ -261,7 +260,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri result.IndexNumberEnd = episodeNumberEnd; } - AddProviderIds(result, episodeId: episode.Id, fileId: file?.Id, anidbId: episode.AniDB.Id.ToString(), tvdbId: mergeFriendly || config.SeriesGrouping == Ordering.GroupType.Default ? episode.TvDB?.Id.ToString() : null); + AddProviderIds(result, episodeId: episode.Id, fileId: file?.Id, anidbId: episode.AniDB.Id.ToString(), tvdbId: (maybeMergeFriendly && episode.TvDB != null) || config.SeriesGrouping == Ordering.GroupType.Default ? episode.TvDB?.Id.ToString() : null); return result; } diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index 76b87fbd..e48289a2 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -9,6 +9,101 @@ namespace Shokofin.Utils { public class Text { + private static HashSet<char> PunctuationMarks = new() { + // Common punctuation marks + '.', // period + ',', // comma + ';', // semicolon + ':', // colon + '!', // exclamation point + '?', // question mark + '-', // hyphen + '_', // underscore + '(', // left parenthesis + ')', // right parenthesis + '[', // left bracket + ']', // right bracket + '{', // left brace + '}', // right brace + '"', // double quote + '\'', // single quote + '/', // forward slash + '\\', // backslash + '|', // vertical bar + '@', // at symbol + '#', // pound or hash + '$', // dollar + '%', // percent + '^', // caret + '&', // ampersand + '*', // asterisk + '+', // plus + '=', // equals + '<', // less-than + '>', // greater-than + '。', // Chinese full stop + ',', // Chinese comma + '、', // Chinese enumeration comma + ';', // Chinese semicolon + ':', // Chinese colon + '!', // Chinese exclamation point + '?', // Chinese question mark + '“', // Chinese double quote + '”', // Chinese double quote + '‘', // Chinese single quote + '’', // Chinese single quote + '【', // Chinese left bracket + '】', // Chinese right bracket + '《', // Chinese left angle bracket + '》', // Chinese right angle bracket + '(', // Chinese left parenthesis + ')', // Chinese right parenthesis + '-', // Chinese hyphen + '・', // Japanese middle dot + + // Less common punctuation marks + '‽', // interrobang + '❞', // double question mark + '❝', // double exclamation mark + '⁇', // question mark variation + '⁈', // exclamation mark variation + '❕', // white exclamation mark + '❔', // white question mark + '‽', // interrobang + '⁉', // exclamation mark + '‽', // interrobang + '※', // reference mark + '‒', // figure dash + '–', // en dash + '—', // em dash + '⸺', // two-em dash + '⸻', // three-em dash + '⟨', // left angle bracket + '⟩', // right angle bracket + '❮', // left angle bracket + '❯', // right angle bracket + '❬', // left angle bracket + '❭', // right angle bracket + '〈', // left angle bracket + '〉', // right angle bracket + '⌈', // left angle bracket + '⌉', // right angle bracket + '⌊', // left angle bracket + '⌋', // right angle bracket + '⦃', // left angle bracket + '⦄', // right angle bracket + '⦅', // left angle bracket + '⦆', // right angle bracket + '⦇', // left angle bracket + '⦈', // right angle bracket + '⦉', // left angle bracket + '⦊', // right angle bracket + '⦋', // left angle bracket + '⦌', // right angle bracket + '⦍', // left angle bracket + '⦎', // right angle bracket + }; + /// <summary> /// Where to get text the text from. /// </summary> @@ -93,6 +188,9 @@ public static string GetDescription(SeriesInfo series) public static string GetDescription(EpisodeInfo episode) => GetDescription(episode.AniDB.Description, episode.TvDB?.Description); + public static string GetDescription(IEnumerable<EpisodeInfo> episodeList) + => JoinText(episodeList.Select(episode => GetDescription(episode))); + private static string GetDescription(string aniDbDescription, string otherDescription) { string overview; @@ -172,6 +270,32 @@ public static ( string, string ) GetTitles(IEnumerable<Title> seriesTitles, IEnu ); } + public static string JoinText(IEnumerable<string> textList) + { + var filteredList = textList + .Where(title => !string.IsNullOrWhiteSpace(title)) + .Select(title => title.Trim()) + // We distinct the list because some episode entries contain the **exact** same description. + .Distinct() + .ToList(); + + if (filteredList.Count == 0) + return ""; + + var index = 1; + var outputText = filteredList[0]; + while (index < filteredList.Count) { + var lastChar = outputText[^1]; + outputText += PunctuationMarks.Contains(lastChar) ? " " : ". "; + outputText += filteredList[index++]; + } + + if (filteredList.Count > 1) + outputText.TrimEnd(); + + return outputText; + } + public static string GetEpisodeTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) => GetTitle(seriesTitles, episodeTitles, null, episodeTitle, DisplayTitleType.SubTitle, metadataLanguage); From 5a9ff266362dd834a09c1bcc1de1a44e9cc134ea Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 10 Jan 2023 20:32:40 +0100 Subject: [PATCH 0435/1103] fix: fix live scrobbling that i broke last night. --- Shokofin/Configuration/UserConfiguration.cs | 7 +++++++ Shokofin/Configuration/configController.js | 5 ++++- Shokofin/Sync/UserDataSyncManager.cs | 14 ++++++++++---- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/Shokofin/Configuration/UserConfiguration.cs b/Shokofin/Configuration/UserConfiguration.cs index d915755d..34bb6387 100644 --- a/Shokofin/Configuration/UserConfiguration.cs +++ b/Shokofin/Configuration/UserConfiguration.cs @@ -42,6 +42,13 @@ public class UserConfiguration [Range(1, 250)] public byte SyncUserDataUnderPlaybackAtEveryXTicks { get; set; } = 6; + /// <summary> + /// Imminently scrobble if the playtime changes above this threshold + /// given in ticks (ticks in a time-span). + /// </summary> + /// <value></value> + public long SyncUserDataUnderPlaybackLiveThreshold { get; set; } = 125000000; // 12.5s + /// <summary> /// Enable syncing user data when an item have been added/updated. /// </summary> diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 67fb19c7..440a67e6 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -85,7 +85,8 @@ async function loadUserConfig(form, userId, config) { 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.SyncUserDataAfterPlayback && userConfig.SyncUserDataUnderPlayback || false; + form.querySelector("#SyncUserDataUnderPlayback").checked = userConfig.SyncUserDataUnderPlayback || false; + form.querySelector("#SyncUserDataUnderPlaybackLive").checked = userConfig.SyncUserDataUnderPlaybackLive || false; form.querySelector("#SyncRestrictedVideos").checked = userConfig.SyncRestrictedVideos || false; form.querySelector("#UserUsername").value = userConfig.Username || ""; // Synchronization settings @@ -192,6 +193,7 @@ async function defaultSubmit(form) { userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataUnderPlayback").checked; userConfig.SyncUserDataUnderPlaybackLive = form.querySelector("#SyncUserDataUnderPlaybackLive").checked; 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. @@ -368,6 +370,7 @@ async function syncUserSettings(form) { userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataUnderPlayback").checked; userConfig.SyncUserDataUnderPlaybackLive = form.querySelector("#SyncUserDataUnderPlaybackLive").checked; 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. diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index 4078a5d5..c7564881 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -174,11 +174,17 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) Logger.LogInformation("Playback was resumed. (File={FileId})", fileId); success = await APIClient.ScrobbleFile(fileId, episodeId, "resume", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); } - // Scrobble. - else { + // Return early if we're not scrobbling. + else if (!userConfig.SyncUserDataUnderPlaybackLive) { sessionMetadata.Ticks = ticks; - if (++sessionMetadata.ScrobbleTicks < userConfig.SyncUserDataUnderPlaybackAtEveryXTicks || - !userConfig.SyncUserDataUnderPlaybackLive) + return; + } + // Live scrobbling. + else { + var deltaTicks = Math.Abs(ticks - sessionMetadata.Ticks); + sessionMetadata.Ticks = ticks; + if (deltaTicks == 0 || deltaTicks < userConfig.SyncUserDataUnderPlaybackLiveThreshold && + ++sessionMetadata.ScrobbleTicks < userConfig.SyncUserDataUnderPlaybackAtEveryXTicks) return; Logger.LogInformation("Scrobbled during playback. (File={FileId})", fileId); From cf185a387fe0647aad842763be065c5505d2af62 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 10 Jan 2023 19:33:28 +0000 Subject: [PATCH 0436/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 31d356ef..bf418f9f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.29", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.29/shoko_2.0.1.29.zip", + "checksum": "8dc7ae5ba853512dfb000f2d59031f7e", + "timestamp": "2023-01-10T19:33:26Z" + }, { "version": "2.0.1.28", "changelog": "NA\n", From 30361bf5274264adbf45cdfbcb2068dbe049b091 Mon Sep 17 00:00:00 2001 From: Kerberos <57227244+krbrs@users.noreply.github.com> Date: Thu, 12 Jan 2023 14:39:24 +0100 Subject: [PATCH 0437/1103] Spellchecking --- Shokofin/API/ShokoAPIManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index edd18ee0..dceff84a 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -604,7 +604,7 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) var result = await APIClient.GetSeriesPathEndsWith(partialPath); Logger.LogTrace("Found result with {Count} matches for {Path}", result.Count, partialPath); - // Retrun the first match where the series unique paths partially match + // Return the first match where the series unique paths partially match // the input path. foreach (var series in result) { @@ -624,7 +624,7 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) } // In the edge case for series with only files with multiple - // cross-refereces we just return the first match. + // cross-references we just return the first match. return result.FirstOrDefault()?.IDs.Shoko.ToString(); } From db3b4aaf063f18fe74cabc95b458a75f8c1a579d Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 12 Jan 2023 14:35:08 +0000 Subject: [PATCH 0438/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index bf418f9f..386f13a0 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.30", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.30/shoko_2.0.1.30.zip", + "checksum": "bcb1f9a440d1d04764ab9b89b47cccc0", + "timestamp": "2023-01-12T14:35:05Z" + }, { "version": "2.0.1.29", "changelog": "NA\n", From e3c1d3a31933daacc49000cfefce9fdf31104680 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 12 Jan 2023 16:32:16 +0100 Subject: [PATCH 0439/1103] fix: add back a missing null check --- Shokofin/Providers/EpisodeProvider.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 606845f8..40752562 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -96,6 +96,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri { var config = Plugin.Instance.Configuration; var maybeMergeFriendly = config.SeriesGrouping == Ordering.GroupType.MergeFriendly && series.TvDB != null; + var mergeFriendly = maybeMergeFriendly && episode.TvDB != null; string displayTitle, alternateTitle, description; if (config.TitleAddForMultipleEpisodes && file != null && file.EpisodeList.Count > 1) { @@ -105,7 +106,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri { string defaultEpisodeTitle = maybeMergeFriendly && episodeInfo.TvDB != null ? episodeInfo.TvDB.Title : episodeInfo.Shoko.Name; if (series.AniDB.Type == SeriesType.Movie && (episodeInfo.AniDB.Type == EpisodeType.Normal || episodeInfo.AniDB.Type == EpisodeType.Special)) { - string defaultSeriesTitle = maybeMergeFriendly ? series.TvDB.Title : series.Shoko.Name; + string defaultSeriesTitle = maybeMergeFriendly && episodeInfo.TvDB != null ? series.TvDB.Title : series.Shoko.Name; var ( dTitle, aTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episodeInfo.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); displayTitles.Add(dTitle); alternateTitles.Add(aTitle); @@ -121,9 +122,9 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri description = Text.GetDescription(file.EpisodeList); } else { - string defaultEpisodeTitle = maybeMergeFriendly && episode.TvDB != null ? episode.TvDB.Title : episode.Shoko.Name; + string defaultEpisodeTitle = mergeFriendly ? episode.TvDB.Title : episode.Shoko.Name; if (series.AniDB.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) { - string defaultSeriesTitle = maybeMergeFriendly ? series.TvDB.Title : series.Shoko.Name; + string defaultSeriesTitle = mergeFriendly ? series.TvDB.Title : series.Shoko.Name; ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); } else { @@ -169,7 +170,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri Episode result; var (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber) = Ordering.GetSpecialPlacement(group, series, episode); - if (maybeMergeFriendly && episode.TvDB != null) { + if (mergeFriendly) { if (season != null) { result = new Episode { Name = displayTitle, @@ -260,7 +261,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri result.IndexNumberEnd = episodeNumberEnd; } - AddProviderIds(result, episodeId: episode.Id, fileId: file?.Id, anidbId: episode.AniDB.Id.ToString(), tvdbId: (maybeMergeFriendly && episode.TvDB != null) || config.SeriesGrouping == Ordering.GroupType.Default ? episode.TvDB?.Id.ToString() : null); + AddProviderIds(result, episodeId: episode.Id, fileId: file?.Id, anidbId: episode.AniDB.Id.ToString(), tvdbId: mergeFriendly || config.SeriesGrouping == Ordering.GroupType.Default ? episode.TvDB?.Id.ToString() : null); return result; } From f816d9a61a8ded3ab83b35203a7d0cbc16137284 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 12 Jan 2023 16:49:26 +0100 Subject: [PATCH 0440/1103] feature: hide unverified and any spoiler tags update the tag model and add/update the settings to account for the new behaviour of optionally hiding unverified and global/local spoilers. --- Shokofin/API/Models/Tag.cs | 35 +++++++++++++++++-- Shokofin/API/ShokoAPIManager.cs | 23 ++++++++++-- Shokofin/Configuration/PluginConfiguration.cs | 3 ++ Shokofin/Configuration/configController.js | 3 ++ Shokofin/Configuration/configPage.html | 6 ++++ 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/Shokofin/API/Models/Tag.cs b/Shokofin/API/Models/Tag.cs index 54442184..6b63fac5 100644 --- a/Shokofin/API/Models/Tag.cs +++ b/Shokofin/API/Models/Tag.cs @@ -1,4 +1,5 @@ #nullable enable +using System; using System.Text.Json.Serialization; #nullable enable @@ -9,10 +10,15 @@ public class Tag /// <summary> /// Tag id. Relative to it's source for now. /// </summary> - /// <value></value> [JsonPropertyName("ID")] public int Id { get; set; } + /// <summary> + /// Parent id relative to the source, if any. + /// </summary> + [JsonPropertyName("ParentID")] + public int? ParentId { get; set; } + /// <summary> /// The tag itself /// </summary> @@ -23,14 +29,39 @@ public class Tag /// </summary> public string? Description { get; set; } + /// <summary> + /// True if the tag has been verified. + /// </summary> + /// <remarks> + /// For anidb does this mean the tag has been verified for use, and is not + /// an unsorted tag. Also, anidb hides unverified tags from appearing in + /// their UI except when the tags are edited. + /// </remarks> + public bool? IsVerified { get; set; } + + /// <summary> + /// True if the tag is considered a spoiler for all series it appears on. + /// </summary> + public bool IsSpoiler { get; set; } + + /// <summary> + /// True if the tag is considered a spoiler for that particular series it is + /// set on. + /// </summary> + public bool? IsLocalSpoiler { get; set; } + /// <summary> /// How relevant is it to the series /// </summary> public int? Weight { get; set; } + /// <summary> + /// When the tag info was last updated. + /// </summary> + public DateTime? LastUpdated { get; set; } + /// <summary> /// Source. AniDB, User, etc. /// </summary> - /// <value></value> public string Source { get; set; } = ""; } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index dceff84a..74b3f983 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -161,7 +161,10 @@ public void Clear() private async Task<string[]> GetTagsForSeries(string seriesId) { - return (await APIClient.GetSeriesTags(seriesId, GetTagFilter()))?.Select(SelectTagName).ToArray() ?? new string[0]; + return (await APIClient.GetSeriesTags(seriesId, GetTagFilter())) + .Where(KeepTag) + .Select(SelectTagName) + .ToArray(); } /// <summary> @@ -176,7 +179,6 @@ private ulong GetTagFilter() if (config.HideAniDbTags) filter |= (1 << 0); if (config.HideArtStyleTags) filter |= (1 << 1); if (config.HideMiscTags) filter |= (1 << 3); - if (config.HidePlotTags) filter |= (1 << 4); if (config.HideSettingTags) filter |= (1 << 5); if (config.HideProgrammingTags) filter |= (1 << 6); @@ -186,7 +188,9 @@ private ulong GetTagFilter() public async Task<string[]> GetGenresForSeries(string seriesId) { // The following magic number is the filter value to allow only genres in the returned list. - var genreSet = (await APIClient.GetSeriesTags(seriesId, 2147483776))?.Select(SelectTagName).ToHashSet() ?? new(); + var genreSet = (await APIClient.GetSeriesTags(seriesId, 2147483776)) + .Select(SelectTagName) + .ToHashSet(); var sourceGenre = await GetSourceGenre(seriesId); genreSet.Add(sourceGenre); return genreSet.ToArray(); @@ -224,6 +228,19 @@ private async Task<string> GetSourceGenre(string seriesId) }; } + private bool KeepTag(Tag tag) + { + // Filter out unverified tags. + if (Plugin.Instance.Configuration.HideUnverifiedTags && tag.IsVerified.HasValue && !tag.IsVerified.Value) + return false; + + // Filter out any and all spoiler tags. + if (Plugin.Instance.Configuration.HidePlotTags && !(tag.IsLocalSpoiler ?? tag.IsSpoiler)) + return false; + + return true; + } + private string SelectTagName(Tag tag) { return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tag.Name); diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 29389792..323e1dd1 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -35,6 +35,8 @@ public virtual string PrettyHost public bool HideSettingTags { get; set; } public bool HideProgrammingTags { get; set; } + + public bool HideUnverifiedTags { get; set; } public bool TitleAddForMultipleEpisodes { get; set; } @@ -92,6 +94,7 @@ public PluginConfiguration() HideAniDbTags = true; HideSettingTags = false; HideProgrammingTags = true; + HideUnverifiedTags = true; TitleAddForMultipleEpisodes = true; SynopsisCleanLinks = true; SynopsisCleanMiscLines = true; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 440a67e6..f0a5622b 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -162,6 +162,7 @@ async function defaultSubmit(form) { config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; // Tag settings + config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; config.HideArtStyleTags = form.querySelector("#HideArtStyleTags").checked; config.HideMiscTags = form.querySelector("#HideMiscTags").checked; config.HidePlotTags = form.querySelector("#HidePlotTags").checked; @@ -314,6 +315,7 @@ async function syncSettings(form) { config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; // Tag settings + config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; config.HideArtStyleTags = form.querySelector("#HideArtStyleTags").checked; config.HideMiscTags = form.querySelector("#HideMiscTags").checked; config.HidePlotTags = form.querySelector("#HidePlotTags").checked; @@ -486,6 +488,7 @@ export default function (page) { userSelector.innerHTML += users.map((user) => `<option value="${user.Id}">${user.Name}</option>`); // Tag settings + form.querySelector("#HideUnverifiedTags").checked = config.HideUnverifiedTags; form.querySelector("#HideArtStyleTags").checked = config.HideArtStyleTags; form.querySelector("#HideMiscTags").checked = config.HideMiscTags; form.querySelector("#HidePlotTags").checked = config.HidePlotTags; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 20844c3b..e9edb507 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -254,6 +254,12 @@ <h3>User Settings</h3> <legend> <h3>Tag Settings</h3> </legend> + <div class="checkboxContainer"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="HideUnverifiedTags" /> + <span>Hide unverified tags.</span> + </label> + </div> <div class="checkboxContainer"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="HideArtStyleTags" /> From a95b1de35925ed9ea804e24941caf22bc1801232 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 12 Jan 2023 16:49:52 +0100 Subject: [PATCH 0441/1103] misc: fix typos --- 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 e9edb507..fb8d941d 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -215,21 +215,21 @@ <h3>User Settings</h3> <input is="emby-checkbox" type="checkbox" id="SyncUserDataUnderPlayback" /> <span>Sync watch-state events during playback</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back back the watch-state to Shoko on every play/pause/resume/stop events during playback.</div> + <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> <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> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back back the watch-state to Shoko live during playback.</div> + <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back the watch-state to Shoko live during playback.</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> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back back the watch-state to Shoko for restricted videos (H).</div> + <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:" /> From 96ec1324f94e940a72be68b1802af79f0fec2060 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 12 Jan 2023 16:53:17 +0100 Subject: [PATCH 0442/1103] misc: reword the multi-episode metadata setting --- Shokofin/Configuration/configPage.html | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index fb8d941d..f2bb9e72 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -59,13 +59,6 @@ <h3>Metadata Settings</h3> </select> <div class="fieldDescription selectFieldDescription">How to select the alternate title for each item. The plugin will fallback to the default title if no title can be found in the target language.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="TitleAddForMultipleEpisodes" /> - <span>Add all titles for multi-episode entries.</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Will add the title for every episode in multi-episode entries to the episode title.</div> - </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="DescriptionSource">Description source:</label> <select is="emby-select" id="DescriptionSource" name="DescriptionSource" class="emby-select-withcolor emby-select"> @@ -77,6 +70,13 @@ <h3>Metadata Settings</h3> </select> <div class="fieldDescription selectFieldDescription">How to select the description to use for each item.</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="CleanupAniDBDescriptions" /> From 3861f23bf22940eafc0c3cb2d53f7fc33f250329 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 12 Jan 2023 15:54:09 +0000 Subject: [PATCH 0443/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 386f13a0..69f57c64 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.31", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.31/shoko_2.0.1.31.zip", + "checksum": "1dc0263731c7dc88d87842fdf91a4d90", + "timestamp": "2023-01-12T15:54:07Z" + }, { "version": "2.0.1.30", "changelog": "NA\n", From 261742361a8cb4a0e74101ff231df3c803343929 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 12 Jan 2023 16:55:30 +0100 Subject: [PATCH 0444/1103] fix: fix mistakenly inverted boolean --- Shokofin/API/ShokoAPIManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 74b3f983..a48b83b2 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -235,7 +235,7 @@ private bool KeepTag(Tag tag) return false; // Filter out any and all spoiler tags. - if (Plugin.Instance.Configuration.HidePlotTags && !(tag.IsLocalSpoiler ?? tag.IsSpoiler)) + if (Plugin.Instance.Configuration.HidePlotTags && (tag.IsLocalSpoiler ?? tag.IsSpoiler)) return false; return true; From 77d4f8a2f59b20307541156f718f9e21ef2092b8 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 12 Jan 2023 15:56:13 +0000 Subject: [PATCH 0445/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 69f57c64..008bec6f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.32", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.32/shoko_2.0.1.32.zip", + "checksum": "083dab201466e7942163c44f0500fa6f", + "timestamp": "2023-01-12T15:56:11Z" + }, { "version": "2.0.1.31", "changelog": "NA\n", From c21008a496716664a4f705bcf1db5b14ebe4f3ba Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Tue, 24 Jan 2023 19:41:22 +0000 Subject: [PATCH 0446/1103] Fix: Spacing before link --- 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 f2bb9e72..dcf62e82 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -129,7 +129,7 @@ <h3>Plugin Compatibility Settings</h3> <legend> <h3>Library Settings</h3> </legend> - <div class="fieldDescription verticalSection-extrabottompadding">If you wish to set the Series/Grouping setting below to "Don't group Series into Seasons", you must disable "Automatically merge series that are spread across multiple folders" in the settings for all Libraries that depend on Shokofin for their metadata.<br>On the other hand, if you want to have Series and Grouping be determined by Shoko, or TvDB/TMDB - you must enable the "Automatically merge series that are spread across multiple folders" setting for all Libraries that use Shokofin for their metadata.<br>See the settings under each individual Library here:<a href="#!/library.html">Library Settings</a></div> + <div class="fieldDescription verticalSection-extrabottompadding">If you wish to set the Series/Grouping setting below to "Don't group Series into Seasons", you must disable "Automatically merge series that are spread across multiple folders" in the settings for all Libraries that depend on Shokofin for their metadata.<br>On the other hand, if you want to have Series and Grouping be determined by Shoko, or TvDB/TMDB - you must enable the "Automatically merge series that are spread across multiple folders" setting for all Libraries that use Shokofin for their metadata.<br>See the settings under each individual Library here: <a href="#!/library.html">Library Settings</a></div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="SeriesGrouping">Series/Season grouping:</label> <select is="emby-select" id="SeriesGrouping" name="SeriesGrouping" class="emby-select-withcolor emby-select"> From 9d452ff3fb0cd5de59a5e044263298bd8c37a990 Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Tue, 24 Jan 2023 19:47:24 +0000 Subject: [PATCH 0447/1103] Misc: Config description tweak - Series grouping Replaces br tags with divs. Adds summary elements to provide enhanced descriptions. --- 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 dcf62e82..69e0c51f 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -137,7 +137,7 @@ <h3>Library Settings</h3> <option value="MergeFriendly">Group series into Seasons using the TvDB/TMDB data stored in Shoko</option> <option value="ShokoGroup">Group series into Seasons based on Shoko's Groups</option> </select> - <div class="fieldDescription selectFieldDescription">Determines how to group Series together and divide them into 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 class="fieldDescription selectFieldDescription"><div>Determines how to group Series together and divide them into Seasons.</div><div><strong>Warning:</strong> Modifying this setting requires the deletion and re-creation of any libraries using this plugin.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">Why can't I just refresh all metadata?</summary>Currently, refreshing and replacing all metadata does not remove all the metadata stored by Jellyfin. Additionally, if two or more show's entries have been merged, they cannot then be unmerged. Recreation of the library is the only way to ensure that you do not have mixed metadata in your libraries.</details><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What data is kept when recreating the libraries?</summary>The user data for each file is still kept after deleting a library. User data includes any ratings, play history, watch status and the last selected audio/subtitle track for a file.</details></div> </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="BoxSetGrouping">Box-set/Movie grouping:</label> From d3778876da2e48f33ea13b3b7f9585569c5073ad Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 24 Jan 2023 22:38:48 +0000 Subject: [PATCH 0448/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 008bec6f..f31fdc4f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.33", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.33/shoko_2.0.1.33.zip", + "checksum": "adb467a4f805cbf43b909e186d7b3f75", + "timestamp": "2023-01-24T22:38:46Z" + }, { "version": "2.0.1.32", "changelog": "NA\n", From b5ea3bfb2dd8ad3d9452b14d2e0af0bb6a6ad249 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 26 Jan 2023 02:02:58 +0100 Subject: [PATCH 0449/1103] clamup: Remove uncessesary puncation marks --- Shokofin/Utils/TextUtil.cs | 43 -------------------------------------- 1 file changed, 43 deletions(-) diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index e48289a2..8600213c 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -17,48 +17,22 @@ public class Text ':', // colon '!', // exclamation point '?', // question mark - '-', // hyphen - '_', // underscore - '(', // left parenthesis ')', // right parenthesis - '[', // left bracket ']', // right bracket - '{', // left brace '}', // right brace '"', // double quote '\'', // single quote - '/', // forward slash - '\\', // backslash - '|', // vertical bar - '@', // at symbol - '#', // pound or hash - '$', // dollar - '%', // percent - '^', // caret - '&', // ampersand - '*', // asterisk - '+', // plus - '=', // equals - '<', // less-than - '>', // greater-than - '。', // Chinese full stop ',', // Chinese comma '、', // Chinese enumeration comma - ';', // Chinese semicolon - ':', // Chinese colon '!', // Chinese exclamation point '?', // Chinese question mark '“', // Chinese double quote '”', // Chinese double quote '‘', // Chinese single quote '’', // Chinese single quote - '【', // Chinese left bracket '】', // Chinese right bracket - '《', // Chinese left angle bracket '》', // Chinese right angle bracket - '(', // Chinese left parenthesis ')', // Chinese right parenthesis - '-', // Chinese hyphen '・', // Japanese middle dot // Less common punctuation marks @@ -73,34 +47,17 @@ public class Text '⁉', // exclamation mark '‽', // interrobang '※', // reference mark - '‒', // figure dash - '–', // en dash - '—', // em dash - '⸺', // two-em dash - '⸻', // three-em dash - '⟨', // left angle bracket '⟩', // right angle bracket - '❮', // left angle bracket '❯', // right angle bracket - '❬', // left angle bracket '❭', // right angle bracket - '〈', // left angle bracket '〉', // right angle bracket - '⌈', // left angle bracket '⌉', // right angle bracket - '⌊', // left angle bracket '⌋', // right angle bracket - '⦃', // left angle bracket '⦄', // right angle bracket - '⦅', // left angle bracket '⦆', // right angle bracket - '⦇', // left angle bracket '⦈', // right angle bracket - '⦉', // left angle bracket '⦊', // right angle bracket - '⦋', // left angle bracket '⦌', // right angle bracket - '⦍', // left angle bracket '⦎', // right angle bracket }; From 096d2358b05751b08e41c0a85d8502538554ccb4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 26 Jan 2023 04:05:10 +0100 Subject: [PATCH 0450/1103] feature: add the ability to merge multiple versions for the entries with the same shoko episode ids. --- Shokofin/API/ShokoAPIClient.cs | 9 +- Shokofin/API/ShokoAPIManager.cs | 14 +- Shokofin/Configuration/PluginConfiguration.cs | 19 +- Shokofin/Configuration/configController.js | 15 +- Shokofin/Configuration/configPage.html | 28 +- Shokofin/MergeVersions/MergeVersionManager.cs | 380 ++++++++++++++++++ Shokofin/PluginServiceRegistrator.cs | 1 + Shokofin/Tasks/MergeAllTask.cs | 68 ++++ Shokofin/Tasks/MergeEpisodesTask.cs | 68 ++++ Shokofin/Tasks/MergeMoviesTask.cs | 68 ++++ Shokofin/Tasks/PostScanTask.cs | 15 +- Shokofin/Tasks/SplitAllTask.cs | 68 ++++ Shokofin/Tasks/SplitEpisodesTask.cs | 68 ++++ Shokofin/Tasks/SplitMoviesTask.cs | 68 ++++ 14 files changed, 872 insertions(+), 17 deletions(-) create mode 100644 Shokofin/MergeVersions/MergeVersionManager.cs create mode 100644 Shokofin/Tasks/MergeAllTask.cs create mode 100644 Shokofin/Tasks/MergeEpisodesTask.cs create mode 100644 Shokofin/Tasks/MergeMoviesTask.cs create mode 100644 Shokofin/Tasks/SplitAllTask.cs create mode 100644 Shokofin/Tasks/SplitEpisodesTask.cs create mode 100644 Shokofin/Tasks/SplitMoviesTask.cs diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index e589eb8c..13020ff2 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -14,7 +14,7 @@ namespace Shokofin.API; /// <summary> /// All API calls to Shoko needs to go through this gateway. /// </summary> -public class ShokoAPIClient +public class ShokoAPIClient : IDisposable { private readonly HttpClient _httpClient; @@ -26,6 +26,13 @@ public ShokoAPIClient(ILogger<ShokoAPIClient> logger) _httpClient.Timeout = TimeSpan.FromMinutes(10); Logger = logger; } + + #region Base Implementation + + public void Dispose() + { + _httpClient.Dispose(); + } private Task<ReturnType> Get<ReturnType>(string url, string? apiKey = null) => Get<ReturnType>(url, HttpMethod.Get, apiKey); diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index a48b83b2..83f82442 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -20,7 +20,7 @@ #nullable enable namespace Shokofin.API; -public class ShokoAPIManager +public class ShokoAPIManager : IDisposable { private readonly ILogger<ShokoAPIManager> Logger; @@ -138,9 +138,9 @@ public bool IsInMixedLibrary(ItemLookupInfo info) #endregion #region Clear - public void Clear() + public void Dispose() { - Logger.LogDebug("Clearing data."); + Logger.LogDebug("Disposing data…"); DataCache.Dispose(); EpisodeIdToEpisodePathDictionary.Clear(); EpisodeIdToSeriesIdDictionary.Clear(); @@ -151,9 +151,17 @@ public void Clear() PathToSeriesIdDictionary.Clear(); SeriesIdToGroupIdDictionary.Clear(); SeriesIdToPathDictionary.Clear(); + } + + public void Clear() + { + Logger.LogDebug("Clearing data…"); + Dispose(); + Logger.LogDebug("Initialising new cache…"); DataCache = (new MemoryCache((new MemoryCacheOptions() { ExpirationScanFrequency = ExpirationScanFrequency, }))); + Logger.LogDebug("Cleanup complete."); } #endregion diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 323e1dd1..9d59001a 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -54,8 +54,6 @@ public virtual string PrettyHost public bool AddTMDBId { get; set; } - public bool MergeQuartSeasons { get; set; } - public TextSourceType DescriptionSource { get; set; } public SeriesAndBoxSetGroupType SeriesGrouping { get; set; } @@ -82,6 +80,18 @@ public virtual string PrettyHost public string[] IgnoredFolders { get; set; } + #region Experimental features + + public bool EXPERIMENTAL_AutoMergeVersions { get; set; } + + public bool EXPERIMENTAL_SplitThenMergeMovies { get; set; } + + public bool EXPERIMENTAL_SplitThenMergeEpisodes { get; set; } + + public bool EXPERIMENTAL_MergeSeasons { get; set; } + + #endregion + public PluginConfiguration() { Host = "http://127.0.0.1:8111"; @@ -103,7 +113,6 @@ public PluginConfiguration() AddAniDBId = true; AddTvDBId = true; AddTMDBId = true; - MergeQuartSeasons = false; TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; DescriptionSource = TextSourceType.Default; @@ -117,6 +126,10 @@ public PluginConfiguration() UserList = Array.Empty<UserConfiguration>(); IgnoredFileExtensions = new [] { ".nfo", ".jpg", ".jpeg", ".png" }; IgnoredFolders = new [] { ".streams", "@recently-snapshot" }; + EXPERIMENTAL_AutoMergeVersions = false; + EXPERIMENTAL_SplitThenMergeMovies = true; + EXPERIMENTAL_SplitThenMergeEpisodes = false; + EXPERIMENTAL_MergeSeasons = false; } } } diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index f0a5622b..fb8ec712 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -176,7 +176,10 @@ async function defaultSubmit(form) { form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); config.IgnoredFolders = ignoredFolders; form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); - config.MergeQuartSeasons = form.querySelector("#MergeQuartSeasons").checked; + 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 const userId = form.querySelector("#UserSelector").value; @@ -329,7 +332,10 @@ async function syncSettings(form) { form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); config.IgnoredFolders = ignoredFolders; form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); - config.MergeQuartSeasons = form.querySelector("#MergeQuartSeasons").checked; + 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); Dashboard.processPluginConfigurationUpdateResult(result); @@ -500,7 +506,10 @@ export default function (page) { form.querySelector("#PublicHost").value = config.PublicHost; form.querySelector("#IgnoredFileExtensions").value = config.IgnoredFileExtensions.join(" "); form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); - form.querySelector("#MergeQuartSeasons").checked = config.MergeQuartSeasons; + form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked = config.EXPERIMENTAL_AutoMergeVersions || false; + form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked = 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) { Dashboard.alert(Messages.ConnectToShoko); diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 69e0c51f..f81d5788 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -318,12 +318,32 @@ <h3>Advanced Settings</h3> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="MergeQuartSeasons" /> - <span>Automatically merge quart seasons</span> + <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_AutoMergeVersions" /> + <span>Automatically merge multiple versions</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enable at your own risk</div> + <div class="fieldDescription checkboxFieldDescription"><div>Merge multiple versions of the same item together. Only applies to items with a Shoko ID set.</div><div><strong>Warning:</strong> Enable at your own risk.</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><div><strong>Warning:</strong> Enable at your own risk.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What is this setting for?</summary>Enabling this setting will probably make the plugin do a lot of unneeded work on 99.5% of all movies, but will help for the last 0.5% that actually need it. An example is ensuring multiple parts of a movie series are not merged because they contain the same shok 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><div><strong>Warning:</strong> Enable at your own risk.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What is this setting for?</summary>Enabling this setting will probably make the plugin do a lot of unneeded work on 99.5% of all movies, but will help for the last 0.5% that actually need it.</details></div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_MergeSeasons" /> + <span>Automatically merge cour seasons</span> + </label> + <div class="fieldDescription checkboxFieldDescription"><div>Coming soon™ to a media library near you.</div><div><strong>Warning:</strong> Enable at your own risk.</div></div> </div> - <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> </button> diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs new file mode 100644 index 00000000..f548147c --- /dev/null +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -0,0 +1,380 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging; +using Jellyfin.Data.Enums; +using System.Globalization; +using MediaBrowser.Model.Entities; +using MediaBrowser.Common.Progress; + +namespace Shokofin.MergeVersions; + +public class MergeVersionsManager +{ + private readonly ILibraryManager LibraryManager; + + private readonly IIdLookup Lookup; + + private readonly ILogger<MergeVersionsManager> Logger; + + public MergeVersionsManager(ILibraryManager libraryManager, IIdLookup lookup, ILogger<MergeVersionsManager> logger) + { + LibraryManager = libraryManager; + Lookup = lookup; + Logger = logger; + } + + #region Shared + + public async Task MergeAll(IProgress<double> progress, CancellationToken cancellationToken, bool canSplit = true) + { + // Shared progress; + double episodeProgressValue = 0d, movieProgressValue = 0d; + + // Setup the movie task. + var movieProgress = new ActionableProgress<double>(); + movieProgress.RegisterAction(value => { + movieProgressValue = value / 2d; + progress?.Report(movieProgressValue + episodeProgressValue); + }); + var movieTask = MergeMovies(movieProgress, cancellationToken, canSplit); + + // Setup the episode task. + var episodeProgress = new ActionableProgress<double>(); + episodeProgress.RegisterAction(value => { + episodeProgressValue = value / 2d; + progress?.Report(movieProgressValue + episodeProgressValue); + progress?.Report(50d + (value / 2d)); + }); + var episodeTask = MergeEpisodes(episodeProgress, cancellationToken, canSplit); + + // Run them in parallel. + await Task.WhenAll(movieTask, episodeTask); + } + + public async Task SplitAll(IProgress<double> progress, CancellationToken cancellationToken) + { + // Shared progress; + double episodeProgressValue = 0d, movieProgressValue = 0d; + + // Setup the movie task. + var movieProgress = new ActionableProgress<double>(); + movieProgress.RegisterAction(value => { + movieProgressValue = value / 2d; + progress?.Report(movieProgressValue + episodeProgressValue); + }); + var movieTask = SplitMovies(movieProgress, cancellationToken); + + // Setup the episode task. + var episodeProgress = new ActionableProgress<double>(); + episodeProgress.RegisterAction(value => { + episodeProgressValue = value / 2d; + progress?.Report(movieProgressValue + episodeProgressValue); + progress?.Report(50d + (value / 2d)); + }); + var episodeTask = SplitEpisodes(episodeProgress, cancellationToken); + + // Run them in parallel. + await Task.WhenAll(movieTask, episodeTask); + } + + #endregion Shared + #region Movies + + private List<Movie> GetMoviesFromLibrary() + { + return LibraryManager.GetItemList(new() { + IncludeItemTypes = new[] { BaseItemKind.Movie }, + IsVirtualItem = false, + Recursive = true, + HasAnyProviderId = new Dictionary<string, string> { {"Shoko Episode", "" } }, + }) + .Cast<Movie>() + .Where(Lookup.IsEnabledForItem) + .ToList(); + } + + public async Task MergeMovies(IEnumerable<Movie> movies) + => await MergeVideos(movies.Cast<Video>().OrderBy(e => e.Id).ToList()); + + public async Task MergeMovies(IProgress<double> progress, CancellationToken cancellationToken, bool canSplit = true) + { + if (canSplit && Plugin.Instance.Configuration.EXPERIMENTAL_SplitThenMergeMovies) { + await SplitAndMergeMovies(progress, cancellationToken); + return; + } + + // Merge all movies with more than one version. + var movies = GetMoviesFromLibrary(); + var duplicationGroups = movies + .GroupBy(x => x.ProviderIds["Shoko Episode"]) + .Where(x => x.Count() > 1) + .ToList(); + double currentCount = 0d; + double totalGroups = duplicationGroups.Count; + foreach (var movieGroup in duplicationGroups) { + // Handle cancelation and update progress. + cancellationToken.ThrowIfCancellationRequested(); + var percent = (currentCount++ / totalGroups) * 100; + progress?.Report(percent); + + // Link the movies together as alternate sources. + await MergeMovies(movieGroup); + } + + progress?.Report(100); + } + + public async Task SplitMovies(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 cancelation and update progress. + cancellationToken.ThrowIfCancellationRequested(); + var percent = (currentCount++ / totalMovies) * 100d; + progress?.Report(percent); + + // Remove all alternate sources linked to the movie. + await RemoveAlternateSources(movie); + } + + progress?.Report(100); + } + + private async Task SplitAndMergeMovies(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 cancelation 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(x => x.ProviderIds["Shoko Episode"]) + .Where(x => x.Count() > 1) + .ToList(); + currentCount = 0d; + totalCount = duplicationGroups.Count; + foreach (var movieGroup in duplicationGroups) { + // Handle cancelation 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 + + private List<Episode> GetEpisodesFromLibrary() + { + return LibraryManager.GetItemList(new() { + IncludeItemTypes = new[] { BaseItemKind.Episode }, + HasAnyProviderId = new Dictionary<string, string> { {"Shoko Episode", "" } }, + IsVirtualItem = false, + Recursive = true, + }) + .Cast<Episode>() + .Where(Lookup.IsEnabledForItem) + .ToList(); + } + + public async Task MergeEpisodes(IEnumerable<Episode> episodes) + => await MergeVideos(episodes.Cast<Video>().OrderBy(e => e.Id).ToList()); + + public async Task MergeEpisodes(IProgress<double> progress, CancellationToken cancellationToken, bool canSplit = true) + { + if (canSplit && Plugin.Instance.Configuration.EXPERIMENTAL_SplitThenMergeMovies) { + await SplitAndMergeEpisodes(progress, cancellationToken); + return; + } + + // Merge episodes with more than one version. + var episodes = GetEpisodesFromLibrary(); + var duplicationGroups = episodes + .GroupBy(x => x.ProviderIds["Shoko Episode"]) + .Where(x => x.Count() > 1) + .ToList(); + double currentCount = 0d; + double totalGroups = duplicationGroups.Count; + foreach (var episodeGroup in duplicationGroups) { + // Handle cancelation and update progress. + cancellationToken.ThrowIfCancellationRequested(); + var percent = (currentCount++ / totalGroups) * 100d; + progress?.Report(percent); + + // Link the episodes together as alternate sources. + await MergeEpisodes(episodeGroup); + } + + progress?.Report(100); + } + + public async Task SplitEpisodes(IProgress<double> progress, CancellationToken cancellationToken) + { + // Split up any existing merged episodes. + var episodes = GetEpisodesFromLibrary(); + double currentCount = 0d; + double totalEpisodes = episodes.Count; + foreach (var e in episodes) { + // Handle cancelation and update progress. + cancellationToken.ThrowIfCancellationRequested(); + var percent = (currentCount++ / totalEpisodes) * 100d; + progress?.Report(percent); + + // Remove all alternate sources linked to the episode. + await RemoveAlternateSources(e); + } + + progress?.Report(100); + } + + private async Task SplitAndMergeEpisodes(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 cancelation 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). + var duplicationGroups = episodes + .GroupBy(x => x.ProviderIds["Shoko Episode"]) + .Where(x => x.Count() > 1) + .ToList(); + currentCount = 0d; + totalCount = duplicationGroups.Count; + foreach (var episodeGroup in duplicationGroups) { + // Handle cancelation 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> + /// 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 async Task MergeVideos(List<Video> videos) + { + if (videos.Count < 2) + return; + + var primaryVersion = videos.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId)); + if (primaryVersion == null) + { + primaryVersion = videos + .OrderBy(i => + { + if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile) + { + return 1; + } + + return 0; + }) + .ThenByDescending(i => i.GetDefaultVideoStream()?.Width ?? 0) + .First(); + } + + var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions.ToList(); + + foreach (var video in videos.Where(i => !i.Id.Equals(primaryVersion.Id))) + { + video.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture)); + + await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + + if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, video.Path, StringComparison.OrdinalIgnoreCase))) + { + alternateVersionsOfPrimary.Add(new() { + Path = video.Path, + ItemId = video.Id, + }); + } + + foreach (var linkedItem in video.LinkedAlternateVersions) + { + if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) + { + alternateVersionsOfPrimary.Add(linkedItem); + } + } + + if (video.LinkedAlternateVersions.Length > 0) + { + video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } + } + + primaryVersion.LinkedAlternateVersions = alternateVersionsOfPrimary.ToArray(); + await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } + + /// <summary> + /// Removes alternate video sources from a video + /// </summary> + /// <param name="baseItem">The video to clean up.</param> + /// + /// Modified from; + /// https://github.com/jellyfin/jellyfin/blob/9c97c533eff94d25463fb649c9572234da4af1ea/Jellyfin.Api/Controllers/VideosController.cs#L152 + private async Task RemoveAlternateSources(Video video) + { + // Find the primary video. + if (video.LinkedAlternateVersions.Length == 0) + video = LibraryManager.GetItemById(video.PrimaryVersionId) as Video; + + // Remove the link for every linked video. + foreach (var linkedVideo in video.GetLinkedAlternateVersions()) + { + linkedVideo.SetPrimaryVersionId(null); + linkedVideo.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + await linkedVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } + + // Remove the link for the primary video. + video.SetPrimaryVersionId(null); + video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + } +} diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index 0ac4b343..f77811ca 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -14,6 +14,7 @@ public void RegisterServices(IServiceCollection serviceCollection) serviceCollection.AddSingleton<Shokofin.API.ShokoAPIManager>(); serviceCollection.AddSingleton<IIdLookup, IdLookup>(); serviceCollection.AddSingleton<Shokofin.Sync.UserDataSyncManager>(); + serviceCollection.AddSingleton<Shokofin.MergeVersions.MergeVersionsManager>(); } } } \ No newline at end of file diff --git a/Shokofin/Tasks/MergeAllTask.cs b/Shokofin/Tasks/MergeAllTask.cs new file mode 100644 index 00000000..27fb3f23 --- /dev/null +++ b/Shokofin/Tasks/MergeAllTask.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.MergeVersions; + +namespace Shokofin.Tasks +{ + /// <summary> + /// Class MergeAllTask. + /// </summary> + public class MergeAllTask : IScheduledTask + { + /// <summary> + /// The merge-versions manager. + /// </summary> + private readonly MergeVersionsManager VersionsManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MergeAllTask" /> class. + /// </summary> + public MergeAllTask(MergeVersionsManager userSyncManager) + { + VersionsManager = userSyncManager; + } + + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new TaskTriggerInfo[0]; + } + + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await VersionsManager.MergeAll(progress, cancellationToken, false); + } + + /// <inheritdoc /> + public string Name => "Merge both movies and episodes"; + + /// <inheritdoc /> + public string Description => "Merge all movie and episode entries with the same Shoko Episode ID set."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoMergeAll"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + } +} diff --git a/Shokofin/Tasks/MergeEpisodesTask.cs b/Shokofin/Tasks/MergeEpisodesTask.cs new file mode 100644 index 00000000..f545bdea --- /dev/null +++ b/Shokofin/Tasks/MergeEpisodesTask.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.MergeVersions; + +namespace Shokofin.Tasks +{ + /// <summary> + /// Class MergeEpisodesTask. + /// </summary> + public class MergeEpisodesTask : IScheduledTask + { + /// <summary> + /// The merge-versions manager. + /// </summary> + private readonly MergeVersionsManager VersionsManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MergeEpisodesTask" /> class. + /// </summary> + public MergeEpisodesTask(MergeVersionsManager userSyncManager) + { + VersionsManager = userSyncManager; + } + + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new TaskTriggerInfo[0]; + } + + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await VersionsManager.MergeEpisodes(progress, cancellationToken, false); + } + + /// <inheritdoc /> + public string Name => "Merge episodes"; + + /// <inheritdoc /> + public string Description => "Merge all episode entries with the same Shoko Episode ID set."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoMergeEpisodes"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + } +} diff --git a/Shokofin/Tasks/MergeMoviesTask.cs b/Shokofin/Tasks/MergeMoviesTask.cs new file mode 100644 index 00000000..5d8fdab4 --- /dev/null +++ b/Shokofin/Tasks/MergeMoviesTask.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.MergeVersions; + +namespace Shokofin.Tasks +{ + /// <summary> + /// Class MergeMoviesTask. + /// </summary> + public class MergeMoviesTask : IScheduledTask + { + /// <summary> + /// The merge-versions manager. + /// </summary> + private readonly MergeVersionsManager VersionsManager; + + /// <summary> + /// Initializes a new instance of the <see cref="MergeMoviesTask" /> class. + /// </summary> + public MergeMoviesTask(MergeVersionsManager userSyncManager) + { + VersionsManager = userSyncManager; + } + + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new TaskTriggerInfo[0]; + } + + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await VersionsManager.MergeMovies(progress, cancellationToken, false); + } + + /// <inheritdoc /> + public string Name => "Merge movies"; + + /// <inheritdoc /> + public string Description => "Merge all movie entries with the same Shoko Episode ID set."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoMergeMovies"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + } +} diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs index a2d7a2e8..d7e1ce43 100644 --- a/Shokofin/Tasks/PostScanTask.cs +++ b/Shokofin/Tasks/PostScanTask.cs @@ -1,5 +1,6 @@ using MediaBrowser.Controller.Library; using Shokofin.API; +using Shokofin.MergeVersions; using System; using System.Threading; using System.Threading.Tasks; @@ -8,16 +9,24 @@ namespace Shokofin.Tasks { public class PostScanTask : ILibraryPostScanTask { - private ShokoAPIManager ApiManager; + private readonly ShokoAPIManager ApiManager; - public PostScanTask(ShokoAPIManager apiManager) + private readonly MergeVersionsManager VersionsManager; + + public PostScanTask(ShokoAPIManager apiManager, MergeVersionsManager versionsManager) { ApiManager = apiManager; + VersionsManager = versionsManager; } public async Task Run(IProgress<double> progress, CancellationToken token) { - await ApiManager.PostProcess(progress, token).ConfigureAwait(false); + // Merge versions now if the setting is enabled. + if (Plugin.Instance.Configuration.EXPERIMENTAL_AutoMergeVersions) + await VersionsManager.MergeAll(progress, token, true); + + // Clear the cache now, since we don't need it anymore. + ApiManager.Clear(); } } } diff --git a/Shokofin/Tasks/SplitAllTask.cs b/Shokofin/Tasks/SplitAllTask.cs new file mode 100644 index 00000000..1e3e04c1 --- /dev/null +++ b/Shokofin/Tasks/SplitAllTask.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.MergeVersions; + +namespace Shokofin.Tasks +{ + /// <summary> + /// Class SplitAllTask. + /// </summary> + public class SplitAllTask : IScheduledTask + { + /// <summary> + /// The merge-versions manager. + /// </summary> + private readonly MergeVersionsManager VersionsManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SplitAllTask" /> class. + /// </summary> + public SplitAllTask(MergeVersionsManager userSyncManager) + { + VersionsManager = userSyncManager; + } + + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new TaskTriggerInfo[0]; + } + + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await VersionsManager.SplitAll(progress, cancellationToken); + } + + /// <inheritdoc /> + public string Name => "Split both movies and episodes"; + + /// <inheritdoc /> + public string Description => "Split all movie and episode entries with a Shoko Episode ID set."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoSplitAll"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + } +} diff --git a/Shokofin/Tasks/SplitEpisodesTask.cs b/Shokofin/Tasks/SplitEpisodesTask.cs new file mode 100644 index 00000000..84f74cf6 --- /dev/null +++ b/Shokofin/Tasks/SplitEpisodesTask.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.MergeVersions; + +namespace Shokofin.Tasks +{ + /// <summary> + /// Class SplitEpisodesTask. + /// </summary> + public class SplitEpisodesTask : IScheduledTask + { + /// <summary> + /// The merge-versions manager. + /// </summary> + private readonly MergeVersionsManager VersionsManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SplitEpisodesTask" /> class. + /// </summary> + public SplitEpisodesTask(MergeVersionsManager userSyncManager) + { + VersionsManager = userSyncManager; + } + + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new TaskTriggerInfo[0]; + } + + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await VersionsManager.SplitEpisodes(progress, cancellationToken); + } + + /// <inheritdoc /> + public string Name => "Split episodes"; + + /// <inheritdoc /> + public string Description => "Split all episode entries with a Shoko Episode ID set."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoSplitEpisodes"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + } +} diff --git a/Shokofin/Tasks/SplitMoviesTask.cs b/Shokofin/Tasks/SplitMoviesTask.cs new file mode 100644 index 00000000..6337b9bc --- /dev/null +++ b/Shokofin/Tasks/SplitMoviesTask.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.MergeVersions; + +namespace Shokofin.Tasks +{ + /// <summary> + /// Class SplitMoviesTask. + /// </summary> + public class SplitMoviesTask : IScheduledTask + { + /// <summary> + /// The merge-versions manager. + /// </summary> + private readonly MergeVersionsManager VersionsManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SplitMoviesTask" /> class. + /// </summary> + public SplitMoviesTask(MergeVersionsManager userSyncManager) + { + VersionsManager = userSyncManager; + } + + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new TaskTriggerInfo[0]; + } + + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await VersionsManager.SplitMovies(progress, cancellationToken); + } + + /// <inheritdoc /> + public string Name => "Split movies"; + + /// <inheritdoc /> + public string Description => "Split all movie entries with a Shoko Episode ID set."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoSplitMovies"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + } +} From 07b0412e510309f346422c538f3b1933c176a0f9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 26 Jan 2023 04:05:41 +0100 Subject: [PATCH 0451/1103] misC: use `is` guard instead of casting with `as` --- Shokofin/LibraryScanner.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index a09f428c..68386cc0 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -40,7 +40,7 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base { // Everything in the root folder is ignored by us. var root = LibraryManager.RootFolder; - if (fileInfo == null || parent == null || root == null || parent == root || !(parent is Folder) || fileInfo.FullName.StartsWith(root.Path)) + if (fileInfo == null || parent == null || root == null || parent == root || !(parent is Folder parentFolder) || fileInfo.FullName.StartsWith(root.Path)) return false; try { @@ -59,10 +59,10 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base } var fullPath = fileInfo.FullName; - var mediaFolder = ApiManager.FindMediaFolder(fullPath, parent as Folder, root); + var mediaFolder = ApiManager.FindMediaFolder(fullPath, parentFolder, root); var partialPath = fullPath.Substring(mediaFolder.Path.Length); if (fileInfo.IsDirectory) - return ScanDirectory(partialPath, fullPath, LibraryManager.GetInheritedContentType(parent)); + return ScanDirectory(partialPath, fullPath, LibraryManager.GetInheritedContentType(parentFolder)); else return ScanFile(partialPath, fullPath); } From 629a66ae70dbbdc47b82df49776fd1a88105e8cc Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 26 Jan 2023 04:06:14 +0100 Subject: [PATCH 0452/1103] misc: use safe cast --- Shokofin/API/ShokoAPIManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 83f82442..be72ca3e 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -67,7 +67,7 @@ public Folder FindMediaFolder(string path) { var mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); if (mediaFolder == null) { - var parent = (Folder?)LibraryManager.FindByPath(Path.GetDirectoryName(path), true); + var parent = LibraryManager.FindByPath(Path.GetDirectoryName(path), true) as Folder; if (parent == null) throw new Exception($"Unable to find parent folder for \"{path}\""); From cf0a713b54ec6348a36e54f4a3c38c13e89bde0f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 26 Jan 2023 04:07:47 +0100 Subject: [PATCH 0453/1103] misc: move episode methods below file methods --- Shokofin/API/ShokoAPIClient.cs | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 13020ff2..d23c361b 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -26,9 +26,9 @@ public ShokoAPIClient(ILogger<ShokoAPIClient> logger) _httpClient.Timeout = TimeSpan.FromMinutes(10); Logger = logger; } - + #region Base Implementation - + public void Dispose() { _httpClient.Dispose(); @@ -158,6 +158,8 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method } } + #endregion Base Implementation + public async Task<ApiKey?> GetApiKey(string username, string password, bool forUser = false) { var postData = JsonSerializer.Serialize(new Dictionary<string, string> @@ -174,16 +176,6 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method return null; } - public Task<Episode> GetEpisode(string id) - { - return Get<Episode>($"/api/v3/Episode/{id}?includeDataFrom=AniDB&includeDataFrom=TvDB"); - } - - public Task<List<Episode>> GetEpisodesFromSeries(string seriesId) - { - return Get<List<Episode>>($"/api/v3/Series/{seriesId}/Episode?includeMissing=true&includeDataFrom=AniDB&includeDataFrom=TvDB"); - } - public Task<File> GetFile(string id) { return Get<File>($"/api/v3/File/{id}?includeXRefs=true"); @@ -239,6 +231,16 @@ public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eve return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); } + public Task<Episode> GetEpisode(string id) + { + return Get<Episode>($"/api/v3/Episode/{id}?includeDataFrom=AniDB&includeDataFrom=TvDB"); + } + + public Task<List<Episode>> GetEpisodesFromSeries(string seriesId) + { + return Get<List<Episode>>($"/api/v3/Series/{seriesId}/Episode?includeMissing=true&includeDataFrom=AniDB&includeDataFrom=TvDB"); + } + public Task<Series> GetSeries(string id) { return Get<Series>($"/api/v3/Series/{id}?includeDataFrom=AniDB&includeDataFrom=TvDB"); From f1d43c4005d507504f9276d781f62501d5594a42 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 26 Jan 2023 04:08:07 +0100 Subject: [PATCH 0454/1103] misc: switch to using query parameter for search --- 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 d23c361b..7ab51c9a 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -288,6 +288,6 @@ public Task<Group> GetGroupFromSeries(string id) public Task<ListResult<Series.AniDB>> SeriesSearch(string query) { - return Get<ListResult<Series.AniDB>>($"/api/v3/Series/AniDB/Search/{Uri.EscapeDataString(query)}?local=true&includeTitles=true&pageSize=0"); + return Get<ListResult<Series.AniDB>>($"/api/v3/Series/AniDB/Search?query={Uri.EscapeDataString(query ?? "")}&local=true&includeTitles=true&pageSize=0"); } } From abd6a24308a93ead474fe7306e52d54c4b0bed4b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 26 Jan 2023 04:15:28 +0100 Subject: [PATCH 0455/1103] misc: add experimental section --- Shokofin/Configuration/configController.js | 2 ++ Shokofin/Configuration/configPage.html | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index fb8ec712..1bba6673 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -421,6 +421,7 @@ export default function (page) { form.querySelector("#UserSection").removeAttribute("hidden"); form.querySelector("#TagSection").removeAttribute("hidden"); form.querySelector("#AdvancedSection").removeAttribute("hidden"); + form.querySelector("#ExperimentalSection").removeAttribute("hidden"); } else { form.querySelector("#Host").removeAttribute("disabled"); @@ -435,6 +436,7 @@ export default function (page) { form.querySelector("#UserSection").setAttribute("hidden", ""); form.querySelector("#TagSection").setAttribute("hidden", ""); form.querySelector("#AdvancedSection").setAttribute("hidden", ""); + form.querySelector("#ExperimentalSection").setAttribute("hidden", ""); } const userId = form.querySelector("#UserSelector").value; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index f81d5788..11fbac45 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -316,33 +316,42 @@ <h3>Advanced Settings</h3> <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 the library scan.</div> </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="ExperimentalSection" class="verticalSection verticalSection-extrabottompadding" 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></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>Merge multiple versions of the same item together. Only applies to items with a Shoko ID set.</div><div><strong>Warning:</strong> Enable at your own risk.</div></div> + <div class="fieldDescription checkboxFieldDescription"><div>Merge multiple versions of the same item together. 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><div><strong>Warning:</strong> Enable at your own risk.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What is this setting for?</summary>Enabling this setting will probably make the plugin do a lot of unneeded work on 99.5% of all movies, but will help for the last 0.5% that actually need it. An example is ensuring multiple parts of a movie series are not merged because they contain the same shok series id.</details></div> + <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 probably make the plugin do a lot of unneeded work on 99.5% of all movies, but will help for the last 0.5% that actually need it. An example is ensuring multiple parts of a movie series are not merged because they contain the same shok 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><div><strong>Warning:</strong> Enable at your own risk.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What is this setting for?</summary>Enabling this setting will probably make the plugin do a lot of unneeded work on 99.5% of all movies, but will help for the last 0.5% that actually need it.</details></div> + <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 probably make the plugin do a lot of unneeded work on 99.5% of all movies, but will help for the last 0.5% that actually need it.</details></div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_MergeSeasons" /> <span>Automatically merge cour seasons</span> </label> - <div class="fieldDescription checkboxFieldDescription"><div>Coming soon™ to a media library near you.</div><div><strong>Warning:</strong> Enable at your own risk.</div></div> + <div class="fieldDescription checkboxFieldDescription"><div>Coming soon™ to a media library near you.</div></div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> From 79ea04e4068af3ac2c1be810c841782c2fc14286 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 26 Jan 2023 03:16:13 +0000 Subject: [PATCH 0456/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index f31fdc4f..72344c68 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.34", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.34/shoko_2.0.1.34.zip", + "checksum": "d37356ec1266c68c975330e5ee24967b", + "timestamp": "2023-01-26T03:16:11Z" + }, { "version": "2.0.1.33", "changelog": "NA\n", From 48b9ffacd553432598823638970e42ca58d1a7ef Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 26 Jan 2023 04:28:58 +0100 Subject: [PATCH 0457/1103] misc: minor style fixes and logic fixes for the merge versions manager. --- Shokofin/MergeVersions/MergeVersionManager.cs | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index f548147c..a030d97a 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -316,39 +316,37 @@ private async Task MergeVideos(List<Video> videos) .First(); } - var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions.ToList(); - - foreach (var video in videos.Where(i => !i.Id.Equals(primaryVersion.Id))) + // Add any videos not already linked to the primary version to the list. + 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)); - - await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - - if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, video.Path, StringComparison.OrdinalIgnoreCase))) - { + if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, video.Path, StringComparison.OrdinalIgnoreCase))) { alternateVersionsOfPrimary.Add(new() { Path = video.Path, ItemId = video.Id, }); } - foreach (var linkedItem in video.LinkedAlternateVersions) - { + foreach (var linkedItem in video.LinkedAlternateVersions) { if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) - { alternateVersionsOfPrimary.Add(linkedItem); - } } + // Reset the linked alternate versions for the linked videos. if (video.LinkedAlternateVersions.Length > 0) - { video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); - } + + // Save the changes back to the repository. + await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) + .ConfigureAwait(false); } - primaryVersion.LinkedAlternateVersions = alternateVersionsOfPrimary.ToArray(); - await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + primaryVersion.LinkedAlternateVersions = alternateVersionsOfPrimary + .ToArray(); + await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) + .ConfigureAwait(false); } /// <summary> @@ -362,19 +360,30 @@ private async Task RemoveAlternateSources(Video video) { // Find the primary video. if (video.LinkedAlternateVersions.Length == 0) + { + // Ensure we're not running on an unlinked item. + if (string.IsNullOrEmpty(video.PrimaryVersionId)) + return; + + // Make sure the primary video still exists before we proceed. video = LibraryManager.GetItemById(video.PrimaryVersionId) as Video; + if (video == null) + return; + } // Remove the link for every linked video. foreach (var linkedVideo in video.GetLinkedAlternateVersions()) { linkedVideo.SetPrimaryVersionId(null); linkedVideo.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - await linkedVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + await linkedVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) + .ConfigureAwait(false); } // Remove the link for the primary video. video.SetPrimaryVersionId(null); video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); - await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) + .ConfigureAwait(false); } } From d1088fb47bce34bc1f0986d73192af7c17ec1a6d Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 26 Jan 2023 03:29:42 +0000 Subject: [PATCH 0458/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 72344c68..70d57507 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.35", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.35/shoko_2.0.1.35.zip", + "checksum": "efe50ae0b63f424698c3e09d4d285f9d", + "timestamp": "2023-01-26T03:29:40Z" + }, { "version": "2.0.1.34", "changelog": "NA\n", From 787f41aa36a24e56bc352f8a09df5d43ff6d207e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 26 Jan 2023 04:30:46 +0100 Subject: [PATCH 0459/1103] misc: update description for auto merge versions --- 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 11fbac45..fbf6d517 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -330,7 +330,7 @@ <h3>Experimental Settings</h3> <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_AutoMergeVersions" /> <span>Automatically merge multiple versions</span> </label> - <div class="fieldDescription checkboxFieldDescription"><div>Merge multiple versions of the same item together. Only applies to items with a Shoko ID set.</div></div> + <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"> From e10f963d97e60bfd875273d9fb944c6d0459ca4d Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 26 Jan 2023 03:31:30 +0000 Subject: [PATCH 0460/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 70d57507..2352a0f6 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.36", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.36/shoko_2.0.1.36.zip", + "checksum": "5a46e15f566d0116f919a92aa1bb2326", + "timestamp": "2023-01-26T03:31:28Z" + }, { "version": "2.0.1.35", "changelog": "NA\n", From 091d1e12a093f62945ec2813d31752041e81d11c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 26 Jan 2023 08:36:26 +0100 Subject: [PATCH 0461/1103] misc: remove early close tag that was automatically added by the IDE and I didn't notice. --- 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 fbf6d517..6d277741 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -324,7 +324,7 @@ <h3>Advanced Settings</h3> <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></strong>You can enable them, but at the risk if them messing up your library.</strong></div> + <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" /> From d6349fa870bbd0d8b661fb5b8c7ae12c4218ee0b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 26 Jan 2023 07:37:09 +0000 Subject: [PATCH 0462/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 2352a0f6..5c696713 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.37", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.37/shoko_2.0.1.37.zip", + "checksum": "843915aa68b889874a717290203bf15f", + "timestamp": "2023-01-26T07:37:07Z" + }, { "version": "2.0.1.36", "changelog": "NA\n", From d08abfc4679637181a6b7f44ab9bea6112a065f9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 26 Jan 2023 19:44:53 +0100 Subject: [PATCH 0463/1103] misc: update endpoint query parameters and add file anidb model. --- Shokofin/API/Models/File.cs | 83 +++++++++++++++++++++++++++++++++ Shokofin/API/ShokoAPIClient.cs | 18 +++---- Shokofin/API/ShokoAPIManager.cs | 7 ++- 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index ef1feffd..bdfde73f 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -49,6 +49,9 @@ public class File [JsonPropertyName("Updated")] public DateTime LastUpdatedAt { get; set; } + [JsonPropertyName("AniDB")] + public AniDB? AniDBData { get; set; } + /// <summary> /// The size of the file in bytes. /// </summary> @@ -102,6 +105,86 @@ public class Location public bool IsAccessible { get; set; } = false; } + /// <summary> + /// AniDB_File info + /// </summary> + public class AniDB + { + /// <summary> + /// The AniDB File ID. + /// </summary> + [JsonPropertyName("ID")] + public int Id { get; set; } + + /// <summary> + /// Blu-ray, DVD, LD, TV, etc.. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public FileSource Source { get; set; } + + /// <summary> + /// The Release Group. This is usually set, but sometimes is set as "raw/unknown" + /// </summary> + public AniDBReleaseGroup ReleaseGroup { get; set; } = new(); + + /// <summary> + /// The file's version, Usually 1, sometimes more when there are edits released later + /// </summary> + public int Version { get; set; } + + /// <summary> + /// The original FileName. Useful for when you obtained from a shady source or when you renamed it without thinking. + /// </summary> + public string OriginalFileName { get; set; } = ""; + + /// <summary> + /// Is the file marked as deprecated. Generally, yes if there's a V2, and this isn't it + /// </summary> + public bool IsDeprecated { get; set; } + + /// <summary> + /// Mostly applicable to hentai, but on occasion a TV release is censored enough to earn this. + /// </summary> + public bool? IsCensored { get; set; } + + /// <summary> + /// Does the file have chapters. This may be wrong, since it was only added in AVDump2 (a more recent version at that) + /// </summary> + [JsonPropertyName("Chaptered")] + public bool IsChaptered { get; set; } + + /// <summary> + /// The file's release date. This is probably not filled in + /// </summary> + [JsonPropertyName("ReleaseDate")] + public DateTime? ReleasedAt { get; set; } + + /// <summary> + /// When we last got data on this file + /// </summary> + [JsonPropertyName("Updated")] + public DateTime LastUpdatedAt { get; set; } + } + + public class AniDBReleaseGroup + { + /// <summary> + /// The AniDB Release Group ID. + /// /// </summary> + [JsonPropertyName("ID")] + public int Id { get; set; } + + /// <summary> + /// The release group's Name (Unlimited Translation Works) + /// </summary> + public string Name { get; set; } = ""; + + /// <summary> + /// The release group's Name (UTW) + /// </summary> + public string ShortName { get; set; } = ""; + } + /// <summary> /// The calculated hashes of the file. Either will all hashes be filled or /// none. diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 7ab51c9a..6a6b5d99 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -178,17 +178,17 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method public Task<File> GetFile(string id) { - return Get<File>($"/api/v3/File/{id}?includeXRefs=true"); + return Get<File>($"/api/v3/File/{id}?includeXRefs=true&includeDataFrom=AniDB"); } - public Task<List<File>> GetFileByPath(string filename) + public Task<List<File>> GetFileByPath(string path) { - return Get<List<File>>($"/api/v3/File/PathEndsWith/{Uri.EscapeDataString(filename)}"); + return Get<List<File>>($"/api/v3/File/PathEndsWith?path={Uri.EscapeDataString(path)}&includeDataFrom=AniDB&limit=1"); } public Task<List<File>> GetFilesForSeries(string seriesId) { - return Get<List<File>>($"/api/v3/Series/{seriesId}/File?includeXRefs=true"); + return Get<List<File>>($"/api/v3/Series/{seriesId}/File?includeXRefs=true&includeDataFrom=AniDB"); } public async Task<File.UserStats?> GetFileUserStats(string fileId, string? apiKey = null) @@ -233,27 +233,27 @@ public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eve public Task<Episode> GetEpisode(string id) { - return Get<Episode>($"/api/v3/Episode/{id}?includeDataFrom=AniDB&includeDataFrom=TvDB"); + return Get<Episode>($"/api/v3/Episode/{id}?includeDataFrom=AniDB,TvDB"); } public Task<List<Episode>> GetEpisodesFromSeries(string seriesId) { - return Get<List<Episode>>($"/api/v3/Series/{seriesId}/Episode?includeMissing=true&includeDataFrom=AniDB&includeDataFrom=TvDB"); + return Get<List<Episode>>($"/api/v3/Series/{seriesId}/Episode?includeMissing=true&includeDataFrom=AniDB,TvDB"); } public Task<Series> GetSeries(string id) { - return Get<Series>($"/api/v3/Series/{id}?includeDataFrom=AniDB&includeDataFrom=TvDB"); + return Get<Series>($"/api/v3/Series/{id}?includeDataFrom=AniDB,TvDB"); } public Task<Series> GetSeriesFromEpisode(string id) { - return Get<Series>($"/api/v3/Episode/{id}/Series?includeDataFrom=AniDB&includeDataFrom=TvDB"); + return Get<Series>($"/api/v3/Episode/{id}/Series?includeDataFrom=AniDB,TvDB"); } public Task<List<Series>> GetSeriesInGroup(string groupID, int filterID = 0) { - return Get<List<Series>>($"/api/v3/Filter/{filterID}/Group/{groupID}/Series?includeMissing=true&includeIgnored=false&includeDataFrom=AniDB&includeDataFrom=TvDB"); + return Get<List<Series>>($"/api/v3/Filter/{filterID}/Group/{groupID}/Series?includeMissing=true&includeIgnored=false&includeDataFrom=AniDB,TvDB"); } public Task<List<Role>> GetSeriesCast(string id) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index be72ca3e..a9d381a5 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -320,20 +320,23 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) // Strip the path and search for a match. var partialPath = StripMediaFolder(path); - Logger.LogDebug("Looking for file matching {Path}", partialPath); var result = await APIClient.GetFileByPath(partialPath); - Logger.LogTrace("Found result with {Count} matches for {Path}", result?.Count ?? 0, partialPath); + Logger.LogDebug("Looking for a match for {Path}", partialPath); // Check if we found a match. var file = result?.FirstOrDefault(); if (file == null || file.CrossReferences.Count == 0) + { + Logger.LogTrace("Found no match for {Path}", partialPath); return (null, null, null); + } // Find the file locations matching the given path. var fileId = file.Id.ToString(); var fileLocations = file.Locations .Where(location => location.Path.EndsWith(partialPath)) .ToList(); + Logger.LogTrace("Found a file match for {Path} (File={FileId})", partialPath, file.Id.ToString()); if (fileLocations.Count != 1) { if (fileLocations.Count == 0) throw new Exception($"I have no idea how this happened, but the path gave a file that doesn't have a matching file location. See you in #support. (File={fileId})"); From eb91996270464d1d78bcaf994e8fe7f2ac3d2560 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 28 Jan 2023 04:56:44 +0100 Subject: [PATCH 0464/1103] cleanup: add doc-comments and rename methods --- Shokofin/Configuration/configPage.html | 6 +- Shokofin/MergeVersions/MergeVersionManager.cs | 148 +++++++++++++++--- Shokofin/Tasks/MergeAllTask.cs | 2 +- Shokofin/Tasks/MergeEpisodesTask.cs | 2 +- Shokofin/Tasks/MergeMoviesTask.cs | 2 +- Shokofin/Tasks/PostScanTask.cs | 2 +- Shokofin/Tasks/SplitEpisodesTask.cs | 2 +- Shokofin/Tasks/SplitMoviesTask.cs | 2 +- 8 files changed, 134 insertions(+), 32 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 6d277741..e5f98f15 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -153,7 +153,7 @@ <h3>Library Settings</h3> <input is="emby-checkbox" type="checkbox" id="FilterOnLibraryTypes" /> <span>Enable library separation</span> </label> - <div class="fieldDescription checkboxFieldDescription">This setting can be used to have one shared root folder on your disk for two libraries in Shoko — one library for movies and one for shows. Enabling this will cause the plugin to actively filter out movies from the show library and everything but movies from the movies library. Also, if you've selected to use Shoko's Group feature to create Series/Seasons then it will also exclude the Movies from within the series — i.e. the "season" for the movie won't appear  — even if they share a group in Shoko.</div> + <div class="fieldDescription checkboxFieldDescription">This setting can be used to have one shared root folder on your disk for two libraries in Shoko — one library for movies and one for shows. Enabling this will cause the plugin to actively filter out movies from the show library and everything but movies from the movies library. Also, if you've selected to use Shoko's Group feature to create Series/Seasons then it will also exclude the Movies from within the series — i.e. the "season" for the movie won't appear — even if they share a group in Shoko.</div> </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="SpecialsPlacement">Specials placement within seasons:</label> @@ -337,14 +337,14 @@ <h3>Experimental Settings</h3> <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 probably make the plugin do a lot of unneeded work on 99.5% of all movies, but will help for the last 0.5% that actually need it. An example is ensuring multiple parts of a movie series are not merged because they contain the same shok series id.</details></div> + <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 probably make the plugin do a lot of unneeded work on 99.5% of all movies, but will help for the last 0.5% that actually need it.</details></div> + <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"> diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index a030d97a..f9f0e0df 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -15,14 +15,37 @@ namespace Shokofin.MergeVersions; +/// <summary> +/// Responsible for merging multiple versions of the same video together into a +/// single UI element (by linking the videos together and letting Jellyfin +/// handle the rest). +/// </summary> +/// +/// Based upon; +/// https://github.com/danieladov/jellyfin-plugin-mergeversions public class MergeVersionsManager { + /// <summary> + /// Library manager. Used to fetch items from the library. + /// </summary> private readonly ILibraryManager LibraryManager; + /// <summary> + /// Shoko ID Lookup. Used to check if the plugin is enabled for the videos. + /// </summary> private readonly IIdLookup Lookup; + /// <summary> + /// Logger. + /// </summary> private readonly ILogger<MergeVersionsManager> Logger; + /// <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> + /// <param name="logger">Logger.</param> public MergeVersionsManager(ILibraryManager libraryManager, IIdLookup lookup, ILogger<MergeVersionsManager> logger) { LibraryManager = libraryManager; @@ -32,7 +55,14 @@ public MergeVersionsManager(ILibraryManager libraryManager, IIdLookup lookup, IL #region Shared - public async Task MergeAll(IProgress<double> progress, CancellationToken cancellationToken, bool canSplit = true) + /// <summary> + /// Group and 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 merging is + /// complete.</returns> + public async Task MergeAll(IProgress<double> progress, CancellationToken cancellationToken) { // Shared progress; double episodeProgressValue = 0d, movieProgressValue = 0d; @@ -43,7 +73,7 @@ public async Task MergeAll(IProgress<double> progress, CancellationToken cancell movieProgressValue = value / 2d; progress?.Report(movieProgressValue + episodeProgressValue); }); - var movieTask = MergeMovies(movieProgress, cancellationToken, canSplit); + var movieTask = MergeAllMovies(movieProgress, cancellationToken); // Setup the episode task. var episodeProgress = new ActionableProgress<double>(); @@ -52,12 +82,19 @@ public async Task MergeAll(IProgress<double> progress, CancellationToken cancell progress?.Report(movieProgressValue + episodeProgressValue); progress?.Report(50d + (value / 2d)); }); - var episodeTask = MergeEpisodes(episodeProgress, cancellationToken, canSplit); + var episodeTask = MergeAllEpisodes(episodeProgress, cancellationToken); // Run them in parallel. await Task.WhenAll(movieTask, episodeTask); } + /// <summary> + /// Split up all 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 SplitAll(IProgress<double> progress, CancellationToken cancellationToken) { // Shared progress; @@ -69,7 +106,7 @@ public async Task SplitAll(IProgress<double> progress, CancellationToken cancell movieProgressValue = value / 2d; progress?.Report(movieProgressValue + episodeProgressValue); }); - var movieTask = SplitMovies(movieProgress, cancellationToken); + var movieTask = SplitAllMovies(movieProgress, cancellationToken); // Setup the episode task. var episodeProgress = new ActionableProgress<double>(); @@ -78,7 +115,7 @@ public async Task SplitAll(IProgress<double> progress, CancellationToken cancell progress?.Report(movieProgressValue + episodeProgressValue); progress?.Report(50d + (value / 2d)); }); - var episodeTask = SplitEpisodes(episodeProgress, cancellationToken); + var episodeTask = SplitAllEpisodes(episodeProgress, cancellationToken); // Run them in parallel. await Task.WhenAll(movieTask, episodeTask); @@ -87,6 +124,10 @@ public async Task SplitAll(IProgress<double> progress, CancellationToken cancell #endregion Shared #region Movies + /// <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() { @@ -100,13 +141,26 @@ private List<Movie> GetMoviesFromLibrary() .ToList(); } + /// <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 async Task MergeMovies(IEnumerable<Movie> movies) => await MergeVideos(movies.Cast<Video>().OrderBy(e => e.Id).ToList()); - public async Task MergeMovies(IProgress<double> progress, CancellationToken cancellationToken, bool canSplit = true) + /// <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) { - if (canSplit && Plugin.Instance.Configuration.EXPERIMENTAL_SplitThenMergeMovies) { - await SplitAndMergeMovies(progress, cancellationToken); + if (Plugin.Instance.Configuration.EXPERIMENTAL_SplitThenMergeMovies) { + await SplitAndMergeAllMovies(progress, cancellationToken); return; } @@ -131,7 +185,14 @@ public async Task MergeMovies(IProgress<double> progress, CancellationToken canc progress?.Report(100); } - public async Task SplitMovies(IProgress<double> progress, CancellationToken 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(); @@ -150,7 +211,14 @@ public async Task SplitMovies(IProgress<double> progress, CancellationToken canc progress?.Report(100); } - private async Task SplitAndMergeMovies(IProgress<double> progress, CancellationToken cancellationToken) + /// <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(); @@ -168,8 +236,8 @@ private async Task SplitAndMergeMovies(IProgress<double> progress, CancellationT // Merge all movies with more than one version (again). var duplicationGroups = movies - .GroupBy(x => x.ProviderIds["Shoko Episode"]) - .Where(x => x.Count() > 1) + .GroupBy(movie => movie.ProviderIds["Shoko Episode"]) + .Where(movie => movie.Count() > 1) .ToList(); currentCount = 0d; totalCount = duplicationGroups.Count; @@ -189,6 +257,10 @@ private async Task SplitAndMergeMovies(IProgress<double> progress, CancellationT #endregion Movies #region Episodes + /// <summary> + /// Get all episodes with a Shoko Episode ID set across all libraries. + /// </summary> + /// <returns>A list of all episodes with a Shoko Episode ID set.</returns> private List<Episode> GetEpisodesFromLibrary() { return LibraryManager.GetItemList(new() { @@ -202,17 +274,32 @@ private List<Episode> GetEpisodesFromLibrary() .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 async Task MergeEpisodes(IEnumerable<Episode> episodes) => await MergeVideos(episodes.Cast<Video>().OrderBy(e => e.Id).ToList()); - public async Task MergeEpisodes(IProgress<double> progress, CancellationToken cancellationToken, bool canSplit = true) + /// <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. + /// </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 MergeAllEpisodes(IProgress<double> progress, CancellationToken cancellationToken) { - if (canSplit && Plugin.Instance.Configuration.EXPERIMENTAL_SplitThenMergeMovies) { - await SplitAndMergeEpisodes(progress, cancellationToken); + if (Plugin.Instance.Configuration.EXPERIMENTAL_SplitThenMergeMovies) { + await SplitAndMergeAllEpisodes(progress, cancellationToken); return; } - // Merge episodes with more than one version. + // Merge episodes with more than one version, and with the same number + // of additional episodes. var episodes = GetEpisodesFromLibrary(); var duplicationGroups = episodes .GroupBy(x => x.ProviderIds["Shoko Episode"]) @@ -233,7 +320,14 @@ public async Task MergeEpisodes(IProgress<double> progress, CancellationToken ca progress?.Report(100); } - public async Task SplitEpisodes(IProgress<double> progress, CancellationToken cancellationToken) + /// <summary> + /// Split up all existing merged 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 is + /// complete.</returns> + public async Task SplitAllEpisodes(IProgress<double> progress, CancellationToken cancellationToken) { // Split up any existing merged episodes. var episodes = GetEpisodesFromLibrary(); @@ -252,7 +346,15 @@ public async Task SplitEpisodes(IProgress<double> progress, CancellationToken ca progress?.Report(100); } - private async Task SplitAndMergeEpisodes(IProgress<double> progress, CancellationToken cancellationToken) + /// <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(); @@ -268,7 +370,8 @@ private async Task SplitAndMergeEpisodes(IProgress<double> progress, Cancellatio await RemoveAlternateSources(e); } - // Merge episodes with more than one version (again). + // Merge episodes with more than one version (again), and with the same + // number of additional episodes. var duplicationGroups = episodes .GroupBy(x => x.ProviderIds["Shoko Episode"]) .Where(x => x.Count() > 1) @@ -306,9 +409,7 @@ private async Task MergeVideos(List<Video> videos) .OrderBy(i => { if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile) - { return 1; - } return 0; }) @@ -350,9 +451,10 @@ await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, Cancel } /// <summary> - /// Removes alternate video sources from a video + /// Removes all alternate video sources from a video and all it's linked + /// videos. /// </summary> - /// <param name="baseItem">The video to clean up.</param> + /// <param name="baseItem">The primary video to clean up.</param> /// /// Modified from; /// https://github.com/jellyfin/jellyfin/blob/9c97c533eff94d25463fb649c9572234da4af1ea/Jellyfin.Api/Controllers/VideosController.cs#L152 diff --git a/Shokofin/Tasks/MergeAllTask.cs b/Shokofin/Tasks/MergeAllTask.cs index 27fb3f23..7a771313 100644 --- a/Shokofin/Tasks/MergeAllTask.cs +++ b/Shokofin/Tasks/MergeAllTask.cs @@ -41,7 +41,7 @@ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() /// <returns>Task.</returns> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - await VersionsManager.MergeAll(progress, cancellationToken, false); + await VersionsManager.MergeAll(progress, cancellationToken); } /// <inheritdoc /> diff --git a/Shokofin/Tasks/MergeEpisodesTask.cs b/Shokofin/Tasks/MergeEpisodesTask.cs index f545bdea..dec3cff0 100644 --- a/Shokofin/Tasks/MergeEpisodesTask.cs +++ b/Shokofin/Tasks/MergeEpisodesTask.cs @@ -41,7 +41,7 @@ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() /// <returns>Task.</returns> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - await VersionsManager.MergeEpisodes(progress, cancellationToken, false); + await VersionsManager.MergeAllEpisodes(progress, cancellationToken); } /// <inheritdoc /> diff --git a/Shokofin/Tasks/MergeMoviesTask.cs b/Shokofin/Tasks/MergeMoviesTask.cs index 5d8fdab4..86c8c267 100644 --- a/Shokofin/Tasks/MergeMoviesTask.cs +++ b/Shokofin/Tasks/MergeMoviesTask.cs @@ -41,7 +41,7 @@ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() /// <returns>Task.</returns> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - await VersionsManager.MergeMovies(progress, cancellationToken, false); + await VersionsManager.MergeAllMovies(progress, cancellationToken); } /// <inheritdoc /> diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs index d7e1ce43..fc263870 100644 --- a/Shokofin/Tasks/PostScanTask.cs +++ b/Shokofin/Tasks/PostScanTask.cs @@ -23,7 +23,7 @@ public async Task Run(IProgress<double> progress, CancellationToken token) { // Merge versions now if the setting is enabled. if (Plugin.Instance.Configuration.EXPERIMENTAL_AutoMergeVersions) - await VersionsManager.MergeAll(progress, token, true); + await VersionsManager.MergeAll(progress, token); // Clear the cache now, since we don't need it anymore. ApiManager.Clear(); diff --git a/Shokofin/Tasks/SplitEpisodesTask.cs b/Shokofin/Tasks/SplitEpisodesTask.cs index 84f74cf6..b419c256 100644 --- a/Shokofin/Tasks/SplitEpisodesTask.cs +++ b/Shokofin/Tasks/SplitEpisodesTask.cs @@ -41,7 +41,7 @@ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() /// <returns>Task.</returns> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - await VersionsManager.SplitEpisodes(progress, cancellationToken); + await VersionsManager.SplitAllEpisodes(progress, cancellationToken); } /// <inheritdoc /> diff --git a/Shokofin/Tasks/SplitMoviesTask.cs b/Shokofin/Tasks/SplitMoviesTask.cs index 6337b9bc..53f1d67c 100644 --- a/Shokofin/Tasks/SplitMoviesTask.cs +++ b/Shokofin/Tasks/SplitMoviesTask.cs @@ -41,7 +41,7 @@ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() /// <returns>Task.</returns> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - await VersionsManager.SplitMovies(progress, cancellationToken); + await VersionsManager.SplitAllMovies(progress, cancellationToken); } /// <inheritdoc /> From 6bb208df6ea73be2f2d3ea520367666f52513eca Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 28 Jan 2023 04:59:46 +0100 Subject: [PATCH 0465/1103] fix: only merge if the episode count is the same. --- Shokofin/MergeVersions/MergeVersionManager.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index f9f0e0df..ba8965e7 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -302,8 +302,8 @@ public async Task MergeAllEpisodes(IProgress<double> progress, CancellationToken // of additional episodes. var episodes = GetEpisodesFromLibrary(); var duplicationGroups = episodes - .GroupBy(x => x.ProviderIds["Shoko Episode"]) - .Where(x => x.Count() > 1) + .GroupBy(e => $"{e.ProviderIds["Shoko Episode"]}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}") + .Where(e => e.Count() > 1) .ToList(); double currentCount = 0d; double totalGroups = duplicationGroups.Count; @@ -373,8 +373,8 @@ private async Task SplitAndMergeAllEpisodes(IProgress<double> progress, Cancella // Merge episodes with more than one version (again), and with the same // number of additional episodes. var duplicationGroups = episodes - .GroupBy(x => x.ProviderIds["Shoko Episode"]) - .Where(x => x.Count() > 1) + .GroupBy(e => $"{e.ProviderIds["Shoko Episode"]}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}") + .Where(e => e.Count() > 1) .ToList(); currentCount = 0d; totalCount = duplicationGroups.Count; From b666c7ca39d4e2e69e5164950f471b58674f4070 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 28 Jan 2023 05:01:24 +0100 Subject: [PATCH 0466/1103] misc: update read-me file --- README.md | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 171 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4c2e49dd..d83c3127 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,198 @@ # Shokofin -A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/docs/) with [Shoko Server](https://shokoanime.com/downloads/shoko-server/). +A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/docs/) with +[Shoko Server](https://shokoanime.com/downloads/shoko-server/). ## Read this before installing -The plugin requires Jellyfin version 10.8.`x` and Shoko Server version **4.1.2** or greater to be installed. **It also requires that you have already set up and are using Shoko Server**, and that the directories/folders you intend to use in Jellyfin are **fully indexed** (and optionally managed) by Shoko Server, **otherwise the plugin won't be able to function properly** — meaning you won't be able to find metadata about any entries that are not indexed by Shoko Server with this plugin, since the metadata is not available. +**This plugin requires that you have already set up and are using Shoko +Server**, and that the directories/folders you intend to use in Jellyfin are +**fully indexed** (and optionally managed) by Shoko Server. **Otherwise, the +plugin won't be able to function properly**; meaning, the plugin won't be able +to any find metadata about any entries that are not indexed by Shoko Server with +this plugin, since there is no metadata to get. -## Breaking Changes +### What Is Shoko? -### 2.0.0 +Shoko is an anime cataloging program designed to automate the cataloging of your +collection regardless of the size and amount of files you have. Unlike other +anime cataloging programs which make you manually add your series or link the +files to them, Shoko removes the tedious, time consuming and boring task of +having to manually add every file and manually input the file information. You +have better things to do with your time like actually watching the series in +your collection so let Shoko handle all the heavy lifting. -**Support for Jellyfin 10.8 has landed, and support for Jellyfin 10.7 has ended**. +Learn more about Shoko at https://shokoanime.com/. -### 1.5.0 +## Feature Overview -If you're upgrading from an older version to version 1.5.0, then be sure to update the "Host" field in the plugin settings before you continue using the plugin. **Update: Starting with 1.7.0 you just need to reset the connection then log in again.** +- [/] Metadata integration + + - [X] Basic metadata, e.g. titles, description, dates, etc. + + - [X] Customisable main title for items + + - [X] Optional customisable alternate/original title for items + + - [X] Customisable description source for items + Choose between AniDB, TvDB, or a mix of the two. + + - [X] Support optionally adding titles and descriptions for all episodes for + multi-entry files. + + - [X] Genres + + - [X] Tags + + With some settings to choose which tags to add. + + - [/] Voice Actors + + - [X] Displayed on the Show/Season/Movie items + + - [ ] Person provider for image and details + + - [/] General staff (e.g. producer, writer, etc.) + + - [X] Displayed on the Show/Season/Movie items + + - [ ] Person provider for image and details + + - [/] Studios + + - [X] Displayed on the Show/Season/Movie items + + - [ ] Studio provider for image and details + +- [/] Library integration + + - [/] Support for different library types + + - [X] Show library + + - [X] Movie library + + - [ ] Mixed show/movie library. + + Coming soon™-ish + + - [/] Supports adding local trailers + + - [X] on Show items + + - [X] on Season items + + - [ ] on Movie items + + - [X] Specials and extra features. + + - [X] Customise how Specials are placed in your library. I.e. if they are + mapped to the normal seasons, or if they are strictly kept in season zero. + + - [X] Extra features. The plugin will map specials stored in Shoko such as + interviews, etc. as extra featues, and all other specials as episodes in + season zero. + + - [X] Map OPs/EDs to Theme Videos so they can be displayed as background video + while you browse your library. + + - [X] Support merging multi-version episodes/movies into a single entry. + + Tidying up the UI if you have multiple versions of the same episode or + movie. + + - [X] Support optionally setting other provider ids Shoko knows about (e.g. + AniDB, TvDB, TMDB, etc.) on some item types when an ID is available for + the items in Shoko. + + - [X] Multiple ways to organise your library. + + - [X] Choose between three ways to group your Shows/Seasons; no grouping, + following TvDB (to-be replaced with TMDB soon™-ish), and using Shoko's + groups feature. + + _For the best compatibility it is **strongly** adviced **not** to use + "season" folders with anime as it limits which grouping you can use._ + + - [X] Optionally create Box-Sets for your Movies… + + - [X] using the Shoko series. + + - [X] using the Shoko groups. + + - [X] Supports separating your on-disc library into a two Show and Movie + libraries. + + _provided you apply the workaround to do it_. + + - [/] Automatically populates all missing episodes not in your collection, so + you can see at a glance what you are missing out on. + + - [ ] Deleting an missing episode item marks the episode as hidden/ignored + in Shoko. + + - [ ] Optionally react to Show/File update events sent from Shoko. + + Coming soon™-ish + +- [X] User data + + - [X] Able to sync the watch data to/from Shoko on a per user basis in + multiple ways. And Shoko can further sync the to/from other linked services. + + - [X] During import. + + - [X] Player events (play/pause/resumve/stop) + + - [X] After playback (stop) + + - [X] Live scrobbling (every 1 minute during playback after the last + play/resume event) ## Install -There are many ways to install the plugin, but the recommended way is to use the official Jellyfin repository. +There are many ways to install the plugin, but the recommended way is to use +the official Jellyfin repository. Alternatively it can be installed from this +GitHub repository. Or you build it from source. + +Below is a version compatibility matrix for which version of Shokofin is +compatible with what. + +| Shokofin | Jellyfin | Shoko Server | +|------------|----------|---------------| +| `0.x.x` | `10.7` | `4.0.0-4.1.2` | +| `1.x.x` | `10.7` | `4.1.0-4.1.2` | +| `2.x.x` | `10.8` | `4.1.2` | +| `unstable` | `10.8` | `dev` | ### Official Repository 1. Go to Dashboard -> Plugins -> Repositories + 2. Add new repository with the following details + * Repository Name: `Shokofin Stable` - * Repository URL: `https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest.json` + + * Repository URL: + `https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest.json` + 3. Go to the catalog in the plugins page + 4. Find and install Shokofin from the Metadata section + 5. Restart your server to apply the changes. ### Github Releases -1. Download the `shokofin_*.zip` file from the latest release from GitHub [here](https://github.com/ShokoAnime/shokofin/releases/latest). +1. Download the `shokofin_*.zip` file from the latest release from GitHub + [here](https://github.com/ShokoAnime/shokofin/releases/latest). -2. Extract the contained `Shokofin.dll` and `meta.json`, place both the files in a folder named `Shokofin` and copy this folder to the `plugins` folder under the Jellyfin program data directory or inside the portable install directory. Refer to the "Data Directory" section on [this page](https://jellyfin.org/docs/general/administration/configuration.html) for where to find your jellyfin install. +2. Extract the contained `Shokofin.dll` and `meta.json`, place both the files in +a folder named `Shokofin` and copy this folder to the `plugins` folder under +the Jellyfin program data directory or inside the portable install directory. +Refer to the "Data Directory" section on +[this page](https://jellyfin.org/docs/general/administration/configuration.html) +for where to find your jellyfin install. 3. Start or restart your server to apply the changes @@ -51,4 +209,5 @@ $ dotnet restore Shokofin/Shokofin.csproj $ dotnet publish -c Release Shokofin/Shokofin.csproj ``` -4. Copy the resulting file `bin/Shokofin.dll` to the `plugins` folder under the Jellyfin program data directory or inside the portable install directory. +4. Copy the resulting file `bin/Shokofin.dll` to the `plugins` folder under the +Jellyfin program data directory or inside the portable install directory. From 447596f9aac0ff0bc85514a438f45d5014e038b6 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 28 Jan 2023 04:02:55 +0000 Subject: [PATCH 0467/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 5c696713..ef0c158e 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.38", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.38/shoko_2.0.1.38.zip", + "checksum": "da2541287d39f79a43e46f5732bc3cb7", + "timestamp": "2023-01-28T04:02:53Z" + }, { "version": "2.0.1.37", "changelog": "NA\n", From dad26e9848ca055d41e77744d1287677508541b3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 30 Jan 2023 23:30:06 +0100 Subject: [PATCH 0468/1103] fix: fix specials showing up in normal seasons when they should not. fixes #37 --- Shokofin/Providers/EpisodeProvider.cs | 10 +++++----- Shokofin/Utils/OrderingUtil.cs | 20 +++++++++++++++----- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 40752562..ea0f08c6 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -169,14 +169,14 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri } Episode result; - var (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber) = Ordering.GetSpecialPlacement(group, series, episode); + var (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber, isSpecial) = Ordering.GetSpecialPlacement(group, series, episode); if (mergeFriendly) { if (season != null) { result = new Episode { Name = displayTitle, OriginalTitle = alternateTitle, IndexNumber = episodeNumber, - ParentIndexNumber = airsAfterSeasonNumber.HasValue || airsBeforeSeasonNumber.HasValue ? 0 : seasonNumber, + ParentIndexNumber = isSpecial ? 0 : seasonNumber, AirsAfterSeasonNumber = airsAfterSeasonNumber, AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, AirsBeforeSeasonNumber = airsBeforeSeasonNumber, @@ -200,7 +200,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri Name = displayTitle, OriginalTitle = alternateTitle, IndexNumber = episodeNumber, - ParentIndexNumber = airsAfterSeasonNumber.HasValue || airsBeforeSeasonNumber.HasValue ? 0 : seasonNumber, + ParentIndexNumber = isSpecial ? 0 : seasonNumber, AirsAfterSeasonNumber = airsAfterSeasonNumber, AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, AirsBeforeSeasonNumber = airsBeforeSeasonNumber, @@ -216,7 +216,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri Name = displayTitle, OriginalTitle = alternateTitle, IndexNumber = episodeNumber, - ParentIndexNumber = airsAfterSeasonNumber.HasValue || airsBeforeSeasonNumber.HasValue ? 0 : seasonNumber, + ParentIndexNumber = isSpecial ? 0 : seasonNumber, AirsAfterSeasonNumber = airsAfterSeasonNumber, AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, AirsBeforeSeasonNumber = airsBeforeSeasonNumber, @@ -242,7 +242,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri Name = displayTitle, OriginalTitle = alternateTitle, IndexNumber = episodeNumber, - ParentIndexNumber = airsAfterSeasonNumber.HasValue || airsBeforeSeasonNumber.HasValue ? 0 : seasonNumber, + ParentIndexNumber = isSpecial ? 0 : seasonNumber, AirsAfterSeasonNumber = airsAfterSeasonNumber, AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, AirsBeforeSeasonNumber = airsBeforeSeasonNumber, diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index edcadb94..f1785f0a 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -216,16 +216,26 @@ public static int GetEpisodeNumber(GroupInfo group, SeriesInfo series, EpisodeIn } } - public static (int?, int?, int?) GetSpecialPlacement(GroupInfo group, SeriesInfo series, EpisodeInfo episode) + public static (int?, int?, int?, bool) GetSpecialPlacement(GroupInfo group, SeriesInfo series, EpisodeInfo episode) { var order = Plugin.Instance.Configuration.SpecialsPlacement; - if (order == SpecialOrderType.Excluded) - return (null, null, null); + + // Return early if we want to exclude them from the normal seasons. + if (order == SpecialOrderType.Excluded) { + // Check if this should go in the specials season. + var isSpecial = Plugin.Instance.Configuration.SeriesGrouping == GroupType.MergeFriendly && episode.TvDB != null ? ( + episode.TvDB.SeasonNumber == 0 + ) : ( + episode.AniDB.Type == EpisodeType.Special + ); + + return (null, null, null, isSpecial); + } // Abort if episode is not a TvDB special or AniDB special var allowOtherData = order == SpecialOrderType.InBetweenSeasonByOtherData || order == SpecialOrderType.InBetweenSeasonMixed; if (allowOtherData ? !(episode?.TvDB?.SeasonNumber == 0 || episode.AniDB.Type == EpisodeType.Special) : episode.AniDB.Type != EpisodeType.Special) - return (null, null, null); + return (null, null, null, false); int? episodeNumber = null; int seasonNumber = GetSeasonNumber(group, series, episode); @@ -282,7 +292,7 @@ public static (int?, int?, int?) GetSpecialPlacement(GroupInfo group, SeriesInfo break; } - return (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber); + return (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber, true); } /// <summary> From 8121bd4b85399c1b130a4fdb8eab9009cee8726e Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 30 Jan 2023 22:30:50 +0000 Subject: [PATCH 0469/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index ef0c158e..b35d4a06 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.39", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.39/shoko_2.0.1.39.zip", + "checksum": "c7e4f7087d45095a0c78e68e53dd36d6", + "timestamp": "2023-01-30T22:30:48Z" + }, { "version": "2.0.1.38", "changelog": "NA\n", From 4610e97843bf48e75c94c2830d83cb135994b763 Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Mon, 20 Feb 2023 23:53:39 +0000 Subject: [PATCH 0470/1103] Fix: Allows on demand syncing of videos with chapter images --- Shokofin/Sync/UserDataSyncManager.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index c7564881..c91ef5f0 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -293,7 +293,6 @@ public async Task ScanAndSync(SyncDirection direction, IProgress<double> progres EnableImages = false }, SourceTypes = new SourceType[] { SourceType.Library }, - HasChapterImages = false, IsVirtualItem = false, }) .OfType<Video>() From b3dd6fd1011891da353e5a1d876c67a97f842472 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 Feb 2023 16:52:41 +0100 Subject: [PATCH 0471/1103] feature: fix some OVA episode titles --- Shokofin/Providers/EpisodeProvider.cs | 11 ++++++++--- Shokofin/Utils/TextUtil.cs | 7 ++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index ea0f08c6..13fa67c4 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -104,9 +104,14 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri var alternateTitles = new List<string>(file.EpisodeList.Count); foreach (var episodeInfo in file.EpisodeList) { - string defaultEpisodeTitle = maybeMergeFriendly && episodeInfo.TvDB != null ? episodeInfo.TvDB.Title : episodeInfo.Shoko.Name; - if (series.AniDB.Type == SeriesType.Movie && (episodeInfo.AniDB.Type == EpisodeType.Normal || episodeInfo.AniDB.Type == EpisodeType.Special)) { - string defaultSeriesTitle = maybeMergeFriendly && episodeInfo.TvDB != null ? series.TvDB.Title : series.Shoko.Name; + string defaultEpisodeTitle = episodeInfo.Shoko.Name; + if ( + // Movies + (series.AniDB.Type == SeriesType.Movie && (episodeInfo.AniDB.Type == EpisodeType.Normal || episodeInfo.AniDB.Type == EpisodeType.Special)) || + // OVAs + (series.AniDB.Type == SeriesType.OVA && episodeInfo.AniDB.Type == EpisodeType.Normal && episodeInfo.AniDB.EpisodeNumber == 1 && episodeInfo.Shoko.Name == "OVA") + ) { + string defaultSeriesTitle = series.Shoko.Name; var ( dTitle, aTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episodeInfo.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); displayTitles.Add(dTitle); alternateTitles.Add(aTitle); diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index 8600213c..c621762f 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -61,6 +61,11 @@ public class Text '⦎', // right angle bracket }; + private static HashSet<string> IgnoredSubTitles = new() { + "Complete Movie", + "OVA", + }; + /// <summary> /// Where to get text the text from. /// </summary> @@ -315,7 +320,7 @@ private static string __GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Ti if (outputType == DisplayTitleType.SubTitle) return title; // Ignore sub-title of movie if it strictly equals the text below. - if (title != "Complete Movie" && !string.IsNullOrEmpty(title?.Trim())) + if (!string.IsNullOrWhiteSpace(title) && !IgnoredSubTitles.Contains(title)) titleBuilder?.Append($": {title}"); return titleBuilder?.ToString() ?? ""; } From ef7dc6435b986d23ee1c4ca36434277188ea6ee5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 Feb 2023 17:16:37 +0100 Subject: [PATCH 0472/1103] misc: update read-me file --- README.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d83c3127..13c44003 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/docs/) with **This plugin requires that you have already set up and are using Shoko Server**, and that the directories/folders you intend to use in Jellyfin are **fully indexed** (and optionally managed) by Shoko Server. **Otherwise, the -plugin won't be able to function properly**; meaning, the plugin won't be able -to any find metadata about any entries that are not indexed by Shoko Server with -this plugin, since there is no metadata to get. +plugin won't be able to function properly**, meaning, the plugin won't be able +to any find metadata about any entries that are not indexed by Shoko Server +since there is no metadata to find. ### What Is Shoko? @@ -35,6 +35,7 @@ Learn more about Shoko at https://shokoanime.com/. - [X] Optional customisable alternate/original title for items - [X] Customisable description source for items + Choose between AniDB, TvDB, or a mix of the two. - [X] Support optionally adding titles and descriptions for all episodes for @@ -101,6 +102,10 @@ Learn more about Shoko at https://shokoanime.com/. Tidying up the UI if you have multiple versions of the same episode or movie. + - [X] Auto merge after library scan (if enabled). + + - [X] Manual merge/split tasks + - [X] Support optionally setting other provider ids Shoko knows about (e.g. AniDB, TvDB, TMDB, etc.) on some item types when an ID is available for the items in Shoko. @@ -131,7 +136,7 @@ Learn more about Shoko at https://shokoanime.com/. - [ ] Deleting an missing episode item marks the episode as hidden/ignored in Shoko. - - [ ] Optionally react to Show/File update events sent from Shoko. + - [ ] Optionally react to events sent from Shoko. Coming soon™-ish @@ -142,12 +147,14 @@ Learn more about Shoko at https://shokoanime.com/. - [X] During import. - - [X] Player events (play/pause/resumve/stop) + - [X] Player events (play/pause/resumve/stop events) - - [X] After playback (stop) + - [X] After playback (stop event) - [X] Live scrobbling (every 1 minute during playback after the last - play/resume event) + play/resume event or when jumping) + + - [X] Import and export user data tasks ## Install From 9d823955226eaff28fa10e1659fb98b1d177300f Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 20 Feb 2023 23:59:22 +0000 Subject: [PATCH 0473/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index b35d4a06..542181f4 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.41", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.41/shoko_2.0.1.41.zip", + "checksum": "b2947b9a11382ce08830a98d34da71d7", + "timestamp": "2023-02-20T23:59:19Z" + }, { "version": "2.0.1.39", "changelog": "NA\n", From 87d1c6cd50faecd264005c93c216dd7575f92e5c Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Tue, 21 Feb 2023 22:32:36 +0000 Subject: [PATCH 0474/1103] Misc: Added targeted error logging for invalid user API keys Provides additional hint towards specific resolution required --- Shokofin/Sync/UserDataSyncManager.cs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index c91ef5f0..b731279f 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Concurrent; using System.Linq; +using System.Net; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Dto; @@ -22,6 +24,8 @@ public class UserDataSyncManager private readonly IUserDataManager UserDataManager; + private readonly IUserManager UserManager; + private readonly ILibraryManager LibraryManager; private readonly ISessionManager SessionManager; @@ -32,9 +36,10 @@ public class UserDataSyncManager private readonly IIdLookup Lookup; - public UserDataSyncManager(IUserDataManager userDataManager, ILibraryManager libraryManager, ISessionManager sessionManager, ILogger<UserDataSyncManager> logger, ShokoAPIClient apiClient, IIdLookup lookup) + public UserDataSyncManager(IUserDataManager userDataManager, IUserManager userManager, ILibraryManager libraryManager, ISessionManager sessionManager, ILogger<UserDataSyncManager> logger, ShokoAPIClient apiClient, IIdLookup lookup) { UserDataManager = userDataManager; + UserManager = userManager; LibraryManager = libraryManager; SessionManager = sessionManager; Logger = logger; @@ -233,6 +238,11 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) } } } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized) { + TryGetUserConfiguration(e.UserId, out var userConfig); + if (userConfig is not null) + Logger.LogError(ex, "Invalid or expired API token used. In the plugin settings, please reset the connection to Shoko Server in the user settings section (Jellyfin User={Username})", UserManager.GetUserById(userConfig.UserId).Username); + } catch (Exception ex) { Logger.LogError(ex, "Threw unexpectedly; {ErrorMessage}", ex.Message); return; @@ -444,7 +454,14 @@ private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDire return; } var localUserStats = UserDataManager.GetUserData(userConfig.UserId, video); - var remoteUserStats = await APIClient.GetFileUserStats(fileId, userConfig.Token); + UserStats remoteUserStats; + try { + remoteUserStats = await APIClient.GetFileUserStats(fileId, userConfig.Token); + } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized) { + Logger.LogError(ex, "Invalid or expired API token used. In the plugin settings, please reset the connection to Shoko Server in the user settings section (Jellyfin User={Username})", UserManager.GetUserById(userConfig.UserId).Username); + return; + } bool isInSync = UserDataEqualsFileUserStats(localUserStats, remoteUserStats); Logger.LogInformation("{SyncDirection} user data for video {VideoName}. (File={FileId},Episode={EpisodeId},Local={HaveLocal},Remote={HaveRemote},InSync={IsInSync})", direction.ToString(), video.Name, fileId, episodeId, localUserStats != null, remoteUserStats != null, isInSync); if (isInSync) From 57c4e0d78b76cea3d078dc6dbd06a6fdfa542fad Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Wed, 1 Mar 2023 09:58:04 +0000 Subject: [PATCH 0475/1103] Suggested changes: OnUserDataSaved() Co-authored-by: revam <revam@users.noreply.github.com> --- Shokofin/Sync/UserDataSyncManager.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index b731279f..c8023908 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -239,9 +239,9 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) } } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized) { - TryGetUserConfiguration(e.UserId, out var userConfig); - if (userConfig is not null) - Logger.LogError(ex, "Invalid or expired API token used. In the plugin settings, please reset the connection to Shoko Server in the user settings section (Jellyfin User={Username})", UserManager.GetUserById(userConfig.UserId).Username); + if (TryGetUserConfiguration(e.UserId, out var userConfig)) + Logger.LogError(ex, "I{Message} (Username={Username},Id={UserId})", ex.Message, UserManager.GetUserById(userConfig.UserId)?.Username, userConfig.UserId); + return; } catch (Exception ex) { Logger.LogError(ex, "Threw unexpectedly; {ErrorMessage}", ex.Message); From 9e5dbf974457888dc2b4eee4ec479a46ea4fdeb6 Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Wed, 1 Mar 2023 10:23:11 +0000 Subject: [PATCH 0476/1103] Suggested changes: SyncVideo() Co-authored-by: revam <revam@users.noreply.github.com> --- Shokofin/Sync/UserDataSyncManager.cs | 167 +++++++++++++-------------- 1 file changed, 83 insertions(+), 84 deletions(-) diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index c8023908..369ded4c 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -449,101 +449,100 @@ private Task SyncVideo(Video video, UserConfiguration userConfig, UserItemData u private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDirection direction, string fileId, string episodeId) { - if (!userConfig.SyncRestrictedVideos && video.CustomRating == "XXX") { - Logger.LogTrace("Skipped {SyncDirection} user data for video {VideoName}. (File={FileId},Episode={EpisodeId})", direction.ToString(), video.Name, fileId, episodeId); - return; - } - var localUserStats = UserDataManager.GetUserData(userConfig.UserId, video); - UserStats remoteUserStats; try { - remoteUserStats = await APIClient.GetFileUserStats(fileId, userConfig.Token); - } - catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized) { - Logger.LogError(ex, "Invalid or expired API token used. In the plugin settings, please reset the connection to Shoko Server in the user settings section (Jellyfin User={Username})", UserManager.GetUserById(userConfig.UserId).Username); - return; - } - bool isInSync = UserDataEqualsFileUserStats(localUserStats, remoteUserStats); - Logger.LogInformation("{SyncDirection} user data for video {VideoName}. (File={FileId},Episode={EpisodeId},Local={HaveLocal},Remote={HaveRemote},InSync={IsInSync})", direction.ToString(), video.Name, fileId, episodeId, localUserStats != null, remoteUserStats != null, isInSync); - if (isInSync) - return; + if (!userConfig.SyncRestrictedVideos && video.CustomRating == "XXX") { + Logger.LogTrace("Skipped {SyncDirection} user data for video {VideoName}. (File={FileId},Episode={EpisodeId})", direction.ToString(), video.Name, fileId, episodeId); + return; + } + var localUserStats = UserDataManager.GetUserData(userConfig.UserId, video); + var remoteUserStats = await APIClient.GetFileUserStats(fileId, userConfig.Token); + bool isInSync = UserDataEqualsFileUserStats(localUserStats, remoteUserStats); + Logger.LogInformation("{SyncDirection} user data for video {VideoName}. (File={FileId},Episode={EpisodeId},Local={HaveLocal},Remote={HaveRemote},InSync={IsInSync})", direction.ToString(), video.Name, fileId, episodeId, localUserStats != null, remoteUserStats != null, isInSync); + if (isInSync) + return; - switch (direction) - { - case SyncDirection.Export: - // Abort since there are no local stats to export. - if (localUserStats == null) - break; - // Export the local stats if there is no remote stats or if the local stats are newer. - if (remoteUserStats == null) - { - remoteUserStats = localUserStats.ToFileUserStats(); - // Don't sync if the local state is considered empty and there is no remote state. - if (remoteUserStats.IsEmpty) + switch (direction) + { + case SyncDirection.Export: + // Abort since there are no local stats to export. + if (localUserStats == null) break; - remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, fileId, episodeId); - } - else if (localUserStats.LastPlayedDate.HasValue && localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) { - remoteUserStats = localUserStats.ToFileUserStats(); - remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, fileId, episodeId); - } - break; - case SyncDirection.Import: - // Abort since there are no remote stats to import. - if (remoteUserStats == null) + // Export the local stats if there is no remote stats or if the local stats are newer. + if (remoteUserStats == null) + { + remoteUserStats = localUserStats.ToFileUserStats(); + // Don't sync if the local state is considered empty and there is no remote state. + if (remoteUserStats.IsEmpty) + break; + remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, fileId, episodeId); + } + else if (localUserStats.LastPlayedDate.HasValue && localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) { + remoteUserStats = localUserStats.ToFileUserStats(); + remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, fileId, episodeId); + } break; - // Create a new local stats entry if there is no local entry. - if (localUserStats == null) - { - UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats = remoteUserStats.ToUserData(video, userConfig.UserId), UserDataSaveReason.Import, CancellationToken.None); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, fileId, episodeId); - } - // Else merge the remote stats into the local stats entry. - else if ((!localUserStats.LastPlayedDate.HasValue || remoteUserStats.LastUpdatedAt > localUserStats.LastPlayedDate.Value)) - { - UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, fileId, episodeId); - } - break; - default: - case SyncDirection.Sync: { - // Export if there is local stats but no remote stats. - if (localUserStats == null && remoteUserStats != null) - goto case SyncDirection.Import; - - // Try to import of there is no local stats ubt there are remote stats. - else if (remoteUserStats == null && localUserStats != null) - goto case SyncDirection.Export; - - // Abort if there are no local or remote stats. - else if (remoteUserStats == null && localUserStats == null) + case SyncDirection.Import: + // Abort since there are no remote stats to import. + if (remoteUserStats == null) + break; + // Create a new local stats entry if there is no local entry. + if (localUserStats == null) + { + UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats = remoteUserStats.ToUserData(video, userConfig.UserId), UserDataSaveReason.Import, CancellationToken.None); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, fileId, episodeId); + } + // Else merge the remote stats into the local stats entry. + else if ((!localUserStats.LastPlayedDate.HasValue || remoteUserStats.LastUpdatedAt > localUserStats.LastPlayedDate.Value)) + { + UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, fileId, episodeId); + } break; + default: + case SyncDirection.Sync: { + // Export if there is local stats but no remote stats. + if (localUserStats == null && remoteUserStats != null) + goto case SyncDirection.Import; - // Try to import if we're unable to read the last played timestamp. - if (!localUserStats.LastPlayedDate.HasValue) - goto case SyncDirection.Import; + // Try to import of there is no local stats ubt there are remote stats. + else if (remoteUserStats == null && localUserStats != null) + goto case SyncDirection.Export; - // Abort if the stats are in sync. - if (isInSync || localUserStats.LastPlayedDate.Value == remoteUserStats.LastUpdatedAt) - break; + // Abort if there are no local or remote stats. + else if (remoteUserStats == null && localUserStats == null) + break; - // Export if the local state is fresher then the remote state. - if (localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) - { - remoteUserStats = localUserStats.ToFileUserStats(); - remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, fileId, episodeId); - } - // Else import if the remote state is fresher then the local state. - else if (localUserStats.LastPlayedDate.Value < remoteUserStats.LastUpdatedAt) - { - UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, fileId, episodeId); + // Try to import if we're unable to read the last played timestamp. + if (!localUserStats.LastPlayedDate.HasValue) + goto case SyncDirection.Import; + + // Abort if the stats are in sync. + if (isInSync || localUserStats.LastPlayedDate.Value == remoteUserStats.LastUpdatedAt) + break; + + // Export if the local state is fresher then the remote state. + if (localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) + { + remoteUserStats = localUserStats.ToFileUserStats(); + remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, fileId, episodeId); + } + // Else import if the remote state is fresher then the local state. + else if (localUserStats.LastPlayedDate.Value < remoteUserStats.LastUpdatedAt) + { + UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, fileId, episodeId); + } + break; } - break; } } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized) { + Logger.LogError(ex, "I{Message} (Username={Username},Id={UserId})", ex.Message, UserManager.GetUserById(userConfig.UserId)?.Username, userConfig.UserId); + throw; + } } /// <summary> From 4d58b6f0ad5a787dbd0279cccb958d71f2244390 Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Wed, 1 Mar 2023 10:45:16 +0000 Subject: [PATCH 0477/1103] Exception wording tweak: Shoko Server API key invalid Adds clarity to the other locations that the API key may need resetting --- 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 6a6b5d99..efd7aabd 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -92,7 +92,7 @@ private async Task<HttpResponseMessage> Get(string url, HttpMethod method, strin requestMessage.Headers.Add("apikey", apiKey); var response = await _httpClient.SendAsync(requestMessage); if (response.StatusCode == HttpStatusCode.Unauthorized) - throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection in the plugin settings.", null, HttpStatusCode.Unauthorized); + throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); return response; } @@ -146,7 +146,7 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method requestMessage.Headers.Add("apikey", apiKey); var response = await _httpClient.SendAsync(requestMessage); if (response.StatusCode == HttpStatusCode.Unauthorized) - throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection in the plugin settings.", null, HttpStatusCode.Unauthorized); + throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); return response; } From 54a4a53ba719392fbcdf7e82171ba07dd1e2d750 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 1 Mar 2023 22:10:34 +0000 Subject: [PATCH 0478/1103] Update unstable repo manifest --- manifest-unstable.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 542181f4..8d1e8b19 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -13,8 +13,8 @@ "changelog": "NA\n", "targetAbi": "10.8.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.41/shoko_2.0.1.41.zip", - "checksum": "b2947b9a11382ce08830a98d34da71d7", - "timestamp": "2023-02-20T23:59:19Z" + "checksum": "f8b88e19200035d057ad5ef3cfe7d68b", + "timestamp": "2023-03-01T22:10:32Z" }, { "version": "2.0.1.39", From cc7df47d57226d6be6a49fdab5bf7395f8002c34 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 4 Mar 2023 15:00:30 +0100 Subject: [PATCH 0479/1103] fix: fix episode endpoint changing it's model --- Shokofin/API/ShokoAPIClient.cs | 4 ++-- Shokofin/API/ShokoAPIManager.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index efd7aabd..b6891307 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -236,9 +236,9 @@ public Task<Episode> GetEpisode(string id) return Get<Episode>($"/api/v3/Episode/{id}?includeDataFrom=AniDB,TvDB"); } - public Task<List<Episode>> GetEpisodesFromSeries(string seriesId) + public Task<ListResult<Episode>> GetEpisodesFromSeries(string seriesId) { - return Get<List<Episode>>($"/api/v3/Series/{seriesId}/Episode?includeMissing=true&includeDataFrom=AniDB,TvDB"); + return Get<ListResult<Episode>>($"/api/v3/Series/{seriesId}/Episode?pageSize=0&includeMissing=true&includeDataFrom=AniDB,TvDB"); } public Task<Series> GetSeries(string id) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index a9d381a5..e443ff9f 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -578,7 +578,7 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId) Logger.LogTrace("Creating info object for series {SeriesName}. (Series={SeriesId})", series.Name, seriesId); - var episodes = (await APIClient.GetEpisodesFromSeries(seriesId) ?? new()) + var episodes = (await APIClient.GetEpisodesFromSeries(seriesId) ?? new()).List .Select(e => CreateEpisodeInfo(e, e.IDs.Shoko.ToString())) .OrderBy(e => e.AniDB.AirDate) .ToList(); From 7af101e090829cacbf745861d48ba240e353331b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 4 Mar 2023 14:01:10 +0000 Subject: [PATCH 0480/1103] Update unstable repo manifest --- manifest-unstable.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 8d1e8b19..4c70fa33 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -13,8 +13,8 @@ "changelog": "NA\n", "targetAbi": "10.8.0.0", "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.41/shoko_2.0.1.41.zip", - "checksum": "f8b88e19200035d057ad5ef3cfe7d68b", - "timestamp": "2023-03-01T22:10:32Z" + "checksum": "85b9e984bccfbf0a067784ca5972e5e7", + "timestamp": "2023-03-04T14:01:08Z" }, { "version": "2.0.1.39", From 460bcaff7039faa401a1e42ae2002956dadd4808 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 4 Mar 2023 21:56:30 +0100 Subject: [PATCH 0481/1103] misc: fix auto builds closes #10 hopefully. --- manifest-unstable.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 4c70fa33..b35d4a06 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,14 +8,6 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ - { - "version": "2.0.1.41", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.41/shoko_2.0.1.41.zip", - "checksum": "85b9e984bccfbf0a067784ca5972e5e7", - "timestamp": "2023-03-04T14:01:08Z" - }, { "version": "2.0.1.39", "changelog": "NA\n", From 5e0601d37626fa31c209b0988590940d3312c1ac Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 4 Mar 2023 20:57:14 +0000 Subject: [PATCH 0482/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index b35d4a06..4f4a559e 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.40", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.40/shoko_2.0.1.40.zip", + "checksum": "0ad9e8024c9ea46a9fec1b0abc978443", + "timestamp": "2023-03-04T20:57:12Z" + }, { "version": "2.0.1.39", "changelog": "NA\n", From d04d7ec0e0128bcf1f29de41823b9d1d9ee92e13 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 7 Mar 2023 19:02:23 +0100 Subject: [PATCH 0483/1103] =?UTF-8?q?feat:=20try=20implementing=20chronolo?= =?UTF-8?q?gical=20season=20sorting=20as=20an=20experimental=20feature.=20?= =?UTF-8?q?Will=20test=20it=20soon=E2=84=A2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/API/Info/GroupInfo.cs | 4 +- Shokofin/API/Info/SeriesInfo.cs | 16 ++- Shokofin/API/ShokoAPIClient.cs | 5 + Shokofin/API/ShokoAPIManager.cs | 3 +- Shokofin/Configuration/configController.js | 9 ++ Shokofin/Configuration/configPage.html | 9 ++ Shokofin/Utils/SeriesInfoRelationComparer.cs | 112 +++++++++++++++++++ 7 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 Shokofin/Utils/SeriesInfoRelationComparer.cs diff --git a/Shokofin/API/Info/GroupInfo.cs b/Shokofin/API/Info/GroupInfo.cs index d906bc2e..c3c6df58 100644 --- a/Shokofin/API/Info/GroupInfo.cs +++ b/Shokofin/API/Info/GroupInfo.cs @@ -61,9 +61,9 @@ public GroupInfo(Group group, List<SeriesInfo> seriesList, Ordering.GroupFilterT case Ordering.OrderType.ReleaseDate: seriesList = seriesList.OrderBy(s => s?.AniDB?.AirDate ?? System.DateTime.MaxValue).ToList(); break; - // Should not be selectable unless a user fiddles with DevTools in the browser to select the option. case Ordering.OrderType.Chronological: - throw new System.Exception("Not implemented yet"); + seriesList.Sort(new SeriesInfoRelationComparer()); + break; } // Select the targeted id if a group specify a default series. diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index 3068aced..0bf7bb47 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -74,7 +74,17 @@ public class SeriesInfo /// </summary> public List<EpisodeInfo> SpecialsList; - public SeriesInfo(Series series, List<EpisodeInfo> episodes, IEnumerable<Role> cast, string[] genres, string[] tags) + /// <summary> + /// Related series data available in Shoko. + /// </summary> + public List<Relation> Relations; + + /// <summary> + /// Map of related series with type. + /// </summary> + public Dictionary<string, RelationType> RelationMap; + + public SeriesInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, List<Relation> relations, string[] genres, string[] tags) { var seriesId = series.IDs.Shoko.ToString(); var studios = cast @@ -85,6 +95,8 @@ public SeriesInfo(Series series, List<EpisodeInfo> episodes, IEnumerable<Role> c .Select(RoleToPersonInfo) .OfType<PersonInfo>() .ToArray(); + var relationMap = relations + .ToDictionary(r => r.RelatedIDs.Shoko!.Value.ToString(), r => r.Type); var specialsAnchorDictionary = new Dictionary<EpisodeInfo, EpisodeInfo>(); var specialsList = new List<EpisodeInfo>(); var episodesList = new List<EpisodeInfo>(); @@ -143,6 +155,8 @@ public SeriesInfo(Series series, List<EpisodeInfo> episodes, IEnumerable<Role> c ExtrasList = extrasList; SpecialsAnchors = specialsAnchorDictionary; SpecialsList = specialsList; + Relations = relations; + RelationMap = relationMap; } private string? GetImagePath(Image image) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index b6891307..111b487f 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -261,6 +261,11 @@ public Task<List<Role>> GetSeriesCast(string id) return Get<List<Role>>($"/api/v3/Series/{id}/Cast"); } + public Task<List<Relation>> GetSeriesRelations(string id) + { + return Get<List<Relation>>($"/api/v3/Series/{id}/Relations"); + } + public Task<Images> GetSeriesImages(string id) { return Get<Images>($"/api/v3/Series/{id}/Images"); diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index e443ff9f..586c9782 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -583,10 +583,11 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId) .OrderBy(e => e.AniDB.AirDate) .ToList(); var cast = await APIClient.GetSeriesCast(seriesId); + var relations = await APIClient.GetSeriesRelations(seriesId); var genres = await GetGenresForSeries(seriesId); var tags = await GetTagsForSeries(seriesId); - seriesInfo = new SeriesInfo(series, episodes, cast, genres, tags); + seriesInfo = new SeriesInfo(series, episodes, cast, relations, genres, tags); foreach (var episode in episodes) EpisodeIdToSeriesIdDictionary[episode.Id] = seriesId; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 1bba6673..d05b5e49 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -176,6 +176,9 @@ async function defaultSubmit(form) { form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); config.IgnoredFolders = ignoredFolders; form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); + + // Experimental settings + config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; config.EXPERIMENTAL_AutoMergeVersions = form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked; config.EXPERIMENTAL_SplitThenMergeMovies = form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked; config.EXPERIMENTAL_SplitThenMergeEpisodes = form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked; @@ -332,6 +335,9 @@ async function syncSettings(form) { form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); config.IgnoredFolders = ignoredFolders; form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); + + // Experimental settings + config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; config.EXPERIMENTAL_AutoMergeVersions = form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked; config.EXPERIMENTAL_SplitThenMergeMovies = form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked; config.EXPERIMENTAL_SplitThenMergeEpisodes = form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked; @@ -508,6 +514,9 @@ export default function (page) { form.querySelector("#PublicHost").value = config.PublicHost; form.querySelector("#IgnoredFileExtensions").value = config.IgnoredFileExtensions.join(" "); form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); + + // Experimental settings + form.querySelector("#SeasonOrdering").value = config.SeasonOrdering; form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked = config.EXPERIMENTAL_AutoMergeVersions || false; form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked = config.EXPERIMENTAL_SplitThenMergeMovies || true; form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked = config.EXPERIMENTAL_SplitThenMergeEpisodes || false; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index e5f98f15..ffb05274 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -325,6 +325,15 @@ <h3>Advanced 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="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="SeasonOrdering">Season ordering:</label> + <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select"> + <option value="Default" selected>Let Shoko decide.</option> + <option value="ReleaseDate">Order season by release date.</option> + <option value="Chronological">Order seasons in chronological order.</option> + </select> + <div class="fieldDescription">Determines how to order Seasons within Shows.</div> + </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_AutoMergeVersions" /> diff --git a/Shokofin/Utils/SeriesInfoRelationComparer.cs b/Shokofin/Utils/SeriesInfoRelationComparer.cs new file mode 100644 index 00000000..3815ad6f --- /dev/null +++ b/Shokofin/Utils/SeriesInfoRelationComparer.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Shokofin.API.Info; +using Shokofin.API.Models; + +#nullable enable +namespace Shokofin.Utils; + +public class SeriesInfoRelationComparer : IComparer<SeriesInfo> +{ + protected static Dictionary<RelationType, int> RelationPriority = new() { + { RelationType.Prequel, 1 }, + { RelationType.MainStory, 2 }, + { RelationType.FullStory, 3 }, + + { RelationType.AlternativeVersion, 21 }, + { RelationType.SameSetting, 22 }, + { RelationType.AlternativeSetting, 23 }, + + { RelationType.SideStory, 41 }, + { RelationType.Summary, 42 }, + { RelationType.Sequel, 43 }, + + { RelationType.SharedCharacters, 99 }, + }; + + public int Compare(SeriesInfo? a, SeriesInfo? b) + { + // Check for `null` since `IComparer<T>` expects `T` to be nullable. + if (a == null && b == null) + return 0; + if (a == null) + return 1; + if (b == null) + return -1; + + // Check for direct relations. + var directRelationComparison = CompareDirectRelations(a, b); + if (directRelationComparison != 0) + return directRelationComparison; + + // Check for indirect relations. + var indirectRelationComparison = CompareIndirectRelations(a, b); + if (indirectRelationComparison != 0) + return indirectRelationComparison; + + // Fallback to checking the air dates if they're not indirectly related + // or if they have the same relations. + return CompareAirDates(a.AniDB.AirDate, b.AniDB.AirDate); + } + + private int CompareDirectRelations(SeriesInfo a, SeriesInfo b) + { + // We check from both sides because one of the entries may be outdated, + // so the relation may only present on one of the entries. + if (a.RelationMap.TryGetValue(b.Id, out var relationType)) + if (relationType == RelationType.Prequel || relationType == RelationType.MainStory) + return -1; + else if (relationType == RelationType.Sequel || relationType == RelationType.SideStory) + return 1; + + if (b.RelationMap.TryGetValue(a.Id, out relationType)) + if (relationType == RelationType.Prequel || relationType == RelationType.MainStory) + return 1; + else if (relationType == RelationType.Sequel || relationType == RelationType.SideStory) + return -1; + + // The entries are not considered to be directly related. + return 0; + } + + private int CompareIndirectRelations(SeriesInfo a, SeriesInfo b) + { + var xRelations = a.Relations + .Where(r => RelationPriority.ContainsKey(r.Type)) + .Select(r => r.Type) + .OrderBy(r => RelationPriority[r]) + .ToList(); + var yRelations = b.Relations + .Where(r => RelationPriority.ContainsKey(r.Type)) + .Select(r => r.Type) + .OrderBy(r => RelationPriority[r]) + .ToList(); + for (int i = 0; i < Math.Max(xRelations.Count, yRelations.Count); i++) { + // The first entry have overall less relations, so it comes after the second entry. + if (i >= xRelations.Count) + return 1; + // The second entry have overall less relations, so it comes after the first entry. + else if (i >= yRelations.Count) + return -1; + + // Compare the relation priority to see which have a higher priority. + var xRelationType = xRelations[i]; + var xRelationPriority = RelationPriority[xRelationType]; + var yRelationType = yRelations[i]; + var yRelationPriority = RelationPriority[yRelationType]; + var relationPriorityComparison = xRelationPriority.CompareTo(yRelationPriority); + if (relationPriorityComparison != 0) + return relationPriorityComparison; + } + + // The entries are not considered to be indirectly related, or they have + // the same relations. + return 0; + } + + private int CompareAirDates(DateTime? a, DateTime? b) + { + return a.HasValue ? b.HasValue ? DateTime.Compare(a.Value, b.Value) : 1 : b.HasValue ? -1 : 0; + } +} From ba04de45b310c816ccc625719269267b4877fe8a Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 7 Mar 2023 18:04:11 +0000 Subject: [PATCH 0484/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 4f4a559e..eb65502a 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.41", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.41/shoko_2.0.1.41.zip", + "checksum": "805c101b03b516be60266c17bf779067", + "timestamp": "2023-03-07T18:04:08Z" + }, { "version": "2.0.1.40", "changelog": "NA\n", From e3e281828f40451f99d524423d634ff3524a8fc9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 9 Mar 2023 00:23:05 +0100 Subject: [PATCH 0485/1103] fix: fix null reference --- Shokofin/API/Info/SeriesInfo.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeriesInfo.cs index 0bf7bb47..9e1c95f6 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeriesInfo.cs @@ -96,6 +96,7 @@ public SeriesInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li .OfType<PersonInfo>() .ToArray(); var relationMap = relations + .Where(r => r.RelatedIDs.Shoko.HasValue) .ToDictionary(r => r.RelatedIDs.Shoko!.Value.ToString(), r => r.Type); var specialsAnchorDictionary = new Dictionary<EpisodeInfo, EpisodeInfo>(); var specialsList = new List<EpisodeInfo>(); From 6a55738b04ec65dea0648a70ce3eedb97704d64e Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 8 Mar 2023 23:23:50 +0000 Subject: [PATCH 0486/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index eb65502a..6cfe8243 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.42", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.42/shoko_2.0.1.42.zip", + "checksum": "ef799de6ae1d52016c5a74315794cc30", + "timestamp": "2023-03-08T23:23:47Z" + }, { "version": "2.0.1.41", "changelog": "NA\n", From b7d8c379f58c816018ebb9ffacc49d88ebe48215 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 20 Mar 2023 18:38:05 +0100 Subject: [PATCH 0487/1103] misc: update read-me file --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 13c44003..db242b4a 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,7 @@ compatible with what. | `0.x.x` | `10.7` | `4.0.0-4.1.2` | | `1.x.x` | `10.7` | `4.1.0-4.1.2` | | `2.x.x` | `10.8` | `4.1.2` | +| `3.x.x` | `10.8` | `4.2.0` | | `unstable` | `10.8` | `dev` | ### Official Repository From 9b366f04857798c15b72812f3d3673d21bb6888b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 20 Mar 2023 17:38:59 +0000 Subject: [PATCH 0488/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 6cfe8243..63ddfa06 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.43", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.43/shoko_2.0.1.43.zip", + "checksum": "f55ecc226671430dc085ea7d3bea962e", + "timestamp": "2023-03-20T17:38:57Z" + }, { "version": "2.0.1.42", "changelog": "NA\n", From 49fc55c8f3c83ed06508acc03b037a330d39e031 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 20 Mar 2023 22:02:08 +0100 Subject: [PATCH 0489/1103] fix: don't set the H rating on TV shows --- Shokofin/Providers/EpisodeProvider.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 13fa67c4..efb731a4 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -216,6 +216,7 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri } } else { + var rating = series.AniDB.Restricted && series.AniDB.Type != SeriesType.TV ? "XXX" : null; if (season != null) { result = new Episode { Name = displayTitle, @@ -235,8 +236,8 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri SeriesName = season.Series.Name, SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, SeasonName = season.Name, - OfficialRating = series.AniDB.Restricted ? "XXX" : null, - CustomRating = series.AniDB.Restricted ? "XXX" : null, + OfficialRating = rating, + CustomRating = rating, DateLastSaved = DateTime.UtcNow, RunTimeTicks = episode.AniDB.Duration.Ticks, }; @@ -253,8 +254,8 @@ private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo seri AirsBeforeSeasonNumber = airsBeforeSeasonNumber, PremiereDate = episode.AniDB.AirDate, Overview = description, - OfficialRating = series.AniDB.Restricted ? "XXX" : null, - CustomRating = series.AniDB.Restricted ? "XXX" : null, + OfficialRating = rating, + CustomRating = rating, CommunityRating = episode.AniDB.Rating.ToFloat(10), }; } From 8f12b1e54b5faccd201dc8c225d5ca2676307122 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 20 Mar 2023 21:03:00 +0000 Subject: [PATCH 0490/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 63ddfa06..8c3910b4 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "2.0.1.44", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.44/shoko_2.0.1.44.zip", + "checksum": "da49cf3b62c8d0259a83d47f3a6f5a84", + "timestamp": "2023-03-20T21:02:58Z" + }, { "version": "2.0.1.43", "changelog": "NA\n", From 9670067ab36b60c7d2bf21d9ea6dc1062007819f Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 29 Mar 2023 17:54:59 +0000 Subject: [PATCH 0491/1103] Update repo manifest --- manifest.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index cb2a6900..7fad0567 100644 --- a/manifest.json +++ b/manifest.json @@ -2,12 +2,20 @@ { "guid": "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", "name": "Shoko", - "description": "A plugin to provide metadata from Shoko Server for your locally organised anime library in Jellyfin.\n", + "description": "A plugin to provide metadata from Shoko Server for your locally organized anime library in Jellyfin.\n", "overview": "Manage your anime from Jellyfin using metadata from Shoko", "owner": "shokoanime", "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.0.0", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.0/shoko_3.0.0.0.zip", + "checksum": "c02b32abb548eb3d2153ac957ad88f80", + "timestamp": "2023-03-29T17:54:57Z" + }, { "version": "2.0.1.0", "changelog": "NA\n", From 218e472f1812deb71cd0e41ea526b563a9a1e474 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 18 Apr 2023 06:33:24 +0200 Subject: [PATCH 0492/1103] feat: show all images for the show if shoko groups are used. --- Shokofin/Providers/ImageProvider.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index e181f5d1..2ad8e356 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -42,7 +42,6 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell { var list = new List<RemoteImageInfo>(); try { - var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Utils.Ordering.GroupFilterType.Others : Utils.Ordering.GroupFilterType.Default; switch (item) { case Episode episode: { @@ -57,11 +56,22 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell } case Series series: { if (Lookup.TryGetSeriesIdFor(series, out var seriesId)) { - var seriesImages = await ApiClient.GetSeriesImages(seriesId); - if (seriesImages != null) { - AddImagesForSeries(ref list, seriesImages); + if (Plugin.Instance.Configuration.SeriesGrouping == Utils.Ordering.GroupType.ShokoGroup) { + var images = series.GetSeasons(null, new(true)) + .Cast<Season>() + .AsParallel() + .SelectMany(season => GetImages(season, cancellationToken).Result) + .DistinctBy(image => image.Url) + .ToList(); Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId})", list.Count, series.Name, seriesId); } + else { + var seriesImages = await ApiClient.GetSeriesImages(seriesId); + if (seriesImages != null) { + AddImagesForSeries(ref list, seriesImages); + Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId})", list.Count, series.Name, seriesId); + } + } } break; } From c3e109dd719e01a35f955238eb4463b22d0ecba1 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 18 Apr 2023 07:14:27 +0200 Subject: [PATCH 0493/1103] fix: add falback in group info Add a fallback for when the main series for the group is not part of the current filtered view of the group. --- Shokofin/API/Info/GroupInfo.cs | 12 ++++++++---- Shokofin/API/ShokoAPIManager.cs | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Shokofin/API/Info/GroupInfo.cs b/Shokofin/API/Info/GroupInfo.cs index c3c6df58..147f9803 100644 --- a/Shokofin/API/Info/GroupInfo.cs +++ b/Shokofin/API/Info/GroupInfo.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.Extensions.Logging; using Shokofin.API.Models; using Shokofin.Utils; @@ -40,7 +41,7 @@ public GroupInfo(Group group) DefaultSeries = null; } - public GroupInfo(Group group, List<SeriesInfo> seriesList, Ordering.GroupFilterType filterByType) + public GroupInfo(Group group, List<SeriesInfo> seriesList, Ordering.GroupFilterType filterByType, ILogger logger) { var groupId = group.IDs.Shoko.ToString(); @@ -87,9 +88,12 @@ public GroupInfo(Group group, List<SeriesInfo> seriesList, Ordering.GroupFilterT } } - // Throw if we can't get a base point for seasons. + // Fallback to the first series if we can't get a base point for seasons. if (foundIndex == -1) - throw new System.Exception("Unable to get a base-point for seasons within the group"); + { + logger.LogWarning("Unable to get a base-point for seasons within the group for the filter, so falling back to the first series in the group. This is most likely due to library separation being enabled. (Filter={FilterByType},Group={GroupID})", filterByType.ToString(), groupId); + foundIndex = 0; + } var seasonOrderDictionary = new Dictionary<int, SeriesInfo>(); var seasonNumberBaseDictionary = new Dictionary<SeriesInfo, int>(); @@ -127,7 +131,7 @@ public GroupInfo(Group group, List<SeriesInfo> seriesList, Ordering.GroupFilterT SeriesList = seriesList; SeasonNumberBaseDictionary = seasonNumberBaseDictionary; SeasonOrderDictionary = seasonOrderDictionary; - DefaultSeries = seriesList[foundIndex]; + DefaultSeries = seriesList.Count > 0 ? seriesList[foundIndex] : null; } public SeriesInfo? GetSeriesInfoBySeasonNumber(int seasonNumber) { diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 586c9782..fcaead63 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -744,7 +744,7 @@ private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Order return groupInfo; } - groupInfo = new GroupInfo(group, seriesList, filterByType); + groupInfo = new GroupInfo(group, seriesList, filterByType, Logger); foreach (var series in seriesList) SeriesIdToGroupIdDictionary[series.Id] = groupId; From f7d1b0b244532a7db1147d58be3675d836505447 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 18 Apr 2023 07:20:08 +0200 Subject: [PATCH 0494/1103] fix: fix faulty dates sent by the server which in turn got them from anidb --- Shokofin/API/Models/Series.cs | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index 4b7950a4..fa2b0ea0 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -145,15 +145,41 @@ public class AniDBWithDate : AniDB /// </summary> public new int EpisodeCount { get; set; } + [JsonIgnore] + private DateTime? _airDate { get; set; } = null; + /// <summary> /// Air date (2013-02-27, shut up avael). Anything without an air date is going to be missing a lot of info. /// </summary> - public DateTime? AirDate { get; set; } + public DateTime? AirDate + { + get + { + return _airDate; + } + set + { + _airDate = value.HasValue && (value.Value == DateTime.UnixEpoch || value.Value == DateTime.MinValue || value.Value == DateTime.MaxValue) ? null : value; + } + } + + [JsonIgnore] + private DateTime? _endDate { get; set; } = null; /// <summary> /// End date, can be omitted. Omitted means that it's still airing (2013-02-27) /// </summary> - public DateTime? EndDate { get; set; } + public DateTime? EndDate + { + get + { + return _endDate; + } + set + { + _endDate = value.HasValue && (value.Value == DateTime.UnixEpoch || value.Value == DateTime.MinValue || value.Value == DateTime.MaxValue) ? null : value; + } + } } public class TvDB From 09cf00799549a06a93784ac59e4cf72e3adc6118 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 18 Apr 2023 07:21:09 +0200 Subject: [PATCH 0495/1103] fix logic for how to determine the default series in a group --- Shokofin/API/Info/GroupInfo.cs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/Shokofin/API/Info/GroupInfo.cs b/Shokofin/API/Info/GroupInfo.cs index 147f9803..76d76a19 100644 --- a/Shokofin/API/Info/GroupInfo.cs +++ b/Shokofin/API/Info/GroupInfo.cs @@ -69,21 +69,14 @@ public GroupInfo(Group group, List<SeriesInfo> seriesList, Ordering.GroupFilterT // Select the targeted id if a group specify a default series. int foundIndex = -1; - int targetId = group.IDs.MainSeries; - if (targetId != 0) - foundIndex = seriesList.FindIndex(s => s.Shoko.IDs.Shoko == targetId); - // Else select the default series as first-to-be-released. - else switch (orderingType) { - // The list is already sorted by release date, so just return the first index. + switch (orderingType) { case Ordering.OrderType.ReleaseDate: foundIndex = 0; break; - // We don't know how Shoko may have sorted it, so just find the earliest series case Ordering.OrderType.Default: - // We can't be sure that the the series in the list was _released_ chronologically, so find the earliest series, and use that as a base. case Ordering.OrderType.Chronological: { - var earliestSeries = seriesList.Aggregate((cur, nxt) => (cur == null || (nxt.AniDB.AirDate ?? System.DateTime.MaxValue) < (cur.AniDB.AirDate ?? System.DateTime.MaxValue)) ? nxt : cur); - foundIndex = seriesList.FindIndex(s => s == earliestSeries); + int targetId = group.IDs.MainSeries; + foundIndex = seriesList.FindIndex(s => s.Shoko.IDs.Shoko == targetId); break; } } From f3ee0121f784c74218e95d87dfb0e11793850627 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 18 Apr 2023 06:12:18 +0000 Subject: [PATCH 0496/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 8c3910b4..77e581dd 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.0.1", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.0.1/shoko_3.0.0.1.zip", + "checksum": "da3e3819e158d1cdd078549711da30f9", + "timestamp": "2023-04-18T06:12:16Z" + }, { "version": "2.0.1.44", "changelog": "NA\n", From 7dfc9c5cb46fc2770135594a01f48c161e9921e4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 19 Apr 2023 02:50:18 +0200 Subject: [PATCH 0497/1103] fix: fix the image provider that i just broke Don't assign the images to a new list, replace the existing list instead. --- 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 2ad8e356..3ee33b90 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -57,7 +57,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell case Series series: { if (Lookup.TryGetSeriesIdFor(series, out var seriesId)) { if (Plugin.Instance.Configuration.SeriesGrouping == Utils.Ordering.GroupType.ShokoGroup) { - var images = series.GetSeasons(null, new(true)) + list = series.GetSeasons(null, new(true)) .Cast<Season>() .AsParallel() .SelectMany(season => GetImages(season, cancellationToken).Result) From 8b90e88c82efa81bdde40c222397b6f345389d4d Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 19 Apr 2023 00:50:58 +0000 Subject: [PATCH 0498/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 77e581dd..be3d223f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.0.2", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.0.2/shoko_3.0.0.2.zip", + "checksum": "fc683a63a29a00d72bf247707cbeb875", + "timestamp": "2023-04-19T00:50:57Z" + }, { "version": "3.0.0.1", "changelog": "NA\n", From 5bb9f1b72c87a976934c26497b67d11b172b4d5c Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 20 Apr 2023 15:44:38 +0000 Subject: [PATCH 0499/1103] Update repo manifest --- manifest.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest.json b/manifest.json index 7fad0567..7862c764 100644 --- a/manifest.json +++ b/manifest.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.0", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1/shoko_3.0.1.0.zip", + "checksum": "8bfa1dc2c430c07c0659bbbc757dbf8e", + "timestamp": "2023-04-20T15:44:36Z" + }, { "version": "3.0.0.0", "changelog": "NA\n", From b50e0d870e5a25d907cc14e5a21add3472a168ad Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 23 Apr 2023 21:12:42 +0200 Subject: [PATCH 0500/1103] fix: fix box-sets for movies this relies on the core collection feature to work. --- Shokofin/API/Models/Series.cs | 35 ++++++++++++++++ Shokofin/API/ShokoAPIClient.cs | 11 +++++ Shokofin/API/ShokoAPIManager.cs | 63 +++++++++++++++++++++++++++- Shokofin/Providers/BoxSetProvider.cs | 53 +++++++++++++++++++++-- Shokofin/Providers/MovieProvider.cs | 13 ++++++ 5 files changed, 169 insertions(+), 6 deletions(-) diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index fa2b0ea0..d2e4a052 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -273,7 +273,42 @@ public class FileSourceCounts public int Camera; } } +} + +/// <summary> +/// An Extended Series Model with Values for Search Results +/// </summary> +public class SeriesSearchResult : Series +{ + /// <summary> + /// Indicates whether the search result is an exact match to the query. + /// </summary> + public bool ExactMatch { get; set; } + + /// <summary> + /// Represents the position of the match within the sanitized string. + /// This property is only applicable when ExactMatch is set to true. + /// A lower value indicates a match that occurs earlier in the string. + /// </summary> + public int Index { get; set; } + /// <summary> + /// Represents the similarity measure between the sanitized query and the sanitized matched result. + /// This may be the sorensen-dice distance or the tag weight when comparing tags for a series. + /// A lower value indicates a more similar match. + /// </summary> + public double Distance { get; set; } + + /// <summary> + /// Represents the absolute difference in length between the sanitized query and the sanitized matched result. + /// A lower value indicates a match with a more similar length to the query. + /// </summary> + public int LengthDifference { get; set; } + + /// <summary> + /// Contains the original matched substring from the original string. + /// </summary> + public string Match { get; set; } = string.Empty; } [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 111b487f..3bf85cf7 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; @@ -276,6 +277,16 @@ public Task<List<Series>> GetSeriesPathEndsWith(string dirname) return Get<List<Series>>($"/api/v3/Series/PathEndsWith/{Uri.EscapeDataString(dirname)}"); } + public async Task<Series?> GetSeriesByName(string name) + { + if (string.IsNullOrEmpty(name)) + return null; + + // Return the first (and hopefully only) exact match on the full title. + var results = await Get<List<SeriesSearchResult>>($"/api/v3/Series/Search?query={Uri.EscapeDataString(name)}&limit=10&fuzzy=false"); + return results?.FirstOrDefault(series => series.ExactMatch && series.Index == 0 && series.LengthDifference == 0 && string.Equals(name, series.Match, StringComparison.Ordinal)); + } + public Task<List<Tag>> GetSeriesTags(string id, ulong filter = 0) { return Get<List<Tag>>($"/api/v3/Series/{id}/Tags?filter={filter}&excludeDescriptions=true"); diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index fcaead63..17b7c4fd 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -32,6 +32,8 @@ public class ShokoAPIManager : IDisposable private readonly ConcurrentDictionary<string, string> PathToSeriesIdDictionary = new(); + private readonly ConcurrentDictionary<string, string> NameToSeriesIdDictionary = new(); + private readonly ConcurrentDictionary<string, List<string>> PathToEpisodeIdsDictionary = new(); private readonly ConcurrentDictionary<string, (string, string)> PathToFileIdAndSeriesIdDictionary = new(); @@ -149,6 +151,7 @@ public void Dispose() PathToEpisodeIdsDictionary.Clear(); PathToFileIdAndSeriesIdDictionary.Clear(); PathToSeriesIdDictionary.Clear(); + NameToSeriesIdDictionary.Clear(); SeriesIdToGroupIdDictionary.Clear(); SeriesIdToPathDictionary.Clear(); } @@ -525,6 +528,22 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) #endregion #region Series Info + public async Task<SeriesInfo?> GetSeriesInfoByName(string name) + { + var seriesId = await GetSeriesIdForName(name); + if (string.IsNullOrEmpty(seriesId)) + return null; + + var key = $"series:{seriesId}"; + if (DataCache.TryGetValue<SeriesInfo>(key, out var seriesInfo)) { + Logger.LogTrace("Reusing info object for series {SeriesName}. (Series={SeriesId})", seriesInfo.Shoko.Name, seriesId); + return seriesInfo; + } + + var series = await APIClient.GetSeries(seriesId); + return await CreateSeriesInfo(series, seriesId); + } + public async Task<SeriesInfo?> GetSeriesInfoByPath(string path) { var seriesId = await GetSeriesIdForPath(path); @@ -622,6 +641,24 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) return SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out groupId); } + private async Task<string?> GetSeriesIdForName(string name) + { + // Reuse cached value. + if (NameToSeriesIdDictionary.TryGetValue(name, out var seriesId)) + return seriesId; + + Logger.LogDebug("Looking for series matching name {Name}", name); + var series = await APIClient.GetSeriesByName(name); + Logger.LogTrace("Found {Count} exact matches for name {Name}", series == null ? 0 : 1, name); + if (series == null) + return null; + + seriesId = series.IDs.Shoko.ToString(); + NameToSeriesIdDictionary[name] = seriesId; + SeriesIdToPathDictionary.TryAdd(seriesId, name); + return seriesId; + } + private async Task<string?> GetSeriesIdForPath(string path) { // Reuse cached value. @@ -629,9 +666,9 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) return seriesId; var partialPath = StripMediaFolder(path); - Logger.LogDebug("Looking for series matching {Path}", partialPath); + Logger.LogDebug("Looking for series matching path {Path}", partialPath); var result = await APIClient.GetSeriesPathEndsWith(partialPath); - Logger.LogTrace("Found result with {Count} matches for {Path}", result.Count, partialPath); + Logger.LogTrace("Found {Count} matches for path {Path}", result.Count, partialPath); // Return the first match where the series unique paths partially match // the input path. @@ -660,6 +697,28 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) #endregion #region Group Info + public async Task<GroupInfo?> GetGroupInfoBySeriesName(string seriesName, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + if (NameToSeriesIdDictionary.TryGetValue(seriesName, out var seriesId)) { + if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { + if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var groupInfo)) { + Logger.LogTrace("Reusing info object for group {GroupName}. (Series={seriesId},Group={GroupId})", groupInfo.Shoko.Name, seriesId, groupId); + return groupInfo; + } + + return await GetGroupInfo(groupId, filterByType); + } + } + else + { + seriesId = await GetSeriesIdForName(seriesName); + if (string.IsNullOrEmpty(seriesId)) + return null; + } + + return await GetGroupInfoForSeries(seriesId, filterByType); + } + public async Task<GroupInfo?> GetGroupInfoByPath(string path, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) { if (PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) { diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 08a0be93..57f6dfc0 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -50,10 +50,25 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<BoxSet>(); - var series = await ApiManager.GetSeriesInfoByPath(info.Path); + + // First try to re-use any existing series id. + API.Info.SeriesInfo series = null; + if (info.ProviderIds.TryGetValue("Shoko Series", out var seriesId)) + series = await ApiManager.GetSeriesInfo(seriesId); + + // Then try to look ir up by path. + if (series == null) + series = await ApiManager.GetSeriesInfoByPath(info.Path); + + // Then try to look it up using the name. + if (series == null) { + var boxSetName = GetBoxSetName(info); + if (boxSetName != null) + series = await ApiManager.GetSeriesInfoByName(boxSetName); + } if (series == null) { - Logger.LogWarning("Unable to find movie box-set info for path {Path}", info.Path); + Logger.LogWarning("Unable to find movie box-set info for name {Name} and path {Path}", info.Name, info.Path); return result; } @@ -88,9 +103,25 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in var result = new MetadataResult<BoxSet>(); var config = Plugin.Instance.Configuration; Ordering.GroupFilterType filterByType = config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Movies : Ordering.GroupFilterType.Default; - var group = await ApiManager.GetGroupInfoByPath(info.Path, filterByType); + + // First try to re-use any existing group id. + API.Info.GroupInfo group = null; + if (info.ProviderIds.TryGetValue("Shoko Group", out var groupId)) + group = await ApiManager.GetGroupInfo(groupId, filterByType); + + // Then try to look ir up by path. + if (group == null) + group = await ApiManager.GetGroupInfoByPath(info.Path, filterByType); + + // Then try to look it up using the name. if (group == null) { - Logger.LogWarning("Unable to find movie box-set info for path {Path}", info.Path); + var boxSetName = GetBoxSetName(info); + if (boxSetName != null) + group = await ApiManager.GetGroupInfoBySeriesName(boxSetName, filterByType); + } + + if (group == null) { + Logger.LogWarning("Unable to find movie box-set info for name {Name} and path {Path}", info.Name, info.Path); return result; } @@ -126,6 +157,20 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in return result; } + private static string GetBoxSetName(BoxSetInfo info) + { + if (string.IsNullOrWhiteSpace(info.Name)) + return null; + + var name = info.Name.Trim(); + if (name.EndsWith("[boxset]")) + name = name[..^8].TrimEnd(); + if (string.IsNullOrWhiteSpace(name)) + return null; + + return name; + } + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) { // Isn't called from anywhere. If it is called, I don't know from where. diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index bb28690d..9b4e1e97 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -48,6 +48,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio return result; } + var collectionName = GetCollectionName(series, group, info.MetadataLanguage); var ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); Logger.LogInformation("Found movie {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", displayTitle, file.Id, episode.Id, series.Id); @@ -60,6 +61,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, + CollectionName = collectionName, // 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(series)), ProductionYear = episode.AniDB.AirDate?.Year, @@ -88,6 +90,17 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio } } + private static string GetCollectionName(API.Info.SeriesInfo series, API.Info.GroupInfo group, string metadataLanguage) + { + return (Plugin.Instance.Configuration.BoxSetGrouping) switch { + Ordering.GroupType.ShokoGroup => + Text.GetSeriesTitle(group.DefaultSeries.AniDB.Titles, group.DefaultSeries.Shoko.Name, metadataLanguage), + Ordering.GroupType.ShokoSeries => + Text.GetSeriesTitle(series.AniDB.Titles, series.Shoko.Name, metadataLanguage), + _ => null, + }; + } + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) { From 2ceafb3b7e065974daf554dd000d8a7b7bd094f6 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 23 Apr 2023 21:27:57 +0200 Subject: [PATCH 0501/1103] misc: don't fetch the series info twice when creating it for an episode. --- Shokofin/API/ShokoAPIManager.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 17b7c4fd..a46439ad 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -582,6 +582,7 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) if (series == null) return null; seriesId = series.IDs.Shoko.ToString(); + return await CreateSeriesInfo(series, seriesId); } return await GetSeriesInfo(seriesId); From 70c56ad1cb4ccf393c80d302776b4f5b5b577148 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 23 Apr 2023 21:28:21 +0200 Subject: [PATCH 0502/1103] misc: expose series id on episode model --- Shokofin/API/Models/Episode.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index 19876562..966ad4d1 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -97,6 +97,8 @@ public class TvDB public class EpisodeIDs : IDs { + public int Series { get; set; } + public int AniDB { get; set; } public List<int> TvDB { get; set; } = new(); From 177ec851e72f5def86d26c051636f4af7a38b7e3 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 23 Apr 2023 19:58:13 +0000 Subject: [PATCH 0503/1103] Update unstable repo manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index be3d223f..c76c4bbf 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.1", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.1/shoko_3.0.1.1.zip", + "checksum": "ae30c98bac4c12c8d7b0fca79b5cd41c", + "timestamp": "2023-04-23T19:58:11Z" + }, { "version": "3.0.0.2", "changelog": "NA\n", From b01598e95253b9aa48bd1365ab780a812acf4a8f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 17 May 2023 21:46:50 +0200 Subject: [PATCH 0504/1103] feat: add sentry and filtering mode [no ci] [skip ci] --- .github/workflows/ReplaceSentryDSN.ps1 | 10 ++ .github/workflows/release-daily.yml | 4 + .github/workflows/release.yml | 11 ++ Shokofin/Configuration/PluginConfiguration.cs | 6 + Shokofin/Configuration/SentryConfiguration.cs | 7 + Shokofin/Configuration/configController.js | 49 ++++++- Shokofin/Configuration/configPage.html | 43 ++++++ Shokofin/IdLookup.cs | 39 ++++- Shokofin/LibraryScanner.cs | 22 +-- Shokofin/Plugin.cs | 134 +++++++++++++----- Shokofin/Providers/BoxSetProvider.cs | 5 +- Shokofin/Providers/EpisodeProvider.cs | 5 +- Shokofin/Providers/ImageProvider.cs | 5 +- Shokofin/Providers/MovieProvider.cs | 5 +- Shokofin/Providers/SeasonProvider.cs | 5 +- Shokofin/Providers/SeriesProvider.cs | 10 +- Shokofin/Shokofin.csproj | 3 +- Shokofin/Sync/UserDataSyncManager.cs | 3 +- Shokofin/Web/WebController.cs | 1 + 19 files changed, 298 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/ReplaceSentryDSN.ps1 create mode 100644 Shokofin/Configuration/SentryConfiguration.cs diff --git a/.github/workflows/ReplaceSentryDSN.ps1 b/.github/workflows/ReplaceSentryDSN.ps1 new file mode 100644 index 00000000..11e75691 --- /dev/null +++ b/.github/workflows/ReplaceSentryDSN.ps1 @@ -0,0 +1,10 @@ +Param( + [string] $dsn = "%SENTRY_DSN%" +) + +$filename = "./Shokofin/Configuration/SentryConfiguration.cs" +$searchString = "%SENTRY_DSN%" + +(Get-Content $filename) | ForEach-Object { + $_ -replace $searchString, $dsn +} | Set-Content $filename diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 7255b4e5..58b89d87 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -19,6 +19,10 @@ jobs: with: fetch-depth: 0 + - name: Replace Sentry DSN key + shell: pwsh + run: ./.github/workflows/ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} + - name: Setup dotnet uses: actions/setup-dotnet@v1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dcc618a9..33b054a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,20 +16,30 @@ jobs: uses: actions/checkout@v2 with: ref: master + + - name: Replace Sentry DSN key + shell: pwsh + run: ./.github/workflows/ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} + - name: Setup dotnet uses: actions/setup-dotnet@v1 with: dotnet-version: 6.0.x + - name: Restore nuget packages run: dotnet restore Shokofin/Shokofin.csproj + - name: Setup python uses: actions/setup-python@v2 with: python-version: 3.8 + - name: Install JPRM run: python -m pip install jprm + - name: Run JPRM run: python build_plugin.py --version=${GITHUB_REF#refs/*/} + - name: Update release uses: svenstaro/upload-release-action@v2 with: @@ -37,6 +47,7 @@ jobs: file: ./artifacts/shoko_*.zip tag: ${{ github.ref }} file_glob: true + - name: Update manifest uses: stefanzweifel/git-auto-commit-action@v4 with: diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 9d59001a..a75e9552 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -80,6 +80,10 @@ public virtual string PrettyHost public string[] IgnoredFolders { get; set; } + public bool? SentryEnabled { get; set; } + + public bool? LibraryFilteringMode { get; set; } + #region Experimental features public bool EXPERIMENTAL_AutoMergeVersions { get; set; } @@ -126,6 +130,8 @@ public PluginConfiguration() UserList = Array.Empty<UserConfiguration>(); IgnoredFileExtensions = new [] { ".nfo", ".jpg", ".jpeg", ".png" }; IgnoredFolders = new [] { ".streams", "@recently-snapshot" }; + SentryEnabled = null; + LibraryFilteringMode = null; EXPERIMENTAL_AutoMergeVersions = false; EXPERIMENTAL_SplitThenMergeMovies = true; EXPERIMENTAL_SplitThenMergeEpisodes = false; diff --git a/Shokofin/Configuration/SentryConfiguration.cs b/Shokofin/Configuration/SentryConfiguration.cs new file mode 100644 index 00000000..c741c64f --- /dev/null +++ b/Shokofin/Configuration/SentryConfiguration.cs @@ -0,0 +1,7 @@ + +namespace Shokofin.Configuration; + +public static class SentryConfiguration +{ + public const string DSN = "%SENTRY_DSN%"; +} \ No newline at end of file diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index d05b5e49..93316f5d 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -137,7 +137,9 @@ async function defaultSubmit(form) { form.querySelector("#PublicHost").value = publicHost; } const ignoredFileExtensions = filterIgnoredExtensions(form.querySelector("#IgnoredFileExtensions").value); - const ignoredFolders = filterIgnoredFolders(from.querySelector("#IgnoredFolders").value); + const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); + const filteringModeRaw = form.querySelector("#LibraryFilteringMode").value; + const filteringMode = filteringModeRaw === "true" ? true : filteringModeRaw === "false" ? false : null; // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; @@ -155,6 +157,7 @@ async function defaultSubmit(form) { config.AddTMDBId = form.querySelector("#AddTMDBId").checked; // Library settings + config.LibraryFilteringMode = filteringMode; config.SeriesGrouping = form.querySelector("#SeriesGrouping").value; config.BoxSetGrouping = form.querySelector("#BoxSetGrouping").value; config.FilterOnLibraryTypes = form.querySelector("#FilterOnLibraryTypes").checked; @@ -171,6 +174,7 @@ async function defaultSubmit(form) { config.HideProgrammingTags = form.querySelector("#HideProgrammingTags").checked; // Advanced settings + config.SentryEnabled = form.querySelector("#SentryEnabled").checked; config.PublicHost = publicHost; config.IgnoredFileExtensions = ignoredFileExtensions; form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); @@ -288,6 +292,19 @@ async function resetConnectionSettings(form) { return config; } +async function disableSentry(form) { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + form.querySelector("#SentryEnabled").checked = false; + + // Connection settings + config.SentryEnabled = false; + + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + Dashboard.processPluginConfigurationUpdateResult(result); + + return config; +} + async function syncSettings(form) { const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); let publicHost = form.querySelector("#PublicHost").value; @@ -297,6 +314,8 @@ async function syncSettings(form) { } const ignoredFileExtensions = filterIgnoredExtensions(form.querySelector("#IgnoredFileExtensions").value); const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); + const filteringModeRaw = form.querySelector("#LibraryFilteringMode").value; + const filteringMode = filteringModeRaw === "true" ? true : filteringModeRaw === "false" ? false : null; // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; @@ -314,6 +333,7 @@ async function syncSettings(form) { config.AddTMDBId = form.querySelector("#AddTMDBId").checked; // Library settings + config.LibraryFilteringMode = filteringMode; config.SeriesGrouping = form.querySelector("#SeriesGrouping").value; config.BoxSetGrouping = form.querySelector("#BoxSetGrouping").value; config.FilterOnLibraryTypes = form.querySelector("#FilterOnLibraryTypes").checked; @@ -330,6 +350,7 @@ async function syncSettings(form) { config.HideProgrammingTags = form.querySelector("#HideProgrammingTags").checked; // Advanced settings + config.SentryEnabled = form.querySelector("#SentryEnabled").checked; config.PublicHost = publicHost; config.IgnoredFileExtensions = ignoredFileExtensions; form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); @@ -413,15 +434,30 @@ export default function (page) { const userSelector = form.querySelector("#UserSelector"); // Refresh the view after we changed the settings, so the view reflect the new settings. const refreshSettings = (config) => { - if (config.ApiKey) { + if (config.SentryEnabled == null) { + form.querySelector("#Host").removeAttribute("disabled"); + form.querySelector("#Username").removeAttribute("disabled"); + form.querySelector("#ConsentSection").removeAttribute("hidden"); + form.querySelector("#ConnectionSetContainer").setAttribute("hidden", ""); + form.querySelector("#ConnectionResetContainer").setAttribute("hidden", ""); + form.querySelector("#ConnectionSection").setAttribute("hidden", ""); + form.querySelector("#MetadataSection").setAttribute("hidden", ""); + form.querySelector("#ProviderSection").setAttribute("hidden", ""); + form.querySelector("#LibrarySection").setAttribute("hidden", ""); + form.querySelector("#UserSection").setAttribute("hidden", ""); + form.querySelector("#TagSection").setAttribute("hidden", ""); + form.querySelector("#AdvancedSection").setAttribute("hidden", ""); + form.querySelector("#ExperimentalSection").setAttribute("hidden", ""); + } + else if (config.ApiKey) { form.querySelector("#Host").setAttribute("disabled", ""); form.querySelector("#Username").setAttribute("disabled", ""); form.querySelector("#Password").value = ""; + form.querySelector("#ConsentSection").setAttribute("hidden", ""); form.querySelector("#ConnectionSetContainer").setAttribute("hidden", ""); form.querySelector("#ConnectionResetContainer").removeAttribute("hidden"); form.querySelector("#ConnectionSection").removeAttribute("hidden"); form.querySelector("#MetadataSection").removeAttribute("hidden"); - form.querySelector("#MetadataSection").removeAttribute("hidden"); form.querySelector("#ProviderSection").removeAttribute("hidden"); form.querySelector("#LibrarySection").removeAttribute("hidden"); form.querySelector("#UserSection").removeAttribute("hidden"); @@ -432,11 +468,11 @@ export default function (page) { else { form.querySelector("#Host").removeAttribute("disabled"); form.querySelector("#Username").removeAttribute("disabled"); + form.querySelector("#ConsentSection").setAttribute("hidden", ""); form.querySelector("#ConnectionSetContainer").removeAttribute("hidden"); form.querySelector("#ConnectionResetContainer").setAttribute("hidden", ""); form.querySelector("#ConnectionSection").removeAttribute("hidden"); form.querySelector("#MetadataSection").setAttribute("hidden", ""); - form.querySelector("#MetadataSection").setAttribute("hidden", ""); form.querySelector("#ProviderSection").setAttribute("hidden", ""); form.querySelector("#LibrarySection").setAttribute("hidden", ""); form.querySelector("#UserSection").setAttribute("hidden", ""); @@ -492,6 +528,7 @@ export default function (page) { form.querySelector("#AddTMDBId").checked = config.AddTMDBId; // Library settings + form.querySelector("#LibraryFilteringMode").value = `${config.LibraryFilteringMode || null}`; form.querySelector("#SeriesGrouping").value = config.SeriesGrouping; form.querySelector("#BoxSetGrouping").value = config.BoxSetGrouping; form.querySelector("#FilterOnLibraryTypes").checked = config.FilterOnLibraryTypes; @@ -511,6 +548,7 @@ export default function (page) { form.querySelector("#HideProgrammingTags").checked = config.HideProgrammingTags; // Advanced settings + form.querySelector("#SentryEnabled").checked = config.SentryEnabled == null ? true : config.SentryEnabled; form.querySelector("#PublicHost").value = config.PublicHost; form.querySelector("#IgnoredFileExtensions").value = config.IgnoredFileExtensions.join(" "); form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); @@ -559,6 +597,9 @@ export default function (page) { Dashboard.showLoadingMsg(); syncUserSettings(form).then(refreshSettings).catch(onError); break; + case "disable-sentry": + Dashboard.showLoadingMsg(); + disableSentry(form).then(refreshSettings).catch(onError); } return false; }); diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index ffb05274..bae6c6bc 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -7,6 +7,18 @@ <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="ConsentSection" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>Improve Plugin Stability</h3> + </legend> + <div class="fieldDescription verticalSection-extrabottompadding">By enabling Sentry crash-reporting, you're helping us fix bugs and enhance the plugin's functionality. When a crash happens, Sentry collects data about the error, which may include paths to your videos involved in the operation. While some users may consider these paths sensitive, we want to assure you that this data is used strictly for diagnostic purposes.<br/><br/>Please be aware that enabling crash reporting is entirely optional. You are in control and can choose to enable or disable the feature at any time.<br/><br/>By choosing to enable this feature, you're not just improving your own experience but also helping us refine the plugin for all users. Your support is greatly appreciated.</div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Yes, Enable Crash Reporting</span> + </button> + <button is="emby-button" type="submit" name="disable-sentry" class="raised block emby-button"> + <span>No, Disable Crash Reporting</span> + </button> + </fieldset> <fieldset id="ConnectionSection" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Connection Settings</h3> @@ -130,6 +142,30 @@ <h3>Plugin Compatibility Settings</h3> <h3>Library Settings</h3> </legend> <div class="fieldDescription verticalSection-extrabottompadding">If you wish to set the Series/Grouping setting below to "Don't group Series into Seasons", you must disable "Automatically merge series that are spread across multiple folders" in the settings for all Libraries that depend on Shokofin for their metadata.<br>On the other hand, if you want to have Series and Grouping be determined by Shoko, or TvDB/TMDB - you must enable the "Automatically merge series that are spread across multiple folders" setting for all Libraries that use Shokofin for their metadata.<br>See the settings under each individual Library here: <a href="#!/library.html">Library Settings</a></div> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="LibraryFilteringMode">Filtering mode:</label> + <select is="emby-select" id="LibraryFilteringMode" name="LibraryFilteringMode" class="emby-select-withcolor emby-select"> + <option value="true" selected>Strict</option> + <option value="null">Auto</option> + <option value="false">Unrestricted</option> + </select> + <div class="fieldDescription"> + <div>Choose how the plugin filters out videos in your library.</div> + <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 active library. + </details> + <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 unrestricted filtering entail?</summary> + Unrestricted filtering means the plugin will not filter out anything from the active library. + </details> + </div> + </div> + <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="SeriesGrouping">Series/Season grouping:</label> <select is="emby-select" id="SeriesGrouping" name="SeriesGrouping" class="emby-select-withcolor emby-select"> @@ -304,6 +340,13 @@ <h3>Tag Settings</h3> <legend> <h3>Advanced Settings</h3> </legend> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SentryEnabled" /> + <span>Enable Crash Reporting.</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Improve the plugin's stability by allowing Sentry to report any crashes, which helps us improve the plugin's stability. This may include paths to your videos involved in the operation, which some users may consider sensitive. We assure you that this data is used solely for diagnostic purposes. Participation is entirely optional but greatly appreciated.</div> + </div> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="text" id="PublicHost" label="Public Shoko host URL:" /> <div class="fieldDescription">This is the public URL leading to where Shoko is running. It is 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. It should include both the protocol and the IP/DNS name.</div> diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index 200a20d4..92c68feb 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -22,6 +22,14 @@ public interface IIdLookup /// <returns>True if the plugin is enabled for the <see cref="MediaBrowser.Controller.Entities.BaseItem" /></returns> bool IsEnabledForItem(BaseItem item); + /// <summary> + /// Check if the plugin is enabled for <see cref="MediaBrowser.Controller.Entities.BaseItem" >the item</see>. + /// </summary> + /// <param name="item">The <see cref="MediaBrowser.Controller.Entities.BaseItem" /> to check.</param> + /// <param name="onlyProvider">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="MediaBrowser.Controller.Entities.BaseItem" /></returns> + bool IsEnabledForItem(BaseItem item, out bool onlyProvider); + #endregion #region Group Id @@ -111,7 +119,10 @@ public IdLookup(ShokoAPIManager apiManager, ILibraryManager libraryManager) private readonly HashSet<string> AllowedTypes = new HashSet<string>() { nameof(Series), nameof(Episode), nameof(Movie) }; - public bool IsEnabledForItem(BaseItem item) + public bool IsEnabledForItem(BaseItem item) => + IsEnabledForItem(item, out var _bool); + + public bool IsEnabledForItem(BaseItem item, out bool singleProvider) { var reItem = item switch { Series s => s, @@ -119,11 +130,31 @@ public bool IsEnabledForItem(BaseItem item) Episode e => e.Series, _ => item, }; - if (reItem == null) + if (reItem == null) { + singleProvider = false; return false; + } + var libraryOptions = LibraryManager.GetLibraryOptions(reItem); - return libraryOptions != null && - libraryOptions.TypeOptions.Any(o => AllowedTypes.Contains(o.Type) && o.MetadataFetchers.Contains(Plugin.MetadataProviderName)); + if (libraryOptions == null) { + singleProvider = false; + return false; + } + + var isEnabled = false; + singleProvider = true; + foreach (var options in libraryOptions.TypeOptions) { + if (!AllowedTypes.Contains(options.Type)) + continue; + var isEnabledForType = options.MetadataFetchers.Contains(Plugin.MetadataProviderName); + if (isEnabledForType) { + if (!isEnabled) + isEnabled = true; + if (options.MetadataFetchers.Length > 1 && singleProvider) + singleProvider = false; + } + } + return isEnabled; } #endregion diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index 68386cc0..ae2ebcbb 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -45,7 +45,7 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base try { // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. - if (!Lookup.IsEnabledForItem(parent)) + if (!Lookup.IsEnabledForItem(parent, out var onlyProvider)) return false; if (fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(Path.GetFileName(fileInfo.FullName).ToLowerInvariant())) { @@ -61,19 +61,21 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base var fullPath = fileInfo.FullName; var mediaFolder = ApiManager.FindMediaFolder(fullPath, parentFolder, root); var partialPath = fullPath.Substring(mediaFolder.Path.Length); + var skipValue = Plugin.Instance.Configuration.LibraryFilteringMode ?? onlyProvider; if (fileInfo.IsDirectory) - return ScanDirectory(partialPath, fullPath, LibraryManager.GetInheritedContentType(parentFolder)); + return ScanDirectory(partialPath, fullPath, LibraryManager.GetInheritedContentType(parentFolder), skipValue); else - return ScanFile(partialPath, fullPath); + return ScanFile(partialPath, fullPath, skipValue); } - catch (System.Exception e) { - if (!(e is System.Net.Http.HttpRequestException && e.Message.Contains("Connection refused"))) - Logger.LogError(e, $"Threw unexpectedly - {e.Message}"); + catch (System.Exception ex) { + if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) + Logger.LogError(ex, $"Threw unexpectedly - {ex.Message}"); + Plugin.Instance.CaptureException(ex); return false; } } - private bool ScanDirectory(string partialPath, string fullPath, string libraryType) + private bool ScanDirectory(string partialPath, string fullPath, string libraryType, bool skipValue) { var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; var series = ApiManager.GetSeriesInfoByPath(fullPath) @@ -83,7 +85,7 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy // We warn here since we enabled the provider in our library, but we can't find a match for the given folder path. if (series == null) { Logger.LogWarning("Skipped unknown folder at path {Path}", partialPath); - return false; + return skipValue; } API.Info.GroupInfo group = null; @@ -128,7 +130,7 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy return false; } - private bool ScanFile(string partialPath, string fullPath) + private bool ScanFile(string partialPath, string fullPath, bool skipValue) { var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; var config = Plugin.Instance.Configuration; @@ -139,7 +141,7 @@ private bool ScanFile(string partialPath, string fullPath) // We warn here since we enabled the provider in our library, but we can't find a match for the given file path. if (file == null) { Logger.LogWarning("Skipped unknown file at path {Path}", partialPath); - return false; + return skipValue; } Logger.LogInformation("Found {EpisodeCount} episode(s) for {SeriesName} (Series={SeriesId},File={FileId})", file.EpisodeList.Count, series.Shoko.Name, series.Id, file.Id); diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index ecbce475..9b360b93 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -6,54 +6,118 @@ using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; using Shokofin.Configuration; +using Sentry; +using System.Reflection; -namespace Shokofin +#nullable enable +namespace Shokofin; + +public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { - public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages - { - public static string MetadataProviderName = "Shoko"; + public static string MetadataProviderName = "Shoko"; - public override string Name => "Shoko"; + public override string Name => "Shoko"; - public override Guid Id => Guid.Parse("5216ccbf-d24a-4eb3-8a7e-7da4230b7052"); + public override Guid Id => Guid.Parse("5216ccbf-d24a-4eb3-8a7e-7da4230b7052"); - public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) - { - Instance = this; - ConfigurationChanged += OnConfigChanged; - IgnoredFileExtensions = this.Configuration.IgnoredFileExtensions.ToHashSet(); - IgnoredFolders = this.Configuration.IgnoredFolders.ToHashSet(); + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) + { + Instance = this; + ConfigurationChanged += OnConfigChanged; + RefreshSentry(); + IgnoredFileExtensions = this.Configuration.IgnoredFileExtensions.ToHashSet(); + IgnoredFolders = this.Configuration.IgnoredFolders.ToHashSet(); + } + + ~Plugin() + { + if (SentryReference != null) { + SentrySdk.EndSession(); + SentryReference.Dispose(); + SentryReference = null; } + } - public void OnConfigChanged(object sender, BasePluginConfiguration e) - { - if (!(e is PluginConfiguration config)) - return; - IgnoredFileExtensions = config.IgnoredFileExtensions.ToHashSet(); - IgnoredFolders = config.IgnoredFolders.ToHashSet(); + public void OnConfigChanged(object? sender, BasePluginConfiguration e) + { + if (!(e is PluginConfiguration config)) + return; + RefreshSentry(); + IgnoredFileExtensions = config.IgnoredFileExtensions.ToHashSet(); + IgnoredFolders = config.IgnoredFolders.ToHashSet(); + } + + private void RefreshSentry() + { + if (IsSentryEnabled) { + if (SentryReference != null && SentryConfiguration.DSN.StartsWith("https://")) { + SentryReference = SentrySdk.Init(options => { + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0.0"; + var environment = version.EndsWith(".0") ? "stable" : "dev"; + var release = string.Join(".", version.Split(".").Take(3)); + var revision = version.Split(".").Last(); + + // Assign the DSN key and release version. + options.Dsn = SentryConfiguration.DSN; + options.Environment = environment; + options.Release = release; + options.AutoSessionTracking = false; + + // Add the dev revision if we're not on stable. + if (environment != "stable") + options.DefaultTags.Add("release.revision", revision); + }); + + SentrySdk.StartSession(); + } } + else { + if (SentryReference != null) + { + SentrySdk.EndSession(); + SentryReference.Dispose(); + SentryReference = null; + } + } + } + + public bool IsSentryEnabled + { + get => Configuration.SentryEnabled ?? true; + } + + public void CaptureException(Exception ex) + { + if (SentryReference == null) + return; + + SentrySdk.CaptureException(ex); + } + + private IDisposable? SentryReference { get; set; } - public HashSet<string> IgnoredFileExtensions; + public HashSet<string> IgnoredFileExtensions; - public HashSet<string> IgnoredFolders; + public HashSet<string> IgnoredFolders; - public static Plugin Instance { get; private set; } +#pragma warning disable 8618 + public static Plugin Instance { get; private set; } +#pragma warning restore 8618 - public IEnumerable<PluginPageInfo> GetPages() + public IEnumerable<PluginPageInfo> GetPages() + { + return new[] { - return new[] + new PluginPageInfo { - new PluginPageInfo - { - Name = Name, - EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configPage.html", - }, - new PluginPageInfo - { - Name = "ShokoController.js", - EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configController.js", - } - }; - } + Name = Name, + EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configPage.html", + }, + new PluginPageInfo + { + Name = "ShokoController.js", + EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configController.js", + } + }; } } diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 57f6dfc0..dbd0f649 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -41,8 +41,9 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat return await GetShokoGroupedMetadata(info, cancellationToken); } } - catch (Exception e) { - Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); + catch (Exception ex) { + Logger.LogError(ex, $"Threw unexpectedly; {ex.Message}"); + Plugin.Instance.CaptureException(ex); return new MetadataResult<BoxSet>(); } } diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index efb731a4..45656ea2 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -80,8 +80,9 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell return result; } - catch (Exception e) { - Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); + catch (Exception ex) { + Logger.LogError(ex, $"Threw unexpectedly; {ex.Message}"); + Plugin.Instance.CaptureException(ex); return new MetadataResult<Episode>(); } } diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 3ee33b90..e852cd5b 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -108,8 +108,9 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell } return list; } - catch (Exception e) { - Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); + catch (Exception ex) { + Logger.LogError(ex, $"Threw unexpectedly; {ex.Message}"); + Plugin.Instance.CaptureException(ex); return list; } } diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 9b4e1e97..c1e31d35 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -84,8 +84,9 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio return result; } - catch (Exception e) { - Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); + catch (Exception ex) { + Logger.LogError(ex, $"Threw unexpectedly; {ex.Message}"); + Plugin.Instance.CaptureException(ex); return new MetadataResult<Movie>(); } } diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 34e04531..09965e02 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -54,8 +54,9 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat return await GetShokoGroupedMetadata(info, cancellationToken); } } - catch (Exception e) { - Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); + catch (Exception ex) { + Logger.LogError(ex, $"Threw unexpectedly; {ex.Message}"); + Plugin.Instance.CaptureException(ex); return new MetadataResult<Season>(); } } diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index f84503e1..6f032d2c 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -48,8 +48,9 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat return await GetShokoGroupedMetadata(info, cancellationToken); } } - catch (Exception e) { - Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); + catch (Exception ex) { + Logger.LogError(ex, $"Threw unexpectedly; {ex.Message}"); + Plugin.Instance.CaptureException(ex); return new MetadataResult<Series>(); } } @@ -216,8 +217,9 @@ public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo i return parsedSeries; }); } - catch (Exception e) { - Logger.LogError(e, $"Threw unexpectedly; {e.Message}"); + catch (Exception ex) { + Logger.LogError(ex, $"Threw unexpectedly; {ex.Message}"); + Plugin.Instance.CaptureException(ex); return new List<RemoteSearchResult>(); } } diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj index 577cb9d0..a390dbfe 100644 --- a/Shokofin/Shokofin.csproj +++ b/Shokofin/Shokofin.csproj @@ -1,12 +1,13 @@ <Project Sdk="Microsoft.NET.Sdk"> - <PropertyGroup> <TargetFramework>net6.0</TargetFramework> + <OutputType>Library</OutputType> </PropertyGroup> <ItemGroup> <PackageReference Include="Jellyfin.Controller" Version="10.8.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> + <PackageReference Include="Sentry" Version="3.31.0" /> </ItemGroup> <ItemGroup> diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index 369ded4c..4337be08 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -240,11 +240,12 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized) { if (TryGetUserConfiguration(e.UserId, out var userConfig)) - Logger.LogError(ex, "I{Message} (Username={Username},Id={UserId})", ex.Message, UserManager.GetUserById(userConfig.UserId)?.Username, userConfig.UserId); + Logger.LogError(ex, "{Message} (Username={Username},Id={UserId})", ex.Message, UserManager.GetUserById(userConfig.UserId)?.Username, userConfig.UserId); return; } catch (Exception ex) { Logger.LogError(ex, "Threw unexpectedly; {ErrorMessage}", ex.Message); + Plugin.Instance.CaptureException(ex); return; } } diff --git a/Shokofin/Web/WebController.cs b/Shokofin/Web/WebController.cs index 6d56301c..7e0b03c8 100644 --- a/Shokofin/Web/WebController.cs +++ b/Shokofin/Web/WebController.cs @@ -57,6 +57,7 @@ public async Task<ActionResult<ApiKey>> PostAsync([FromBody] ApiLoginRequest bod } catch (Exception ex) { Logger.LogError(ex, "Failed to create an API-key for user {Username} — unable to complete the request.", body.username); + Plugin.Instance.CaptureException(ex); return new StatusCodeResult(StatusCodes.Status500InternalServerError); } } From 18ddbd002e3c138b75cef2e699054c279a301169 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 19 May 2023 22:56:03 +0200 Subject: [PATCH 0505/1103] fix: fix assembly reference [no ci] [skip ci] --- Shokofin/Plugin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 9b360b93..d3494228 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -52,7 +52,7 @@ private void RefreshSentry() if (IsSentryEnabled) { if (SentryReference != null && SentryConfiguration.DSN.StartsWith("https://")) { SentryReference = SentrySdk.Init(options => { - var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0.0"; + var version = Assembly.GetAssembly(typeof(Plugin))?.GetName().Version?.ToString() ?? "0.0.0.0"; var environment = version.EndsWith(".0") ? "stable" : "dev"; var release = string.Join(".", version.Split(".").Take(3)); var revision = version.Split(".").Last(); From 17c0ee725260b087cadb22dafbd8d2af651bfdf8 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 20 May 2023 00:30:03 +0200 Subject: [PATCH 0506/1103] fix: fix missing sentry dll on build and also fix the github action flows --- .github/workflows/release-daily.yml | 44 +++++++++++++++++------------ .github/workflows/release.yml | 37 ++++++++++++++++++------ Shokofin/Plugin.cs | 21 ++++++++------ Shokofin/Shokofin.csproj | 18 ++++++++---- 4 files changed, 79 insertions(+), 41 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 58b89d87..b232d4ec 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -1,37 +1,40 @@ -name: Daily Release +name: Unstable Release on: push: branches: [ master ] - release: - types: - - prereleased - branches: master jobs: build: runs-on: ubuntu-latest - name: Build & Release Daily Version + name: Build & Release (Unstable) steps: - name: Checkout uses: actions/checkout@v2 with: + ref: ${{ github.ref }} fetch-depth: 0 - name: Replace Sentry DSN key shell: pwsh run: ./.github/workflows/ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} - - name: Setup dotnet + - name: Get previous release version + id: previoustag + uses: "WyriHaximus/github-action-get-previous-tag@v1" + with: + fallback: 1.0.0 + + - name: Setup .Net uses: actions/setup-dotnet@v1 with: dotnet-version: 6.0.x - - name: Restore nuget packages + - name: Restore Nuget Packages run: dotnet restore Shokofin/Shokofin.csproj - - name: Setup python + - name: Setup Python uses: actions/setup-python@v2 with: python-version: 3.8 @@ -39,16 +42,10 @@ jobs: - name: Install JPRM run: python -m pip install jprm - - name: Get previous release version - id: previoustag - uses: "WyriHaximus/github-action-get-previous-tag@v1" - with: - fallback: 1.0.0 # Optional fallback tag to use when no tag can be found - - name: Run JPRM run: echo "NEW_VERSION=$(python build_plugin.py --version=${{ steps.previoustag.outputs.tag }} --prerelease=True)" >> $GITHUB_ENV - - name: Release + - name: Create Pre-Release uses: softprops/action-gh-release@v1 with: files: ./artifacts/shoko_*.zip @@ -59,10 +56,21 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Update manifest + - name: Update Unstable Manifest uses: stefanzweifel/git-auto-commit-action@v4 with: branch: master - commit_message: Update unstable repo manifest + commit_message: "misc: update unstable manifest" file_pattern: manifest-unstable.json skip_fetch: true + + - name: Push Sentry release "${{ env.NEW_VERSION }}" + uses: getsentry/action-release@v1.2.1 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + # SENTRY_URL: https://sentry.io/ + with: + environment: 'dev' + version: ${{ env.NEW_VERSION }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33b054a9..4dca05ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Release +name: Stable Release on: release: @@ -15,21 +15,28 @@ jobs: - name: Checkout uses: actions/checkout@v2 with: - ref: master + ref: ${{ github.ref }} + fetch-depth: 0 - name: Replace Sentry DSN key shell: pwsh run: ./.github/workflows/ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} - - name: Setup dotnet + - name: Get release version + id: currenttag + uses: "WyriHaximus/github-action-get-previous-tag@v1" + with: + fallback: 1.0.0 + + - name: Setup .Net uses: actions/setup-dotnet@v1 with: dotnet-version: 6.0.x - - name: Restore nuget packages + - name: Restore Nuget Packages run: dotnet restore Shokofin/Shokofin.csproj - - name: Setup python + - name: Setup Python uses: actions/setup-python@v2 with: python-version: 3.8 @@ -38,9 +45,9 @@ jobs: run: python -m pip install jprm - name: Run JPRM - run: python build_plugin.py --version=${GITHUB_REF#refs/*/} + run: python build_plugin.py --version=${{ steps.currenttag.outputs.tag }} - - name: Update release + - name: Update Release uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} @@ -48,9 +55,21 @@ jobs: tag: ${{ github.ref }} file_glob: true - - name: Update manifest + - name: Update Stable Manifest uses: stefanzweifel/git-auto-commit-action@v4 with: branch: master - commit_message: Update repo manifest + commit_message: "misc: update stable manifest" file_pattern: manifest.json + skip_fetch: true + + - name: Push Sentry release "${{ steps.currenttag.outputs.tag }}" + uses: getsentry/action-release@v1.2.1 + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + # SENTRY_URL: https://sentry.io/ + with: + environment: 'stable' + version: ${{ steps.currenttag.outputs.tag }} diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index d3494228..c0b164da 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -50,22 +50,25 @@ public void OnConfigChanged(object? sender, BasePluginConfiguration e) private void RefreshSentry() { if (IsSentryEnabled) { - if (SentryReference != null && SentryConfiguration.DSN.StartsWith("https://")) { + if (SentryReference == null && SentryConfiguration.DSN.StartsWith("https://")) { SentryReference = SentrySdk.Init(options => { - var version = Assembly.GetAssembly(typeof(Plugin))?.GetName().Version?.ToString() ?? "0.0.0.0"; - var environment = version.EndsWith(".0") ? "stable" : "dev"; - var release = string.Join(".", version.Split(".").Take(3)); - var revision = version.Split(".").Last(); + var release = Assembly.GetAssembly(typeof(Plugin))?.GetName().Version?.ToString() ?? "1.0.0.0"; + var environment = release.EndsWith(".0") ? "stable" : "dev"; + + // Cut off the build number for stable releases. + if (environment == "stable") + release = release[..^2]; // Assign the DSN key and release version. options.Dsn = SentryConfiguration.DSN; options.Environment = environment; options.Release = release; options.AutoSessionTracking = false; - - // Add the dev revision if we're not on stable. - if (environment != "stable") - options.DefaultTags.Add("release.revision", revision); + + // Disable auto-exception captures. + options.DisableUnobservedTaskExceptionCapture(); + options.DisableAppDomainUnhandledExceptionCapture(); + options.CaptureFailedRequests = false; }); SentrySdk.StartSession(); diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj index a390dbfe..07737a1d 100644 --- a/Shokofin/Shokofin.csproj +++ b/Shokofin/Shokofin.csproj @@ -2,19 +2,27 @@ <PropertyGroup> <TargetFramework>net6.0</TargetFramework> <OutputType>Library</OutputType> + <!-- Update the sentry version here. --> + <SentryVersion>3.31.0</SentryVersion> </PropertyGroup> <ItemGroup> - <PackageReference Include="Jellyfin.Controller" Version="10.8.0" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> - <PackageReference Include="Sentry" Version="3.31.0" /> + <PackageReference Include="Jellyfin.Controller" Version="10.8.0" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> + <PackageReference Include="Sentry" Version="$(SentryVersion)" /> </ItemGroup> + <Target Name="CopySentryDLLToOutputPath" AfterTargets="Build"> + <ItemGroup> + <SentryPackage Include="$(NuGetPackageRoot)\sentry\$(SentryVersion)\lib\$(TargetFramework)\Sentry.dll" /> + </ItemGroup> + <Copy SourceFiles="@(SentryPackage)" DestinationFolder="$(OutputPath)" /> + </Target> + <ItemGroup> <None Remove="Configuration\configController.js" /> <None Remove="Configuration\configPage.html" /> <EmbeddedResource Include="Configuration\configController.js" /> <EmbeddedResource Include="Configuration\configPage.html" /> </ItemGroup> - -</Project> +</Project> \ No newline at end of file From 4039514734ac7198d156e3d8f8824584431e915c Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 19 May 2023 22:31:53 +0000 Subject: [PATCH 0507/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index c76c4bbf..1a346fe3 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.2", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.2/shoko_3.0.1.2.zip", + "checksum": "73df8ebd208b91ca2a1b679b40ba1081", + "timestamp": "2023-05-19T22:31:51Z" + }, { "version": "3.0.1.1", "changelog": "NA\n", From dcec70d7efbc17cfc6b1fb71d2dd458ef4e55a6d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 20 May 2023 00:43:36 +0200 Subject: [PATCH 0508/1103] misc: expose one more artifact --- build.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/build.yaml b/build.yaml index 3093c286..5cbe2f16 100644 --- a/build.yaml +++ b/build.yaml @@ -8,6 +8,7 @@ description: > A plugin to provide metadata from Shoko Server for your locally organized anime library in Jellyfin. category: "Metadata" artifacts: +- "Sentry.dll" - "Shokofin.dll" changelog: > NA From dda3457cd43e5699cf304f7b52b37394e80d50b4 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 19 May 2023 22:44:24 +0000 Subject: [PATCH 0509/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 1a346fe3..9a8f702f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.3", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.3/shoko_3.0.1.3.zip", + "checksum": "a9fd79589a41624311e2457011f2138e", + "timestamp": "2023-05-19T22:44:22Z" + }, { "version": "3.0.1.2", "changelog": "NA\n", From 0780143ff1a27698be763adacf9af2998ee40638 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 21 May 2023 16:04:13 +0200 Subject: [PATCH 0510/1103] misc: filter calling shoko before the plugin is set up --- Shokofin/Plugin.cs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index c0b164da..f92834d3 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -69,6 +69,18 @@ private void RefreshSentry() options.DisableUnobservedTaskExceptionCapture(); options.DisableAppDomainUnhandledExceptionCapture(); options.CaptureFailedRequests = false; + + // Filter exceptions. + options.AddExceptionFilter(new SentryExceptionFilter(ex => + { + if (ex.Message == "Unable to call the API before an connection is established to Shoko Server!") + return true; + + // If we need more filtering in the future then add them + // above this comment. + + return false; + })); }); SentrySdk.StartSession(); @@ -123,4 +135,22 @@ public IEnumerable<PluginPageInfo> GetPages() } }; } + + /// <summary> + /// An IException filter class to convert a function to a filter. It's weird + /// they don't have a method that just accepts a pure function and converts + /// it internally, but oh well. ¯\_(ツ)_/¯ + /// </summary> + private class SentryExceptionFilter : Sentry.Extensibility.IExceptionFilter + { + private Func<Exception, bool> _action; + + public SentryExceptionFilter(Func<Exception, bool> action) + { + _action = action; + } + + public bool Filter(Exception ex) => + _action(ex); + } } From bab6dcdc9043a504582c5ec4a21fa5276bf395f7 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 21 May 2023 14:05:06 +0000 Subject: [PATCH 0511/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 9a8f702f..b8fd09fb 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.4", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.4/shoko_3.0.1.4.zip", + "checksum": "394e754e37a4dc2c474e194f0b2b8c04", + "timestamp": "2023-05-21T14:05:03Z" + }, { "version": "3.0.1.3", "changelog": "NA\n", From 38984278e04a1e34baa71a5764236b5b008d6209 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 21 May 2023 16:09:01 +0200 Subject: [PATCH 0512/1103] misc: add jellyfin.release tag to sentry events --- Shokofin/Plugin.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index f92834d3..fa6f16e3 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -65,6 +65,10 @@ private void RefreshSentry() options.Release = release; options.AutoSessionTracking = false; + // Additional tags for easier filtering in Sentry. + var jellyfinRelease = Assembly.GetAssembly(typeof(Jellyfin.Data.Entities.Preference))?.GetName().Version?.ToString() ?? "0.0.0.0"; + options.DefaultTags.Add("jellyfin.release", jellyfinRelease); + // Disable auto-exception captures. options.DisableUnobservedTaskExceptionCapture(); options.DisableAppDomainUnhandledExceptionCapture(); From 73648f70e053c65cda0b11073bd47d84a593cbfd Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 21 May 2023 14:09:52 +0000 Subject: [PATCH 0513/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index b8fd09fb..52874046 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.5", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.5/shoko_3.0.1.5.zip", + "checksum": "6868577bf03967bedcbcf5fa253c39bb", + "timestamp": "2023-05-21T14:09:50Z" + }, { "version": "3.0.1.4", "changelog": "NA\n", From 0339baaa166a9ad49fb1f26375f29390d865442f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 21 May 2023 16:27:44 +0200 Subject: [PATCH 0514/1103] fix: fix error while iterating media folder list while the list is modified by another thread. --- Shokofin/API/ShokoAPIManager.cs | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index a46439ad..a1859786 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -28,6 +28,8 @@ public class ShokoAPIManager : IDisposable private readonly ILibraryManager LibraryManager; + private readonly object MediaFolderListLock = new(); + private readonly List<Folder> MediaFolderList = new(); private readonly ConcurrentDictionary<string, string> PathToSeriesIdDictionary = new(); @@ -67,7 +69,10 @@ public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient public Folder FindMediaFolder(string path) { - var mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); + Folder? mediaFolder = null; + lock (MediaFolderListLock) { + mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); + } if (mediaFolder == null) { var parent = LibraryManager.FindByPath(Path.GetDirectoryName(path), true) as Folder; if (parent == null) @@ -81,7 +86,10 @@ public Folder FindMediaFolder(string path) public Folder FindMediaFolder(string path, Folder parent, Folder root) { - var mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); + Folder? mediaFolder = null; + lock (MediaFolderListLock) { + mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); + } // Look for the root folder for the current item. if (mediaFolder != null) { return mediaFolder; @@ -95,13 +103,18 @@ public Folder FindMediaFolder(string path, Folder parent, Folder root) mediaFolder = (Folder)mediaFolder.GetParent(); } - MediaFolderList.Add(mediaFolder); + lock (MediaFolderListLock) { + MediaFolderList.Add(mediaFolder); + } return mediaFolder; } public string StripMediaFolder(string fullPath) { - var mediaFolder = MediaFolderList.FirstOrDefault((folder) => fullPath.StartsWith(folder.Path + Path.DirectorySeparatorChar)); + Folder? mediaFolder = null; + lock (MediaFolderListLock) { + mediaFolder = MediaFolderList.FirstOrDefault((folder) => fullPath.StartsWith(folder.Path + Path.DirectorySeparatorChar)); + } if (mediaFolder != null) { return fullPath.Substring(mediaFolder.Path.Length); } @@ -126,7 +139,9 @@ public string StripMediaFolder(string fullPath) mediaFolder = (Folder)mediaFolder.GetParent(); } - MediaFolderList.Add(mediaFolder); + lock (MediaFolderListLock) { + MediaFolderList.Add(mediaFolder); + } return fullPath.Substring(mediaFolder.Path.Length); } @@ -147,7 +162,9 @@ public void Dispose() EpisodeIdToEpisodePathDictionary.Clear(); EpisodeIdToSeriesIdDictionary.Clear(); FileIdToEpisodeIdDictionary.Clear(); - MediaFolderList.Clear(); + lock (MediaFolderListLock) { + MediaFolderList.Clear(); + } PathToEpisodeIdsDictionary.Clear(); PathToFileIdAndSeriesIdDictionary.Clear(); PathToSeriesIdDictionary.Clear(); From f68100052eaf2bfbad9eb6aa98278b9efe201e2b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 21 May 2023 14:28:54 +0000 Subject: [PATCH 0515/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 52874046..9b9448d6 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.6", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.6/shoko_3.0.1.6.zip", + "checksum": "c7dba2fa1cfccdead4619f5671bfc950", + "timestamp": "2023-05-21T14:28:52Z" + }, { "version": "3.0.1.5", "changelog": "NA\n", From a6c879097a6538c2dc2cc9d713573e1d49e2b4ef Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 23 May 2023 19:51:41 +0200 Subject: [PATCH 0516/1103] fix: fix strict filtering for directories by searching the sub-directories for a series if the directory is placed in the root of a media folder. --- Shokofin/IdLookup.cs | 16 ++++++------ Shokofin/LibraryScanner.cs | 52 ++++++++++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index 92c68feb..47d54b35 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -26,9 +26,9 @@ public interface IIdLookup /// Check if the plugin is enabled for <see cref="MediaBrowser.Controller.Entities.BaseItem" >the item</see>. /// </summary> /// <param name="item">The <see cref="MediaBrowser.Controller.Entities.BaseItem" /> to check.</param> - /// <param name="onlyProvider">True if the plugin is the only metadata provider enabled for the item.</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="MediaBrowser.Controller.Entities.BaseItem" /></returns> - bool IsEnabledForItem(BaseItem item, out bool onlyProvider); + bool IsEnabledForItem(BaseItem item, out bool isSoleProvider); #endregion #region Group Id @@ -122,7 +122,7 @@ public IdLookup(ShokoAPIManager apiManager, ILibraryManager libraryManager) public bool IsEnabledForItem(BaseItem item) => IsEnabledForItem(item, out var _bool); - public bool IsEnabledForItem(BaseItem item, out bool singleProvider) + public bool IsEnabledForItem(BaseItem item, out bool isSoleProvider) { var reItem = item switch { Series s => s, @@ -131,18 +131,18 @@ public bool IsEnabledForItem(BaseItem item, out bool singleProvider) _ => item, }; if (reItem == null) { - singleProvider = false; + isSoleProvider = false; return false; } var libraryOptions = LibraryManager.GetLibraryOptions(reItem); if (libraryOptions == null) { - singleProvider = false; + isSoleProvider = false; return false; } var isEnabled = false; - singleProvider = true; + isSoleProvider = true; foreach (var options in libraryOptions.TypeOptions) { if (!AllowedTypes.Contains(options.Type)) continue; @@ -150,8 +150,8 @@ public bool IsEnabledForItem(BaseItem item, out bool singleProvider) if (isEnabledForType) { if (!isEnabled) isEnabled = true; - if (options.MetadataFetchers.Length > 1 && singleProvider) - singleProvider = false; + if (options.MetadataFetchers.Length > 1 && isSoleProvider) + isSoleProvider = false; } } return isEnabled; diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index ae2ebcbb..ff6f6e9b 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -1,6 +1,8 @@ +using System.Linq; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.API.Models; @@ -18,13 +20,16 @@ public class LibraryScanner : IResolverIgnoreRule private readonly ILibraryManager LibraryManager; + private readonly IFileSystem FileSystem; + private readonly ILogger<LibraryScanner> Logger; - public LibraryScanner(ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager, ILogger<LibraryScanner> logger) + public LibraryScanner(ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager, IFileSystem fileSystem, ILogger<LibraryScanner> logger) { ApiManager = apiManager; Lookup = lookup; LibraryManager = libraryManager; + FileSystem = fileSystem; Logger = logger; } @@ -45,7 +50,7 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base try { // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. - if (!Lookup.IsEnabledForItem(parent, out var onlyProvider)) + if (!Lookup.IsEnabledForItem(parent, out var isSoleProvider)) return false; if (fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(Path.GetFileName(fileInfo.FullName).ToLowerInvariant())) { @@ -61,11 +66,11 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base var fullPath = fileInfo.FullName; var mediaFolder = ApiManager.FindMediaFolder(fullPath, parentFolder, root); var partialPath = fullPath.Substring(mediaFolder.Path.Length); - var skipValue = Plugin.Instance.Configuration.LibraryFilteringMode ?? onlyProvider; + var shouldIgnore = Plugin.Instance.Configuration.LibraryFilteringMode ?? isSoleProvider; if (fileInfo.IsDirectory) - return ScanDirectory(partialPath, fullPath, LibraryManager.GetInheritedContentType(parentFolder), skipValue); + return ScanDirectory(partialPath, fullPath, LibraryManager.GetInheritedContentType(parentFolder), shouldIgnore); else - return ScanFile(partialPath, fullPath, skipValue); + return ScanFile(partialPath, fullPath, shouldIgnore); } catch (System.Exception ex) { if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) @@ -75,17 +80,33 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base } } - private bool ScanDirectory(string partialPath, string fullPath, string libraryType, bool skipValue) + private bool ScanDirectory(string partialPath, string fullPath, string libraryType, bool shouldIgnore) { var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; var series = ApiManager.GetSeriesInfoByPath(fullPath) .GetAwaiter() .GetResult(); - // We warn here since we enabled the provider in our library, but we can't find a match for the given folder path. + // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given folder path. if (series == null) { - Logger.LogWarning("Skipped unknown folder at path {Path}", partialPath); - return skipValue; + // Check the sub-directories if we have a <Show>/<Season>/<Episodes> structure. + if (partialPath[1..].Split(Path.DirectorySeparatorChar).Length == 1) { + var entries = FileSystem.GetDirectories(fullPath, false).ToList(); + foreach (var entry in entries) { + series = ApiManager.GetSeriesInfoByPath(entry.FullName) + .GetAwaiter() + .GetResult(); + if (series != null) + break; + } + } + if (series == null) { + if (shouldIgnore) + Logger.LogInformation("Ignored unknown folder at path {Path}", partialPath); + else + Logger.LogWarning("Skipped unknown folder at path {Path}", partialPath); + return shouldIgnore; + } } API.Info.GroupInfo group = null; @@ -130,7 +151,7 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy return false; } - private bool ScanFile(string partialPath, string fullPath, bool skipValue) + private bool ScanFile(string partialPath, string fullPath, bool shouldIgnore) { var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; var config = Plugin.Instance.Configuration; @@ -138,10 +159,13 @@ private bool ScanFile(string partialPath, string fullPath, bool skipValue) .GetAwaiter() .GetResult(); - // We warn here since we enabled the provider in our library, but we can't find a match for the given file path. - if (file == null) { - Logger.LogWarning("Skipped unknown file at path {Path}", partialPath); - return skipValue; + // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given file path. + if (file == null) { + if (shouldIgnore) + Logger.LogInformation("Ignored unknown file at path {Path}", partialPath); + else + Logger.LogWarning("Skipped unknown file at path {Path}", partialPath); + return shouldIgnore; } Logger.LogInformation("Found {EpisodeCount} episode(s) for {SeriesName} (Series={SeriesId},File={FileId})", file.EpisodeList.Count, series.Shoko.Name, series.Id, file.Id); From bcc6249703e0c9357acd5311153e8839d37325f9 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 23 May 2023 17:52:31 +0000 Subject: [PATCH 0517/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 9b9448d6..ec8d25da 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.7", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.7/shoko_3.0.1.7.zip", + "checksum": "57715abe755b72aafb85e48fcce00515", + "timestamp": "2023-05-23T17:52:29Z" + }, { "version": "3.0.1.6", "changelog": "NA\n", From 10051a7d3b3b632dcc17983a7757bbc41bb03430 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 23 May 2023 22:24:24 +0200 Subject: [PATCH 0518/1103] misc: more logging for sub-directories --- Shokofin/LibraryScanner.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index ff6f6e9b..58fd24fa 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -92,12 +92,16 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy // Check the sub-directories if we have a <Show>/<Season>/<Episodes> structure. if (partialPath[1..].Split(Path.DirectorySeparatorChar).Length == 1) { var entries = FileSystem.GetDirectories(fullPath, false).ToList(); + Logger.LogDebug("Unable to find series for {Path}, trying {DirCount} sub-directories.", entries.Count, partialPath); foreach (var entry in entries) { series = ApiManager.GetSeriesInfoByPath(entry.FullName) .GetAwaiter() .GetResult(); if (series != null) + { + Logger.LogDebug("Found series {SeriesName} for sub-directory of path {Path} (Series={SeriesId})", series.Shoko.Name, partialPath, series.Id); break; + } } } if (series == null) { From 3d76dd39fbfd492346783241356dffc652888d43 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 23 May 2023 20:25:23 +0000 Subject: [PATCH 0519/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index ec8d25da..34b77292 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.8", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.8/shoko_3.0.1.8.zip", + "checksum": "e4c15d4344077b02a016e3b1a8902c4b", + "timestamp": "2023-05-23T20:25:21Z" + }, { "version": "3.0.1.7", "changelog": "NA\n", From ac4078285e5b0b0d19776dd7a582b72d4a25a5a2 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 23 May 2023 22:47:11 +0200 Subject: [PATCH 0520/1103] fix: only check sub-dirs if strict filtering is on --- Shokofin/LibraryScanner.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index 58fd24fa..dcb5b489 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -89,8 +89,8 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given folder path. if (series == null) { - // Check the sub-directories if we have a <Show>/<Season>/<Episodes> structure. - if (partialPath[1..].Split(Path.DirectorySeparatorChar).Length == 1) { + // If we're in strict mode, then check the sub-directories if we have a <Show>/<Season>/<Episodes> structure. + if (shouldIgnore && partialPath[1..].Split(Path.DirectorySeparatorChar).Length == 1) { var entries = FileSystem.GetDirectories(fullPath, false).ToList(); Logger.LogDebug("Unable to find series for {Path}, trying {DirCount} sub-directories.", entries.Count, partialPath); foreach (var entry in entries) { From bc28a353b330920b51af896969eac62663c6cd2b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 23 May 2023 20:48:02 +0000 Subject: [PATCH 0521/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 34b77292..b9ae53c1 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.9", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.9/shoko_3.0.1.9.zip", + "checksum": "f5cf1d91e7e46b5fa12c2dc34106636f", + "timestamp": "2023-05-23T20:48:00Z" + }, { "version": "3.0.1.8", "changelog": "NA\n", From c8e56ee55b09c5f997e49ec4b7e85277754a39e8 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 23 May 2023 23:32:35 +0200 Subject: [PATCH 0522/1103] fix: fix loading filtering mode setting --- Shokofin/Configuration/configController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 93316f5d..9cfc7a6d 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -528,7 +528,7 @@ export default function (page) { form.querySelector("#AddTMDBId").checked = config.AddTMDBId; // Library settings - form.querySelector("#LibraryFilteringMode").value = `${config.LibraryFilteringMode || null}`; + form.querySelector("#LibraryFilteringMode").value = `${config.LibraryFilteringMode != null ? config.LibraryFilteringMode : null}`; form.querySelector("#SeriesGrouping").value = config.SeriesGrouping; form.querySelector("#BoxSetGrouping").value = config.BoxSetGrouping; form.querySelector("#FilterOnLibraryTypes").checked = config.FilterOnLibraryTypes; From ecf22607b4cad9fd0f511288190fedb46f1b65b2 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 23 May 2023 21:33:22 +0000 Subject: [PATCH 0523/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index b9ae53c1..ca6f0fe1 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.10", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.10/shoko_3.0.1.10.zip", + "checksum": "0d3b06b263e984fed5d36c688b510211", + "timestamp": "2023-05-23T21:33:20Z" + }, { "version": "3.0.1.9", "changelog": "NA\n", From f6471541874604e6666f65c537eedc8a76df4e91 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 24 May 2023 00:01:56 +0200 Subject: [PATCH 0524/1103] misc: more fixes for sentry exception captures --- Shokofin/API/ShokoAPIClient.cs | 4 ++-- Shokofin/Plugin.cs | 20 +++++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 3bf85cf7..507876ce 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -59,7 +59,7 @@ private async Task<HttpResponseMessage> Get(string url, HttpMethod method, strin // Check if we have a key to use. if (string.IsNullOrEmpty(apiKey)) { _httpClient.DefaultRequestHeaders.Clear(); - throw new Exception("Unable to call the API before an connection is established to Shoko Server!"); + throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); } try { @@ -129,7 +129,7 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method // Check if we have a key to use. if (string.IsNullOrEmpty(apiKey)) { _httpClient.DefaultRequestHeaders.Clear(); - throw new Exception("Unable to call the API before an connection is established to Shoko Server!"); + throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); } try { diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index fa6f16e3..7308dad1 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -1,13 +1,16 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Threading.Tasks; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; +using Shokofin.API.Models; using Shokofin.Configuration; using Sentry; -using System.Reflection; #nullable enable namespace Shokofin; @@ -77,8 +80,19 @@ private void RefreshSentry() // Filter exceptions. options.AddExceptionFilter(new SentryExceptionFilter(ex => { - if (ex.Message == "Unable to call the API before an connection is established to Shoko Server!") - return true; + switch (ex) { + // Ignore any and all http request exceptions and + // task cancellation exceptions. + case TaskCanceledException: + case HttpRequestException: + return true; + + case ApiException apiEx: + // Server is not ready to accept requests yet. + if (ex.Message.Contains("The Server is not running.")) + return true; + break; + } // If we need more filtering in the future then add them // above this comment. From bba80068fd528631e0a033fd59439e2d899a4cd8 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 23 May 2023 22:02:43 +0000 Subject: [PATCH 0525/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index ca6f0fe1..0831c710 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.11", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.11/shoko_3.0.1.11.zip", + "checksum": "df8547d70c67ea451a3395e18c2a84ff", + "timestamp": "2023-05-23T22:02:42Z" + }, { "version": "3.0.1.10", "changelog": "NA\n", From e180989a8a2c2f8f0c873c796ac2bdb121d835d2 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 28 May 2023 18:07:46 +0200 Subject: [PATCH 0526/1103] misc: remove search results since we as a plugin don't need them. If there is no automatic match then something is wrong with the user's shoko collection and they should fix that instead of trying to override it in Jellyfin. --- Shokofin/API/ShokoAPIClient.cs | 7 ------- Shokofin/Providers/BoxSetProvider.cs | 3 +-- Shokofin/Providers/EpisodeProvider.cs | 3 +-- Shokofin/Providers/MovieProvider.cs | 3 +-- Shokofin/Providers/SeasonProvider.cs | 3 +-- Shokofin/Providers/SeriesProvider.cs | 24 ++---------------------- 6 files changed, 6 insertions(+), 37 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 507876ce..be779d42 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -58,7 +58,6 @@ private async Task<HttpResponseMessage> Get(string url, HttpMethod method, strin // Check if we have a key to use. if (string.IsNullOrEmpty(apiKey)) { - _httpClient.DefaultRequestHeaders.Clear(); throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); } @@ -128,7 +127,6 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method // Check if we have a key to use. if (string.IsNullOrEmpty(apiKey)) { - _httpClient.DefaultRequestHeaders.Clear(); throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); } @@ -301,9 +299,4 @@ public Task<Group> GetGroupFromSeries(string id) { return Get<Group>($"/api/v3/Series/{id}/Group"); } - - public Task<ListResult<Series.AniDB>> SeriesSearch(string query) - { - return Get<ListResult<Series.AniDB>>($"/api/v3/Series/AniDB/Search?query={Uri.EscapeDataString(query ?? "")}&local=true&includeTitles=true&pageSize=0"); - } } diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index dbd0f649..ff29a7c8 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -174,8 +174,7 @@ private static string GetBoxSetName(BoxSetInfo info) public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) { - // Isn't called from anywhere. If it is called, I don't know from where. - throw new NotImplementedException(); + return Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); } diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 45656ea2..fa16a88e 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -289,8 +289,7 @@ private static void AddProviderIds(IHasProviderIds item, string episodeId, strin public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) { - // Isn't called from anywhere. If it is called, I don't know from where. - throw new NotImplementedException(); + return Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); } public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index c1e31d35..fc50516f 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -105,8 +105,7 @@ private static string GetCollectionName(API.Info.SeriesInfo series, API.Info.Gro public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) { - // Isn't called from anywhere. If it is called, I don't know from where. - throw new NotImplementedException(); + return Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); } public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 09965e02..8fb4138a 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -277,8 +277,7 @@ public static Season CreateMetadata(Info.SeriesInfo seriesInfo, int seasonNumber public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchInfo, CancellationToken cancellationToken) { - // Isn't called from anywhere. If it is called, I don't know from where. - throw new NotImplementedException(); + return Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); } public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 6f032d2c..a7526838 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -199,29 +199,9 @@ public static void AddProviderIds(IHasProviderIds item, string seriesId, string } - public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo info, CancellationToken cancellationToken) + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo info, CancellationToken cancellationToken) { - try { - var searchResults = await ApiClient.SeriesSearch(info.Name).ContinueWith((e) => e.Result.List); - Logger.LogInformation($"Series search returned {searchResults.Count} results."); - return searchResults.Select(series => { - var seriesId = (series?.ShokoId ?? 0).ToString(); - var imageUrl = series?.Poster.IsAvailable ?? false ? series.Poster.ToPrettyURLString() : null; - var parsedSeries = new RemoteSearchResult { - Name = Text.GetSeriesTitle(series.Titles, series.Title, info.MetadataLanguage), - SearchProviderName = Name, - ImageUrl = imageUrl, - Overview = Text.SanitizeTextSummary(series.Description), - }; - AddProviderIds(parsedSeries, seriesId: seriesId, groupId: null, anidbId: series.Id.ToString(), tvdbId: null); - return parsedSeries; - }); - } - catch (Exception ex) { - Logger.LogError(ex, $"Threw unexpectedly; {ex.Message}"); - Plugin.Instance.CaptureException(ex); - return new List<RemoteSearchResult>(); - } + return Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); } public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) From f0997327110826b91f329f083a4c4599cc4b60dd Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 28 May 2023 16:08:31 +0000 Subject: [PATCH 0527/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 0831c710..eb78d4fd 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.12", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.12/shoko_3.0.1.12.zip", + "checksum": "33a024e2258d101cac3e9d1ddde34179", + "timestamp": "2023-05-28T16:08:29Z" + }, { "version": "3.0.1.11", "changelog": "NA\n", From d93e20dca0a274c215dfafe573398426b9a3f752 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 8 Jun 2023 15:56:42 +0200 Subject: [PATCH 0528/1103] fix: fix pseudo random image order for series and also move the logging so it will always log the image count as long as we have a valid id for the base item. --- Shokofin/Providers/ImageProvider.cs | 32 ++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index e852cd5b..9f0c1efb 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -49,29 +49,29 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell var episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); if (episodeInfo != null) { AddImagesForEpisode(ref list, episodeInfo); - Logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId})", list.Count, episode.Name, episodeId); } + Logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId})", list.Count, episode.Name, episodeId); } break; } case Series series: { if (Lookup.TryGetSeriesIdFor(series, out var seriesId)) { + var seriesImages = await ApiClient.GetSeriesImages(seriesId); + if (seriesImages != null) { + AddImagesForSeries(ref list, seriesImages); + } + // Also attach any images linked to the "seasons" (AKA series within the group). if (Plugin.Instance.Configuration.SeriesGrouping == Utils.Ordering.GroupType.ShokoGroup) { - list = series.GetSeasons(null, new(true)) - .Cast<Season>() - .AsParallel() - .SelectMany(season => GetImages(season, cancellationToken).Result) + list = list + .Concat( + series.GetSeasons(null, new(true)) + .Cast<Season>() + .SelectMany(season => GetImages(season, cancellationToken).Result) + ) .DistinctBy(image => image.Url) .ToList(); - Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId})", list.Count, series.Name, seriesId); - } - else { - var seriesImages = await ApiClient.GetSeriesImages(seriesId); - if (seriesImages != null) { - AddImagesForSeries(ref list, seriesImages); - 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})", list.Count, series.Name, seriesId); } break; } @@ -80,8 +80,8 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages != null) { AddImagesForSeries(ref list, seriesImages); - 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})", list.Count, season.IndexNumber, season.SeriesName, seriesId); } break; } @@ -90,8 +90,8 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages != null) { AddImagesForSeries(ref list, seriesImages); - Logger.LogInformation("Getting {Count} images for movie {MovieName} (Series={SeriesId})", list.Count, movie.Name, seriesId); } + Logger.LogInformation("Getting {Count} images for movie {MovieName} (Series={SeriesId})", list.Count, movie.Name, seriesId); } break; } @@ -100,8 +100,8 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages != null) { AddImagesForSeries(ref list, seriesImages); - Logger.LogInformation("Getting {Count} images for box-set {BoxSetName} (Series={SeriesId})", list.Count, boxSet.Name, seriesId); } + Logger.LogInformation("Getting {Count} images for box-set {BoxSetName} (Series={SeriesId})", list.Count, boxSet.Name, seriesId); } break; } From 65edc1ce03abff7506b81f3d1f2c8468b3f61464 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 8 Jun 2023 13:57:41 +0000 Subject: [PATCH 0529/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index eb78d4fd..ab27f89d 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.13", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.13/shoko_3.0.1.13.zip", + "checksum": "5a5ecc8bfc87c86159f1a45965fe8d90", + "timestamp": "2023-06-08T13:57:39Z" + }, { "version": "3.0.1.12", "changelog": "NA\n", From 668332883f925427f264be235d832b6edc95bf0d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 11 Jun 2023 22:48:59 +0200 Subject: [PATCH 0530/1103] fix: fix user sync and log when the file doesn't exist instead of throwing. --- Shokofin/API/ShokoAPIClient.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index be779d42..a02f96fb 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -199,8 +199,11 @@ public Task<List<File>> GetFilesForSeries(string seriesId) catch (ApiException e) { // File user stats were not found. - if (e.StatusCode == HttpStatusCode.NotFound && e.Message.Contains("FileUserStats")) + if (e.StatusCode == HttpStatusCode.NotFound) { + if (!e.Message.Contains("FileUserStats")) + Logger.LogWarning("Unable to find user stats for a file that doesn't exist. (File={FileID})", fileId); return null; + } throw; } } From 1a83b22b352e126e7849c55a59506f7f81028725 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 11 Jun 2023 20:49:50 +0000 Subject: [PATCH 0531/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index ab27f89d..bdc0868d 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.14", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.14/shoko_3.0.1.14.zip", + "checksum": "cf987ccfde4dea78c73e2a5d12c29afd", + "timestamp": "2023-06-11T20:49:49Z" + }, { "version": "3.0.1.13", "changelog": "NA\n", From bdecfec196af7cfff51c426f750d366b011cae5c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 12 Jun 2023 17:08:34 +0200 Subject: [PATCH 0532/1103] fix: simplify user data sync logic --- Shokofin/API/Models/File.cs | 2 +- Shokofin/Sync/SyncExtensions.cs | 4 ++-- Shokofin/Sync/UserDataSyncManager.cs | 16 ++++------------ 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index bdfde73f..d8f65b2a 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -259,7 +259,7 @@ public class UserStats /// </summary> public virtual bool IsEmpty { - get => ResumePosition == null && WatchedCount == 0 && WatchedCount == 0; + get => ResumePosition == null && LastWatchedAt == null && WatchedCount == 0; } } } diff --git a/Shokofin/Sync/SyncExtensions.cs b/Shokofin/Sync/SyncExtensions.cs index 24c958dc..936ea01d 100644 --- a/Shokofin/Sync/SyncExtensions.cs +++ b/Shokofin/Sync/SyncExtensions.cs @@ -21,7 +21,7 @@ public static File.UserStats ToFileUserStats(this UserItemData userData) WatchedCount = userData.PlayCount, }; } - + public static UserItemData MergeWithFileUserStats(this UserItemData userData, File.UserStats userStats) { userData.Played = userStats.LastWatchedAt.HasValue; @@ -30,7 +30,7 @@ public static UserItemData MergeWithFileUserStats(this UserItemData userData, Fi userData.LastPlayedDate = userStats.ResumePosition.HasValue ? userStats.LastUpdatedAt : userStats.LastWatchedAt ?? userStats.LastUpdatedAt; return userData; } - + public static UserItemData ToUserData(this File.UserStats userStats, Video video, Guid userId) { return new UserItemData diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index 4337be08..096622b4 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -567,26 +567,18 @@ private static bool UserDataEqualsFileUserStats(UserItemData localUserData, User if (localUserStats.IsEmpty && remoteUserStats.IsEmpty) return true; - TimeSpan? resumePosition = new TimeSpan(localUserData.PlaybackPositionTicks); - if (Math.Floor(resumePosition.Value.TotalMilliseconds) == 0d) - resumePosition = null; - if (resumePosition != remoteUserStats.ResumePosition) + var resumePosition = localUserStats.ResumePosition; + if (localUserStats.ResumePosition != remoteUserStats.ResumePosition) return false; - if (localUserData.PlayCount != remoteUserStats.WatchedCount) + if (localUserStats.WatchedCount != remoteUserStats.WatchedCount) return false; var played = remoteUserStats.LastWatchedAt.HasValue; if (localUserData.Played != played) return false; - var lastWatchedAt = localUserData.Played && !resumePosition.HasValue ? localUserData.LastPlayedDate : null; - if (lastWatchedAt.HasValue != remoteUserStats.LastWatchedAt.HasValue || lastWatchedAt.HasValue && lastWatchedAt != remoteUserStats.LastWatchedAt) - return false; - - var isUpdated = resumePosition.HasValue || localUserData.Played; - var lastUpdatedAt = isUpdated ? localUserData.LastPlayedDate : null; - if (isUpdated && (!lastUpdatedAt.HasValue || lastUpdatedAt.Value != remoteUserStats.LastUpdatedAt)) + if (localUserStats.LastUpdatedAt != remoteUserStats.LastUpdatedAt) return false; return true; From c1e8e5b0c7ebd9d77051ddb434c45bf0edaca435 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 12 Jun 2023 15:09:43 +0000 Subject: [PATCH 0533/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index bdc0868d..174ba997 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.15", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.15/shoko_3.0.1.15.zip", + "checksum": "c19b4f67c93d87d90f9b5096453109d8", + "timestamp": "2023-06-12T15:09:40Z" + }, { "version": "3.0.1.14", "changelog": "NA\n", From ee49b46943206627e211d4f3512ea1cb73a3232a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 19 Jul 2023 22:42:19 +0200 Subject: [PATCH 0534/1103] misc: fix compat. with anidb plugin set the anidb series id instead of the anidb episode id for movies. --- Shokofin/Providers/MovieProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index fc50516f..ec726dbd 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -74,7 +74,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio result.Item.SetProviderId("Shoko Episode", episode.Id); result.Item.SetProviderId("Shoko Series", series.Id); if (config.AddAniDBId) - result.Item.SetProviderId("AniDB", episode.AniDB.Id.ToString()); + result.Item.SetProviderId("AniDB", series.AniDB.Id.ToString()); result.HasMetadata = true; From bf44818d3b6c60b29903d11fad099e8129e12313 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 19 Jul 2023 22:51:02 +0200 Subject: [PATCH 0535/1103] feature: add main title and allow any title add an option to allow selecting to use the anidb main title, and another option to use any title in the selected language(s) if no official title is found first. --- Shokofin/Configuration/PluginConfiguration.cs | 7 + Shokofin/Configuration/configController.js | 3 + Shokofin/Configuration/configPage.html | 9 ++ Shokofin/Utils/TextUtil.cs | 130 +++++++++++------- 4 files changed, 97 insertions(+), 52 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index a75e9552..b51c5a00 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -74,6 +74,12 @@ public virtual string PrettyHost public DisplayLanguageType TitleAlternateType { get; set; } + /// <summary> + /// Allow choosing any title in the selected language if no official + /// title is available. + /// </summary> + public bool TitleAllowAny { get; set; } + public UserConfiguration[] UserList { get; set; } public string[] IgnoredFileExtensions { get; set; } @@ -119,6 +125,7 @@ public PluginConfiguration() AddTMDBId = true; TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; + TitleAllowAny = false; DescriptionSource = TextSourceType.Default; SeriesGrouping = SeriesAndBoxSetGroupType.Default; SeasonOrdering = OrderType.Default; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 9cfc7a6d..c849598a 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -144,6 +144,7 @@ async function defaultSubmit(form) { // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; config.TitleAlternateType = form.querySelector("#TitleAlternateType").value; + config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; config.DescriptionSource = form.querySelector("#DescriptionSource").value; config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; @@ -320,6 +321,7 @@ async function syncSettings(form) { // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; config.TitleAlternateType = form.querySelector("#TitleAlternateType").value; + config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; config.DescriptionSource = form.querySelector("#DescriptionSource").value; config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; @@ -517,6 +519,7 @@ export default function (page) { // Metadata settings form.querySelector("#TitleMainType").value = config.TitleMainType; form.querySelector("#TitleAlternateType").value = config.TitleAlternateType; + form.querySelector("#TitleAllowAny").checked = config.TitleAllowAny; form.querySelector("#TitleAddForMultipleEpisodes").checked = config.TitleAddForMultipleEpisodes || true; form.querySelector("#DescriptionSource").value = config.DescriptionSource; form.querySelector("#CleanupAniDBDescriptions").checked = config.SynopsisCleanMultiEmptyLines || config.SynopsisCleanLinks; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index bae6c6bc..cd550b97 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -58,6 +58,7 @@ <h3>Metadata Settings</h3> <option value="Default">Let Shoko decide the title</option> <option value="MetadataPreferred">Use the preferred library metadata language</option> <option value="Origin">Use the language from country of origin</option> + <option value="Main">Use the main title on AniDB</option> </select> <div class="fieldDescription selectFieldDescription">How to select the main title for each item. The plugin will fallback to the default title if no title can be found in the target language.</div> </div> @@ -67,6 +68,7 @@ <h3>Metadata Settings</h3> <option value="Default">Let Shoko decide the title</option> <option value="MetadataPreferred">Use the preferred library metadata language</option> <option value="Origin">Use the language from country of origin</option> + <option value="Main">Use the main title on AniDB</option> <option value="Ignore">Do not use alternate titles</option> </select> <div class="fieldDescription selectFieldDescription">How to select the alternate title for each item. The plugin will fallback to the default title if no title can be found in the target language.</div> @@ -82,6 +84,13 @@ <h3>Metadata Settings</h3> </select> <div class="fieldDescription selectFieldDescription">How to select the description to use for each item.</div> </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <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"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="TitleAddForMultipleEpisodes" /> diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index c621762f..5db1c751 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -1,8 +1,8 @@ using Shokofin.API.Info; using Shokofin.API.Models; +using System; using System.Collections.Generic; using System.Linq; -using System.Text; using System.Text.RegularExpressions; namespace Shokofin.Utils @@ -122,6 +122,11 @@ public enum DisplayLanguageType { /// Don't display a title. /// </summary> Ignore = 4, + + /// <summary> + /// Use the main title for the series. + /// </summary> + Main = 5, } /// <summary> @@ -224,11 +229,10 @@ public static ( string, string ) GetTitles(IEnumerable<Title> seriesTitles, IEnu { // Don't process anything if the series titles are not provided. if (seriesTitles == null) - return ( null, null ); - var originLanguage = GuessOriginLanguage(seriesTitles); + return (null, null); return ( - GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage, originLanguage), - GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleAlternateType, outputType, metadataLanguage, originLanguage) + GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage), + GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleAlternateType, outputType, metadataLanguage) ); } @@ -267,66 +271,83 @@ public static string GetSeriesTitle(IEnumerable<Title> seriesTitles, string seri public static string GetMovieTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplayTitleType.FullTitle, metadataLanguage); - public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayTitleType outputType, string metadataLanguage, params string[] originLanguages) - => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage, originLanguages); + public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayTitleType outputType, string metadataLanguage) + => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage); - public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayLanguageType languageType, DisplayTitleType outputType, string displayLanguage, params string[] originLanguages) + public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayLanguageType languageType, DisplayTitleType outputType, string displayLanguage) { // Don't process anything if the series titles are not provided. if (seriesTitles == null) return null; - // Guess origin language if not provided. - if (originLanguages.Length == 0) - originLanguages = GuessOriginLanguage(seriesTitles); + var mainTitleLanguage = GetMainLanguage(seriesTitles); + var originLanguages = GuessOriginLanguage(mainTitleLanguage); switch (languageType) { // 'Ignore' will always return null, and all other values will also return null. default: case DisplayLanguageType.Ignore: return null; // Let Shoko decide the title. - case DisplayLanguageType.Default: - return __GetTitle(null, null, seriesTitle, episodeTitle, outputType); + case DisplayLanguageType.Default: + return ConstructTitle(() => seriesTitle, () => episodeTitle, outputType); // Display in metadata-preferred language, or fallback to default. - case DisplayLanguageType.MetadataPreferred: - var title = __GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, outputType, displayLanguage); + case DisplayLanguageType.MetadataPreferred: { + var allowAny = Plugin.Instance.Configuration.TitleAllowAny; + var getSeriesTitle = () => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, displayLanguage) ?? (allowAny ? GetTitleByLanguages(seriesTitles, displayLanguage) : null) ?? seriesTitle; + var getEpisodeTitle = () => GetTitleByLanguages(episodeTitles, displayLanguage) ?? episodeTitle; + var title = ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); if (string.IsNullOrEmpty(title)) goto case DisplayLanguageType.Default; return title; - // Display in origin language without fallback. - case DisplayLanguageType.Origin: - return __GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, outputType, originLanguages); + } + // Display in origin language. + case DisplayLanguageType.Origin: { + var allowAny = Plugin.Instance.Configuration.TitleAllowAny; + var getSeriesTitle = () => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, originLanguages) ?? (allowAny ? GetTitleByLanguages(seriesTitles, originLanguages) : null) ?? seriesTitle; + var getEpisodeTitle = () => GetTitleByLanguages(episodeTitles, originLanguages) ?? episodeTitle; + return ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); + } + // Display the main title. + case DisplayLanguageType.Main: { + var getSeriesTitle = () => GetTitleByType(seriesTitles, TitleType.Main) ?? seriesTitle; + var getEpisodeTitle = () => GetTitleByLanguages(episodeTitles, "en", mainTitleLanguage) ?? episodeTitle; + return ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); + } } } - private static string __GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayTitleType outputType, params string[] languageCandidates) + private static string ConstructTitle(Func<string> getSeriesTitle, Func<string> getEpisodeTitle, DisplayTitleType outputType) { - // Lazy init string builder when/if we need it. - StringBuilder titleBuilder = null; switch (outputType) { - default: - return null; + // Return series title. case DisplayTitleType.MainTitle: + return getSeriesTitle()?.Trim(); + // Return episode title. + case DisplayTitleType.SubTitle: + return getEpisodeTitle()?.Trim(); + // Return combined series and episode title. case DisplayTitleType.FullTitle: { - string title = (GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, languageCandidates) ?? seriesTitle)?.Trim(); - // Return series title. - if (outputType == DisplayTitleType.MainTitle) - return title; - titleBuilder = new StringBuilder(title); - goto case DisplayTitleType.SubTitle; - } - case DisplayTitleType.SubTitle: { - string title = (GetTitleByLanguages(episodeTitles, languageCandidates) ?? episodeTitle)?.Trim(); - // Return episode title. - if (outputType == DisplayTitleType.SubTitle) - return title; - // Ignore sub-title of movie if it strictly equals the text below. - if (!string.IsNullOrWhiteSpace(title) && !IgnoredSubTitles.Contains(title)) - titleBuilder?.Append($": {title}"); - return titleBuilder?.ToString() ?? ""; + var mainTitle = getSeriesTitle()?.Trim(); + var subTitle = getEpisodeTitle()?.Trim(); + // Include sub-title if it does not strictly equals any ignored sub titles. + if (!string.IsNullOrWhiteSpace(subTitle) && !IgnoredSubTitles.Contains(mainTitle)) + return $"{mainTitle}: {subTitle}"; + return mainTitle; } + default: + return null; } } + public static string GetTitleByType(IEnumerable<Title> titles, TitleType type) + { + if (titles != null) { + string title = titles.FirstOrDefault(s => s.Type == type)?.Value; + if (title != null) + return title; + } + return null; + } + public static string GetTitleByTypeAndLanguage(IEnumerable<Title> titles, TitleType type, params string[] langs) { if (titles != null) foreach (string lang in langs) { @@ -347,24 +368,29 @@ public static string GetTitleByLanguages(IEnumerable<Title> titles, params strin return null; } + /// <summary> + /// Get the main title language from the series list. + /// </summary> + /// <param name="titles">Series title list.</param> + /// <returns></returns> + private static string GetMainLanguage(IEnumerable<Title> titles) { + return titles.FirstOrDefault(t => t?.Type == TitleType.Main)?.LanguageCode ?? titles.FirstOrDefault()?.LanguageCode ?? "x-other"; + } + /// <summary> /// Guess the origin language based on the main title. /// </summary> + /// <param name="titles">Series title list.</param> /// <returns></returns> - private static string[] GuessOriginLanguage(IEnumerable<Title> titles) + private static string[] GuessOriginLanguage(string langCode) { - string langCode = titles.FirstOrDefault(t => t?.Type == TitleType.Main)?.LanguageCode; - // Guess the origin language based on the main title. - switch (langCode) { - case null: // fallback - case "x-other": - case "x-jat": - return new string[] { "ja" }; - case "x-zht": - return new string[] { "zn-hans", "zn-hant", "zn-c-mcm", "zn" }; - default: - return new string[] { langCode }; - } + // Guess the origin language based on the main title language. + return 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 }, + }; } } } From d61e9010d61064be922e86bf2a23f002d20bab50 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 19 Jul 2023 20:52:16 +0000 Subject: [PATCH 0536/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 174ba997..f308ee11 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.16", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.16/shoko_3.0.1.16.zip", + "checksum": "0521efa7adea78623bce9c1cb73a2c49", + "timestamp": "2023-07-19T20:52:14Z" + }, { "version": "3.0.1.15", "changelog": "NA\n", From 3393763e7790c1b8c93fc6accc5ced9a1d2e12af Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 8 Aug 2023 03:53:08 +0200 Subject: [PATCH 0537/1103] misc: set default solution --- .vscode/settings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 40d66586..647ec169 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,6 @@ "editor.tabSize": 4, "files.trimTrailingWhitespace": false, "files.trimFinalNewlines": false, - "files.insertFinalNewline": false + "files.insertFinalNewline": false, + "dotnet.defaultSolution": "Shokofin.sln" } From e88af243edcda7fe31466871006c1478377242e2 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 8 Aug 2023 03:53:38 +0200 Subject: [PATCH 0538/1103] =?UTF-8?q?fix:=20fix=20shoko=20redirects=20at?= =?UTF-8?q?=20least=20for=20groups=20and=20series=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/ExternalIds.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/ExternalIds.cs b/Shokofin/ExternalIds.cs index 7c4141bc..aba88e74 100644 --- a/Shokofin/ExternalIds.cs +++ b/Shokofin/ExternalIds.cs @@ -21,7 +21,7 @@ public ExternalIdMediaType? Type => null; public virtual string UrlFormatString - => $"{Plugin.Instance.Configuration.PrettyHost}/webui/group/{{0}}"; + => $"{Plugin.Instance.Configuration.PrettyHost}/webui/collection/group/{{0}}"; } public class ShokoSeriesExternalId : IExternalId @@ -39,7 +39,7 @@ public ExternalIdMediaType? Type => null; public virtual string UrlFormatString - => $"{Plugin.Instance.Configuration.PrettyHost}/webui/series/{{0}}"; + => $"{Plugin.Instance.Configuration.PrettyHost}/webui/collection/series/{{0}}"; } public class ShokoEpisodeExternalId : IExternalId @@ -57,7 +57,7 @@ public ExternalIdMediaType? Type => null; public virtual string UrlFormatString - => $"{Plugin.Instance.Configuration.PrettyHost}/webui/episode/{{0}}"; + => $"{Plugin.Instance.Configuration.PrettyHost}/webui/redirect/episode/{{0}}"; } public class ShokoFileExternalId : IExternalId From c4de683cfaa99f92620508c64772f606a4cbec1a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 8 Aug 2023 03:54:49 +0200 Subject: [PATCH 0539/1103] fix: fix log messages for user data sync manager --- Shokofin/Sync/UserDataSyncManager.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index 096622b4..03f55036 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -458,7 +458,7 @@ private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDire var localUserStats = UserDataManager.GetUserData(userConfig.UserId, video); var remoteUserStats = await APIClient.GetFileUserStats(fileId, userConfig.Token); bool isInSync = UserDataEqualsFileUserStats(localUserStats, remoteUserStats); - Logger.LogInformation("{SyncDirection} user data for video {VideoName}. (File={FileId},Episode={EpisodeId},Local={HaveLocal},Remote={HaveRemote},InSync={IsInSync})", direction.ToString(), video.Name, fileId, episodeId, localUserStats != null, remoteUserStats != null, isInSync); + Logger.LogInformation("{SyncDirection} user data for video {VideoName}. (User={UserId},File={FileId},Episode={EpisodeId},Local={HaveLocal},Remote={HaveRemote},InSync={IsInSync})", direction.ToString(), video.Name, userConfig.UserId, fileId, episodeId, localUserStats != null, remoteUserStats != null, isInSync); if (isInSync) return; @@ -476,12 +476,12 @@ private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDire if (remoteUserStats.IsEmpty) break; remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, fileId, episodeId); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, episodeId); } else if (localUserStats.LastPlayedDate.HasValue && localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) { remoteUserStats = localUserStats.ToFileUserStats(); remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, fileId, episodeId); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, episodeId); } break; case SyncDirection.Import: @@ -492,13 +492,13 @@ private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDire if (localUserStats == null) { UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats = remoteUserStats.ToUserData(video, userConfig.UserId), UserDataSaveReason.Import, CancellationToken.None); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, fileId, episodeId); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); } // Else merge the remote stats into the local stats entry. else if ((!localUserStats.LastPlayedDate.HasValue || remoteUserStats.LastUpdatedAt > localUserStats.LastPlayedDate.Value)) { UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, fileId, episodeId); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); } break; default: @@ -528,20 +528,20 @@ private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDire { remoteUserStats = localUserStats.ToFileUserStats(); remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, fileId, episodeId); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, episodeId); } // Else import if the remote state is fresher then the local state. else if (localUserStats.LastPlayedDate.Value < remoteUserStats.LastUpdatedAt) { UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, fileId, episodeId); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); } break; } } } catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized) { - Logger.LogError(ex, "I{Message} (Username={Username},Id={UserId})", ex.Message, UserManager.GetUserById(userConfig.UserId)?.Username, userConfig.UserId); + Logger.LogError(ex, "{Message} (Username={Username},Id={UserId})", ex.Message, UserManager.GetUserById(userConfig.UserId)?.Username, userConfig.UserId); throw; } } From 1d12f1bcb1c4606a541d53f096ca285b80571c8b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 8 Aug 2023 06:44:48 +0200 Subject: [PATCH 0540/1103] refactor: fix shows nested groups + more Fixed show creation for nested shoko groups, refactored everything to align better with the Jellyfin structure (imo), and lay the groundwork for adding universal collections in the future. --- Shokofin/API/Info/CollectionInfo.cs | 46 +++ .../API/Info/{SeriesInfo.cs => SeasonInfo.cs} | 110 +++--- .../API/Info/{GroupInfo.cs => ShowInfo.cs} | 101 +++-- Shokofin/API/ShokoAPIClient.cs | 7 +- Shokofin/API/ShokoAPIManager.cs | 369 ++++++++++++------ Shokofin/ExternalIds.cs | 8 +- Shokofin/IdLookup.cs | 35 +- Shokofin/LibraryScanner.cs | 58 +-- Shokofin/Providers/BoxSetProvider.cs | 123 +++--- Shokofin/Providers/EpisodeProvider.cs | 36 +- Shokofin/Providers/ExtraMetadataProvider.cs | 154 ++++---- Shokofin/Providers/ImageProvider.cs | 20 +- Shokofin/Providers/MovieProvider.cs | 16 +- Shokofin/Providers/SeasonProvider.cs | 155 ++++---- Shokofin/Providers/SeriesProvider.cs | 140 ++++--- Shokofin/Utils/OrderingUtil.cs | 40 +- Shokofin/Utils/SeriesInfoRelationComparer.cs | 8 +- Shokofin/Utils/TextUtil.cs | 2 +- 18 files changed, 788 insertions(+), 640 deletions(-) create mode 100644 Shokofin/API/Info/CollectionInfo.cs rename Shokofin/API/Info/{SeriesInfo.cs => SeasonInfo.cs} (67%) rename Shokofin/API/Info/{GroupInfo.cs => ShowInfo.cs} (56%) diff --git a/Shokofin/API/Info/CollectionInfo.cs b/Shokofin/API/Info/CollectionInfo.cs new file mode 100644 index 00000000..3c321157 --- /dev/null +++ b/Shokofin/API/Info/CollectionInfo.cs @@ -0,0 +1,46 @@ + +using System.Collections.Generic; +using Shokofin.API.Models; +using Shokofin.Utils; + +#nullable enable +namespace Shokofin.API.Info; + +public class CollectionInfo +{ + public string Id; + + public string? ParentId; + + public bool IsTopLevel; + + public string Name; + + public Group Shoko; + + public IReadOnlyList<ShowInfo> Shows; + + public IReadOnlyList<CollectionInfo> SubCollections; + + public CollectionInfo(Group group) + { + Id = group.IDs.Shoko.ToString(); + ParentId = group.IDs.ParentGroup?.ToString(); + IsTopLevel = group.IDs.TopLevelGroup == group.IDs.Shoko; + Name = group.Name; + Shoko = group; + Shows = new List<ShowInfo>(); + SubCollections = new List<CollectionInfo>(); + } + + public CollectionInfo(Group group, List<ShowInfo> shows, List<CollectionInfo> subCollections, Ordering.GroupFilterType filterByType) + { + Id = group.IDs.Shoko.ToString(); + ParentId = group.IDs.ParentGroup?.ToString(); + IsTopLevel = group.IDs.TopLevelGroup == group.IDs.Shoko; + Name = group.Name; + Shoko = group; + Shows = shows; + SubCollections = subCollections; + } +} diff --git a/Shokofin/API/Info/SeriesInfo.cs b/Shokofin/API/Info/SeasonInfo.cs similarity index 67% rename from Shokofin/API/Info/SeriesInfo.cs rename to Shokofin/API/Info/SeasonInfo.cs index 9e1c95f6..d70ce51f 100644 --- a/Shokofin/API/Info/SeriesInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -8,7 +8,7 @@ #nullable enable namespace Shokofin.API.Info; -public class SeriesInfo +public class SeasonInfo { public string Id; @@ -84,7 +84,7 @@ public class SeriesInfo /// </summary> public Dictionary<string, RelationType> RelationMap; - public SeriesInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, List<Relation> relations, string[] genres, string[] tags) + public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, List<Relation> relations, string[] genres, string[] tags) { var seriesId = series.IDs.Shoko.ToString(); var studios = cast @@ -160,59 +160,55 @@ public SeriesInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li RelationMap = relationMap; } - private string? GetImagePath(Image image) - { - return image != null && image.IsAvailable ? image.ToURLString() : null; - } - - private PersonInfo? RoleToPersonInfo(Role role) - { - switch (role.Type) { - default: - return null; - case CreatorRoleType.Director: - return new PersonInfo { - Type = PersonType.Director, - Name = role.Staff.Name, - Role = role.Name, - ImageUrl = GetImagePath(role.Staff.Image), - }; - case CreatorRoleType.Producer: - return new PersonInfo { - Type = PersonType.Producer, - Name = role.Staff.Name, - Role = role.Name, - ImageUrl = GetImagePath(role.Staff.Image), - }; - case CreatorRoleType.Music: - return new PersonInfo { - Type = PersonType.Lyricist, - Name = role.Staff.Name, - Role = role.Name, - ImageUrl = GetImagePath(role.Staff.Image), - }; - case CreatorRoleType.SourceWork: - return new PersonInfo { - Type = PersonType.Writer, - Name = role.Staff.Name, - Role = role.Name, - ImageUrl = GetImagePath(role.Staff.Image), - }; - case CreatorRoleType.SeriesComposer: - return new PersonInfo { - Type = PersonType.Composer, - Name = role.Staff.Name, - ImageUrl = GetImagePath(role.Staff.Image), - }; - case CreatorRoleType.Seiyuu: - return new PersonInfo { - Type = PersonType.Actor, - Name = role.Staff.Name, - // The character will always be present if the role is a VA. - // We make it a conditional check since otherwise will the compiler complain. - Role = role.Character?.Name ?? "", - ImageUrl = GetImagePath(role.Staff.Image), - }; - } - } + private static string? GetImagePath(Image image) + => image != null && image.IsAvailable ? image.ToURLString() : null; + + private static PersonInfo? RoleToPersonInfo(Role role) + => role.Type switch + { + CreatorRoleType.Director => new PersonInfo + { + Type = PersonType.Director, + Name = role.Staff.Name, + Role = role.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }, + CreatorRoleType.Producer => new PersonInfo + { + Type = PersonType.Producer, + Name = role.Staff.Name, + Role = role.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }, + CreatorRoleType.Music => new PersonInfo + { + Type = PersonType.Lyricist, + Name = role.Staff.Name, + Role = role.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }, + CreatorRoleType.SourceWork => new PersonInfo + { + Type = PersonType.Writer, + Name = role.Staff.Name, + Role = role.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }, + CreatorRoleType.SeriesComposer => new PersonInfo + { + Type = PersonType.Composer, + Name = role.Staff.Name, + ImageUrl = GetImagePath(role.Staff.Image), + }, + CreatorRoleType.Seiyuu => new PersonInfo + { + Type = PersonType.Actor, + Name = role.Staff.Name, + // The character will always be present if the role is a VA. + // We make it a conditional check since otherwise will the compiler complain. + Role = role.Character?.Name ?? "", + ImageUrl = GetImagePath(role.Staff.Image), + }, + _ => null, + }; } diff --git a/Shokofin/API/Info/GroupInfo.cs b/Shokofin/API/Info/ShowInfo.cs similarity index 56% rename from Shokofin/API/Info/GroupInfo.cs rename to Shokofin/API/Info/ShowInfo.cs index 76d76a19..deb66eed 100644 --- a/Shokofin/API/Info/GroupInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -8,11 +8,13 @@ #nullable enable namespace Shokofin.API.Info; -public class GroupInfo +public class ShowInfo { - public string Id; + public string? Id; - public Group Shoko; + public string Name; + + public bool IsStandalone; public string[] Tags; @@ -20,28 +22,68 @@ public class GroupInfo public string[] Studios; - public List<SeriesInfo> SeriesList; + public List<SeasonInfo> SeasonList; + + public Dictionary<int, SeasonInfo> SeasonOrderDictionary; - public Dictionary<int, SeriesInfo> SeasonOrderDictionary; + public Dictionary<SeasonInfo, int> SeasonNumberBaseDictionary; - public Dictionary<SeriesInfo, int> SeasonNumberBaseDictionary; + public SeasonInfo? DefaultSeason; - public SeriesInfo? DefaultSeries; + public ShowInfo(Series series) + { + Id = null; + Name = series.Name; + IsStandalone = true; + Tags = System.Array.Empty<string>(); + Genres = System.Array.Empty<string>(); + Studios = System.Array.Empty<string>(); + SeasonList = new(); + SeasonNumberBaseDictionary = new(); + SeasonOrderDictionary = new(); + DefaultSeason = null; + } - public GroupInfo(Group group) + public ShowInfo(Group group) { Id = group.IDs.Shoko.ToString(); - Shoko = group; - Tags = new string[0]; - Genres = new string[0]; - Studios = new string[0]; - SeriesList = new(); + Name = group.Name; + IsStandalone = false; + Tags = System.Array.Empty<string>(); + Genres = System.Array.Empty<string>(); + Studios = System.Array.Empty<string>(); + SeasonList = new(); SeasonNumberBaseDictionary = new(); SeasonOrderDictionary = new(); - DefaultSeries = null; + DefaultSeason = null; + } + + public ShowInfo(SeasonInfo seasonInfo) + { + var seasonOrderDictionary = new Dictionary<int, SeasonInfo>(); + var seasonNumberBaseDictionary = new Dictionary<SeasonInfo, int>(); + var offset = 0; + if (seasonInfo.AlternateEpisodesList.Count > 0) + offset++; + if (seasonInfo.OthersList.Count > 0) + offset++; + seasonNumberBaseDictionary.Add(seasonInfo, 1); + seasonOrderDictionary.Add(1, seasonInfo); + for (var i = 0; i < offset; i++) + seasonOrderDictionary.Add(i + 2, seasonInfo); + + Name = seasonInfo.Shoko.Name; + IsStandalone = true; + Tags = seasonInfo.Tags; + Genres = seasonInfo.Genres; + Studios = seasonInfo.Studios; + SeasonList = new() { seasonInfo }; + SeasonNumberBaseDictionary = seasonNumberBaseDictionary; + SeasonOrderDictionary = seasonOrderDictionary; + DefaultSeason = seasonInfo; } - public GroupInfo(Group group, List<SeriesInfo> seriesList, Ordering.GroupFilterType filterByType, ILogger logger) + public ShowInfo(Group group, List<SeasonInfo> seriesList, Ordering.GroupFilterType filterByType, ILogger logger) { var groupId = group.IDs.Shoko.ToString(); @@ -88,16 +130,16 @@ public GroupInfo(Group group, List<SeriesInfo> seriesList, Ordering.GroupFilterT foundIndex = 0; } - var seasonOrderDictionary = new Dictionary<int, SeriesInfo>(); - var seasonNumberBaseDictionary = new Dictionary<SeriesInfo, int>(); + var seasonOrderDictionary = new Dictionary<int, SeasonInfo>(); + var seasonNumberBaseDictionary = new Dictionary<SeasonInfo, int>(); var positiveSeasonNumber = 1; var negativeSeasonNumber = -1; - foreach (var (seriesInfo, index) in seriesList.Select((s, i) => (s, i))) { + foreach (var (seasonInfo, index) in seriesList.Select((s, i) => (s, i))) { int seasonNumber; var offset = 0; - if (seriesInfo.AlternateEpisodesList.Count > 0) + if (seasonInfo.AlternateEpisodesList.Count > 0) offset++; - if (seriesInfo.OthersList.Count > 0) + if (seasonInfo.OthersList.Count > 0) offset++; // Series before the default series get a negative season number @@ -110,27 +152,28 @@ public GroupInfo(Group group, List<SeriesInfo> seriesList, Ordering.GroupFilterT positiveSeasonNumber += offset + 1; } - seasonNumberBaseDictionary.Add(seriesInfo, seasonNumber); - seasonOrderDictionary.Add(seasonNumber, seriesInfo); + seasonNumberBaseDictionary.Add(seasonInfo, seasonNumber); + seasonOrderDictionary.Add(seasonNumber, seasonInfo); for (var i = 0; i < offset; i++) - seasonOrderDictionary.Add(seasonNumber + (index < foundIndex ? -(i + 1) : (i + 1)), seriesInfo); + seasonOrderDictionary.Add(seasonNumber + (index < foundIndex ? -(i + 1) : (i + 1)), seasonInfo); } Id = groupId; - Shoko = group; + Name = seriesList.Count > 0 ? seriesList[foundIndex].Shoko.Name : group.Name; + IsStandalone = false; Tags = seriesList.SelectMany(s => s.Tags).Distinct().ToArray(); Genres = seriesList.SelectMany(s => s.Genres).Distinct().ToArray(); Studios = seriesList.SelectMany(s => s.Studios).Distinct().ToArray(); - SeriesList = seriesList; + SeasonList = seriesList; SeasonNumberBaseDictionary = seasonNumberBaseDictionary; SeasonOrderDictionary = seasonOrderDictionary; - DefaultSeries = seriesList.Count > 0 ? seriesList[foundIndex] : null; + DefaultSeason = seriesList.Count > 0 ? seriesList[foundIndex] : null; } - public SeriesInfo? GetSeriesInfoBySeasonNumber(int seasonNumber) { - if (seasonNumber == 0 || !(SeasonOrderDictionary.TryGetValue(seasonNumber, out var seriesInfo) && seriesInfo != null)) + public SeasonInfo? GetSeriesInfoBySeasonNumber(int seasonNumber) { + if (seasonNumber == 0 || !(SeasonOrderDictionary.TryGetValue(seasonNumber, out var seasonInfo) && seasonInfo != null)) return null; - return seriesInfo; + return seasonInfo; } } diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index a02f96fb..d80a28f2 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -255,7 +255,7 @@ public Task<Series> GetSeriesFromEpisode(string id) public Task<List<Series>> GetSeriesInGroup(string groupID, int filterID = 0) { - return Get<List<Series>>($"/api/v3/Filter/{filterID}/Group/{groupID}/Series?includeMissing=true&includeIgnored=false&includeDataFrom=AniDB,TvDB"); + return Get<List<Series>>($"/api/v3/Filter/{filterID}/Group/{groupID}/Series?recursive=false&includeMissing=true&includeIgnored=false&includeDataFrom=AniDB,TvDB"); } public Task<List<Role>> GetSeriesCast(string id) @@ -298,6 +298,11 @@ public Task<Group> GetGroup(string id) return Get<Group>($"/api/v3/Group/{id}"); } + public Task<List<Group>> GetGroupsInGroup(string id) + { + return Get<List<Group>>($"/api/v3/Group/{id}/Group?includeEmpty=true"); + } + public Task<Group> GetGroupFromSeries(string id) { return Get<Group>($"/api/v3/Series/{id}/Group"); diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index a1859786..b7a1c62e 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -38,11 +38,13 @@ public class ShokoAPIManager : IDisposable private readonly ConcurrentDictionary<string, List<string>> PathToEpisodeIdsDictionary = new(); - private readonly ConcurrentDictionary<string, (string, string)> PathToFileIdAndSeriesIdDictionary = new(); + private readonly ConcurrentDictionary<string, (string FileId, string SeriesId)> PathToFileIdAndSeriesIdDictionary = new(); private readonly ConcurrentDictionary<string, string> SeriesIdToPathDictionary = new(); - private readonly ConcurrentDictionary<string, string> SeriesIdToGroupIdDictionary = new(); + private readonly ConcurrentDictionary<string, (string? GroupId, string DefaultSeriesId)> SeriesIdToGroupIdDictionary = new(); + + private readonly ConcurrentDictionary<string, string?> SeriesIdToCollectionIdDictionary = new(); private readonly ConcurrentDictionary<string, string> EpisodeIdToEpisodePathDictionary = new(); @@ -61,9 +63,9 @@ public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient ExpirationScanFrequency = ExpirationScanFrequency, }); - private static readonly System.TimeSpan ExpirationScanFrequency = new System.TimeSpan(0, 25, 0); + private static readonly TimeSpan ExpirationScanFrequency = new(0, 25, 0); - private static readonly System.TimeSpan DefaultTimeSpan = new System.TimeSpan(1, 30, 0); + private static readonly TimeSpan DefaultTimeSpan = new(1, 30, 0); #region Ignore rule @@ -170,6 +172,7 @@ public void Dispose() PathToSeriesIdDictionary.Clear(); NameToSeriesIdDictionary.Clear(); SeriesIdToGroupIdDictionary.Clear(); + SeriesIdToCollectionIdDictionary.Clear(); SeriesIdToPathDictionary.Clear(); } @@ -199,16 +202,16 @@ private async Task<string[]> GetTagsForSeries(string seriesId) /// Get the tag filter /// </summary> /// <returns></returns> - private ulong GetTagFilter() + private static ulong GetTagFilter() { var config = Plugin.Instance.Configuration; ulong filter = 132L; // We exclude genres and source by default - if (config.HideAniDbTags) filter |= (1 << 0); - if (config.HideArtStyleTags) filter |= (1 << 1); - if (config.HideMiscTags) filter |= (1 << 3); - if (config.HideSettingTags) filter |= (1 << 5); - if (config.HideProgrammingTags) filter |= (1 << 6); + if (config.HideAniDbTags) filter |= 1 << 0; + if (config.HideArtStyleTags) filter |= 1 << 1; + if (config.HideMiscTags) filter |= 1 << 3; + if (config.HideSettingTags) filter |= 1 << 5; + if (config.HideProgrammingTags) filter |= 1 << 6; return filter; } @@ -285,7 +288,7 @@ private string SelectTagName(Tag tag) /// <returns>Unique path set for the series</returns> public async Task<HashSet<string>> GetPathSetForSeries(string seriesId) { - var (pathSet, _episodeIds) = await GetPathSetAndLocalEpisodeIdsForSeries(seriesId); + var (pathSet, _) = await GetPathSetAndLocalEpisodeIdsForSeries(seriesId); return pathSet; } @@ -296,7 +299,7 @@ public async Task<HashSet<string>> GetPathSetForSeries(string seriesId) /// <returns>Local episode ids for the series</returns> public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) { - var (_pathSet, episodeIds) = GetPathSetAndLocalEpisodeIdsForSeries(seriesId) + var (_, episodeIds) = GetPathSetAndLocalEpisodeIdsForSeries(seriesId) .GetAwaiter() .GetResult(); return episodeIds; @@ -327,15 +330,15 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) #endregion #region File Info - public async Task<(FileInfo?, SeriesInfo?, GroupInfo?)> GetFileInfoByPath(string path, Ordering.GroupFilterType? filterGroupByType) + public async Task<(FileInfo?, SeasonInfo?, ShowInfo?)> GetFileInfoByPath(string path, Ordering.GroupFilterType? filterGroupByType) { // Use pointer for fast lookup. if (PathToFileIdAndSeriesIdDictionary.ContainsKey(path)) { var (fI, sI) = PathToFileIdAndSeriesIdDictionary[path]; var fileInfo = await GetFileInfo(fI, sI); - var seriesInfo = await GetSeriesInfo(sI); - var groupInfo = filterGroupByType.HasValue ? await GetGroupInfoForSeries(sI, filterGroupByType.Value) : null; - return new(fileInfo, seriesInfo, groupInfo); + var seasonInfo = await GetSeasonInfoForSeries(sI); + var showInfo = filterGroupByType.HasValue ? await GetShowInfoForSeries(sI, filterGroupByType.Value) : null; + return new(fileInfo, seasonInfo, showInfo); } // Strip the path and search for a match. @@ -374,17 +377,17 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) if (!pathSet.Contains(selectedPath)) continue; - // Find the group info. - GroupInfo? groupInfo = null; + // Find the show info. + ShowInfo? showInfo = null; if (filterGroupByType.HasValue) { - groupInfo = await GetGroupInfoForSeries(seriesId, filterGroupByType.Value); - if (groupInfo == null) + showInfo = await GetShowInfoForSeries(seriesId, filterGroupByType.Value); + if (showInfo == null) return (null, null, null); } - // Find the series info. - var seriesInfo = await GetSeriesInfo(seriesId); - if (seriesInfo == null) + // Find the season info. + var seasonInfo = await GetSeasonInfoForSeries(seriesId); + if (seasonInfo == null) return (null, null, null); // Find the file info for the series. @@ -399,7 +402,7 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) PathToEpisodeIdsDictionary.TryAdd(path, fileInfo.EpisodeList.Select(episode => episode.Id).ToList()); // Return the result. - return new(fileInfo, seriesInfo, groupInfo); + return new(fileInfo, seasonInfo, showInfo); } throw new Exception($"Unable to find the series to use for the file. (File={fileId})"); @@ -457,7 +460,7 @@ private async Task<FileInfo> CreateFileInfo(File file, string fileId, string ser public bool TryGetFileIdForPath(string path, out string? fileId) { if (!string.IsNullOrEmpty(path) && PathToFileIdAndSeriesIdDictionary.TryGetValue(path, out var pair)) { - fileId = pair.Item1; + fileId = pair.FileId; return true; } @@ -543,56 +546,56 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) } #endregion - #region Series Info + #region Season Info - public async Task<SeriesInfo?> GetSeriesInfoByName(string name) + public async Task<SeasonInfo?> GetSeasonInfoBySeriesName(string seriesName) { - var seriesId = await GetSeriesIdForName(name); + var seriesId = await GetSeriesIdForName(seriesName); if (string.IsNullOrEmpty(seriesId)) return null; - var key = $"series:{seriesId}"; - if (DataCache.TryGetValue<SeriesInfo>(key, out var seriesInfo)) { - Logger.LogTrace("Reusing info object for series {SeriesName}. (Series={SeriesId})", seriesInfo.Shoko.Name, seriesId); - return seriesInfo; + var key = $"season:{seriesId}"; + if (DataCache.TryGetValue<SeasonInfo>(key, out var seasonInfo)) { + Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId); + return seasonInfo; } var series = await APIClient.GetSeries(seriesId); return await CreateSeriesInfo(series, seriesId); } - public async Task<SeriesInfo?> GetSeriesInfoByPath(string path) + public async Task<SeasonInfo?> GetSeasonInfoByPath(string path) { var seriesId = await GetSeriesIdForPath(path); if (string.IsNullOrEmpty(seriesId)) return null; - var key = $"series:{seriesId}"; - if (DataCache.TryGetValue<SeriesInfo>(key, out var seriesInfo)) { - Logger.LogTrace("Reusing info object for series {SeriesName}. (Series={SeriesId})", seriesInfo.Shoko.Name, seriesId); - return seriesInfo; + var key = $"season:{seriesId}"; + if (DataCache.TryGetValue<SeasonInfo>(key, out var seasonInfo)) { + Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId); + return seasonInfo; } var series = await APIClient.GetSeries(seriesId); return await CreateSeriesInfo(series, seriesId); } - public async Task<SeriesInfo?> GetSeriesInfo(string seriesId) + public async Task<SeasonInfo?> GetSeasonInfoForSeries(string seriesId) { if (string.IsNullOrEmpty(seriesId)) return null; - var cachedKey = $"series:{seriesId}"; - if (DataCache.TryGetValue<SeriesInfo>(cachedKey, out var seriesInfo)) { - Logger.LogTrace("Reusing info object for series {SeriesName}. (Series={SeriesId})", seriesInfo.Shoko.Name, seriesId); - return seriesInfo; + var cachedKey = $"season:{seriesId}"; + if (DataCache.TryGetValue<SeasonInfo>(cachedKey, out var seasonInfo)) { + Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId); + return seasonInfo; } var series = await APIClient.GetSeries(seriesId); return await CreateSeriesInfo(series, seriesId); } - public async Task<SeriesInfo?> GetSeriesInfoForEpisode(string episodeId) + public async Task<SeasonInfo?> GetSeasonInfoForEpisode(string episodeId) { if (!EpisodeIdToSeriesIdDictionary.TryGetValue(episodeId, out var seriesId)) { var series = await APIClient.GetSeriesFromEpisode(episodeId); @@ -602,18 +605,18 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) return await CreateSeriesInfo(series, seriesId); } - return await GetSeriesInfo(seriesId); + return await GetSeasonInfoForSeries(seriesId); } - private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId) + private async Task<SeasonInfo> CreateSeriesInfo(Series series, string seriesId) { - var cacheKey = $"series:{seriesId}"; - if (DataCache.TryGetValue<SeriesInfo>(cacheKey, out var seriesInfo)) { - Logger.LogTrace("Reusing info object for series {SeriesName}. (Series={SeriesId})", seriesInfo.Shoko.Name, seriesId); - return seriesInfo; + var cacheKey = $"season:{seriesId}"; + if (DataCache.TryGetValue<SeasonInfo>(cacheKey, out var seasonInfo)) { + Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId); + return seasonInfo; } - Logger.LogTrace("Creating info object for series {SeriesName}. (Series={SeriesId})", series.Name, seriesId); + Logger.LogTrace("Creating info object for season {SeriesName}. (Series={SeriesId})", series.Name, seriesId); var episodes = (await APIClient.GetEpisodesFromSeries(seriesId) ?? new()).List .Select(e => CreateEpisodeInfo(e, e.IDs.Shoko.ToString())) @@ -624,14 +627,17 @@ private async Task<SeriesInfo> CreateSeriesInfo(Series series, string seriesId) var genres = await GetGenresForSeries(seriesId); var tags = await GetTagsForSeries(seriesId); - seriesInfo = new SeriesInfo(series, episodes, cast, relations, genres, tags); + seasonInfo = new SeasonInfo(series, episodes, cast, relations, genres, tags); foreach (var episode in episodes) EpisodeIdToSeriesIdDictionary[episode.Id] = seriesId; - DataCache.Set<SeriesInfo>(cacheKey, seriesInfo, DefaultTimeSpan); - return seriesInfo; + DataCache.Set<SeasonInfo>(cacheKey, seasonInfo, DefaultTimeSpan); + return seasonInfo; } + #endregion + #region Series Helpers + public bool TryGetSeriesIdForPath(string path, out string? seriesId) { if (string.IsNullOrEmpty(path)) { @@ -650,13 +656,16 @@ public bool TryGetSeriesPathForId(string seriesId, out string? path) return SeriesIdToPathDictionary.TryGetValue(seriesId, out path); } - public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) + public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId, out string? defaultSeriesId) { - if (string.IsNullOrEmpty(seriesId)) { + if (string.IsNullOrEmpty(seriesId) || !SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var tuple)) { groupId = null; + defaultSeriesId = null; return false; } - return SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out groupId); + groupId = tuple.GroupId; + defaultSeriesId = tuple.DefaultSeriesId; + return true; } private async Task<string?> GetSeriesIdForName(string name) @@ -665,7 +674,7 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) if (NameToSeriesIdDictionary.TryGetValue(name, out var seriesId)) return seriesId; - Logger.LogDebug("Looking for series matching name {Name}", name); + Logger.LogDebug("Looking for shoko series matching name {Name}", name); var series = await APIClient.GetSeriesByName(name); Logger.LogTrace("Found {Count} exact matches for name {Name}", series == null ? 0 : 1, name); if (series == null) @@ -684,7 +693,7 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) return seriesId; var partialPath = StripMediaFolder(path); - Logger.LogDebug("Looking for series matching path {Path}", partialPath); + Logger.LogDebug("Looking for shoko series matching path {Path}", partialPath); var result = await APIClient.GetSeriesPathEndsWith(partialPath); Logger.LogTrace("Found {Count} matches for path {Path}", result.Count, partialPath); @@ -713,40 +722,142 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) } #endregion - #region Group Info + #region Show Info - public async Task<GroupInfo?> GetGroupInfoBySeriesName(string seriesName, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + public async Task<ShowInfo?> GetShowInfoByPath(string path, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) { - if (NameToSeriesIdDictionary.TryGetValue(seriesName, out var seriesId)) { - if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { - if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var groupInfo)) { - Logger.LogTrace("Reusing info object for group {GroupName}. (Series={seriesId},Group={GroupId})", groupInfo.Shoko.Name, seriesId, groupId); - return groupInfo; - } + if (PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) { + if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var tuple)) { + if (string.IsNullOrEmpty(tuple.GroupId)) + return await GetOrCreateShowInfoForStandaloneSeries(seriesId, filterByType); - return await GetGroupInfo(groupId, filterByType); + return await GetShowInfoForGroup(tuple.GroupId, filterByType); } } else { - seriesId = await GetSeriesIdForName(seriesName); + seriesId = await GetSeriesIdForPath(path); if (string.IsNullOrEmpty(seriesId)) return null; } - return await GetGroupInfoForSeries(seriesId, filterByType); + return await GetShowInfoForSeries(seriesId, filterByType); + } + + public async Task<ShowInfo?> GetShowInfoForSeries(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + if (string.IsNullOrEmpty(seriesId)) + return null; + + if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var tuple)) { + if (string.IsNullOrEmpty(tuple.GroupId)) + return await GetOrCreateShowInfoForStandaloneSeries(seriesId, filterByType); + + return await GetShowInfoForGroup(tuple.GroupId, filterByType); + } + + var group = await APIClient.GetGroupFromSeries(seriesId); + if (group == null) + return null; + + // Create a standalone group for each series in a group with sub-groups. + if (group.Sizes.SubGroups > 0) + return await GetOrCreateShowInfoForStandaloneSeries(seriesId, filterByType); + + return await CreateShowInfo(group, group.IDs.Shoko.ToString(), filterByType); + } + + private async Task<ShowInfo?> GetShowInfoForGroup(string groupId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + if (string.IsNullOrEmpty(groupId)) + return null; + + if (DataCache.TryGetValue<ShowInfo>($"show:{filterByType}:by-group-id:{groupId}", out var showInfo)) { + Logger.LogTrace("Reusing info object for show {GroupName}. (Group={GroupId})", showInfo.Name, groupId); + return showInfo; + } + + var group = await APIClient.GetGroup(groupId); + return await CreateShowInfo(group, groupId, filterByType); + } + + private async Task<ShowInfo> CreateShowInfo(Group group, string groupId, Ordering.GroupFilterType filterByType) + { + var cacheKey = $"show:{filterByType}:by-group-id:{groupId}"; + if (DataCache.TryGetValue<ShowInfo>(cacheKey, out var showInfo)) { + Logger.LogTrace("Reusing info object for show {GroupName}. (Group={GroupId})", showInfo.Name, groupId); + return showInfo; + } + + Logger.LogTrace("Creating info object for show {GroupName}. (Group={GroupId})", group.Name, groupId); + + var seriesList = (await APIClient.GetSeriesInGroup(groupId) + .ContinueWith(task => Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s, s.IDs.Shoko.ToString())))) + .Unwrap()) + .Where(s => s != null) + .ToList(); + + // Return early if no series matched the filter or if the list was empty. + if (seriesList.Count == 0) { + Logger.LogWarning("Creating an empty show info for filter {Filter}! (Group={GroupId})", filterByType.ToString(), groupId); + + showInfo = new ShowInfo(group); + + DataCache.Set<ShowInfo>(cacheKey, showInfo, DefaultTimeSpan); + return showInfo; + } + + showInfo = new ShowInfo(group, seriesList, filterByType, Logger); + + foreach (var series in seriesList) + SeriesIdToGroupIdDictionary[series.Id] = (groupId, showInfo.DefaultSeason!.Id); + DataCache.Set<ShowInfo>(cacheKey, showInfo, DefaultTimeSpan); + return showInfo; } - public async Task<GroupInfo?> GetGroupInfoByPath(string path, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + private async Task<ShowInfo?> GetOrCreateShowInfoForStandaloneSeries(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + var cacheKey = $"show:{filterByType}:by-series-id:{seriesId}"; + if (DataCache.TryGetValue<ShowInfo>(cacheKey, out var showInfo)) { + Logger.LogTrace("Reusing info object for show {GroupName}. (Series={SeriesId})", showInfo.Name, seriesId); + return showInfo; + } + + var seasonInfo = await GetSeasonInfoForSeries(seriesId); + if (seasonInfo == null) + return null; + + var shouldAbort = filterByType switch { + Ordering.GroupFilterType.Movies => seasonInfo.AniDB.Type != SeriesType.Movie, + Ordering.GroupFilterType.Others => seasonInfo.AniDB.Type == SeriesType.Movie, + _ => false, + }; + if (shouldAbort) { + Logger.LogWarning("Creating an empty show info for filter {Filter}! (Series={SeriesId})", filterByType.ToString(), seriesId); + + showInfo = new ShowInfo(seasonInfo.Shoko); + + DataCache.Set<ShowInfo>(cacheKey, showInfo, DefaultTimeSpan); + return showInfo; + } + + showInfo = new ShowInfo(seasonInfo); + SeriesIdToGroupIdDictionary[seriesId] = (null, seriesId); + DataCache.Set<ShowInfo>(cacheKey, showInfo, DefaultTimeSpan); + return showInfo; + } + + #endregion + #region Collection Info + + public async Task<CollectionInfo?> GetCollectionInfoByPath(string path, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) { if (PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) { - if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { - if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var groupInfo)) { - Logger.LogTrace("Reusing info object for group {GroupName}. (Series={seriesId},Group={GroupId})", groupInfo.Shoko.Name, seriesId, groupId); - return groupInfo; - } + if (SeriesIdToCollectionIdDictionary.TryGetValue(seriesId, out var groupId)) { + if (string.IsNullOrEmpty(groupId)) + return null; - return await GetGroupInfo(groupId, filterByType); + return await GetCollectionInfoForGroup(groupId, filterByType); } } else @@ -756,77 +867,109 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId) return null; } - return await GetGroupInfoForSeries(seriesId, filterByType); + return await GetCollectionInfoForGroup(seriesId, filterByType); } - public async Task<GroupInfo?> GetGroupInfo(string groupId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + public async Task<CollectionInfo?> GetCollectionInfoBySeriesName(string seriesName, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + { + if (NameToSeriesIdDictionary.TryGetValue(seriesName, out var seriesId)) { + if (SeriesIdToCollectionIdDictionary.TryGetValue(seriesId, out var groupId)) { + if (string.IsNullOrEmpty(groupId)) + return null; + + return await GetCollectionInfoForGroup(groupId, filterByType); + } + } + else + { + seriesId = await GetSeriesIdForName(seriesName); + if (string.IsNullOrEmpty(seriesId)) + return null; + } + + return await GetCollectionInfoForSeries(seriesId, filterByType); + } + + public async Task<CollectionInfo?> GetCollectionInfoForGroup(string groupId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) { if (string.IsNullOrEmpty(groupId)) return null; - if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var groupInfo)) { - Logger.LogTrace("Reusing info object for group {GroupName}. (Group={GroupId})", groupInfo.Shoko.Name, groupId); - return groupInfo; + if (DataCache.TryGetValue<CollectionInfo>($"collection:{filterByType}:by-group-id:{groupId}", out var seasonInfo)) { + Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", seasonInfo.Name, groupId); + return seasonInfo; } var group = await APIClient.GetGroup(groupId); - return await CreateGroupInfo(group, groupId, filterByType); + return await CreateCollectionInfo(group, groupId, filterByType); } - public async Task<GroupInfo?> GetGroupInfoForSeries(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + public async Task<CollectionInfo?> GetCollectionInfoForSeries(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) { if (string.IsNullOrEmpty(seriesId)) return null; - if (!SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var groupId)) { - var group = await APIClient.GetGroupFromSeries(seriesId); - if (group == null) + if (SeriesIdToCollectionIdDictionary.TryGetValue(seriesId, out var groupId)) { + if (string.IsNullOrEmpty(groupId)) return null; - groupId = group.IDs.Shoko.ToString(); - if (DataCache.TryGetValue<GroupInfo>($"group:{filterByType}:{groupId}", out var groupInfo)) { - Logger.LogTrace("Reusing info object for group {GroupName}. (Series={SeriesId},Group={GroupId})", groupInfo.Shoko.Name, seriesId, groupId); - return groupInfo; - } - - return await CreateGroupInfo(group, groupId, filterByType); + return await GetCollectionInfoForGroup(groupId, filterByType); } - return await GetGroupInfo(groupId, filterByType); + var group = await APIClient.GetGroupFromSeries(seriesId); + if (group == null) + return null; + + return await CreateCollectionInfo(group, group.IDs.Shoko.ToString(), filterByType); } - private async Task<GroupInfo> CreateGroupInfo(Group group, string groupId, Ordering.GroupFilterType filterByType) + private async Task<CollectionInfo?> CreateCollectionInfo(Group group, string groupId, Ordering.GroupFilterType filterByType) { - var cacheKey = $"group:{filterByType}:{groupId}"; - if (DataCache.TryGetValue<GroupInfo>(cacheKey, out var groupInfo)) { - Logger.LogTrace("Reusing info object for group {GroupName}. (Group={GroupId})", groupInfo.Shoko.Name, groupId); - return groupInfo; + // Only create a collection + if (group.Sizes.SubGroups == 0) + return null; + + var cacheKey = $"collection:{filterByType}:by-group-id:{groupId}"; + if (DataCache.TryGetValue<CollectionInfo>(cacheKey, out var collectionInfo)) { + Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", collectionInfo.Name, groupId); + return collectionInfo; } - Logger.LogTrace("Creating info object for group {GroupName}. (Group={GroupId})", group.Name, groupId); + Logger.LogTrace("Creating info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); - var seriesList = (await APIClient.GetSeriesInGroup(groupId) - .ContinueWith(task => Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s, s.IDs.Shoko.ToString())))) + var groups = await APIClient.GetGroupsInGroup(groupId); + var multiSeasonShows = await Task.WhenAll(groups + .Where(group => group.Sizes.SubGroups == 0) + .Select(group => CreateShowInfo(group, group.IDs.Shoko.ToString(groupId), filterByType))); + var singleSeasonShows = (await APIClient.GetSeriesInGroup(groupId) + .ContinueWith(task => Task.WhenAll(task.Result.Select(s => GetOrCreateShowInfoForStandaloneSeries(s.IDs.Shoko.ToString(), filterByType)))) .Unwrap()) - .Where(s => s != null) + .OfType<ShowInfo>() + .ToList(); + var showList = multiSeasonShows.Concat(singleSeasonShows).ToList(); + var groupList = groups + .Where(group => group.Sizes.SubGroups > 0) + .Select(s => CreateCollectionInfo(s, s.IDs.Shoko.ToString(), filterByType)) + .OfType<CollectionInfo>() .ToList(); // Return early if no series matched the filter or if the list was empty. - if (seriesList.Count == 0) { - Logger.LogWarning("Creating an empty group info for filter {Filter}! (Group={GroupId})", filterByType.ToString(), groupId); + if (showList.Count == 0 && groupList.Count == 0) { + Logger.LogWarning("Creating an empty collection info for filter {Filter}! (Group={GroupId})", filterByType.ToString(), groupId); - groupInfo = new GroupInfo(group); + collectionInfo = new CollectionInfo(group); - DataCache.Set<GroupInfo>(cacheKey, groupInfo, DefaultTimeSpan); - return groupInfo; + DataCache.Set<CollectionInfo>(cacheKey, collectionInfo, DefaultTimeSpan); + return collectionInfo; } - groupInfo = new GroupInfo(group, seriesList, filterByType, Logger); + collectionInfo = new CollectionInfo(group, showList, groupList, filterByType); - foreach (var series in seriesList) - SeriesIdToGroupIdDictionary[series.Id] = groupId; - DataCache.Set<GroupInfo>(cacheKey, groupInfo, DefaultTimeSpan); - return groupInfo; + foreach (var showInfo in showList) + foreach (var seasonInfo in showInfo.SeasonList) + SeriesIdToCollectionIdDictionary[seasonInfo.Id] = groupId; + DataCache.Set<CollectionInfo>(cacheKey, collectionInfo, DefaultTimeSpan); + return collectionInfo; } #endregion diff --git a/Shokofin/ExternalIds.cs b/Shokofin/ExternalIds.cs index aba88e74..fd6b2ca8 100644 --- a/Shokofin/ExternalIds.cs +++ b/Shokofin/ExternalIds.cs @@ -9,7 +9,7 @@ namespace Shokofin public class ShokoGroupExternalId : IExternalId { public bool Supports(IHasProviderIds item) - => item is Series || item is BoxSet; + => item is Series or BoxSet; public string ProviderName => "Shoko Group"; @@ -27,7 +27,7 @@ public virtual string UrlFormatString public class ShokoSeriesExternalId : IExternalId { public bool Supports(IHasProviderIds item) - => item is Series || item is Season || item is Movie || item is BoxSet; + => item is Series or Season or Movie; public string ProviderName => "Shoko Series"; @@ -45,7 +45,7 @@ public virtual string UrlFormatString public class ShokoEpisodeExternalId : IExternalId { public bool Supports(IHasProviderIds item) - => item is Episode || item is Movie; + => item is Episode or Movie; public string ProviderName => "Shoko Episode"; @@ -63,7 +63,7 @@ public virtual string UrlFormatString public class ShokoFileExternalId : IExternalId { public bool Supports(IHasProviderIds item) - => item is Episode || item is Movie; + => item is Episode or Movie; public string ProviderName => "Shoko File"; diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index 47d54b35..c0856063 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -30,11 +30,6 @@ 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); - #endregion - #region Group Id - - bool TryGetGroupIdFromSeriesId(string seriesId, out string groupId); - #endregion #region Series Id @@ -117,10 +112,10 @@ public IdLookup(ShokoAPIManager apiManager, ILibraryManager libraryManager) #region Base Item - private readonly HashSet<string> AllowedTypes = new HashSet<string>() { nameof(Series), nameof(Episode), nameof(Movie) }; + private readonly HashSet<string> AllowedTypes = new() { nameof(Series), nameof(Episode), nameof(Movie) }; public bool IsEnabledForItem(BaseItem item) => - IsEnabledForItem(item, out var _bool); + IsEnabledForItem(item, out var _); public bool IsEnabledForItem(BaseItem item, out bool isSoleProvider) { @@ -157,14 +152,6 @@ public bool IsEnabledForItem(BaseItem item, out bool isSoleProvider) return isEnabled; } - #endregion - #region Group Id - - public bool TryGetGroupIdFromSeriesId(string seriesId, out string groupId) - { - return ApiManager.TryGetGroupIdForSeriesId(seriesId, out groupId); - } - #endregion #region Series Id @@ -186,14 +173,8 @@ public bool TryGetSeriesIdFor(Series series, out string seriesId) if (TryGetSeriesIdFor(series.Path, out seriesId)) { // Set the "Shoko Group" and "Shoko Series" provider ids for the series, since it haven't been set again. It doesn't matter if it's not saved to the database, since we only need it while running the following code. - if (TryGetGroupIdFromSeriesId(seriesId, out var groupId)) { - var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var groupInfo = ApiManager.GetGroupInfo(groupId, filterByType) - .GetAwaiter() - .GetResult(); - seriesId = groupInfo.DefaultSeries.Id; - - SeriesProvider.AddProviderIds(series, seriesId, groupInfo.Id); + if (ApiManager.TryGetGroupIdForSeriesId(seriesId, out var groupId, out seriesId)) { + SeriesProvider.AddProviderIds(series, seriesId, groupId); } // Same as above, but only set the "Shoko Series" id. else { @@ -236,12 +217,8 @@ public bool TryGetSeriesIdFor(BoxSet boxSet, out string seriesId) } if (TryGetSeriesIdFor(boxSet.Path, out seriesId)) { - if (TryGetGroupIdFromSeriesId(seriesId, out var groupId)) { - var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var groupInfo = ApiManager.GetGroupInfo(groupId, filterByType) - .GetAwaiter() - .GetResult(); - seriesId = groupInfo.DefaultSeries.Id; + if (ApiManager.TryGetGroupIdForSeriesId(seriesId, out var _, out var defaultSeriesId)) { + seriesId = defaultSeriesId; } return true; } diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index dcb5b489..27d757aa 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -41,7 +41,7 @@ public LibraryScanner(ShokoAPIManager apiManager, IIdLookup lookup, ILibraryMana /// <param name="fileInfo"></param> /// <param name="parent"></param> /// <returns>True if the entry should be ignored.</returns> - public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, BaseItem parent) + public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent) { // Everything in the root folder is ignored by us. var root = LibraryManager.RootFolder; @@ -74,37 +74,39 @@ public bool ShouldIgnore(MediaBrowser.Model.IO.FileSystemMetadata fileInfo, Base } catch (System.Exception ex) { if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) - Logger.LogError(ex, $"Threw unexpectedly - {ex.Message}"); - Plugin.Instance.CaptureException(ex); + { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + Plugin.Instance.CaptureException(ex); + } return false; } } private bool ScanDirectory(string partialPath, string fullPath, string libraryType, bool shouldIgnore) { - var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; - var series = ApiManager.GetSeriesInfoByPath(fullPath) + var preloadShow = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; + var season = ApiManager.GetSeasonInfoByPath(fullPath) .GetAwaiter() .GetResult(); // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given folder path. - if (series == null) { + if (season == null) { // If we're in strict mode, then check the sub-directories if we have a <Show>/<Season>/<Episodes> structure. if (shouldIgnore && partialPath[1..].Split(Path.DirectorySeparatorChar).Length == 1) { var entries = FileSystem.GetDirectories(fullPath, false).ToList(); - Logger.LogDebug("Unable to find series for {Path}, trying {DirCount} sub-directories.", entries.Count, partialPath); + Logger.LogDebug("Unable to find shoko series for {Path}, trying {DirCount} sub-directories.", entries.Count, partialPath); foreach (var entry in entries) { - series = ApiManager.GetSeriesInfoByPath(entry.FullName) + season = ApiManager.GetSeasonInfoByPath(entry.FullName) .GetAwaiter() .GetResult(); - if (series != null) + if (season != null) { - Logger.LogDebug("Found series {SeriesName} for sub-directory of path {Path} (Series={SeriesId})", series.Shoko.Name, partialPath, series.Id); + Logger.LogDebug("Found shoko series {SeriesName} for sub-directory of path {Path} (Series={SeriesId})", season.Shoko.Name, partialPath, season.Id); break; } } } - if (series == null) { + if (season == null) { if (shouldIgnore) Logger.LogInformation("Ignored unknown folder at path {Path}", partialPath); else @@ -113,53 +115,51 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy } } - API.Info.GroupInfo group = null; + API.Info.ShowInfo show = null; // Filter library if we enabled the option. if (Plugin.Instance.Configuration.FilterOnLibraryTypes) switch (libraryType) { case "tvshows": - if (series.AniDB.Type == SeriesType.Movie) { - Logger.LogInformation("Library separation is enabled, ignoring series. (Series={SeriesId})", series.Id); + if (season.AniDB.Type == SeriesType.Movie) { + Logger.LogInformation("Library separation is enabled, ignoring series. (Series={SeriesId})", season.Id); return true; } // If we're using series grouping, pre-load the group now to help reduce load times later. - if (includeGroup) - group = ApiManager.GetGroupInfoForSeries(series.Id, Ordering.GroupFilterType.Others) + if (preloadShow) + show = ApiManager.GetShowInfoForSeries(season.Id, Ordering.GroupFilterType.Others) .GetAwaiter() .GetResult(); break; case "movies": - if (series.AniDB.Type != SeriesType.Movie) { - Logger.LogInformation("Library separation is enabled, ignoring series. (Series={SeriesId})", series.Id); + if (season.AniDB.Type != SeriesType.Movie) { + Logger.LogInformation("Library separation is enabled, ignoring series. (Series={SeriesId})", season.Id); return true; } // If we're using series grouping, pre-load the group now to help reduce load times later. - if (includeGroup) - group = ApiManager.GetGroupInfoForSeries(series.Id, Ordering.GroupFilterType.Movies) + if (preloadShow) + show = ApiManager.GetShowInfoForSeries(season.Id, Ordering.GroupFilterType.Movies) .GetAwaiter() .GetResult(); break; } // If we're using series grouping, pre-load the group now to help reduce load times later. - else if (includeGroup) - group = ApiManager.GetGroupInfoForSeries(series.Id) + else if (preloadShow) + show = ApiManager.GetShowInfoForSeries(season.Id) .GetAwaiter() .GetResult(); - if (group != null) - Logger.LogInformation("Found group {GroupName} (Series={SeriesId},Group={GroupId})", group.Shoko.Name, series.Id, group.Id); + if (show != null) + Logger.LogInformation("Found shoko group {GroupName} (Series={SeriesId},Group={GroupId})", show.Name, season.Id, show.Id); else - Logger.LogInformation("Found series {SeriesName} (Series={SeriesId})", series.Shoko.Name, series.Id); + Logger.LogInformation("Found shoko series {SeriesName} (Series={SeriesId})", season.Shoko.Name, season.Id); return false; } private bool ScanFile(string partialPath, string fullPath, bool shouldIgnore) { - var includeGroup = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; - var config = Plugin.Instance.Configuration; - var (file, series, _group) = ApiManager.GetFileInfoByPath(fullPath, null) + var (file, series, _) = ApiManager.GetFileInfoByPath(fullPath, null) .GetAwaiter() .GetResult(); @@ -172,7 +172,7 @@ private bool ScanFile(string partialPath, string fullPath, bool shouldIgnore) return shouldIgnore; } - Logger.LogInformation("Found {EpisodeCount} episode(s) for {SeriesName} (Series={SeriesId},File={FileId})", file.EpisodeList.Count, series.Shoko.Name, series.Id, file.Id); + Logger.LogInformation("Found {EpisodeCount} shoko episode(s) for {SeriesName} (Series={SeriesId},File={FileId})", file.EpisodeList.Count, series.Shoko.Name, series.Id, file.Id); // We're going to post process this file later, but we don't want to include it in our library for now. if (file.ExtraType != null) { diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index ff29a7c8..71f16ced 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -12,6 +12,7 @@ using Shokofin.API; using Shokofin.Utils; +#nullable enable namespace Shokofin.Providers { public class BoxSetProvider : IRemoteMetadataProvider<BoxSet, BoxSetInfo> @@ -34,142 +35,118 @@ public BoxSetProvider(IHttpClientFactory httpClientFactory, ILogger<BoxSetProvid public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) { try { - switch (Plugin.Instance.Configuration.BoxSetGrouping) { - default: - return await GetDefaultMetadata(info, cancellationToken); - case Ordering.GroupType.ShokoGroup: - return await GetShokoGroupedMetadata(info, cancellationToken); - } + return Plugin.Instance.Configuration.BoxSetGrouping switch + { + Ordering.GroupType.ShokoGroup => await GetShokoGroupedMetadata(info), + _ => await GetDefaultMetadata(info), + }; } catch (Exception ex) { - Logger.LogError(ex, $"Threw unexpectedly; {ex.Message}"); + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); Plugin.Instance.CaptureException(ex); return new MetadataResult<BoxSet>(); } } - public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, CancellationToken cancellationToken) + public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info) { var result = new MetadataResult<BoxSet>(); // First try to re-use any existing series id. - API.Info.SeriesInfo series = null; + API.Info.SeasonInfo? season = null; if (info.ProviderIds.TryGetValue("Shoko Series", out var seriesId)) - series = await ApiManager.GetSeriesInfo(seriesId); + season = await ApiManager.GetSeasonInfoForSeries(seriesId); // Then try to look ir up by path. - if (series == null) - series = await ApiManager.GetSeriesInfoByPath(info.Path); + if (season == null) + season = await ApiManager.GetSeasonInfoByPath(info.Path); // Then try to look it up using the name. - if (series == null) { - var boxSetName = GetBoxSetName(info); - if (boxSetName != null) - series = await ApiManager.GetSeriesInfoByName(boxSetName); - } + if (season == null && TryGetBoxSetName(info, out var boxSetName)) + season = await ApiManager.GetSeasonInfoBySeriesName(boxSetName); - if (series == null) { + if (season == null) { Logger.LogWarning("Unable to find movie box-set info for name {Name} and path {Path}", info.Name, info.Path); return result; } - if (series.EpisodeList.Count <= 1) { - Logger.LogWarning("Series did not contain multiple movies! Skipping path {Path} (Series={SeriesId})", info.Path, series.Id); + if (season.EpisodeList.Count <= 1) { + Logger.LogWarning("Series did not contain multiple movies! Skipping path {Path} (Series={SeriesId})", info.Path, season.Id); return result; } - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.AniDB.Title, info.MetadataLanguage); + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(season.AniDB.Titles, season.AniDB.Title, info.MetadataLanguage); result.Item = new BoxSet { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.GetDescription(series), - PremiereDate = series.AniDB.AirDate, - EndDate = series.AniDB.EndDate, - ProductionYear = series.AniDB.AirDate?.Year, - Tags = series.Tags.ToArray(), - CommunityRating = series.AniDB.Rating.ToFloat(10), + Overview = Text.GetDescription(season), + PremiereDate = season.AniDB.AirDate, + EndDate = season.AniDB.EndDate, + ProductionYear = season.AniDB.AirDate?.Year, + Tags = season.Tags.ToArray(), + CommunityRating = season.AniDB.Rating.ToFloat(10), }; - result.Item.SetProviderId("Shoko Series", series.Id); + result.Item.SetProviderId("Shoko Series", season.Id); if (Plugin.Instance.Configuration.AddAniDBId) - result.Item.SetProviderId("AniDB", series.AniDB.Id.ToString()); + result.Item.SetProviderId("AniDB", season.AniDB.Id.ToString()); result.HasMetadata = true; return result; } - private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo info, CancellationToken cancellationToken) + private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo info) { var result = new MetadataResult<BoxSet>(); var config = Plugin.Instance.Configuration; Ordering.GroupFilterType filterByType = config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Movies : Ordering.GroupFilterType.Default; // First try to re-use any existing group id. - API.Info.GroupInfo group = null; + API.Info.CollectionInfo? collection = null; if (info.ProviderIds.TryGetValue("Shoko Group", out var groupId)) - group = await ApiManager.GetGroupInfo(groupId, filterByType); + collection = await ApiManager.GetCollectionInfoForGroup(groupId, filterByType); - // Then try to look ir up by path. - if (group == null) - group = await ApiManager.GetGroupInfoByPath(info.Path, filterByType); + // Then try to look it up by path. + if (collection == null) + collection = await ApiManager.GetCollectionInfoByPath(info.Path, filterByType); // Then try to look it up using the name. - if (group == null) { - var boxSetName = GetBoxSetName(info); - if (boxSetName != null) - group = await ApiManager.GetGroupInfoBySeriesName(boxSetName, filterByType); - } - - if (group == null) { - Logger.LogWarning("Unable to find movie box-set info for name {Name} and path {Path}", info.Name, info.Path); - return result; - } + if (collection == null && TryGetBoxSetName(info, out var boxSetName)) + collection = await ApiManager.GetCollectionInfoBySeriesName(boxSetName, filterByType); - var series = group.DefaultSeries; - - if (group.SeriesList.Count <= 1 && series.EpisodeList.Count <= 1 && series.AlternateEpisodesList.Count == 0) { - Logger.LogWarning("Group did not contain multiple movies! Skipping path {Path} (Series={SeriesId},Group={GroupId})", info.Path, group.Id, series.Id); + if (collection == null) { + Logger.LogWarning("Unable to find collection info for name {Name} and path {Path}", info.Name, info.Path); return result; } - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, series.Shoko.Name, info.MetadataLanguage); result.Item = new BoxSet { - Name = displayTitle, - OriginalTitle = alternateTitle, - Overview = Text.GetDescription(series), - PremiereDate = series.AniDB.AirDate, - EndDate = series.AniDB.EndDate, - ProductionYear = series.AniDB.AirDate?.Year, - Tags = group.Tags.ToArray(), - CommunityRating = (float)((series.AniDB.Rating.Value * 10) / series.AniDB.Rating.MaxValue) + Name = collection.Name, + Overview = collection.Shoko.Description, }; - result.Item.SetProviderId("Shoko Series", series.Id); - result.Item.SetProviderId("Shoko Group", group.Id); - if (config.AddAniDBId) - result.Item.SetProviderId("AniDB", series.AniDB.Id.ToString()); - + result.Item.SetProviderId("Shoko Group", collection.Id); result.HasMetadata = true; - result.ResetPeople(); - foreach (var person in series.Staff) - result.AddPerson(person); - return result; } - private static string GetBoxSetName(BoxSetInfo info) + private static bool TryGetBoxSetName(BoxSetInfo info, out string boxSetName) { - if (string.IsNullOrWhiteSpace(info.Name)) - return null; + if (string.IsNullOrWhiteSpace(info.Name)) { + boxSetName = string.Empty; + return false; + } var name = info.Name.Trim(); if (name.EndsWith("[boxset]")) name = name[..^8].TrimEnd(); - if (string.IsNullOrWhiteSpace(name)) - return null; + if (string.IsNullOrWhiteSpace(name)) { + boxSetName = string.Empty; + return false; + } - return name; + boxSetName = name; + return true; } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index fa16a88e..d6d17b65 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -45,8 +45,8 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell // Fetch the episode, series and group info (and file info, but that's not really used (yet)) Info.FileInfo fileInfo = null; Info.EpisodeInfo episodeInfo = null; - Info.SeriesInfo seriesInfo = null; - Info.GroupInfo groupInfo = null; + Info.SeasonInfo seasonInfo = null; + Info.ShowInfo showInfo = null; if (info.IsMissingEpisode || string.IsNullOrEmpty(info.Path)) { // We're unable to fetch the latest metadata for the virtual episode. if (!info.ProviderIds.TryGetValue("Shoko Episode", out var episodeId)) @@ -56,14 +56,14 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell if (episodeInfo == null) return result; - seriesInfo = await ApiManager.GetSeriesInfoForEpisode(episodeId); - if (seriesInfo == null) + seasonInfo = await ApiManager.GetSeasonInfoForEpisode(episodeId); + if (seasonInfo == null) return result; - groupInfo = filterByType.HasValue ? (await ApiManager.GetGroupInfoForSeries(seriesInfo.Id, filterByType.Value)) : null; + showInfo = filterByType.HasValue ? (await ApiManager.GetShowInfoForSeries(seasonInfo.Id, filterByType.Value)) : null; } else { - (fileInfo, seriesInfo, groupInfo) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); + (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); episodeInfo = fileInfo?.EpisodeList.FirstOrDefault(); } @@ -73,27 +73,27 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell return result; } - result.Item = CreateMetadata(groupInfo, seriesInfo, episodeInfo, fileInfo, info.MetadataLanguage); - Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},Group={GroupId})", result.Item.Name, fileInfo?.Id, episodeInfo.Id, seriesInfo.Id, groupInfo?.Id); + result.Item = CreateMetadata(showInfo, seasonInfo, episodeInfo, fileInfo, info.MetadataLanguage); + Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},Group={GroupId})", result.Item.Name, fileInfo?.Id, episodeInfo.Id, seasonInfo.Id, showInfo?.Id); result.HasMetadata = true; return result; } catch (Exception ex) { - Logger.LogError(ex, $"Threw unexpectedly; {ex.Message}"); + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); Plugin.Instance.CaptureException(ex); return new MetadataResult<Episode>(); } } - public static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo series, Info.EpisodeInfo episode, Season season, System.Guid episodeId) + public static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Season season, System.Guid episodeId) => CreateMetadata(group, series, episode, null, season.GetPreferredMetadataLanguage(), season, episodeId); - public static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo series, Info.EpisodeInfo episode, Info.FileInfo file, string metadataLanguage) + 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); - private static Episode CreateMetadata(Info.GroupInfo group, Info.SeriesInfo series, Info.EpisodeInfo episode, Info.FileInfo file, string metadataLanguage, Season season, System.Guid episodeId) + private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Info.FileInfo file, string metadataLanguage, Season season, System.Guid episodeId) { var config = Plugin.Instance.Configuration; var maybeMergeFriendly = config.SeriesGrouping == Ordering.GroupType.MergeFriendly && series.TvDB != null; @@ -283,18 +283,14 @@ private static void AddProviderIds(IHasProviderIds item, string episodeId, strin item.SetProviderId("AniDB", anidbId); if (config.AddTvDBId && !string.IsNullOrEmpty(tvdbId) && tvdbId != "0") item.SetProviderId(MetadataProvider.Tvdb, tvdbId); - if (Plugin.Instance.Configuration.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") - item.SetProviderId(MetadataProvider.Tvdb, tmdbId); + if (config.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") + item.SetProviderId(MetadataProvider.Tmdb, tmdbId); } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) - { - return Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); - } + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); - } + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index b24e2dd8..3c585aae 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -10,7 +10,6 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Plugins; -using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Globalization; using Microsoft.Extensions.Logging; using Shokofin.API; @@ -28,18 +27,15 @@ public class ExtraMetadataProvider : IServerEntryPoint private readonly ILibraryManager LibraryManager; - private readonly IProviderManager ProviderManager; - private readonly ILocalizationManager LocalizationManager; private readonly ILogger<ExtraMetadataProvider> Logger; - public ExtraMetadataProvider(ShokoAPIManager apiManager, IIdLookup lookUp, ILibraryManager libraryManager, IProviderManager providerManager, ILocalizationManager localizationManager, ILogger<ExtraMetadataProvider> logger) + public ExtraMetadataProvider(ShokoAPIManager apiManager, IIdLookup lookUp, ILibraryManager libraryManager, ILocalizationManager localizationManager, ILogger<ExtraMetadataProvider> logger) { ApiManager = apiManager; Lookup = lookUp; LibraryManager = libraryManager; - ProviderManager = providerManager; LocalizationManager = localizationManager; Logger = logger; } @@ -62,7 +58,7 @@ public void Dispose() #region Locking - private readonly ConcurrentDictionary<string, HashSet<string>> LockedIdDictionary = new ConcurrentDictionary<string, HashSet<string>>(); + private readonly ConcurrentDictionary<string, HashSet<string>> LockedIdDictionary = new(); public bool TryLockActionForIdOFType(string type, string id, string action) { @@ -121,7 +117,7 @@ private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs e) if (!season.IndexNumber.HasValue) return; - if (!(e.Parent is Series series)) + if (e.Parent is not Series series) return; // Abort if we're unable to get the shoko series id @@ -310,10 +306,10 @@ private void UpdateSeries(Series series, string seriesId) { // Provide metadata for a series using Shoko's Group feature if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { - var groupInfo = ApiManager.GetGroupInfoForSeries(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) + var showInfo = ApiManager.GetShowInfoForSeries(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) .GetAwaiter() .GetResult(); - if (groupInfo == null) { + if (showInfo == null) { Logger.LogWarning("Unable to find group info for series. (Series={SeriesID})", seriesId); return; } @@ -322,44 +318,44 @@ private void UpdateSeries(Series series, string seriesId) var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); // Add missing seasons - foreach (var (seasonNumber, season) in CreateMissingSeasons(groupInfo, series, seasons)) + foreach (var (seasonNumber, season) in CreateMissingSeasons(showInfo, series, seasons)) seasons.TryAdd(seasonNumber, season); // Handle specials when grouped. if (seasons.TryGetValue(0, out var zeroSeason)) { - foreach (var seriesInfo in groupInfo.SeriesList) { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesInfo.Id)) + foreach (var seasonInfo in showInfo.SeasonList) { + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) episodeIds.Add(episodeId); - foreach (var episodeInfo in seriesInfo.SpecialsList) { + foreach (var episodeInfo in seasonInfo.SpecialsList) { if (episodeIds.Contains(episodeInfo.Id)) continue; - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, zeroSeason); + AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, zeroSeason); } } } // Add missing episodes - foreach (var pair in groupInfo.SeasonOrderDictionary) { + foreach (var pair in showInfo.SeasonOrderDictionary) { var seasonNumber= pair.Key; if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) continue; - var seriesInfo = pair.Value; - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesInfo.Id)) + var seasonInfo = pair.Value; + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) episodeIds.Add(episodeId); - foreach (var episodeInfo in seriesInfo.EpisodeList) { + foreach (var episodeInfo in seasonInfo.EpisodeList) { if (episodeIds.Contains(episodeInfo.Id)) continue; - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); } } // We add the extras to the season if we're using Shoko Groups. - AddExtras(series, groupInfo.DefaultSeries); + AddExtras(series, showInfo.DefaultSeason); - foreach (var pair in groupInfo.SeasonOrderDictionary) { + foreach (var pair in showInfo.SeasonOrderDictionary) { if (!seasons.TryGetValue(pair.Key, out var season) || season == null) continue; @@ -368,10 +364,10 @@ private void UpdateSeries(Series series, string seriesId) } // Provide metadata for other series else { - var seriesInfo = ApiManager.GetSeriesInfo(seriesId) + var seasonInfo = ApiManager.GetSeasonInfoForSeries(seriesId) .GetAwaiter() .GetResult(); - if (seriesInfo == null) { + if (seasonInfo == null) { Logger.LogWarning("Unable to find series info. (Series={SeriesID})", seriesId); return; } @@ -380,17 +376,17 @@ private void UpdateSeries(Series series, string seriesId) var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); // Compute the season numbers for each episode in the series in advance, since we need to filter out the missing seasons - var episodeInfoToSeasonNumberDirectory = seriesInfo.RawEpisodeList.ToDictionary(e => e, e => Ordering.GetSeasonNumber(null, seriesInfo, e)); + var episodeInfoToSeasonNumberDirectory = seasonInfo.RawEpisodeList.ToDictionary(e => e, e => Ordering.GetSeasonNumber(null, seasonInfo, e)); // Add missing seasons var allKnownSeasonNumbers = episodeInfoToSeasonNumberDirectory.Values.Distinct().ToList(); - foreach (var (seasonNumber, season) in CreateMissingSeasons(seriesInfo, series, seasons, allKnownSeasonNumbers)) + foreach (var (seasonNumber, season) in CreateMissingSeasons(seasonInfo, series, seasons, allKnownSeasonNumbers)) seasons.Add(seasonNumber, season); // Add missing episodes - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesInfo.Id)) + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) episodeIds.Add(episodeId); - foreach (var episodeInfo in seriesInfo.RawEpisodeList) { + foreach (var episodeInfo in seasonInfo.RawEpisodeList) { if (episodeInfo.ExtraType != null) continue; @@ -401,11 +397,11 @@ private void UpdateSeries(Series series, string seriesId) if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) continue; - AddVirtualEpisode(null, seriesInfo, episodeInfo, season); + AddVirtualEpisode(null, seasonInfo, episodeInfo, season); } // We add the extras to the series if not. - AddExtras(series, seriesInfo); + AddExtras(series, seasonInfo); } } @@ -413,14 +409,14 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de { var seasonNumber = season.IndexNumber!.Value; var seriesGrouping = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; - Info.GroupInfo groupInfo = null; - Info.SeriesInfo seriesInfo = null; + Info.ShowInfo showInfo = null; + Info.SeasonInfo seasonInfo = null; // Provide metadata for a season using Shoko's Group feature if (seriesGrouping) { - groupInfo = ApiManager.GetGroupInfoForSeries(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) + showInfo = ApiManager.GetShowInfoForSeries(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) .GetAwaiter() .GetResult(); - if (groupInfo == null) { + if (showInfo == null) { Logger.LogWarning("Unable to find group info for series. (Series={SeriesId})", seriesId); return; } @@ -431,31 +427,31 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de } } else { - seriesInfo = groupInfo.GetSeriesInfoBySeasonNumber(seasonNumber); - if (seriesInfo == null) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00} in group for series. (Group={GroupId})", seasonNumber, groupInfo.Id); + seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); + if (seasonInfo == null) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00} in group for series. (Group={GroupId})", seasonNumber, showInfo.Id); return; } if (deleted) { - var offset = seasonNumber - groupInfo.SeasonNumberBaseDictionary[seriesInfo]; - season = AddVirtualSeason(seriesInfo, offset, seasonNumber, series); + var offset = seasonNumber - showInfo.SeasonNumberBaseDictionary[seasonInfo]; + season = AddVirtualSeason(seasonInfo, offset, seasonNumber, series); } } } // Provide metadata for other seasons else { - seriesInfo = ApiManager.GetSeriesInfo(seriesId) + seasonInfo = ApiManager.GetSeasonInfoForSeries(seriesId) .GetAwaiter() .GetResult(); - if (seriesInfo == null) { + if (seasonInfo == null) { Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00}. (Series={SeriesId})", seasonNumber, seriesId); return; } if (deleted) { - var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && seriesInfo.TvDB != null; - season = seasonNumber == 1 && (!mergeFriendly) ? AddVirtualSeason(seriesInfo, 0, 1, series) : AddVirtualSeason(seasonNumber, series); + var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && seasonInfo.TvDB != null; + season = seasonNumber == 1 && (!mergeFriendly) ? AddVirtualSeason(seasonInfo, 0, 1, series) : AddVirtualSeason(seasonNumber, series); } } @@ -472,64 +468,64 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de // Handle specials when grouped. if (seasonNumber == 0) { if (seriesGrouping) { - foreach (var sI in groupInfo.SeriesList) { + foreach (var sI in showInfo.SeasonList) { foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(sI.Id)) existingEpisodes.Add(episodeId); foreach (var episodeInfo in sI.SpecialsList) { if (existingEpisodes.Contains(episodeInfo.Id)) continue; - AddVirtualEpisode(groupInfo, sI, episodeInfo, season); + AddVirtualEpisode(showInfo, sI, episodeInfo, season); } } } else { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesInfo.Id)) + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) existingEpisodes.Add(episodeId); - foreach (var episodeInfo in seriesInfo.SpecialsList) { + foreach (var episodeInfo in seasonInfo.SpecialsList) { if (existingEpisodes.Contains(episodeInfo.Id)) continue; - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); } } } else { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesInfo.Id)) + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) existingEpisodes.Add(episodeId); - foreach (var episodeInfo in seriesInfo.EpisodeList) { - var episodeParentIndex = Ordering.GetSeasonNumber(groupInfo, seriesInfo, episodeInfo); + foreach (var episodeInfo in seasonInfo.EpisodeList) { + var episodeParentIndex = Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); if (episodeParentIndex != seasonNumber) continue; if (existingEpisodes.Contains(episodeInfo.Id)) continue; - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, season); + AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); } // We add the extras to the season if we're using Shoko Groups. if (seriesGrouping) { - AddExtras(season, seriesInfo); + AddExtras(season, seasonInfo); } } } private void UpdateEpisode(Episode episode, string episodeId) { - Info.GroupInfo groupInfo = null; - Info.SeriesInfo seriesInfo = ApiManager.GetSeriesInfoForEpisode(episodeId) + Info.ShowInfo showInfo = null; + Info.SeasonInfo seasonInfo = ApiManager.GetSeasonInfoForEpisode(episodeId) .GetAwaiter() .GetResult(); - Info.EpisodeInfo episodeInfo = seriesInfo.EpisodeList.FirstOrDefault(e => e.Id == episodeId); + Info.EpisodeInfo episodeInfo = seasonInfo.EpisodeList.FirstOrDefault(e => e.Id == episodeId); if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) - groupInfo = ApiManager.GetGroupInfoForSeries(seriesInfo.Id, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) + showInfo = ApiManager.GetShowInfoForSeries(seasonInfo.Id, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) .GetAwaiter() .GetResult(); - var episodeIds = ApiManager.GetLocalEpisodeIdsForSeries(seriesInfo.Id); + var episodeIds = ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id); if (!episodeIds.Contains(episodeId)) - AddVirtualEpisode(groupInfo, seriesInfo, episodeInfo, episode.Season); + AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, episode.Season); } private (Dictionary<int, Season>, HashSet<string>) GetExistingSeasonsAndEpisodeIds(Series series) @@ -559,27 +555,27 @@ private void UpdateEpisode(Episode episode, string episodeId) #region Seasons - private IEnumerable<(int, Season)> CreateMissingSeasons(Info.SeriesInfo seriesInfo, Series series, Dictionary<int, Season> existingSeasons, List<int> allSeasonNumbers) + private IEnumerable<(int, Season)> CreateMissingSeasons(Info.SeasonInfo seasonInfo, Series series, Dictionary<int, Season> existingSeasons, List<int> allSeasonNumbers) { var missingSeasonNumbers = allSeasonNumbers.Except(existingSeasons.Keys).ToList(); - var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && seriesInfo.TvDB != null; + var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && seasonInfo.TvDB != null; foreach (var seasonNumber in missingSeasonNumbers) { - var season = seasonNumber == 1 && !mergeFriendly ? AddVirtualSeason(seriesInfo, 0, 1, series) : AddVirtualSeason(seasonNumber, series); + var season = seasonNumber == 1 && !mergeFriendly ? AddVirtualSeason(seasonInfo, 0, 1, series) : AddVirtualSeason(seasonNumber, series); if (season == null) continue; yield return (seasonNumber, season); } } - private IEnumerable<(int, Season)> CreateMissingSeasons(Info.GroupInfo groupInfo, Series series, Dictionary<int, Season> seasons) + private IEnumerable<(int, Season)> CreateMissingSeasons(Info.ShowInfo showInfo, Series series, Dictionary<int, Season> seasons) { bool hasSpecials = false; - foreach (var pair in groupInfo.SeasonOrderDictionary) { + foreach (var pair in showInfo.SeasonOrderDictionary) { if (seasons.ContainsKey(pair.Key)) continue; if (pair.Value.SpecialsList.Count > 0) hasSpecials = true; - var offset = pair.Key - groupInfo.SeasonNumberBaseDictionary[pair.Value]; + var offset = pair.Key - showInfo.SeasonNumberBaseDictionary[pair.Value]; var season = AddVirtualSeason(pair.Value, offset, pair.Key, series); if (season == null) continue; @@ -645,15 +641,15 @@ private Season AddVirtualSeason(int seasonNumber, Series series) return season; } - private Season AddVirtualSeason(Info.SeriesInfo seriesInfo, int offset, int seasonNumber, Series series) + private Season AddVirtualSeason(Info.SeasonInfo seasonInfo, int offset, int seasonNumber, Series series) { if (SeasonExists(series.GetPresentationUniqueKey(), series.Name, seasonNumber)) return null; var seasonId = LibraryManager.GetNewItemId(series.Id + "Season " + seasonNumber.ToString(CultureInfo.InvariantCulture), typeof(Season)); - var season = SeasonProvider.CreateMetadata(seriesInfo, seasonNumber, offset, series, seasonId); + var season = SeasonProvider.CreateMetadata(seasonInfo, seasonNumber, offset, series, seasonId); - Logger.LogInformation("Adding virtual Season {SeasonNumber:00} to Series {SeriesName}. (Series={SeriesId})", seasonNumber, series.Name, seriesInfo.Id); + Logger.LogInformation("Adding virtual Season {SeasonNumber:00} to Series {SeriesName}. (Series={SeriesId})", seasonNumber, series.Name, seasonInfo.Id); series.AddChild(season); @@ -731,16 +727,16 @@ private bool EpisodeExists(string episodeId, string seriesId, string groupId) return false; } - private void AddVirtualEpisode(Info.GroupInfo groupInfo, Info.SeriesInfo seriesInfo, Info.EpisodeInfo episodeInfo, MediaBrowser.Controller.Entities.TV.Season season) + private void AddVirtualEpisode(Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, MediaBrowser.Controller.Entities.TV.Season season) { - var groupId = groupInfo?.Id ?? null; - if (EpisodeExists(episodeInfo.Id, seriesInfo.Id, groupId)) + var groupId = showInfo?.Id ?? null; + if (EpisodeExists(episodeInfo.Id, seasonInfo.Id, groupId)) return; - var episodeId = LibraryManager.GetNewItemId(season.Series.Id + "Season " + seriesInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); - var episode = EpisodeProvider.CreateMetadata(groupInfo, seriesInfo, episodeInfo, season, episodeId); + var episodeId = LibraryManager.GetNewItemId(season.Series.Id + "Season " + seasonInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); + var episode = EpisodeProvider.CreateMetadata(showInfo, seasonInfo, episodeInfo, season, episodeId); - Logger.LogInformation("Adding virtual Episode {EpisodeNumber:000} in Season {SeasonNumber:00} for Series {SeriesName}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episode.IndexNumber, season.Name, groupInfo?.Shoko.Name ?? seriesInfo.Shoko.Name, episodeInfo.Id, seriesInfo.Id, groupId); + Logger.LogInformation("Adding virtual Episode {EpisodeNumber:000} in Season {SeasonNumber:00} for Series {SeriesName}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episode.IndexNumber, season.Name, showInfo?.Name ?? seasonInfo.Shoko.Name, episodeInfo.Id, seasonInfo.Id, groupId); season.AddChild(episode); } @@ -773,14 +769,14 @@ private void RemoveDuplicateEpisodes(Episode episode, string episodeId) #endregion #region Extras - private void AddExtras(Folder parent, Info.SeriesInfo seriesInfo) + private void AddExtras(Folder parent, Info.SeasonInfo seasonInfo) { - if (seriesInfo.ExtrasList.Count == 0) + if (seasonInfo.ExtrasList.Count == 0) return; var needsUpdate = false; var extraIds = new List<Guid>(); - foreach (var episodeInfo in seriesInfo.ExtrasList) { + foreach (var episodeInfo in seasonInfo.ExtrasList) { if (!Lookup.TryGetPathForEpisodeId(episodeInfo.Id, out var episodePath)) continue; @@ -801,7 +797,7 @@ private void AddExtras(Folder parent, Info.SeriesInfo seriesInfo) video.Name = episodeInfo.Shoko.Name; video.ExtraType = episodeInfo.ExtraType; video.ProviderIds.TryAdd("Shoko Episode", episodeInfo.Id); - video.ProviderIds.TryAdd("Shoko Series", seriesInfo.Id); + video.ProviderIds.TryAdd("Shoko Series", seasonInfo.Id); LibraryManager.UpdateItemAsync(video, null, ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); if (!parent.ExtraIds.Contains(video.Id)) { needsUpdate = true; @@ -809,7 +805,7 @@ private void AddExtras(Folder parent, Info.SeriesInfo seriesInfo) } } else { - Logger.LogInformation("Adding {ExtraType} {VideoName} to parent {ParentName} (Series={SeriesId})", episodeInfo.ExtraType, episodeInfo.Shoko.Name, parent.Name, seriesInfo.Id); + Logger.LogInformation("Adding {ExtraType} {VideoName} to parent {ParentName} (Series={SeriesId})", episodeInfo.ExtraType, episodeInfo.Shoko.Name, parent.Name, seasonInfo.Id); video = new Video { Id = LibraryManager.GetNewItemId($"{parent.Id} {episodeInfo.ExtraType} {episodeInfo.Id}", typeof (Video)), Name = episodeInfo.Shoko.Name, @@ -821,7 +817,7 @@ private void AddExtras(Folder parent, Info.SeriesInfo seriesInfo) DateModified = DateTime.UtcNow, }; video.ProviderIds.Add("Shoko Episode", episodeInfo.Id); - video.ProviderIds.Add("Shoko Series", seriesInfo.Id); + video.ProviderIds.Add("Shoko Series", seasonInfo.Id); LibraryManager.CreateItem(video, null); needsUpdate = true; extraIds.Add(video.Id); diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 9f0c1efb..da1f57a2 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -109,18 +109,18 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell return list; } catch (Exception ex) { - Logger.LogError(ex, $"Threw unexpectedly; {ex.Message}"); + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); Plugin.Instance.CaptureException(ex); return list; } } - private void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Info.EpisodeInfo episodeInfo) + private static void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Info.EpisodeInfo episodeInfo) { AddImage(ref list, ImageType.Primary, episodeInfo?.TvDB?.Thumbnail); } - private void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Images images) + private static void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Images images) { foreach (var image in images.Posters.OrderByDescending(image => image.IsDefault)) AddImage(ref list, ImageType.Primary, image); @@ -130,7 +130,7 @@ private void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Image AddImage(ref list, ImageType.Banner, image); } - private 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) { if (image == null || !image.IsAvailable) return; @@ -145,18 +145,12 @@ private void AddImage(ref List<RemoteImageInfo> list, ImageType imageType, API.M } public IEnumerable<ImageType> GetSupportedImages(BaseItem item) - { - return new[] { ImageType.Primary, ImageType.Backdrop, ImageType.Banner }; - } + => new[] { ImageType.Primary, ImageType.Backdrop, ImageType.Banner }; public bool Supports(BaseItem item) - { - return item is Series || item is Season || item is Episode || item is Movie || item is BoxSet; - } + => item is Series or Season or Episode or Movie or BoxSet; public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); - } + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index ec726dbd..b118b2ba 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -85,17 +85,17 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio return result; } catch (Exception ex) { - Logger.LogError(ex, $"Threw unexpectedly; {ex.Message}"); + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); Plugin.Instance.CaptureException(ex); return new MetadataResult<Movie>(); } } - private static string GetCollectionName(API.Info.SeriesInfo series, API.Info.GroupInfo group, string metadataLanguage) + private static string GetCollectionName(API.Info.SeasonInfo series, API.Info.ShowInfo group, string metadataLanguage) { - return (Plugin.Instance.Configuration.BoxSetGrouping) switch { + return Plugin.Instance.Configuration.BoxSetGrouping switch { Ordering.GroupType.ShokoGroup => - Text.GetSeriesTitle(group.DefaultSeries.AniDB.Titles, group.DefaultSeries.Shoko.Name, metadataLanguage), + Text.GetSeriesTitle(group.DefaultSeason.AniDB.Titles, group.DefaultSeason.Shoko.Name, metadataLanguage), Ordering.GroupType.ShokoSeries => Text.GetSeriesTitle(series.AniDB.Titles, series.Shoko.Name, metadataLanguage), _ => null, @@ -104,13 +104,9 @@ private static string GetCollectionName(API.Info.SeriesInfo series, API.Info.Gro public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) - { - return Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); - } + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); - } + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 8fb4138a..89fc6986 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -39,29 +39,29 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat return new MetadataResult<Season>(); if (info.IndexNumber.Value == 1) - return await GetShokoGroupedMetadata(info, cancellationToken); + return await GetShokoGroupedMetadata(info); - return GetDefaultMetadata(info, cancellationToken); + return GetDefaultMetadata(info); case Ordering.GroupType.MergeFriendly: if (!info.IndexNumber.HasValue) return new MetadataResult<Season>(); - return GetDefaultMetadata(info, cancellationToken); + return GetDefaultMetadata(info); case Ordering.GroupType.ShokoGroup: if (info.IndexNumber.HasValue && info.IndexNumber.Value == 0) - return GetDefaultMetadata(info, cancellationToken); + return GetDefaultMetadata(info); - return await GetShokoGroupedMetadata(info, cancellationToken); + return await GetShokoGroupedMetadata(info); } } catch (Exception ex) { - Logger.LogError(ex, $"Threw unexpectedly; {ex.Message}"); + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); Plugin.Instance.CaptureException(ex); return new MetadataResult<Season>(); } } - private MetadataResult<Season> GetDefaultMetadata(SeasonInfo info, CancellationToken cancellationToken) + private static MetadataResult<Season> GetDefaultMetadata(SeasonInfo info) { var result = new MetadataResult<Season>(); @@ -78,70 +78,70 @@ private MetadataResult<Season> GetDefaultMetadata(SeasonInfo info, CancellationT return result; } - private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo info, CancellationToken cancellationToken) + private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo info) { var result = new MetadataResult<Season>(); int offset = 0; int seasonNumber = 1; - API.Info.SeriesInfo series; + API.Info.SeasonInfo season; // All previously known seasons if (info.ProviderIds.TryGetValue("Shoko Series", out var seriesId) && info.ProviderIds.TryGetValue("Shoko Season Offset", out var offsetText) && int.TryParse(offsetText, out offset)) { - series = await ApiManager.GetSeriesInfo(seriesId); + season = await ApiManager.GetSeasonInfoForSeries(seriesId); - if (series == null) { + if (season == null) { Logger.LogWarning("Unable to find series info for Season. (Series={SeriesId})", seriesId); return result; } if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var group = await ApiManager.GetGroupInfoForSeries(seriesId, filterByType); - if (group == null) { - Logger.LogWarning("Unable to find group info for Season. (Series={SeriesId})", series.Id); + var show = await ApiManager.GetShowInfoForSeries(seriesId, filterByType); + if (show == null) { + Logger.LogWarning("Unable to find group info for Season. (Series={SeriesId})", season.Id); return result; } - if (!group.SeasonNumberBaseDictionary.TryGetValue(series, out seasonNumber)) { - Logger.LogWarning("Unable to find season number for Season. (Series={SeriesId},Group={GroupId})", series.Id, group.Id); + if (!show.SeasonNumberBaseDictionary.TryGetValue(season, out seasonNumber)) { + Logger.LogWarning("Unable to find season number for Season. (Series={SeriesId},Group={GroupId})", season.Id, show.Id); return result; } seasonNumber = seasonNumber < 0 ? seasonNumber - offset : seasonNumber + offset; - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, group.Shoko.Name, series.Id, group.Id); + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, show.Name, season.Id, show.Id); } else { seasonNumber += offset; - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Shoko.Name, series.Id); + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId})", seasonNumber, season.Shoko.Name, season.Id); } } // New physical seasons else if (info.Path != null) { - series = await ApiManager.GetSeriesInfoByPath(info.Path); + season = await ApiManager.GetSeasonInfoByPath(info.Path); - if (series == null) { + if (season == null) { Logger.LogWarning("Unable to find series info for Season by path {Path}.", info.Path); return result; } if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var group = await ApiManager.GetGroupInfoForSeries(series.Id, filterByType); - if (group == null) { - Logger.LogWarning("Unable to find group info for Season by path {Path}. (Series={SeriesId})", info.Path, series.Id); + var show = await ApiManager.GetShowInfoForSeries(season.Id, filterByType); + if (show == null) { + Logger.LogWarning("Unable to find group info for Season by path {Path}. (Series={SeriesId})", info.Path, season.Id); return result; } - if (!group.SeasonNumberBaseDictionary.TryGetValue(series, out seasonNumber)) { - Logger.LogWarning("Unable to find season number for Season by path {Path}. (Series={SeriesId},Group={GroupId})", info.Path, series.Id, group.Id); + if (!show.SeasonNumberBaseDictionary.TryGetValue(season, out seasonNumber)) { + Logger.LogWarning("Unable to find season number for Season by path {Path}. (Series={SeriesId},Group={GroupId})", info.Path, season.Id, show.Id); return result; } - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, group.Shoko.Name, series.Id, group.Id); + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, show.Name, season.Id, show.Id); } else { - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Shoko.Name, series.Id); + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId})", seasonNumber, season.Shoko.Name, season.Id); } } // New virtual seasons @@ -149,31 +149,31 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in seasonNumber = info.IndexNumber.Value; if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var group = await ApiManager.GetGroupInfoForSeries(seriesId, filterByType); - if (group == null) { + var show = await ApiManager.GetShowInfoForSeries(seriesId, filterByType); + if (show == null) { Logger.LogWarning("Unable to find group info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); return result; } - series = group.GetSeriesInfoBySeasonNumber(seasonNumber); - if (series == null || !group.SeasonNumberBaseDictionary.TryGetValue(series, out var baseSeasonNumber)) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Group={GroupId})", seasonNumber, group.Id); + season = show.GetSeriesInfoBySeasonNumber(seasonNumber); + if (season == null || !show.SeasonNumberBaseDictionary.TryGetValue(season, out var baseSeasonNumber)) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Group={GroupId})", seasonNumber, show.Id); return result; } offset = Math.Abs(seasonNumber - baseSeasonNumber); - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, group.Shoko.Name, series.Id, group.Id); + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, show.Name, season.Id, show.Id); } else { - series = await ApiManager.GetSeriesInfo(seriesId); + season = await ApiManager.GetSeasonInfoForSeries(seriesId); offset = seasonNumber - 1; - if (series == null) { + if (season == null) { Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); return result; } - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Shoko.Name, series.Id); + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId})", seasonNumber, season.Shoko.Name, season.Id); } } // Everything else. @@ -182,27 +182,27 @@ private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo in return result; } - result.Item = CreateMetadata(series, seasonNumber, offset, info.MetadataLanguage); + result.Item = CreateMetadata(season, seasonNumber, offset, info.MetadataLanguage); result.HasMetadata = true; result.ResetPeople(); - foreach (var person in series.Staff) + foreach (var person in season.Staff) result.AddPerson(person); return result; } - public static Season CreateMetadata(Info.SeriesInfo seriesInfo, int seasonNumber, int offset, string metadataLanguage) - => CreateMetadata(seriesInfo, seasonNumber, offset, metadataLanguage, null, Guid.Empty); + 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.SeriesInfo seriesInfo, int seasonNumber, int offset, Series series, System.Guid seasonId) - => CreateMetadata(seriesInfo, seasonNumber, offset, series.GetPreferredMetadataLanguage(), series, seasonId); + public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, Series series, System.Guid seasonId) + => CreateMetadata(seasonInfo, seasonNumber, offset, series.GetPreferredMetadataLanguage(), series, seasonId); - public static Season CreateMetadata(Info.SeriesInfo seriesInfo, int seasonNumber, int offset, string metadataLanguage, Series series, System.Guid seasonId) + public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage, Series series, System.Guid seasonId) { - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(seriesInfo.AniDB.Titles, seriesInfo.Shoko.Name, metadataLanguage); - var sortTitle = $"S{seasonNumber} - {seriesInfo.Shoko.Name}"; + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(seasonInfo.AniDB.Titles, seasonInfo.Shoko.Name, metadataLanguage); + var sortTitle = $"S{seasonNumber} - {seasonInfo.Shoko.Name}"; if (offset > 0) { string type = ""; @@ -211,7 +211,7 @@ public static Season CreateMetadata(Info.SeriesInfo seriesInfo, int seasonNumber break; case -1: case 1: - if (seriesInfo.AlternateEpisodesList.Count > 0) + if (seasonInfo.AlternateEpisodesList.Count > 0) type = "Alternate Stories"; else type = "Other Episodes"; @@ -235,14 +235,14 @@ public static Season CreateMetadata(Info.SeriesInfo seriesInfo, int seasonNumber ForcedSortName = sortTitle, Id = seasonId, IsVirtualItem = true, - Overview = Text.GetDescription(seriesInfo), - PremiereDate = seriesInfo.AniDB.AirDate, - EndDate = seriesInfo.AniDB.EndDate, - ProductionYear = seriesInfo.AniDB.AirDate?.Year, - Tags = seriesInfo.Tags.ToArray(), - Genres = seriesInfo.Genres.ToArray(), - Studios = seriesInfo.Studios.ToArray(), - CommunityRating = seriesInfo.AniDB.Rating?.ToFloat(10), + Overview = Text.GetDescription(seasonInfo), + PremiereDate = seasonInfo.AniDB.AirDate, + EndDate = seasonInfo.AniDB.EndDate, + ProductionYear = seasonInfo.AniDB.AirDate?.Year, + Tags = seasonInfo.Tags.ToArray(), + Genres = seasonInfo.Genres.ToArray(), + Studios = seasonInfo.Studios.ToArray(), + CommunityRating = seasonInfo.AniDB.Rating?.ToFloat(10), SeriesId = series.Id, SeriesName = series.Name, SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), @@ -257,20 +257,20 @@ public static Season CreateMetadata(Info.SeriesInfo seriesInfo, int seasonNumber IndexNumber = seasonNumber, SortName = sortTitle, ForcedSortName = sortTitle, - Overview = Text.GetDescription(seriesInfo), - PremiereDate = seriesInfo.AniDB.AirDate, - EndDate = seriesInfo.AniDB.EndDate, - ProductionYear = seriesInfo.AniDB.AirDate?.Year, - Tags = seriesInfo.Tags.ToArray(), - Genres = seriesInfo.Genres.ToArray(), - Studios = seriesInfo.Studios.ToArray(), - CommunityRating = seriesInfo.AniDB.Rating?.ToFloat(10), + Overview = Text.GetDescription(seasonInfo), + PremiereDate = seasonInfo.AniDB.AirDate, + EndDate = seasonInfo.AniDB.EndDate, + ProductionYear = seasonInfo.AniDB.AirDate?.Year, + Tags = seasonInfo.Tags.ToArray(), + Genres = seasonInfo.Genres.ToArray(), + Studios = seasonInfo.Studios.ToArray(), + CommunityRating = seasonInfo.AniDB.Rating?.ToFloat(10), }; } - season.ProviderIds.Add("Shoko Series", seriesInfo.Id); + season.ProviderIds.Add("Shoko Series", seasonInfo.Id); season.ProviderIds.Add("Shoko Season Offset", offset.ToString()); if (Plugin.Instance.Configuration.AddAniDBId) - season.ProviderIds.Add("AniDB", seriesInfo.AniDB.Id.ToString()); + season.ProviderIds.Add("AniDB", seasonInfo.AniDB.Id.ToString()); return season; } @@ -285,22 +285,15 @@ public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } - private string GetSeasonName(int seasonNumber, string seasonName) - { - switch (seasonNumber) { - case 127: - return "Misc."; - case 126: - return "Credits"; - case 125: - return "Trailers"; - case 124: - return "Others"; - case 123: - return "Unknown"; - default: - return seasonName; - } - } + private static string GetSeasonName(int seasonNumber, string seasonName) + => seasonNumber switch + { + 127 => "Misc.", + 126 => "Credits", + 125 => "Trailers", + 124 => "Others", + 123 => "Unknown", + _ => seasonName, + }; } } diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index a7526838..3729828d 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -23,17 +23,14 @@ public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> private readonly ILogger<SeriesProvider> Logger; - private readonly ShokoAPIClient ApiClient; - private readonly ShokoAPIManager ApiManager; private readonly IFileSystem FileSystem; - public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIClient apiClient, ShokoAPIManager apiManager, IFileSystem fileSystem) + public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager, IFileSystem fileSystem) { Logger = logger; HttpClientFactory = httpClientFactory; - ApiClient = apiClient; ApiManager = apiManager; FileSystem = fileSystem; } @@ -41,140 +38,139 @@ public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvid public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) { try { - switch (Plugin.Instance.Configuration.SeriesGrouping) { - default: - return await GetDefaultMetadata(info, cancellationToken); - case Ordering.GroupType.ShokoGroup: - return await GetShokoGroupedMetadata(info, cancellationToken); - } + return Plugin.Instance.Configuration.SeriesGrouping switch + { + Ordering.GroupType.ShokoGroup => await GetShokoGroupedMetadata(info), + _ => await GetDefaultMetadata(info), + }; } catch (Exception ex) { - Logger.LogError(ex, $"Threw unexpectedly; {ex.Message}"); + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); Plugin.Instance.CaptureException(ex); return new MetadataResult<Series>(); } } - private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info, CancellationToken cancellationToken) + private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info) { var result = new MetadataResult<Series>(); - var series = await ApiManager.GetSeriesInfoByPath(info.Path); - if (series == null) { + var season = await ApiManager.GetSeasonInfoByPath(info.Path); + if (season == null) { // Look for the "season" directories to probe for the series information var entries = FileSystem.GetDirectories(info.Path, false); foreach (var entry in entries) { - series = await ApiManager.GetSeriesInfoByPath(entry.FullName); - if (series != null) + season = await ApiManager.GetSeasonInfoByPath(entry.FullName); + if (season != null) break; } - if (series == null) { + if (season == null) { Logger.LogWarning("Unable to find series info for path {Path}", info.Path); return result; } } - var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && series.TvDB != null; + var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && season.TvDB != null; - var defaultSeriesTitle = mergeFriendly ? series.TvDB.Title : series.Shoko.Name; - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, defaultSeriesTitle, info.MetadataLanguage); - Logger.LogInformation("Found series {SeriesName} (Series={SeriesId})", displayTitle, series.Id); + var defaultSeriesTitle = mergeFriendly ? season.TvDB.Title : season.Shoko.Name; + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(season.AniDB.Titles, defaultSeriesTitle, info.MetadataLanguage); + Logger.LogInformation("Found series {SeriesName} (Series={SeriesId})", displayTitle, season.Id); if (mergeFriendly) { result.Item = new Series { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.GetDescription(series), - PremiereDate = series.TvDB.AirDate, - EndDate = series.TvDB.EndDate, - ProductionYear = series.TvDB.AirDate?.Year, - Status = series.TvDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = series.Tags.ToArray(), - Genres = series.Genres.ToArray(), - Studios = series.Studios.ToArray(), - CommunityRating = series.TvDB.Rating?.ToFloat(10), + Overview = Text.GetDescription(season), + PremiereDate = season.TvDB.AirDate, + EndDate = season.TvDB.EndDate, + ProductionYear = season.TvDB.AirDate?.Year, + Status = season.TvDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, + Tags = season.Tags.ToArray(), + Genres = season.Genres.ToArray(), + Studios = season.Studios.ToArray(), + CommunityRating = season.TvDB.Rating?.ToFloat(10), }; } else { result.Item = new Series { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.GetDescription(series), - PremiereDate = series.AniDB.AirDate, - EndDate = series.AniDB.EndDate, - ProductionYear = series.AniDB.AirDate?.Year, - Status = series.AniDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = series.Tags.ToArray(), - Genres = series.Genres.ToArray(), - Studios = series.Studios.ToArray(), - OfficialRating = series.AniDB.Restricted ? "XXX" : null, - CustomRating = series.AniDB.Restricted ? "XXX" : null, - CommunityRating = series.AniDB.Rating.ToFloat(10), + Overview = Text.GetDescription(season), + PremiereDate = season.AniDB.AirDate, + EndDate = season.AniDB.EndDate, + ProductionYear = season.AniDB.AirDate?.Year, + Status = season.AniDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, + Tags = season.Tags.ToArray(), + Genres = season.Genres.ToArray(), + Studios = season.Studios.ToArray(), + OfficialRating = season.AniDB.Restricted ? "XXX" : null, + CustomRating = season.AniDB.Restricted ? "XXX" : null, + CommunityRating = season.AniDB.Rating.ToFloat(10), }; } - AddProviderIds(result.Item, seriesId: series.Id, anidbId: series.AniDB.Id.ToString(), tvdbId: mergeFriendly ? series.TvDB.Id.ToString() : null, tmdbId: series.Shoko.IDs.TMDB.FirstOrDefault().ToString()); + AddProviderIds(result.Item, seriesId: season.Id, anidbId: season.AniDB.Id.ToString(), tvdbId: mergeFriendly ? season.TvDB.Id.ToString() : null, tmdbId: season.Shoko.IDs.TMDB.FirstOrDefault().ToString()); result.HasMetadata = true; result.ResetPeople(); - foreach (var person in series.Staff) + foreach (var person in season.Staff) result.AddPerson(person); return result; } - private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo info, CancellationToken cancellationToken) + private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo info) { var result = new MetadataResult<Series>(); var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var group = await ApiManager.GetGroupInfoByPath(info.Path, filterLibrary); - if (group == null) { + var show = await ApiManager.GetShowInfoByPath(info.Path, filterLibrary); + if (show == null) { // Look for the "season" directories to probe for the group information var entries = FileSystem.GetDirectories(info.Path, false); foreach (var entry in entries) { - group = await ApiManager.GetGroupInfoByPath(entry.FullName, filterLibrary); - if (group != null) + show = await ApiManager.GetShowInfoByPath(entry.FullName, filterLibrary); + if (show != null) break; } - if (group == null) { + if (show == null) { Logger.LogWarning("Unable to find group info for path {Path}", info.Path); return result; } } - var series = group.DefaultSeries; - var premiereDate = group.SeriesList + var season = show.DefaultSeason; + var premiereDate = show.SeasonList .Select(s => s.AniDB.AirDate) .Where(s => s != null) .OrderBy(s => s) .FirstOrDefault(); - var endDate = group.SeriesList.Any(s => s.AniDB.EndDate == null) ? null : group.SeriesList + var endDate = show.SeasonList.Any(s => s.AniDB.EndDate == null) ? null : show.SeasonList .Select(s => s.AniDB.AirDate) .OrderBy(s => s) .LastOrDefault(); - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(series.AniDB.Titles, group.Shoko.Name, info.MetadataLanguage); - Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, series.Id, group.Id); + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(season.AniDB.Titles, show.Name, info.MetadataLanguage); + Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, season.Id, show.Id); result.Item = new Series { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.GetDescription(series), + Overview = Text.GetDescription(season), PremiereDate = premiereDate, ProductionYear = premiereDate?.Year, EndDate = endDate, Status = endDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = group.Tags.ToArray(), - Genres = group.Genres.ToArray(), - Studios = group.Studios.ToArray(), - OfficialRating = series.AniDB.Restricted ? "XXX" : null, - CustomRating = series.AniDB.Restricted ? "XXX" : null, - CommunityRating = series.AniDB.Rating.ToFloat(10), + Tags = show.Tags.ToArray(), + Genres = show.Genres.ToArray(), + Studios = show.Studios.ToArray(), + OfficialRating = season.AniDB.Restricted ? "XXX" : null, + CustomRating = season.AniDB.Restricted ? "XXX" : null, + CommunityRating = season.AniDB.Rating.ToFloat(10), }; - AddProviderIds(result.Item, seriesId: series.Id, groupId: group.Id, anidbId: series.AniDB.Id.ToString()); + AddProviderIds(result.Item, seriesId: season.Id, groupId: show.Id, anidbId: season.AniDB.Id.ToString()); result.HasMetadata = true; result.ResetPeople(); - foreach (var person in series.Staff) + foreach (var person in season.Staff) result.AddPerson(person); return result; @@ -187,26 +183,22 @@ public static void AddProviderIds(IHasProviderIds item, string seriesId, string if (string.IsNullOrEmpty(tvdbId)) item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{seriesId}"); + var config = Plugin.Instance.Configuration; item.SetProviderId("Shoko Series", seriesId); if (!string.IsNullOrEmpty(groupId)) item.SetProviderId("Shoko Group", groupId); - if (Plugin.Instance.Configuration.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") + if (config.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") item.SetProviderId("AniDB", anidbId); - if (Plugin.Instance.Configuration.AddTvDBId &&!string.IsNullOrEmpty(tvdbId) && tvdbId != "0") + if (config.AddTvDBId &&!string.IsNullOrEmpty(tvdbId) && tvdbId != "0") item.SetProviderId(MetadataProvider.Tvdb, tvdbId); - if (Plugin.Instance.Configuration.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") - item.SetProviderId(MetadataProvider.Tvdb, tmdbId); + if (config.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") + item.SetProviderId(MetadataProvider.Tmdb, tmdbId); } - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo info, CancellationToken cancellationToken) - { - return Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); - } + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); - } + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index f1785f0a..8dbfa1f9 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -97,7 +97,7 @@ public enum SpecialOrderType { /// Get index number for a movie in a box-set. /// </summary> /// <returns>Absolute index.</returns> - public static int GetMovieIndexNumber(GroupInfo group, SeriesInfo series, EpisodeInfo episode) + public static int GetMovieIndexNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) { switch (Plugin.Instance.Configuration.BoxSetGrouping) { default: @@ -107,7 +107,7 @@ public static int GetMovieIndexNumber(GroupInfo group, SeriesInfo series, Episod return episode.AniDB.EpisodeNumber; case GroupType.ShokoGroup: { int offset = 0; - foreach (SeriesInfo s in group.SeriesList) { + foreach (SeasonInfo s in group.SeasonList) { var sizes = s.Shoko.Sizes.Total; if (s != series) { if (episode.AniDB.Type == EpisodeType.Special) { @@ -159,7 +159,7 @@ public static int GetMovieIndexNumber(GroupInfo group, SeriesInfo series, Episod /// Get index number for an episode in a series. /// </summary> /// <returns>Absolute index.</returns> - public static int GetEpisodeNumber(GroupInfo group, SeriesInfo series, EpisodeInfo episode) + public static int GetEpisodeNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) { switch (Plugin.Instance.Configuration.SeriesGrouping) { @@ -181,13 +181,13 @@ public static int GetEpisodeNumber(GroupInfo group, SeriesInfo series, EpisodeIn case GroupType.ShokoGroup: { int offset = 0; if (episode.AniDB.Type == EpisodeType.Special) { - var seriesIndex = group.SeriesList.FindIndex(s => string.Equals(s.Id, series.Id)); + var seriesIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); if (seriesIndex == -1) throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); if (index == -1) throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); - offset = group.SeriesList.GetRange(0, seriesIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); + offset = group.SeasonList.GetRange(0, seriesIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); return offset + (index + 1); } var sizes = series.Shoko.Sizes.Total; @@ -216,7 +216,7 @@ public static int GetEpisodeNumber(GroupInfo group, SeriesInfo series, EpisodeIn } } - public static (int?, int?, int?, bool) GetSpecialPlacement(GroupInfo group, SeriesInfo series, EpisodeInfo episode) + public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo group, SeasonInfo series, EpisodeInfo episode) { var order = Plugin.Instance.Configuration.SpecialsPlacement; @@ -302,27 +302,21 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(GroupInfo group, Seri /// <param name="series"></param> /// <param name="episode"></param> /// <returns></returns> - public static int GetSeasonNumber(GroupInfo group, SeriesInfo series, EpisodeInfo episode) + public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) { switch (Plugin.Instance.Configuration.SeriesGrouping) { default: case GroupType.Default: - switch (episode.AniDB.Type) { - case EpisodeType.Normal: - return 1; - case EpisodeType.Special: - return 0; - case EpisodeType.Unknown: - return 123; - case EpisodeType.Other: - return 124; - case EpisodeType.Trailer: - return 125; - case EpisodeType.ThemeSong: - return 126; - default: - return 127; - } + return episode.AniDB.Type switch + { + EpisodeType.Normal => 1, + EpisodeType.Special => 0, + EpisodeType.Unknown => 123, + EpisodeType.Other => 124, + EpisodeType.Trailer => 125, + EpisodeType.ThemeSong => 126, + _ => 127, + }; case GroupType.MergeFriendly: { int? seasonNumber = null; if (episode.TvDB != null) { diff --git a/Shokofin/Utils/SeriesInfoRelationComparer.cs b/Shokofin/Utils/SeriesInfoRelationComparer.cs index 3815ad6f..334aeaa1 100644 --- a/Shokofin/Utils/SeriesInfoRelationComparer.cs +++ b/Shokofin/Utils/SeriesInfoRelationComparer.cs @@ -7,7 +7,7 @@ #nullable enable namespace Shokofin.Utils; -public class SeriesInfoRelationComparer : IComparer<SeriesInfo> +public class SeriesInfoRelationComparer : IComparer<SeasonInfo> { protected static Dictionary<RelationType, int> RelationPriority = new() { { RelationType.Prequel, 1 }, @@ -25,7 +25,7 @@ public class SeriesInfoRelationComparer : IComparer<SeriesInfo> { RelationType.SharedCharacters, 99 }, }; - public int Compare(SeriesInfo? a, SeriesInfo? b) + public int Compare(SeasonInfo? a, SeasonInfo? b) { // Check for `null` since `IComparer<T>` expects `T` to be nullable. if (a == null && b == null) @@ -50,7 +50,7 @@ public int Compare(SeriesInfo? a, SeriesInfo? b) return CompareAirDates(a.AniDB.AirDate, b.AniDB.AirDate); } - private int CompareDirectRelations(SeriesInfo a, SeriesInfo b) + private int CompareDirectRelations(SeasonInfo a, SeasonInfo b) { // We check from both sides because one of the entries may be outdated, // so the relation may only present on one of the entries. @@ -70,7 +70,7 @@ private int CompareDirectRelations(SeriesInfo a, SeriesInfo b) return 0; } - private int CompareIndirectRelations(SeriesInfo a, SeriesInfo b) + private int CompareIndirectRelations(SeasonInfo a, SeasonInfo b) { var xRelations = a.Relations .Where(r => RelationPriority.ContainsKey(r.Type)) diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index 5db1c751..a796583a 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -149,7 +149,7 @@ public enum DisplayTitleType { FullTitle = 3, } - public static string GetDescription(SeriesInfo series) + public static string GetDescription(SeasonInfo series) => GetDescription(series.AniDB.Description, series.TvDB?.Description); public static string GetDescription(EpisodeInfo episode) From ed5d9b98909526ffedefcb4ebaeeac5c6e5923fa Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 8 Aug 2023 04:49:12 +0000 Subject: [PATCH 0541/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index f308ee11..74831ef9 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.17", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.17/shoko_3.0.1.17.zip", + "checksum": "5e7814c4f073a8a3d8272586000c2234", + "timestamp": "2023-08-08T04:49:10Z" + }, { "version": "3.0.1.16", "changelog": "NA\n", From 36dcccee20e16cf7564055d87518c2f1f02f4582 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 8 Aug 2023 07:03:51 +0200 Subject: [PATCH 0542/1103] fix: don't use negative season numbers --- Shokofin/API/Info/ShowInfo.cs | 40 +++++++++-------------------------- 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index deb66eed..0898786e 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -60,17 +60,13 @@ public ShowInfo(Group group) public ShowInfo(SeasonInfo seasonInfo) { - var seasonOrderDictionary = new Dictionary<int, SeasonInfo>(); - var seasonNumberBaseDictionary = new Dictionary<SeasonInfo, int>(); - var offset = 0; + var seasonNumberBaseDictionary = new Dictionary<SeasonInfo, int>() { { seasonInfo, 1 } }; + var seasonOrderDictionary = new Dictionary<int, SeasonInfo>() { { 1, seasonInfo } }; + var seasonNumberOffset = 1; if (seasonInfo.AlternateEpisodesList.Count > 0) - offset++; + seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); if (seasonInfo.OthersList.Count > 0) - offset++; - seasonNumberBaseDictionary.Add(seasonInfo, 1); - seasonOrderDictionary.Add(1, seasonInfo); - for (var i = 0; i < offset; i++) - seasonOrderDictionary.Add(i + 2, seasonInfo); + seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); Name = seasonInfo.Shoko.Name; IsStandalone = true; @@ -132,30 +128,14 @@ public ShowInfo(Group group, List<SeasonInfo> seriesList, Ordering.GroupFilterTy var seasonOrderDictionary = new Dictionary<int, SeasonInfo>(); var seasonNumberBaseDictionary = new Dictionary<SeasonInfo, int>(); - var positiveSeasonNumber = 1; - var negativeSeasonNumber = -1; + var seasonNumberOffset = 0; foreach (var (seasonInfo, index) in seriesList.Select((s, i) => (s, i))) { - int seasonNumber; - var offset = 0; + seasonNumberBaseDictionary.Add(seasonInfo, ++seasonNumberOffset); + seasonOrderDictionary.Add(seasonNumberOffset, seasonInfo); if (seasonInfo.AlternateEpisodesList.Count > 0) - offset++; + seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); if (seasonInfo.OthersList.Count > 0) - offset++; - - // Series before the default series get a negative season number - if (index < foundIndex) { - seasonNumber = negativeSeasonNumber; - negativeSeasonNumber -= offset + 1; - } - else { - seasonNumber = positiveSeasonNumber; - positiveSeasonNumber += offset + 1; - } - - seasonNumberBaseDictionary.Add(seasonInfo, seasonNumber); - seasonOrderDictionary.Add(seasonNumber, seasonInfo); - for (var i = 0; i < offset; i++) - seasonOrderDictionary.Add(seasonNumber + (index < foundIndex ? -(i + 1) : (i + 1)), seasonInfo); + seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); } Id = groupId; From c056f8a22673f43adc773c8d9e1aa0355bd8b34c Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 8 Aug 2023 05:04:39 +0000 Subject: [PATCH 0543/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 74831ef9..9142547c 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.18", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.18/shoko_3.0.1.18.zip", + "checksum": "0671f93c4e6b3762d37d73f1c530dd02", + "timestamp": "2023-08-08T05:04:38Z" + }, { "version": "3.0.1.17", "changelog": "NA\n", From 6ecbec9d855b59bd9d846dfa31e04de57e752cfc Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 8 Aug 2023 07:06:32 +0200 Subject: [PATCH 0544/1103] misc: late night thoughts [skip ci] [no ci] --- thoughts.md | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 thoughts.md diff --git a/thoughts.md b/thoughts.md new file mode 100644 index 00000000..8c65d123 --- /dev/null +++ b/thoughts.md @@ -0,0 +1,167 @@ +Collection → Shoko Group + +An object holding information about the shoko group that may contain sub-groups +and/or shoko series. + +**Properties** + +- `Group` (ShokoGroup) — The Shoko Group entry linked to the Collection item. + +- `Collections` (Collection[]) — Any sub-collections within the collection. + +- `Shows` (Show[]) — The Show entries within the collection. + +↓ + +Show → Shoko Group, Shoko Series + +An object holding information about the direct parent shoko group and the +main shoko series for the group if the parent group does not contain sub-groups, +or just the shoko series if the parent group also contains other sub-groups. + +**Properties** + +- `IsStandalone` (boolean) — Indicates the Shoko Series linked is in a Shoko Group with + sub-groups in it, and thus should not contain references to other series. + +- `Group` (ShokoGroup?) — The Shoko Group, if available. + +- `MainSeries` (ShokoSeries) — The main (and/or only) Shoko Series entry. Used for metadata. + +- `AllSeries` (ShokoSeries[]) — All the Shoko Series entries linked to the Show item. + +- `Seasons` (Season[]) — The Season entries within the show. + +↓ + +Season → Shoko Series + +An object holding information about the shoko series and the episodes both +available and not available in the user's library. Can contain multiple shoko +series references. + +**Properties** + +- `SeasonNumber` (int) — The season number. + +- `Name` (string) — The season name. + +- `EpisodeTypes` (EpisodeType[]) — The Shoko Episode Types this season is for. Only if a Shoko Series is split into multiple different + seasons, each for a different Episode Type. E.g. One for "Normal" and "Special", and one for "Other", etc.. + +- `IsMixed` (boolean) — Indicates the Season contains Episode entries from multiple Shoko Series entries. + +- `MainSeries` (ShokoSeries) — The main (and/or only) Shoko Series entry. Used for metadata. + +- `AllSeries` (ShokoSeries[]) — All Shoko Series entries linked to the Season entry. + +- `Episodes` (Episode[]) — All Episode entries within the Season. + +- `Extras` (Episode[]) — Extras that doesn't' count as any episodes. We're re-using the episode model for now. + +↓ + +Episode → Shoko Episode + +An object holding information about the shoko episode and the alternatve +versions available from the user's library. + +**Properties** + +- `Name` — The episode name. + +- `Number` — The computed episode number to use. + +- `AbsoluteNumber` — The absolute episode number to use, if available. + +- `EpisodeType` (EpisodeType) — The Shoko Episode Type for the Episode entry. + +- `ExtraType` (ExtraType) — The extra type assigned to the Episode entry. + +- `MainEpisode` (ShokoEpisode) — The main Shoko Episode entry to use for most of the metadata. + +- `AllEpisodes` (ShokoEpisode[]) — All the Shoko Episode entries linked to the Episode entry. + +- `AlternateVersions` (AlternateVersion) — All alternate episode versions that exist. + +↓ + +Alternate Versions → Shoko Episode, Shoko File + +An object holding information about which shoko files are linked to the same +episodes. + +**Properties** + +- `AllEpisodes` (ShokoEpisode[]) — All the Shoko Episode entries linked to the Episode entry. + +- `AllFiles` (ShokoFile[]) — All Shoko File entries linked to the Episode entry. + +- `PartialVersions` (PartialVersion[]) — All Partial Versions linked to this alternate version. + +↓ + +Partial Versions → Shoko Episode, Shoko File + +An object holding information about which shoko files are part of a multi-file +episode. + +Holds the references to all the file links that form up one alternate version of +the episode. + +**Properties** + +- `MainEpisode` (ShokoEpisode) — The main Shoko Episode entry to use for most of the metadata. + +- `AllEpisodes` (ShokoEpisode[]) — All the Shoko Episode entries linked to the Episode entry. + +- `MainFile` (ShokoFile) — The primary Shoko File to use for file info. + +- `AllFiles` (ShokoFile[]) — All Shoko File entries linked to the Episode entry. + +- `Files` (File[]) — All File entries linked to the Episode entry. + +↓ + +File → Shoko File + +A reference to the shoko file. + +**Properties** + +- `File` (ShokoFile) — The primary Shoko File to use for file info. + +- `Locations` (FileLocation[]) — All File Locations linked to the file, including + the physical file location and any symbolic ones needed to compliment and fill + out the library. + +↓ + +File Location → Shoko File + +An object holding information about either the original file or a symbolic-link +(managed by the plugin) pointing to the original file. Will be used to link the +same file to different episodes within the same series and across different +series. + +When encountering a shoko file with multiple cross-reference for different +episode types within the same series, or cross-series references, then each +episode type within each series referenced will get their own link. The original +link is assigned to the normal episode for the series it is place in physically +in the library. The other links will be created on-demand and placed in a +directory managed by the plugin (outside the actual library), and added to their +respective series. + +The links will be removed from the managed directory when either the library is +destroyed or when the links are otherwise no longer needed. + +**Properties** + +- `Path` (string) — The full path of the file location. + +- `IsSymbolic` (boolean) — Indicates the file location is a symbolic link leading + to the physical file, and not the physical file itself. + +- `File` (ShokoFile) — The primary Shoko File to use for file info. + +- `Series` (ShokoSeries) — The ShokoSeries entry for this file location. \ No newline at end of file From 7b83fe68b4754761df11be95449b5430eb4343f4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 8 Aug 2023 07:15:42 +0200 Subject: [PATCH 0545/1103] misc: fix typos in read-me file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] [no ci] --- README.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index db242b4a..4b3f3c4e 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ since there is no metadata to find. Shoko is an anime cataloging program designed to automate the cataloging of your collection regardless of the size and amount of files you have. Unlike other anime cataloging programs which make you manually add your series or link the -files to them, Shoko removes the tedious, time consuming and boring task of +files to them, Shoko removes the tedious, time-consuming and boring task of having to manually add every file and manually input the file information. You have better things to do with your time like actually watching the series in your collection so let Shoko handle all the heavy lifting. @@ -91,10 +91,10 @@ Learn more about Shoko at https://shokoanime.com/. mapped to the normal seasons, or if they are strictly kept in season zero. - [X] Extra features. The plugin will map specials stored in Shoko such as - interviews, etc. as extra featues, and all other specials as episodes in + interviews, etc. as extra features, and all other specials as episodes in season zero. - - [X] Map OPs/EDs to Theme Videos so they can be displayed as background video + - [X] Map OPs/EDs to Theme Videos, so they can be displayed as background video while you browse your library. - [X] Support merging multi-version episodes/movies into a single entry. @@ -106,7 +106,7 @@ Learn more about Shoko at https://shokoanime.com/. - [X] Manual merge/split tasks - - [X] Support optionally setting other provider ids Shoko knows about (e.g. + - [X] Support optionally setting other provider IDs Shoko knows about (e.g. AniDB, TvDB, TMDB, etc.) on some item types when an ID is available for the items in Shoko. @@ -116,8 +116,10 @@ Learn more about Shoko at https://shokoanime.com/. following TvDB (to-be replaced with TMDB soon™-ish), and using Shoko's groups feature. - _For the best compatibility it is **strongly** adviced **not** to use - "season" folders with anime as it limits which grouping you can use._ + _For the best compatibility it is **strongly** advised **not** to use + "season" folders with anime as it limits which grouping you can use, you + can still create "seasons" in the UI using Shoko's groups or using the + TvDB/TMDB compatibility mode._ - [X] Optionally create Box-Sets for your Movies… @@ -128,12 +130,12 @@ Learn more about Shoko at https://shokoanime.com/. - [X] Supports separating your on-disc library into a two Show and Movie libraries. - _provided you apply the workaround to do it_. + _Provided you apply the workaround to do it_. - [/] Automatically populates all missing episodes not in your collection, so you can see at a glance what you are missing out on. - - [ ] Deleting an missing episode item marks the episode as hidden/ignored + - [ ] Deleting a missing episode item marks the episode as hidden/ignored in Shoko. - [ ] Optionally react to events sent from Shoko. @@ -142,12 +144,12 @@ Learn more about Shoko at https://shokoanime.com/. - [X] User data - - [X] Able to sync the watch data to/from Shoko on a per user basis in + - [X] Able to sync the watch data to/from Shoko on a per-user basis in multiple ways. And Shoko can further sync the to/from other linked services. - [X] During import. - - [X] Player events (play/pause/resumve/stop events) + - [X] Player events (play/pause/resume/stop events) - [X] After playback (stop event) From eaa405c23b7311a8408826fe14efcb907f03cef7 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 8 Aug 2023 07:29:25 +0200 Subject: [PATCH 0546/1103] misc: more late night thoughts [skip ci] [no ci] --- thoughts.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/thoughts.md b/thoughts.md index 8c65d123..5974adcf 100644 --- a/thoughts.md +++ b/thoughts.md @@ -1,3 +1,12 @@ +# Late nights thoughts + +**Disclaimer**: This file is all about some late night thoughts about how I +envision the plugin to work, and now how it is working currently. The parts +around the seasons and down are completely different from how it currently is, +and the changes below the episode cannot be implemented yet because of lacking +api data… for now. The data is available in Shoko, just not exposed in the +"correct" way in the v3 api yet. + Collection → Shoko Group An object holding information about the shoko group that may contain sub-groups @@ -157,10 +166,15 @@ destroyed or when the links are otherwise no longer needed. **Properties** +- `ID` (string) — An unique ID for this file location, even among other copies + of the file location. + + In the format `<fileId>-<seriesId>-<episodeIds>.<fileExt>`. + - `Path` (string) — The full path of the file location. -- `IsSymbolic` (boolean) — Indicates the file location is a symbolic link leading - to the physical file, and not the physical file itself. +- `IsCopy` (boolean) — Indicates the file location is a symbolic link managed + by the plugin. - `File` (ShokoFile) — The primary Shoko File to use for file info. From 6f20ccc7909850f8a7bd76efcd4cd8f298b37951 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 8 Aug 2023 08:13:07 +0200 Subject: [PATCH 0547/1103] refactor: always use show info --- Shokofin/API/ShokoAPIManager.cs | 8 +- Shokofin/LibraryScanner.cs | 17 +-- Shokofin/Providers/SeriesProvider.cs | 200 ++++++++++----------------- 3 files changed, 84 insertions(+), 141 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index b7a1c62e..469fb0cb 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -761,7 +761,8 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId, out s return null; // Create a standalone group for each series in a group with sub-groups. - if (group.Sizes.SubGroups > 0) + var onlyStandalone = Plugin.Instance.Configuration.SeriesGrouping != Ordering.GroupType.ShokoGroup; + if (onlyStandalone || group.Sizes.SubGroups > 0) return await GetOrCreateShowInfoForStandaloneSeries(seriesId, filterByType); return await CreateShowInfo(group, group.IDs.Shoko.ToString(), filterByType); @@ -937,9 +938,10 @@ private async Task<ShowInfo> CreateShowInfo(Group group, string groupId, Orderin Logger.LogTrace("Creating info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); + var onlyStandalone = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; var groups = await APIClient.GetGroupsInGroup(groupId); var multiSeasonShows = await Task.WhenAll(groups - .Where(group => group.Sizes.SubGroups == 0) + .Where(group => !onlyStandalone && group.Sizes.SubGroups == 0) .Select(group => CreateShowInfo(group, group.IDs.Shoko.ToString(groupId), filterByType))); var singleSeasonShows = (await APIClient.GetSeriesInGroup(groupId) .ContinueWith(task => Task.WhenAll(task.Result.Select(s => GetOrCreateShowInfoForStandaloneSeries(s.IDs.Shoko.ToString(), filterByType)))) @@ -948,7 +950,7 @@ private async Task<ShowInfo> CreateShowInfo(Group group, string groupId, Orderin .ToList(); var showList = multiSeasonShows.Concat(singleSeasonShows).ToList(); var groupList = groups - .Where(group => group.Sizes.SubGroups > 0) + .Where(group => onlyStandalone || group.Sizes.SubGroups > 0) .Select(s => CreateCollectionInfo(s, s.IDs.Shoko.ToString(), filterByType)) .OfType<CollectionInfo>() .ToList(); diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index 27d757aa..c246c68f 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -84,7 +84,6 @@ public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent) private bool ScanDirectory(string partialPath, string fullPath, string libraryType, bool shouldIgnore) { - var preloadShow = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; var season = ApiManager.GetSeasonInfoByPath(fullPath) .GetAwaiter() .GetResult(); @@ -125,10 +124,9 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy } // If we're using series grouping, pre-load the group now to help reduce load times later. - if (preloadShow) - show = ApiManager.GetShowInfoForSeries(season.Id, Ordering.GroupFilterType.Others) - .GetAwaiter() - .GetResult(); + show = ApiManager.GetShowInfoForSeries(season.Id, Ordering.GroupFilterType.Others) + .GetAwaiter() + .GetResult(); break; case "movies": if (season.AniDB.Type != SeriesType.Movie) { @@ -137,14 +135,13 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy } // If we're using series grouping, pre-load the group now to help reduce load times later. - if (preloadShow) - show = ApiManager.GetShowInfoForSeries(season.Id, Ordering.GroupFilterType.Movies) - .GetAwaiter() - .GetResult(); + show = ApiManager.GetShowInfoForSeries(season.Id, Ordering.GroupFilterType.Movies) + .GetAwaiter() + .GetResult(); break; } // If we're using series grouping, pre-load the group now to help reduce load times later. - else if (preloadShow) + else show = ApiManager.GetShowInfoForSeries(season.Id) .GetAwaiter() .GetResult(); diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 3729828d..de14aa0e 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -38,142 +38,86 @@ public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvid public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) { try { - return Plugin.Instance.Configuration.SeriesGrouping switch - { - Ordering.GroupType.ShokoGroup => await GetShokoGroupedMetadata(info), - _ => await GetDefaultMetadata(info), - }; - } - catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - Plugin.Instance.CaptureException(ex); - return new MetadataResult<Series>(); - } - } + var result = new MetadataResult<Series>(); + var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; + var show = await ApiManager.GetShowInfoByPath(info.Path, filterLibrary); + if (show == null) { + // Look for the "season" directories to probe for the group information + var entries = FileSystem.GetDirectories(info.Path, false); + foreach (var entry in entries) { + show = await ApiManager.GetShowInfoByPath(entry.FullName, filterLibrary); + if (show != null) + break; + } + if (show == null) { + Logger.LogWarning("Unable to find show info for path {Path}", info.Path); + return result; + } + } - private async Task<MetadataResult<Series>> GetDefaultMetadata(SeriesInfo info) - { - var result = new MetadataResult<Series>(); - var season = await ApiManager.GetSeasonInfoByPath(info.Path); - if (season == null) { - // Look for the "season" directories to probe for the series information - var entries = FileSystem.GetDirectories(info.Path, false); - foreach (var entry in entries) { - season = await ApiManager.GetSeasonInfoByPath(entry.FullName); - if (season != null) - break; + var season = show.DefaultSeason; + var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && season.TvDB != null; + var defaultSeriesTitle = mergeFriendly ? season.TvDB.Title : season.Shoko.Name; + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(season.AniDB.Titles, show.Name, info.MetadataLanguage); + Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, season.Id, show.Id); + if (mergeFriendly) { + result.Item = new Series { + Name = displayTitle, + OriginalTitle = alternateTitle, + Overview = Text.GetDescription(season), + PremiereDate = season.TvDB.AirDate, + EndDate = season.TvDB.EndDate, + ProductionYear = season.TvDB.AirDate?.Year, + Status = season.TvDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, + Tags = season.Tags.ToArray(), + Genres = season.Genres.ToArray(), + Studios = season.Studios.ToArray(), + CommunityRating = season.TvDB.Rating?.ToFloat(10), + }; + AddProviderIds(result.Item, season.Id, show.Id, season.AniDB.Id.ToString(), season.TvDB.Id.ToString(), season.Shoko.IDs.TMDB.FirstOrDefault().ToString()); } - if (season == null) { - Logger.LogWarning("Unable to find series info for path {Path}", info.Path); - return result; + else { + var premiereDate = show.SeasonList + .Select(s => s.AniDB.AirDate) + .Where(s => s != null) + .OrderBy(s => s) + .FirstOrDefault(); + var endDate = show.SeasonList.Any(s => s.AniDB.EndDate == null) ? null : show.SeasonList + .Select(s => s.AniDB.AirDate) + .OrderBy(s => s) + .LastOrDefault(); + result.Item = new Series { + Name = displayTitle, + OriginalTitle = alternateTitle, + Overview = Text.GetDescription(season), + PremiereDate = premiereDate, + ProductionYear = premiereDate?.Year, + EndDate = endDate, + Status = endDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, + Tags = show.Tags.ToArray(), + Genres = show.Genres.ToArray(), + Studios = show.Studios.ToArray(), + OfficialRating = season.AniDB.Restricted ? "XXX" : null, + CustomRating = season.AniDB.Restricted ? "XXX" : null, + CommunityRating = mergeFriendly ? season.TvDB.Rating.ToFloat(10) : season.AniDB.Rating.ToFloat(10), + }; + AddProviderIds(result.Item, season.Id, show.Id, season.AniDB.Id.ToString()); } - } - var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && season.TvDB != null; - - var defaultSeriesTitle = mergeFriendly ? season.TvDB.Title : season.Shoko.Name; - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(season.AniDB.Titles, defaultSeriesTitle, info.MetadataLanguage); - Logger.LogInformation("Found series {SeriesName} (Series={SeriesId})", displayTitle, season.Id); - - if (mergeFriendly) { - result.Item = new Series { - Name = displayTitle, - OriginalTitle = alternateTitle, - Overview = Text.GetDescription(season), - PremiereDate = season.TvDB.AirDate, - EndDate = season.TvDB.EndDate, - ProductionYear = season.TvDB.AirDate?.Year, - Status = season.TvDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = season.Tags.ToArray(), - Genres = season.Genres.ToArray(), - Studios = season.Studios.ToArray(), - CommunityRating = season.TvDB.Rating?.ToFloat(10), - }; - } - else { - result.Item = new Series { - Name = displayTitle, - OriginalTitle = alternateTitle, - Overview = Text.GetDescription(season), - PremiereDate = season.AniDB.AirDate, - EndDate = season.AniDB.EndDate, - ProductionYear = season.AniDB.AirDate?.Year, - Status = season.AniDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = season.Tags.ToArray(), - Genres = season.Genres.ToArray(), - Studios = season.Studios.ToArray(), - OfficialRating = season.AniDB.Restricted ? "XXX" : null, - CustomRating = season.AniDB.Restricted ? "XXX" : null, - CommunityRating = season.AniDB.Rating.ToFloat(10), - }; - } - AddProviderIds(result.Item, seriesId: season.Id, anidbId: season.AniDB.Id.ToString(), tvdbId: mergeFriendly ? season.TvDB.Id.ToString() : null, tmdbId: season.Shoko.IDs.TMDB.FirstOrDefault().ToString()); - result.HasMetadata = true; + result.HasMetadata = true; - result.ResetPeople(); - foreach (var person in season.Staff) - result.AddPerson(person); + result.ResetPeople(); + foreach (var person in season.Staff) + result.AddPerson(person); - return result; - } - - private async Task<MetadataResult<Series>> GetShokoGroupedMetadata(SeriesInfo info) - { - var result = new MetadataResult<Series>(); - var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var show = await ApiManager.GetShowInfoByPath(info.Path, filterLibrary); - if (show == null) { - // Look for the "season" directories to probe for the group information - var entries = FileSystem.GetDirectories(info.Path, false); - foreach (var entry in entries) { - show = await ApiManager.GetShowInfoByPath(entry.FullName, filterLibrary); - if (show != null) - break; - } - if (show == null) { - Logger.LogWarning("Unable to find group info for path {Path}", info.Path); - return result; - } + return result; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + Plugin.Instance.CaptureException(ex); + return new MetadataResult<Series>(); } - - var season = show.DefaultSeason; - var premiereDate = show.SeasonList - .Select(s => s.AniDB.AirDate) - .Where(s => s != null) - .OrderBy(s => s) - .FirstOrDefault(); - var endDate = show.SeasonList.Any(s => s.AniDB.EndDate == null) ? null : show.SeasonList - .Select(s => s.AniDB.AirDate) - .OrderBy(s => s) - .LastOrDefault(); - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(season.AniDB.Titles, show.Name, info.MetadataLanguage); - Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, season.Id, show.Id); - - result.Item = new Series { - Name = displayTitle, - OriginalTitle = alternateTitle, - Overview = Text.GetDescription(season), - PremiereDate = premiereDate, - ProductionYear = premiereDate?.Year, - EndDate = endDate, - Status = endDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = show.Tags.ToArray(), - Genres = show.Genres.ToArray(), - Studios = show.Studios.ToArray(), - OfficialRating = season.AniDB.Restricted ? "XXX" : null, - CustomRating = season.AniDB.Restricted ? "XXX" : null, - CommunityRating = season.AniDB.Rating.ToFloat(10), - }; - AddProviderIds(result.Item, seriesId: season.Id, groupId: show.Id, anidbId: season.AniDB.Id.ToString()); - - result.HasMetadata = true; - - result.ResetPeople(); - foreach (var person in season.Staff) - result.AddPerson(person); - - return result; } public static void AddProviderIds(IHasProviderIds item, string seriesId, string groupId = null, string anidbId = null, string tvdbId = null, string tmdbId = null) From 9beae961d73aaa5485f2bf845ba8ceced4f87deb Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 8 Aug 2023 08:13:28 +0200 Subject: [PATCH 0548/1103] misc: simplify helper functions --- Shokofin/Providers/BoxSetProvider.cs | 8 ++------ Shokofin/Providers/SeasonProvider.cs | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 71f16ced..486b144c 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -150,14 +150,10 @@ private static bool TryGetBoxSetName(BoxSetInfo info, out string boxSetName) } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) - { - return Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); - } + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); - } + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 89fc6986..33a8e745 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -276,14 +276,10 @@ public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchInfo, CancellationToken cancellationToken) - { - return Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); - } + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - { - return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); - } + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); private static string GetSeasonName(int seasonNumber, string seasonName) => seasonNumber switch From b64f3ce85ecc46567430558b55ec93918e25cfb4 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 8 Aug 2023 06:14:38 +0000 Subject: [PATCH 0549/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 9142547c..63b30ae4 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.19", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.19/shoko_3.0.1.19.zip", + "checksum": "06dd265bbe20caa8fabb7ed360a6b133", + "timestamp": "2023-08-08T06:14:36Z" + }, { "version": "3.0.1.18", "changelog": "NA\n", From fde4e860f3c61658c5606afa9185cfada0adc209 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 29 Aug 2023 00:24:11 +0200 Subject: [PATCH 0550/1103] misc: skip initial sync events to prevent accidential sync when miss-clicking a video --- Shokofin/Configuration/UserConfiguration.cs | 8 ++++ Shokofin/Configuration/configController.js | 1 + Shokofin/Sync/UserDataSyncManager.cs | 52 ++++++++++++++++----- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/Shokofin/Configuration/UserConfiguration.cs b/Shokofin/Configuration/UserConfiguration.cs index 34bb6387..963cd7f8 100644 --- a/Shokofin/Configuration/UserConfiguration.cs +++ b/Shokofin/Configuration/UserConfiguration.cs @@ -35,6 +35,14 @@ public class UserConfiguration /// </summary> public bool SyncUserDataUnderPlaybackLive { get; set; } + /// <summary> + /// Number of playback events to skip before starting to send the events + /// to Shoko. This is to prevent accidentially updating user watch data + /// when a user miss clicked on a video. + /// </summary> + [Range(0, 200)] + public byte SyncUserDataInitialSkipEventCount { get; set; } = 3; + /// <summary> /// Number of ticks to skip (1 tick is 10 seconds) before scrobbling to /// shoko. diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index c849598a..90d825c3 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -204,6 +204,7 @@ async function defaultSubmit(form) { userConfig.SyncUserDataAfterPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked; userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataUnderPlayback").checked; userConfig.SyncUserDataUnderPlaybackLive = form.querySelector("#SyncUserDataUnderPlaybackLive").checked; + userConfig.SyncUserDataInitialSkipEventCount = 3; userConfig.SyncUserDataUnderPlaybackAtEveryXTicks = 6; userConfig.SyncUserDataUnderPlaybackLiveThreshold = 125000000; // 12.5s userConfig.SyncRestrictedVideos = form.querySelector("#SyncRestrictedVideos").checked; diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index 03f55036..41d3220d 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -71,12 +71,32 @@ private bool TryGetUserConfiguration(Guid userId, out UserConfiguration config) #region Export/Scrobble internal class SessionMetadata { + private readonly ILogger Logger; + public Guid ItemId; public string FileId; public SessionInfo Session; public long Ticks; public byte ScrobbleTicks; public bool SentPaused; + public int SkipCount; + + public SessionMetadata(ILogger logger) + { + Logger = logger; + } + + public bool ShouldSendEvent(bool isPauseOrResumeEvent = false) + { + if (SkipCount == 0) + return true; + + if (!isPauseOrResumeEvent && SkipCount > 0) + SkipCount--; + + Logger.LogDebug("Scrobble event was skipped. (File={FileId})", FileId); + return false; + } } private readonly ConcurrentDictionary<Guid, SessionMetadata> ActiveSessions = new ConcurrentDictionary<Guid, SessionMetadata>(); @@ -84,7 +104,7 @@ internal class SessionMetadata { public void OnSessionStarted(object sender, SessionEventArgs e) { if (TryGetUserConfiguration(e.SessionInfo.UserId, out var userConfig) && userConfig.SyncUserDataUnderPlayback) { - var sessionMetadata = new SessionMetadata { + var sessionMetadata = new SessionMetadata(Logger) { ItemId = Guid.Empty, Session = e.SessionInfo, FileId = null, @@ -95,7 +115,7 @@ public void OnSessionStarted(object sender, SessionEventArgs e) } foreach (var user in e.SessionInfo.AdditionalUsers) { if (TryGetUserConfiguration(e.SessionInfo.UserId, out userConfig) && userConfig.SyncUserDataUnderPlayback) { - var sessionMetadata = new SessionMetadata { + var sessionMetadata = new SessionMetadata(Logger) { ItemId = Guid.Empty, Session = e.SessionInfo, FileId = null, @@ -148,15 +168,17 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) return; // The active video changed, so send a start event. - if (!Guid.Equals(sessionMetadata.ItemId, itemId)) { + if (sessionMetadata.ItemId != itemId) { sessionMetadata.ItemId = e.Item.Id; sessionMetadata.FileId = fileId; sessionMetadata.Ticks = userData.PlaybackPositionTicks; sessionMetadata.ScrobbleTicks = 0; sessionMetadata.SentPaused = false; + sessionMetadata.SkipCount = userConfig.SyncUserDataInitialSkipEventCount; Logger.LogInformation("Playback has started. (File={FileId})", fileId); - success = await APIClient.ScrobbleFile(fileId, episodeId, "play", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); + if (sessionMetadata.ShouldSendEvent()) + success = await APIClient.ScrobbleFile(fileId, episodeId, "play", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); } else { long ticks = sessionMetadata.Session.PlayState.PositionTicks ?? userData.PlaybackPositionTicks; @@ -168,7 +190,8 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) sessionMetadata.SentPaused = true; Logger.LogInformation("Playback was paused. (File={FileId})", fileId); - success = await APIClient.ScrobbleFile(fileId, episodeId, "pause", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); + if (sessionMetadata.ShouldSendEvent(true)) + success = await APIClient.ScrobbleFile(fileId, episodeId, "pause", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); } // The playback was resumed. else if (sessionMetadata.SentPaused) { @@ -177,7 +200,8 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) sessionMetadata.SentPaused = false; Logger.LogInformation("Playback was resumed. (File={FileId})", fileId); - success = await APIClient.ScrobbleFile(fileId, episodeId, "resume", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); + if (sessionMetadata.ShouldSendEvent(true)) + success = await APIClient.ScrobbleFile(fileId, episodeId, "resume", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); } // Return early if we're not scrobbling. else if (!userConfig.SyncUserDataUnderPlaybackLive) { @@ -194,7 +218,8 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) Logger.LogInformation("Scrobbled during playback. (File={FileId})", fileId); sessionMetadata.ScrobbleTicks = 0; - success = await APIClient.ScrobbleFile(fileId, episodeId, "scrobble", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); + if (sessionMetadata.ShouldSendEvent()) + success = await APIClient.ScrobbleFile(fileId, episodeId, "scrobble", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); } } break; @@ -203,19 +228,24 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) if (!(userConfig.SyncUserDataAfterPlayback || userConfig.SyncUserDataUnderPlayback)) return; + var shouldSendEvent = true; if (ActiveSessions.TryGetValue(e.UserId, out var sessionMetadata) && sessionMetadata.ItemId == e.Item.Id) { + shouldSendEvent = sessionMetadata.ShouldSendEvent(); + sessionMetadata.ItemId = Guid.Empty; sessionMetadata.FileId = null; sessionMetadata.Ticks = 0; sessionMetadata.ScrobbleTicks = 0; sessionMetadata.SentPaused = false; + sessionMetadata.SkipCount = -1; } Logger.LogInformation("Playback has ended. (File={FileId})", fileId); - if (!userData.Played && userData.PlaybackPositionTicks > 0) - success = await APIClient.ScrobbleFile(fileId, episodeId, "stop", userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); - else - success = await APIClient.ScrobbleFile(fileId, episodeId, "stop", userData.PlaybackPositionTicks, userData.Played, userConfig.Token).ConfigureAwait(false); + if (shouldSendEvent) + if (!userData.Played && userData.PlaybackPositionTicks > 0) + success = await APIClient.ScrobbleFile(fileId, episodeId, "stop", userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); + else + success = await APIClient.ScrobbleFile(fileId, episodeId, "stop", userData.PlaybackPositionTicks, userData.Played, userConfig.Token).ConfigureAwait(false); break; } case UserDataSaveReason.TogglePlayed: From 09b9f2639d629693fc0441ccba4c2df8ce51b988 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 28 Aug 2023 23:15:45 +0000 Subject: [PATCH 0551/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 63b30ae4..a245f4c1 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.20", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.20/shoko_3.0.1.20.zip", + "checksum": "df7578099f719fd9a68a4ddbfec003ed", + "timestamp": "2023-08-28T23:15:43Z" + }, { "version": "3.0.1.19", "changelog": "NA\n", From f9c44ee649d28d8519abbde893fd63502643bf7e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 2 Sep 2023 03:21:39 +0200 Subject: [PATCH 0552/1103] misc: fix skip sync init. events --- Shokofin/Configuration/UserConfiguration.cs | 2 +- Shokofin/Sync/UserDataSyncManager.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/Configuration/UserConfiguration.cs b/Shokofin/Configuration/UserConfiguration.cs index 963cd7f8..f1bd3eb4 100644 --- a/Shokofin/Configuration/UserConfiguration.cs +++ b/Shokofin/Configuration/UserConfiguration.cs @@ -41,7 +41,7 @@ public class UserConfiguration /// when a user miss clicked on a video. /// </summary> [Range(0, 200)] - public byte SyncUserDataInitialSkipEventCount { get; set; } = 3; + public byte SyncUserDataInitialSkipEventCount { get; set; } = 2; /// <summary> /// Number of ticks to skip (1 tick is 10 seconds) before scrobbling to diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index 41d3220d..c96992a6 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -95,7 +95,7 @@ public bool ShouldSendEvent(bool isPauseOrResumeEvent = false) SkipCount--; Logger.LogDebug("Scrobble event was skipped. (File={FileId})", FileId); - return false; + return SkipCount == 0; } } @@ -159,7 +159,7 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) var itemId = e.Item.Id; var userData = e.UserData; var config = Plugin.Instance.Configuration; - bool? success = false; + bool? success = null; switch (e.SaveReason) { // case UserDataSaveReason.PlaybackStart: // The progress event is sent at the same time, so this event is not needed. case UserDataSaveReason.PlaybackProgress: { @@ -230,7 +230,7 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) var shouldSendEvent = true; if (ActiveSessions.TryGetValue(e.UserId, out var sessionMetadata) && sessionMetadata.ItemId == e.Item.Id) { - shouldSendEvent = sessionMetadata.ShouldSendEvent(); + shouldSendEvent = sessionMetadata.ShouldSendEvent(true); sessionMetadata.ItemId = Guid.Empty; sessionMetadata.FileId = null; From a3c7cc73c385f1de97e5ad75d2137d1efefc13d4 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 2 Sep 2023 01:22:32 +0000 Subject: [PATCH 0553/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index a245f4c1..403adbae 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.21", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.21/shoko_3.0.1.21.zip", + "checksum": "07437dd75d9bce5ccda53f4d7e2dc06b", + "timestamp": "2023-09-02T01:22:30Z" + }, { "version": "3.0.1.20", "changelog": "NA\n", From d695797e0105e4ca69e316c65d526984a4402e43 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 4 Sep 2023 06:55:48 +0200 Subject: [PATCH 0554/1103] misc: fix skip sync init. event count in js file --- Shokofin/Configuration/configController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 90d825c3..7f5aa8ab 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -204,7 +204,7 @@ async function defaultSubmit(form) { userConfig.SyncUserDataAfterPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked; userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataUnderPlayback").checked; userConfig.SyncUserDataUnderPlaybackLive = form.querySelector("#SyncUserDataUnderPlaybackLive").checked; - userConfig.SyncUserDataInitialSkipEventCount = 3; + userConfig.SyncUserDataInitialSkipEventCount = 2; userConfig.SyncUserDataUnderPlaybackAtEveryXTicks = 6; userConfig.SyncUserDataUnderPlaybackLiveThreshold = 125000000; // 12.5s userConfig.SyncRestrictedVideos = form.querySelector("#SyncRestrictedVideos").checked; From 58646fe30431b75663e6ae144ac44f062e908e96 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 4 Sep 2023 04:56:35 +0000 Subject: [PATCH 0555/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 403adbae..e7aacf86 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.22", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.22/shoko_3.0.1.22.zip", + "checksum": "8d3e4a0083638ad4a25fc71ae245bf89", + "timestamp": "2023-09-04T04:56:33Z" + }, { "version": "3.0.1.21", "changelog": "NA\n", From 1be3cf9889eb6c0c83def7ce8ef26bfc3429795a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 21 Sep 2023 17:24:57 +0200 Subject: [PATCH 0556/1103] fix: track watch sessions but don't sync remote Always track the watch session if syncing during or after playback is enabled, but don't actually sync during the playback if syncing during playback is not enabled. This is to make sure the syncing after playback is not treated as an accidental event because it is below the initial skip event count if we don't track the events that would be fired during the playback. --- Shokofin/Sync/UserDataSyncManager.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index c96992a6..95bfa531 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -103,7 +103,7 @@ public bool ShouldSendEvent(bool isPauseOrResumeEvent = false) public void OnSessionStarted(object sender, SessionEventArgs e) { - if (TryGetUserConfiguration(e.SessionInfo.UserId, out var userConfig) && userConfig.SyncUserDataUnderPlayback) { + if (TryGetUserConfiguration(e.SessionInfo.UserId, out var userConfig) && (userConfig.SyncUserDataUnderPlayback || userConfig.SyncUserDataAfterPlayback)) { var sessionMetadata = new SessionMetadata(Logger) { ItemId = Guid.Empty, Session = e.SessionInfo, @@ -114,7 +114,7 @@ public void OnSessionStarted(object sender, SessionEventArgs e) ActiveSessions.TryAdd(e.SessionInfo.UserId, sessionMetadata); } foreach (var user in e.SessionInfo.AdditionalUsers) { - if (TryGetUserConfiguration(e.SessionInfo.UserId, out userConfig) && userConfig.SyncUserDataUnderPlayback) { + if (TryGetUserConfiguration(e.SessionInfo.UserId, out userConfig) && (userConfig.SyncUserDataUnderPlayback || userConfig.SyncUserDataAfterPlayback)) { var sessionMetadata = new SessionMetadata(Logger) { ItemId = Guid.Empty, Session = e.SessionInfo, @@ -177,7 +177,7 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) sessionMetadata.SkipCount = userConfig.SyncUserDataInitialSkipEventCount; Logger.LogInformation("Playback has started. (File={FileId})", fileId); - if (sessionMetadata.ShouldSendEvent()) + if (sessionMetadata.ShouldSendEvent() && userConfig.SyncUserDataUnderPlayback) success = await APIClient.ScrobbleFile(fileId, episodeId, "play", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); } else { @@ -190,7 +190,7 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) sessionMetadata.SentPaused = true; Logger.LogInformation("Playback was paused. (File={FileId})", fileId); - if (sessionMetadata.ShouldSendEvent(true)) + if (sessionMetadata.ShouldSendEvent(true) && userConfig.SyncUserDataUnderPlayback ) success = await APIClient.ScrobbleFile(fileId, episodeId, "pause", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); } // The playback was resumed. @@ -200,7 +200,7 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) sessionMetadata.SentPaused = false; Logger.LogInformation("Playback was resumed. (File={FileId})", fileId); - if (sessionMetadata.ShouldSendEvent(true)) + if (sessionMetadata.ShouldSendEvent(true) && userConfig.SyncUserDataUnderPlayback ) success = await APIClient.ScrobbleFile(fileId, episodeId, "resume", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); } // Return early if we're not scrobbling. @@ -216,10 +216,11 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) ++sessionMetadata.ScrobbleTicks < userConfig.SyncUserDataUnderPlaybackAtEveryXTicks) return; - Logger.LogInformation("Scrobbled during playback. (File={FileId})", fileId); + Logger.LogInformation("Playback is running. (File={FileId})", fileId); sessionMetadata.ScrobbleTicks = 0; - if (sessionMetadata.ShouldSendEvent()) + if (sessionMetadata.ShouldSendEvent() && userConfig.SyncUserDataUnderPlayback ) { success = await APIClient.ScrobbleFile(fileId, episodeId, "scrobble", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); + } } } break; From bb2a285820328ac8eea60898f6f16c39ec37295b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 21 Sep 2023 15:25:50 +0000 Subject: [PATCH 0557/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index e7aacf86..f6790154 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.23", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.23/shoko_3.0.1.23.zip", + "checksum": "7ee21b959208055f94dd61d6afcd612f", + "timestamp": "2023-09-21T15:25:49Z" + }, { "version": "3.0.1.22", "changelog": "NA\n", From 20e0a868add6e0384dd4d4cb0f714e69cd48853d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 25 Sep 2023 18:03:42 +0200 Subject: [PATCH 0558/1103] fix: fix status for not-yet-ended shows --- Shokofin/Providers/SeriesProvider.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index de14aa0e..d258fab6 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -68,7 +68,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat PremiereDate = season.TvDB.AirDate, EndDate = season.TvDB.EndDate, ProductionYear = season.TvDB.AirDate?.Year, - Status = season.TvDB.EndDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, + Status = !season.TvDB.EndDate.HasValue || season.TvDB.EndDate.Value > DateTime.UtcNow ? SeriesStatus.Continuing : SeriesStatus.Ended, Tags = season.Tags.ToArray(), Genres = season.Genres.ToArray(), Studios = season.Studios.ToArray(), @@ -93,7 +93,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat PremiereDate = premiereDate, ProductionYear = premiereDate?.Year, EndDate = endDate, - Status = endDate == null ? SeriesStatus.Continuing : SeriesStatus.Ended, + Status = !endDate.HasValue || endDate.Value > DateTime.UtcNow ? SeriesStatus.Continuing : SeriesStatus.Ended, Tags = show.Tags.ToArray(), Genres = show.Genres.ToArray(), Studios = show.Studios.ToArray(), @@ -104,7 +104,6 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat AddProviderIds(result.Item, season.Id, show.Id, season.AniDB.Id.ToString()); } - result.HasMetadata = true; result.ResetPeople(); From 36e5e527a15cdd1c259c2d729797dd106793c53b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 25 Sep 2023 16:04:38 +0000 Subject: [PATCH 0559/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index f6790154..0f3a6459 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.24", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.24/shoko_3.0.1.24.zip", + "checksum": "0ed70482e00836e8dc3e8fdae4415f86", + "timestamp": "2023-09-25T16:04:36Z" + }, { "version": "3.0.1.23", "changelog": "NA\n", From 6a1edfb9afcab64a791e2197286de03b968c7e78 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 25 Sep 2023 21:44:57 +0200 Subject: [PATCH 0560/1103] fix: catch directory not existing --- Shokofin/LibraryScanner.cs | 26 ++++++++++++++------------ Shokofin/Providers/SeriesProvider.cs | 22 ++++++++++++++-------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs index c246c68f..42ff18a4 100644 --- a/Shokofin/LibraryScanner.cs +++ b/Shokofin/LibraryScanner.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Linq; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -8,8 +9,6 @@ using Shokofin.API.Models; using Shokofin.Utils; -using Path = System.IO.Path; - namespace Shokofin { public class LibraryScanner : IResolverIgnoreRule @@ -92,18 +91,21 @@ private bool ScanDirectory(string partialPath, string fullPath, string libraryTy if (season == null) { // If we're in strict mode, then check the sub-directories if we have a <Show>/<Season>/<Episodes> structure. if (shouldIgnore && partialPath[1..].Split(Path.DirectorySeparatorChar).Length == 1) { - var entries = FileSystem.GetDirectories(fullPath, false).ToList(); - Logger.LogDebug("Unable to find shoko series for {Path}, trying {DirCount} sub-directories.", entries.Count, partialPath); - foreach (var entry in entries) { - season = ApiManager.GetSeasonInfoByPath(entry.FullName) - .GetAwaiter() - .GetResult(); - if (season != null) - { - Logger.LogDebug("Found shoko series {SeriesName} for sub-directory of path {Path} (Series={SeriesId})", season.Shoko.Name, partialPath, season.Id); - break; + try { + var entries = FileSystem.GetDirectories(fullPath, false).ToList(); + Logger.LogDebug("Unable to find shoko series for {Path}, trying {DirCount} sub-directories.", partialPath, entries.Count); + foreach (var entry in entries) { + season = ApiManager.GetSeasonInfoByPath(entry.FullName) + .GetAwaiter() + .GetResult(); + if (season != null) + { + Logger.LogDebug("Found shoko series {SeriesName} for sub-directory of path {Path} (Series={SeriesId})", season.Shoko.Name, partialPath, season.Id); + break; + } } } + catch (DirectoryNotFoundException) { } } if (season == null) { if (shouldIgnore) diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index d258fab6..b93f1e45 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net.Http; using System.Threading; @@ -42,15 +43,20 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; var show = await ApiManager.GetShowInfoByPath(info.Path, filterLibrary); if (show == null) { - // Look for the "season" directories to probe for the group information - var entries = FileSystem.GetDirectories(info.Path, false); - foreach (var entry in entries) { - show = await ApiManager.GetShowInfoByPath(entry.FullName, filterLibrary); - if (show != null) - break; + try { + // Look for the "season" directories to probe for the group information + var entries = FileSystem.GetDirectories(info.Path, false); + foreach (var entry in entries) { + show = await ApiManager.GetShowInfoByPath(entry.FullName, filterLibrary); + if (show != null) + break; + } + if (show == null) { + Logger.LogWarning("Unable to find show info for path {Path}", info.Path); + return result; + } } - if (show == null) { - Logger.LogWarning("Unable to find show info for path {Path}", info.Path); + catch (DirectoryNotFoundException) { return result; } } From 2650e9b696967e7737db94a1431e11b2ecd39176 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 25 Sep 2023 19:45:52 +0000 Subject: [PATCH 0561/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 0f3a6459..495082fe 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.25", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.25/shoko_3.0.1.25.zip", + "checksum": "0913584dfe30dc68dc5b45516a996ee4", + "timestamp": "2023-09-25T19:45:50Z" + }, { "version": "3.0.1.24", "changelog": "NA\n", From ab89167979df6dbe2de15121688a73f5b6a032f6 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 15 Oct 2023 17:09:15 +0200 Subject: [PATCH 0562/1103] fix: fix specials placement in normal seasons --- Shokofin/API/Info/EpisodeInfo.cs | 12 ++++++++++++ Shokofin/Providers/ExtraMetadataProvider.cs | 2 +- Shokofin/Utils/OrderingUtil.cs | 5 ++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Shokofin/API/Info/EpisodeInfo.cs b/Shokofin/API/Info/EpisodeInfo.cs index 6b19d15b..1ce029fe 100644 --- a/Shokofin/API/Info/EpisodeInfo.cs +++ b/Shokofin/API/Info/EpisodeInfo.cs @@ -2,6 +2,8 @@ using Shokofin.API.Models; using Shokofin.Utils; +using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; + #nullable enable namespace Shokofin.API.Info; @@ -17,6 +19,16 @@ public class EpisodeInfo public Episode.TvDB? TvDB; + public bool IsSpecial + { + get + { + var order = Plugin.Instance.Configuration.SpecialsPlacement; + var allowOtherData = order == SpecialOrderType.InBetweenSeasonByOtherData || order == SpecialOrderType.InBetweenSeasonMixed; + return allowOtherData ? (TvDB?.SeasonNumber == 0 || AniDB.Type == EpisodeType.Special) : AniDB.Type == EpisodeType.Special; + } + } + public EpisodeInfo(Episode episode) { Id = episode.IDs.Shoko.ToString(); diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index 3c585aae..8ae1dbde 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -494,7 +494,7 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) existingEpisodes.Add(episodeId); foreach (var episodeInfo in seasonInfo.EpisodeList) { - var episodeParentIndex = Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); + var episodeParentIndex = episodeInfo.IsSpecial ? 0 : Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); if (episodeParentIndex != seasonNumber) continue; diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 8dbfa1f9..e6bf8b63 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -233,8 +233,7 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo group, Seaso } // Abort if episode is not a TvDB special or AniDB special - var allowOtherData = order == SpecialOrderType.InBetweenSeasonByOtherData || order == SpecialOrderType.InBetweenSeasonMixed; - if (allowOtherData ? !(episode?.TvDB?.SeasonNumber == 0 || episode.AniDB.Type == EpisodeType.Special) : episode.AniDB.Type != EpisodeType.Special) + if (!episode.IsSpecial) return (null, null, null, false); int? episodeNumber = null; @@ -310,7 +309,7 @@ public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo return episode.AniDB.Type switch { EpisodeType.Normal => 1, - EpisodeType.Special => 0, + EpisodeType.Special => 1, EpisodeType.Unknown => 123, EpisodeType.Other => 124, EpisodeType.Trailer => 125, From 3a3380529ed1dd11488a012026dbc58fd3557851 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 15 Oct 2023 15:10:12 +0000 Subject: [PATCH 0563/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 495082fe..355e58ea 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.26", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.26/shoko_3.0.1.26.zip", + "checksum": "8151eb0e1981c7a6c7e588b45905b153", + "timestamp": "2023-10-15T15:10:10Z" + }, { "version": "3.0.1.25", "changelog": "NA\n", From 60f2b406cff73f2f1486aab78d07fd4430328785 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 17 Nov 2023 20:29:52 +0100 Subject: [PATCH 0564/1103] refactor: only assign file to one episode group to-do for the future: create a managed symbolic link for each episode group, and assign them to the different show/seasons where they belong. --- Shokofin/API/Info/FileInfo.cs | 7 ++++++- Shokofin/API/Models/Episode.cs | 5 +++++ Shokofin/API/ShokoAPIManager.cs | 18 +++++++++++++----- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Shokofin/API/Info/FileInfo.cs b/Shokofin/API/Info/FileInfo.cs index 7f04920c..9b3189e0 100644 --- a/Shokofin/API/Info/FileInfo.cs +++ b/Shokofin/API/Info/FileInfo.cs @@ -17,12 +17,17 @@ public class FileInfo public List<EpisodeInfo> EpisodeList; - public FileInfo(File file, List<EpisodeInfo> episodeList, string seriesId) + public List<List<EpisodeInfo>> AlternateEpisodeLists; + + public FileInfo(File file, List<List<EpisodeInfo>> groupedEpisodeLists, string seriesId) { + var episodeList = groupedEpisodeLists.FirstOrDefault() ?? new(); + var alternateEpisodeLists = groupedEpisodeLists.Count > 1 ? groupedEpisodeLists.GetRange(1, groupedEpisodeLists.Count - 1) : new(); Id = file.Id.ToString(); SeriesId = seriesId; ExtraType = episodeList.FirstOrDefault(episode => episode.ExtraType != null)?.ExtraType; File = file; EpisodeList = episodeList; + AlternateEpisodeLists = alternateEpisodeLists; } } diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index 966ad4d1..a923d3fe 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -20,6 +20,11 @@ public class Episode /// </summary> public TimeSpan Duration { get; set; } + /// <summary> + /// Indicates the episode is hidden. + /// </summary> + public bool IsHidden { get; set; } + /// <summary> /// Number of files /// </summary> diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 469fb0cb..12e22c01 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -421,6 +421,8 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) return await CreateFileInfo(file, fileId, seriesId); } + private static readonly EpisodeType[] EpisodePickOrder = { EpisodeType.Special, EpisodeType.Normal, EpisodeType.Other }; + private async Task<FileInfo> CreateFileInfo(File file, string fileId, string seriesId) { var cacheKey = $"file:{fileId}:{seriesId}"; @@ -441,16 +443,21 @@ private async Task<FileInfo> CreateFileInfo(File file, string fileId, string ser var episodeInfo = await GetEpisodeInfo(episodeId); if (episodeInfo == null) throw new Exception($"Unable to find episode cross-reference for the specified series and episode for the file. (File={fileId},Episode={episodeId},Series={seriesId})"); + if (episodeInfo.Shoko.IsHidden) { + Logger.LogDebug("Skipped hidden episode linked to file. (File={FileId},Episode={EpisodeId},Series={SeriesId})", fileId, episodeId, seriesId); + continue; + } episodeList.Add(episodeInfo); } - // Order the episodes. - episodeList = episodeList - .OrderBy(episode => episode.AniDB.Type) - .ThenBy(episode => episode.AniDB.EpisodeNumber) + // Group and order the episodes. + var groupedEpisodeLists = episodeList + .GroupBy(episode => episode.AniDB.Type) + .OrderByDescending(a => EpisodePickOrder.IndexOf(a.Key)) + .Select(epList => epList.OrderBy(episode => episode.AniDB.EpisodeNumber).ToList()) .ToList(); - fileInfo = new FileInfo(file, episodeList, seriesId); + fileInfo = new FileInfo(file, groupedEpisodeLists, seriesId); DataCache.Set<FileInfo>(cacheKey, fileInfo, DefaultTimeSpan); FileIdToEpisodeIdDictionary.TryAdd(fileId, episodeList.Select(episode => episode.Id).ToList()); @@ -620,6 +627,7 @@ private async Task<SeasonInfo> CreateSeriesInfo(Series series, string seriesId) var episodes = (await APIClient.GetEpisodesFromSeries(seriesId) ?? new()).List .Select(e => CreateEpisodeInfo(e, e.IDs.Shoko.ToString())) + .Where(e => !e.Shoko.IsHidden) .OrderBy(e => e.AniDB.AirDate) .ToList(); var cast = await APIClient.GetSeriesCast(seriesId); From 8287b5996e3feac1c127532a13a6bde5018c4878 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 17 Nov 2023 20:57:19 +0100 Subject: [PATCH 0565/1103] fix: don't try to use an extension method for `.IndexOf` --- Shokofin/API/ShokoAPIManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 12e22c01..2afe8427 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -453,7 +453,7 @@ private async Task<FileInfo> CreateFileInfo(File file, string fileId, string ser // Group and order the episodes. var groupedEpisodeLists = episodeList .GroupBy(episode => episode.AniDB.Type) - .OrderByDescending(a => EpisodePickOrder.IndexOf(a.Key)) + .OrderByDescending(a => Array.IndexOf(EpisodePickOrder, a.Key)) .Select(epList => epList.OrderBy(episode => episode.AniDB.EpisodeNumber).ToList()) .ToList(); From ff3908c2547128e15d2809d8a926ce654469b4ce Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 17 Nov 2023 19:57:56 +0000 Subject: [PATCH 0566/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 355e58ea..4ef2db06 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.27", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.27/shoko_3.0.1.27.zip", + "checksum": "4ae344ba55277d8aaf5d8aae59e2c6aa", + "timestamp": "2023-11-17T19:57:54Z" + }, { "version": "3.0.1.26", "changelog": "NA\n", From 6a99d331f98199d3d85aec38a66531820f715d5a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 28 Nov 2023 00:11:42 +0100 Subject: [PATCH 0567/1103] fix: don't double report --- Shokofin/MergeVersions/MergeVersionManager.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index ba8965e7..b65c39da 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -80,7 +80,6 @@ public async Task MergeAll(IProgress<double> progress, CancellationToken cancell episodeProgress.RegisterAction(value => { episodeProgressValue = value / 2d; progress?.Report(movieProgressValue + episodeProgressValue); - progress?.Report(50d + (value / 2d)); }); var episodeTask = MergeAllEpisodes(episodeProgress, cancellationToken); From d8954eb945db1f0c488953f563fe8a7adcaa7c3c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 28 Nov 2023 00:41:53 +0100 Subject: [PATCH 0568/1103] fix: fix image display issues for clients that need to use the public host url. --- Shokofin/API/Models/Image.cs | 20 +++++++------------- Shokofin/Providers/ImageProvider.cs | 1 - 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index bb6f9a9b..c565c43b 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -67,22 +67,16 @@ public virtual string Path => $"/api/v3/Image/{Source.ToString()}/{Type.ToString()}/{ID}"; /// <summary> - /// Get an URL to download the image on the backend. + /// Get an URL to both download the image on the backend and preview it for + /// the clients. /// </summary> + /// <remarks> + /// May or may not work 100% depending on how the servers and clients are + /// set up, but better than nothing. + /// </remarks> /// <returns>The image URL</returns> public string ToURLString() - { - return string.Concat(Plugin.Instance.Configuration.Host, Path); - } - - /// <summary> - /// Get an URL to display the image in the clients. - /// </summary> - /// <returns>The image URL</returns> - public string ToPrettyURLString() - { - return string.Concat(Plugin.Instance.Configuration.PrettyHost, Path); - } + => string.Concat(Plugin.Instance.Configuration.PrettyHost, Path); } /// <summary> diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index da1f57a2..cb2449a6 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -140,7 +140,6 @@ private static void AddImage(ref List<RemoteImageInfo> list, ImageType imageType Width = image.Width, Height = image.Height, Url = image.ToURLString(), - ThumbnailUrl = image.ToPrettyURLString(), }); } From 84014b6749041fe6f3cfbea59c30c2c4d168f5f8 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 27 Nov 2023 23:42:53 +0000 Subject: [PATCH 0569/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 4ef2db06..54e2f5a4 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.28", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.28/shoko_3.0.1.28.zip", + "checksum": "61b812ff1a2f3129956474faaf1afb2e", + "timestamp": "2023-11-27T23:42:51Z" + }, { "version": "3.0.1.27", "changelog": "NA\n", From 0d63a2f4be5cea3e5b3262dbfef62da9f18ff914 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 28 Nov 2023 02:53:39 +0100 Subject: [PATCH 0570/1103] misc: use shoko preferred title for episode when we want the main title for the series, since there is no main title for the episode, and we currently do not have any per-entity type settings. --- Shokofin/Utils/OrderingUtil.cs | 85 ++++++---------------------------- Shokofin/Utils/TextUtil.cs | 2 +- 2 files changed, 15 insertions(+), 72 deletions(-) diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index e6bf8b63..b666cc1b 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -38,6 +38,12 @@ public enum GroupType /// Group movies based on Shoko's series. /// </summary> ShokoSeries = 3, + + /// <summary> + /// Group both movies and shows into collections based on shoko's + /// groups. + /// </summary> + ShokoGroupPlus = 4, } /// <summary> @@ -78,7 +84,7 @@ public enum SpecialOrderType { AfterSeason = 2, /// <summary> - /// Use a mix of <see cref="Shokofin.Utils.Ordering.SpecialOrderType.InBetweenSeasonByOtherData" /> and <see cref="Shokofin.Utils.Ordering.SpecialOrderType.InBetweenSeasonByAirDate" />. + /// Use a mix of <see cref="InBetweenSeasonByOtherData" /> and <see cref="InBetweenSeasonByAirDate" />. /// </summary> InBetweenSeasonMixed = 3, @@ -93,68 +99,6 @@ public enum SpecialOrderType { InBetweenSeasonByOtherData = 5, } - /// <summary> - /// Get index number for a movie in a box-set. - /// </summary> - /// <returns>Absolute index.</returns> - public static int GetMovieIndexNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) - { - switch (Plugin.Instance.Configuration.BoxSetGrouping) { - default: - case GroupType.Default: - return 1; - case GroupType.ShokoSeries: - return episode.AniDB.EpisodeNumber; - case GroupType.ShokoGroup: { - int offset = 0; - foreach (SeasonInfo s in group.SeasonList) { - var sizes = s.Shoko.Sizes.Total; - if (s != series) { - if (episode.AniDB.Type == EpisodeType.Special) { - var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); - if (index == -1) - throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); - return offset - (index + 1); - } - switch (episode.AniDB.Type) { - case EpisodeType.Normal: - // offset += 0; // it's not needed, so it's just here as a comment instead. - break; - case EpisodeType.Special: - offset += sizes?.Episodes ?? 0; - goto case EpisodeType.Normal; - case EpisodeType.Unknown: - offset += sizes?.Specials ?? 0; - goto case EpisodeType.Special; - // Add them to the bottom of the list if we didn't filter them out properly. - case EpisodeType.Parody: - offset += sizes?.Others ?? 0; - goto case EpisodeType.Unknown; - case EpisodeType.OpeningSong: - offset += sizes?.Parodies ?? 0; - goto case EpisodeType.Parody; - case EpisodeType.Trailer: - offset += sizes?.Credits ?? 0; - goto case EpisodeType.OpeningSong; - default: - offset += sizes?.Trailers ?? 0; - goto case EpisodeType.Trailer; - } - return offset + episode.AniDB.EpisodeNumber; - } - else { - if (episode.AniDB.Type == EpisodeType.Special) { - offset -= series.SpecialsList.Count; - } - offset += (sizes?.Episodes ?? 0) + (sizes?.Parodies ?? 0) + (sizes?.Others ?? 0); - } - } - break; - } - } - return 0; - } - /// <summary> /// Get index number for an episode in a series. /// </summary> @@ -169,7 +113,7 @@ public static int GetEpisodeNumber(ShowInfo group, SeasonInfo series, EpisodeInf var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); if (index == -1) throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Series={series.Id},Episode={episode.Id})"); - return (index + 1); + return index + 1; } return episode.AniDB.EpisodeNumber; case GroupType.MergeFriendly: { @@ -181,14 +125,14 @@ public static int GetEpisodeNumber(ShowInfo group, SeasonInfo series, EpisodeInf case GroupType.ShokoGroup: { int offset = 0; if (episode.AniDB.Type == EpisodeType.Special) { - var seriesIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); - if (seriesIndex == -1) + var seasonIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); + if (seasonIndex == -1) throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); if (index == -1) throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); - offset = group.SeasonList.GetRange(0, seriesIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); - return offset + (index + 1); + offset = group.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); + return offset + index + 1; } var sizes = series.Shoko.Sizes.Total; switch (episode.AniDB.Type) { @@ -332,7 +276,6 @@ public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo if (!group.SeasonNumberBaseDictionary.TryGetValue(series, out var seasonNumber)) throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id})"); - var offset = 0; switch (episode.AniDB.Type) { default: @@ -347,7 +290,7 @@ public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo } } - return seasonNumber + (seasonNumber < 0 ? -offset : offset); + return seasonNumber + offset; } } } @@ -384,7 +327,7 @@ public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo return ExtraType.Clip; // Music videos if (title.Contains("music video", System.StringComparison.OrdinalIgnoreCase)) - return ExtraType.Clip; + return ExtraType.ThemeVideo; // Behind the Scenes if (title.Contains("making of", System.StringComparison.CurrentCultureIgnoreCase)) return ExtraType.BehindTheScenes; diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index a796583a..c17dbb4c 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -309,7 +309,7 @@ public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title // Display the main title. case DisplayLanguageType.Main: { var getSeriesTitle = () => GetTitleByType(seriesTitles, TitleType.Main) ?? seriesTitle; - var getEpisodeTitle = () => GetTitleByLanguages(episodeTitles, "en", mainTitleLanguage) ?? episodeTitle; + var getEpisodeTitle = () => episodeTitle; return ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); } } From 78763aca5a2bb8a951496597975ee5d6e5751483 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 28 Nov 2023 03:15:40 +0100 Subject: [PATCH 0571/1103] Partially revert "misc: use shoko preferred title for episode" This partially reverts commit 0d63a2f4be5cea3e5b3262dbfef62da9f18ff914. --- Shokofin/Utils/OrderingUtil.cs | 85 ++++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index b666cc1b..e6bf8b63 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -38,12 +38,6 @@ public enum GroupType /// Group movies based on Shoko's series. /// </summary> ShokoSeries = 3, - - /// <summary> - /// Group both movies and shows into collections based on shoko's - /// groups. - /// </summary> - ShokoGroupPlus = 4, } /// <summary> @@ -84,7 +78,7 @@ public enum SpecialOrderType { AfterSeason = 2, /// <summary> - /// Use a mix of <see cref="InBetweenSeasonByOtherData" /> and <see cref="InBetweenSeasonByAirDate" />. + /// Use a mix of <see cref="Shokofin.Utils.Ordering.SpecialOrderType.InBetweenSeasonByOtherData" /> and <see cref="Shokofin.Utils.Ordering.SpecialOrderType.InBetweenSeasonByAirDate" />. /// </summary> InBetweenSeasonMixed = 3, @@ -99,6 +93,68 @@ public enum SpecialOrderType { InBetweenSeasonByOtherData = 5, } + /// <summary> + /// Get index number for a movie in a box-set. + /// </summary> + /// <returns>Absolute index.</returns> + public static int GetMovieIndexNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) + { + switch (Plugin.Instance.Configuration.BoxSetGrouping) { + default: + case GroupType.Default: + return 1; + case GroupType.ShokoSeries: + return episode.AniDB.EpisodeNumber; + case GroupType.ShokoGroup: { + int offset = 0; + foreach (SeasonInfo s in group.SeasonList) { + var sizes = s.Shoko.Sizes.Total; + if (s != series) { + if (episode.AniDB.Type == EpisodeType.Special) { + var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); + if (index == -1) + throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); + return offset - (index + 1); + } + switch (episode.AniDB.Type) { + case EpisodeType.Normal: + // offset += 0; // it's not needed, so it's just here as a comment instead. + break; + case EpisodeType.Special: + offset += sizes?.Episodes ?? 0; + goto case EpisodeType.Normal; + case EpisodeType.Unknown: + offset += sizes?.Specials ?? 0; + goto case EpisodeType.Special; + // Add them to the bottom of the list if we didn't filter them out properly. + case EpisodeType.Parody: + offset += sizes?.Others ?? 0; + goto case EpisodeType.Unknown; + case EpisodeType.OpeningSong: + offset += sizes?.Parodies ?? 0; + goto case EpisodeType.Parody; + case EpisodeType.Trailer: + offset += sizes?.Credits ?? 0; + goto case EpisodeType.OpeningSong; + default: + offset += sizes?.Trailers ?? 0; + goto case EpisodeType.Trailer; + } + return offset + episode.AniDB.EpisodeNumber; + } + else { + if (episode.AniDB.Type == EpisodeType.Special) { + offset -= series.SpecialsList.Count; + } + offset += (sizes?.Episodes ?? 0) + (sizes?.Parodies ?? 0) + (sizes?.Others ?? 0); + } + } + break; + } + } + return 0; + } + /// <summary> /// Get index number for an episode in a series. /// </summary> @@ -113,7 +169,7 @@ public static int GetEpisodeNumber(ShowInfo group, SeasonInfo series, EpisodeInf var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); if (index == -1) throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Series={series.Id},Episode={episode.Id})"); - return index + 1; + return (index + 1); } return episode.AniDB.EpisodeNumber; case GroupType.MergeFriendly: { @@ -125,14 +181,14 @@ public static int GetEpisodeNumber(ShowInfo group, SeasonInfo series, EpisodeInf case GroupType.ShokoGroup: { int offset = 0; if (episode.AniDB.Type == EpisodeType.Special) { - var seasonIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); - if (seasonIndex == -1) + var seriesIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); + if (seriesIndex == -1) throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); if (index == -1) throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); - offset = group.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); - return offset + index + 1; + offset = group.SeasonList.GetRange(0, seriesIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); + return offset + (index + 1); } var sizes = series.Shoko.Sizes.Total; switch (episode.AniDB.Type) { @@ -276,6 +332,7 @@ public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo if (!group.SeasonNumberBaseDictionary.TryGetValue(series, out var seasonNumber)) throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id})"); + var offset = 0; switch (episode.AniDB.Type) { default: @@ -290,7 +347,7 @@ public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo } } - return seasonNumber + offset; + return seasonNumber + (seasonNumber < 0 ? -offset : offset); } } } @@ -327,7 +384,7 @@ public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo return ExtraType.Clip; // Music videos if (title.Contains("music video", System.StringComparison.OrdinalIgnoreCase)) - return ExtraType.ThemeVideo; + return ExtraType.Clip; // Behind the Scenes if (title.Contains("making of", System.StringComparison.CurrentCultureIgnoreCase)) return ExtraType.BehindTheScenes; From add53a7f31a028c272bdd1bf68953769d3ac719b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 28 Nov 2023 02:16:22 +0000 Subject: [PATCH 0572/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 54e2f5a4..34b87bf1 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.29", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.29/shoko_3.0.1.29.zip", + "checksum": "325d37ceab46ce21a311a8a2387d159c", + "timestamp": "2023-11-28T02:16:20Z" + }, { "version": "3.0.1.28", "changelog": "NA\n", From 80287a697ed4254d26134a5216c465c8b49121bb Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 20 Dec 2023 16:09:27 +0530 Subject: [PATCH 0573/1103] Fix API call for `Series/{seriesId}/File` --- 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 d80a28f2..2488ca62 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -187,7 +187,7 @@ public Task<List<File>> GetFileByPath(string path) public Task<List<File>> GetFilesForSeries(string seriesId) { - return Get<List<File>>($"/api/v3/Series/{seriesId}/File?includeXRefs=true&includeDataFrom=AniDB"); + return Get<List<File>>($"/api/v3/Series/{seriesId}/File?include=XRefs&includeDataFrom=AniDB"); } public async Task<File.UserStats?> GetFileUserStats(string fileId, string? apiKey = null) From 881fe8179c18f13b1c3e9ba0185e2afba53aae37 Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Wed, 20 Dec 2023 10:40:49 +0000 Subject: [PATCH 0574/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 34b87bf1..af67988c 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.30", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.30/shoko_3.0.1.30.zip", + "checksum": "f8be84bf21f98a3e18a02f52a0483079", + "timestamp": "2023-12-20T10:40:46Z" + }, { "version": "3.0.1.29", "changelog": "NA\n", From 6e4bcf89cf84dff5f7b3a484445b261e50ae38bd Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 20 Dec 2023 17:09:46 +0530 Subject: [PATCH 0575/1103] Fix API call for `Series/{seriesId}/File` again --- Shokofin/API/ShokoAPIClient.cs | 4 ++-- Shokofin/API/ShokoAPIManager.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 2488ca62..b458eb01 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -185,9 +185,9 @@ public Task<List<File>> GetFileByPath(string path) return Get<List<File>>($"/api/v3/File/PathEndsWith?path={Uri.EscapeDataString(path)}&includeDataFrom=AniDB&limit=1"); } - public Task<List<File>> GetFilesForSeries(string seriesId) + public Task<ListResult<File>> GetFilesForSeries(string seriesId) { - return Get<List<File>>($"/api/v3/Series/{seriesId}/File?include=XRefs&includeDataFrom=AniDB"); + return Get<ListResult<File>>($"/api/v3/Series/{seriesId}/File?include=XRefs&includeDataFrom=AniDB"); } public async Task<File.UserStats?> GetFileUserStats(string fileId, string? apiKey = null) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 2afe8427..2d428c8b 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -314,7 +314,7 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) var pathSet = new HashSet<string>(); var episodeIds = new HashSet<string>(); - foreach (var file in await APIClient.GetFilesForSeries(seriesId)) { + foreach (var file in (await APIClient.GetFilesForSeries(seriesId) ?? new ()).List) { if (file.CrossReferences.Count == 1) foreach (var fileLocation in file.Locations) pathSet.Add((Path.GetDirectoryName(fileLocation.Path) ?? "") + Path.DirectorySeparatorChar); From c83a539a6aca14eca8378ddad0da7a16dce289ff Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Wed, 20 Dec 2023 11:40:40 +0000 Subject: [PATCH 0576/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index af67988c..955b4934 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.31", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.31/shoko_3.0.1.31.zip", + "checksum": "9a6538dd9ff1ddc432f3630ddda5bd7b", + "timestamp": "2023-12-20T11:40:38Z" + }, { "version": "3.0.1.30", "changelog": "NA\n", From 2a6916d60efb5435435e3534d798bb68b7194ac0 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Wed, 20 Dec 2023 19:38:04 +0530 Subject: [PATCH 0577/1103] Set pageSize to 0 on `Series/{seriesId}/File` --- 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 b458eb01..9eb6c3b8 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -187,7 +187,7 @@ public Task<List<File>> GetFileByPath(string path) public Task<ListResult<File>> GetFilesForSeries(string seriesId) { - return Get<ListResult<File>>($"/api/v3/Series/{seriesId}/File?include=XRefs&includeDataFrom=AniDB"); + return Get<ListResult<File>>($"/api/v3/Series/{seriesId}/File?pageSize=0&include=XRefs&includeDataFrom=AniDB"); } public async Task<File.UserStats?> GetFileUserStats(string fileId, string? apiKey = null) From 915a3f406e6249c9aafa4a54ecc13ba7aa7463f0 Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Wed, 20 Dec 2023 14:09:09 +0000 Subject: [PATCH 0578/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 955b4934..434d2f57 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.32", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.32/shoko_3.0.1.32.zip", + "checksum": "fffee5d1fb54590bf91dd0266c65d8c0", + "timestamp": "2023-12-20T14:09:07Z" + }, { "version": "3.0.1.31", "changelog": "NA\n", From b5d24940e74bd3b52ee9dfe8c876401027db389c Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Sat, 30 Dec 2023 08:41:59 +0530 Subject: [PATCH 0579/1103] Fix `File/{id}` API call --- 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 9eb6c3b8..8a8abf58 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -177,7 +177,7 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method public Task<File> GetFile(string id) { - return Get<File>($"/api/v3/File/{id}?includeXRefs=true&includeDataFrom=AniDB"); + return Get<File>($"/api/v3/File/{id}?include=XRefs&includeDataFrom=AniDB"); } public Task<List<File>> GetFileByPath(string path) From c5e93b2ae2a6411beca5de40c2c9d7f544d6b41f Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Sat, 30 Dec 2023 03:13:06 +0000 Subject: [PATCH 0580/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 434d2f57..37d8b3dc 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.33", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.33/shoko_3.0.1.33.zip", + "checksum": "63494f377f9f89eeae46d02f1fd8bd67", + "timestamp": "2023-12-30T03:13:04Z" + }, { "version": "3.0.1.32", "changelog": "NA\n", From 729c1392340a18f268c4cc2e1efd65a7cac6b902 Mon Sep 17 00:00:00 2001 From: Mikal S <7761729+revam@users.noreply.github.com> Date: Sat, 6 Jan 2024 00:42:30 +0100 Subject: [PATCH 0581/1103] fix: update merge all episodes to use the correct configuration value to check if it should split the existing merged episodes before merging. --- Shokofin/MergeVersions/MergeVersionManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index b65c39da..3eb69056 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -292,7 +292,7 @@ public async Task MergeEpisodes(IEnumerable<Episode> episodes) /// complete.</returns> public async Task MergeAllEpisodes(IProgress<double> progress, CancellationToken cancellationToken) { - if (Plugin.Instance.Configuration.EXPERIMENTAL_SplitThenMergeMovies) { + if (Plugin.Instance.Configuration.EXPERIMENTAL_SplitThenMergeEpisodes) { await SplitAndMergeAllEpisodes(progress, cancellationToken); return; } From 29f5691f0fc81f76e16cf93f4e8cb16d5d864536 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 5 Jan 2024 23:43:08 +0000 Subject: [PATCH 0582/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 37d8b3dc..15e4a6f1 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.34", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.34/shoko_3.0.1.34.zip", + "checksum": "6ff9a10177d5e16019d64108162a4ee2", + "timestamp": "2024-01-05T23:43:06Z" + }, { "version": "3.0.1.33", "changelog": "NA\n", From 11e8c10ecd1a6e270dbbe3d3642ece89c34631fd Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 3 Mar 2024 01:27:42 +0100 Subject: [PATCH 0583/1103] fix: lazy sync watch state --- Shokofin/Configuration/UserConfiguration.cs | 2 +- Shokofin/Configuration/configController.js | 5 ++++- Shokofin/Configuration/configPage.html | 7 +++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Shokofin/Configuration/UserConfiguration.cs b/Shokofin/Configuration/UserConfiguration.cs index f1bd3eb4..d122fe4d 100644 --- a/Shokofin/Configuration/UserConfiguration.cs +++ b/Shokofin/Configuration/UserConfiguration.cs @@ -41,7 +41,7 @@ public class UserConfiguration /// when a user miss clicked on a video. /// </summary> [Range(0, 200)] - public byte SyncUserDataInitialSkipEventCount { get; set; } = 2; + public byte SyncUserDataInitialSkipEventCount { get; set; } = 0; /// <summary> /// Number of ticks to skip (1 tick is 10 seconds) before scrobbling to diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 7f5aa8ab..f4987a11 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -87,6 +87,7 @@ async function loadUserConfig(form, userId, config) { 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 @@ -204,7 +205,7 @@ async function defaultSubmit(form) { userConfig.SyncUserDataAfterPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked; userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataUnderPlayback").checked; userConfig.SyncUserDataUnderPlaybackLive = form.querySelector("#SyncUserDataUnderPlaybackLive").checked; - userConfig.SyncUserDataInitialSkipEventCount = 2; + userConfig.SyncUserDataInitialSkipEventCount = form.querySelector("#SyncUserDataInitialSkipEventCount").checked ? 2 : 0; userConfig.SyncUserDataUnderPlaybackAtEveryXTicks = 6; userConfig.SyncUserDataUnderPlaybackLiveThreshold = 125000000; // 12.5s userConfig.SyncRestrictedVideos = form.querySelector("#SyncRestrictedVideos").checked; @@ -407,6 +408,7 @@ async function syncUserSettings(form) { 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; @@ -504,6 +506,7 @@ export default function (page) { form.querySelector("#SyncUserDataAfterPlayback").disabled = disabled; form.querySelector("#SyncUserDataUnderPlayback").disabled = disabled; form.querySelector("#SyncUserDataUnderPlaybackLive").disabled = disabled; + form.querySelector("#SyncUserDataInitialSkipEventCount").disabled = disabled; }); page.addEventListener("viewshow", async function () { diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index cd550b97..3811faa1 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -269,6 +269,13 @@ <h3>User Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back the watch-state to Shoko live during 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> + </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 accidential 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> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SyncRestrictedVideos" /> From 1abd5239d91465cbafea6281854b923e2a3fed38 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 3 Mar 2024 00:28:24 +0000 Subject: [PATCH 0584/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 15e4a6f1..63408c7f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.35", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.35/shoko_3.0.1.35.zip", + "checksum": "85e81a2b8249974a9695244aca39bc2a", + "timestamp": "2024-03-03T00:28:23Z" + }, { "version": "3.0.1.34", "changelog": "NA\n", From 1c8fac44f88691d6944b230d39a9e4876210bf93 Mon Sep 17 00:00:00 2001 From: Mikal S <7761729+revam@users.noreply.github.com> Date: Sun, 10 Mar 2024 22:53:59 +0100 Subject: [PATCH 0585/1103] misc: add 10.9 to the version matrix [skip ci] --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4b3f3c4e..d5c6d2a9 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ compatible with what. | `2.x.x` | `10.8` | `4.1.2` | | `3.x.x` | `10.8` | `4.2.0` | | `unstable` | `10.8` | `dev` | +| `N/A` | `10.9` | `N/A` | ### Official Repository From bee74e2b5843f0b5f16213c8a8d1e8c9f1f55be8 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 24 Mar 2024 05:05:00 +0100 Subject: [PATCH 0586/1103] feat: add EXPERIMENTAL custom resolver missing subtitle support and maybe more. --- Shokofin/API/ShokoAPIClient.cs | 6 + Shokofin/API/ShokoAPIManager.cs | 42 +- Shokofin/Configuration/PluginConfiguration.cs | 3 + Shokofin/Configuration/configController.js | 3 + Shokofin/Configuration/configPage.html | 7 + Shokofin/IdLookup.cs | 2 +- Shokofin/LibraryScanner.cs | 185 ------- Shokofin/Plugin.cs | 3 + Shokofin/PluginServiceRegistrator.cs | 9 +- Shokofin/Providers/ImageProvider.cs | 1 - Shokofin/Resolvers/ShokoResolveManager.cs | 517 ++++++++++++++++++ Shokofin/Resolvers/ShokoResolver.cs | 31 ++ Shokofin/StringExtensions.cs | 42 ++ 13 files changed, 633 insertions(+), 218 deletions(-) delete mode 100644 Shokofin/LibraryScanner.cs create mode 100644 Shokofin/Resolvers/ShokoResolveManager.cs create mode 100644 Shokofin/Resolvers/ShokoResolver.cs create mode 100644 Shokofin/StringExtensions.cs diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 8a8abf58..4ef8c18f 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -190,6 +190,12 @@ public Task<ListResult<File>> GetFilesForSeries(string seriesId) return Get<ListResult<File>>($"/api/v3/Series/{seriesId}/File?pageSize=0&include=XRefs&includeDataFrom=AniDB"); } + public async Task<IReadOnlyList<File>> GetFilesForImportFolder(int importFolderId) + { + var listResult = await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?pageSize=0&includeXRefs=true"); + return listResult.List; + } + public async Task<File.UserStats?> GetFileUserStats(string fileId, string? apiKey = null) { try diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 2d428c8b..8d6e92d7 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -69,34 +69,29 @@ public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient #region Ignore rule - public Folder FindMediaFolder(string path) - { - Folder? mediaFolder = null; - lock (MediaFolderListLock) { - mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); - } - if (mediaFolder == null) { - var parent = LibraryManager.FindByPath(Path.GetDirectoryName(path), true) as Folder; - if (parent == null) - throw new Exception($"Unable to find parent folder for \"{path}\""); - - mediaFolder = FindMediaFolder(path, parent, LibraryManager.RootFolder); - } - - return mediaFolder; - } + public static string GetVirtualRootForMediaFolder(Folder mediaFolder) + => Path.Combine(Plugin.Instance.VirtualRoot, mediaFolder.Id.ToString()); - public Folder FindMediaFolder(string path, Folder parent, Folder root) + public (Folder mediaFolder, string partialPath) FindMediaFolder(string path, Folder parent, Folder root) { Folder? mediaFolder = null; + if (path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { + var mediaFolderId = Guid.Parse(path[(Plugin.Instance.VirtualRoot.Length + 1)..].Split(Path.DirectorySeparatorChar).First()); + mediaFolder = LibraryManager.GetItemById(mediaFolderId) as Folder; + if (mediaFolder != null) { + var mediaRootVirtualPath = GetVirtualRootForMediaFolder(mediaFolder); + return (mediaFolder, path[mediaRootVirtualPath.Length..]); + } + return (root, path); + } lock (MediaFolderListLock) { mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); } - // Look for the root folder for the current item. if (mediaFolder != null) { - return mediaFolder; + return (mediaFolder, path[mediaFolder.Path.Length..]); } + // Look for the root folder for the current item. mediaFolder = parent; while (!mediaFolder.ParentId.Equals(root.Id)) { if (mediaFolder.GetParent() == null) { @@ -108,7 +103,7 @@ public Folder FindMediaFolder(string path, Folder parent, Folder root) lock (MediaFolderListLock) { MediaFolderList.Add(mediaFolder); } - return mediaFolder; + return (mediaFolder, path[mediaFolder.Path.Length..]); } public string StripMediaFolder(string fullPath) @@ -147,13 +142,6 @@ public string StripMediaFolder(string fullPath) return fullPath.Substring(mediaFolder.Path.Length); } - public bool IsInMixedLibrary(ItemLookupInfo info) - { - var mediaFolder = FindMediaFolder(info.Path); - var type = LibraryManager.GetInheritedContentType(mediaFolder); - return !string.IsNullOrEmpty(type) && type == "mixed"; - } - #endregion #region Clear diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index b51c5a00..d16606e7 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -92,6 +92,8 @@ public virtual string PrettyHost #region Experimental features + public bool EXPERIMENTAL_EnableResolver { get; set; } + public bool EXPERIMENTAL_AutoMergeVersions { get; set; } public bool EXPERIMENTAL_SplitThenMergeMovies { get; set; } @@ -139,6 +141,7 @@ public PluginConfiguration() IgnoredFolders = new [] { ".streams", "@recently-snapshot" }; SentryEnabled = null; LibraryFilteringMode = null; + EXPERIMENTAL_EnableResolver = false; EXPERIMENTAL_AutoMergeVersions = false; EXPERIMENTAL_SplitThenMergeMovies = true; EXPERIMENTAL_SplitThenMergeEpisodes = false; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index f4987a11..6aba07ae 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -185,6 +185,7 @@ async function defaultSubmit(form) { // Experimental settings config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; + config.EXPERIMENTAL_EnableResolver = form.querySelector("#EXPERIMENTAL_EnableResolver").checked; config.EXPERIMENTAL_AutoMergeVersions = form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked; config.EXPERIMENTAL_SplitThenMergeMovies = form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked; config.EXPERIMENTAL_SplitThenMergeEpisodes = form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked; @@ -363,6 +364,7 @@ async function syncSettings(form) { // Experimental settings config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; + config.EXPERIMENTAL_EnableResolver = form.querySelector("#EXPERIMENTAL_EnableResolver").checked; config.EXPERIMENTAL_AutoMergeVersions = form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked; config.EXPERIMENTAL_SplitThenMergeMovies = form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked; config.EXPERIMENTAL_SplitThenMergeEpisodes = form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked; @@ -562,6 +564,7 @@ export default function (page) { // Experimental settings form.querySelector("#SeasonOrdering").value = config.SeasonOrdering; + form.querySelector("#EXPERIMENTAL_EnableResolver").checked = config.EXPERIMENTAL_EnableResolver || false; form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked = config.EXPERIMENTAL_AutoMergeVersions || false; form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked = config.EXPERIMENTAL_SplitThenMergeMovies || true; form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked = config.EXPERIMENTAL_SplitThenMergeEpisodes || false; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 3811faa1..9e7c9603 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -384,6 +384,13 @@ <h3>Advanced 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_EnableResolver" /> + <span>Enable Custom Resolver</span> + </label> + <div class="fieldDescription checkboxFieldDescription"><div>Enables the custom resolver for the plugin.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What is this setting for?</summary>Enabling this setting should in theory make it so you won't have to think about file structure imcompatibilities, since it will override your existing file structure and replace it with a layer of symbolic links instead. <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="SeasonOrdering">Season ordering:</label> <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select"> diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index c0856063..2ba19bf0 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -112,7 +112,7 @@ public IdLookup(ShokoAPIManager apiManager, ILibraryManager libraryManager) #region Base Item - private readonly HashSet<string> AllowedTypes = new() { nameof(Series), nameof(Episode), nameof(Movie) }; + private readonly HashSet<string> AllowedTypes = new() { nameof(Series), nameof(Season), nameof(Episode), nameof(Movie) }; public bool IsEnabledForItem(BaseItem item) => IsEnabledForItem(item, out var _); diff --git a/Shokofin/LibraryScanner.cs b/Shokofin/LibraryScanner.cs deleted file mode 100644 index 42ff18a4..00000000 --- a/Shokofin/LibraryScanner.cs +++ /dev/null @@ -1,185 +0,0 @@ -using System.IO; -using System.Linq; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Resolvers; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; -using Shokofin.API; -using Shokofin.API.Models; -using Shokofin.Utils; - -namespace Shokofin -{ - public class LibraryScanner : IResolverIgnoreRule - { - private readonly ShokoAPIManager ApiManager; - - private readonly IIdLookup Lookup; - - private readonly ILibraryManager LibraryManager; - - private readonly IFileSystem FileSystem; - - private readonly ILogger<LibraryScanner> Logger; - - public LibraryScanner(ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager, IFileSystem fileSystem, ILogger<LibraryScanner> logger) - { - ApiManager = apiManager; - Lookup = lookup; - LibraryManager = libraryManager; - FileSystem = fileSystem; - Logger = logger; - } - - /// <summary> - /// It's not really meant to be used this way, but this is our library - /// "scanner". It scans the files and folders, and conditionally filters - /// out _some_ of the files and/or folders. - /// </summary> - /// <param name="fileInfo"></param> - /// <param name="parent"></param> - /// <returns>True if the entry should be ignored.</returns> - public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent) - { - // Everything in the root folder is ignored by us. - var root = LibraryManager.RootFolder; - if (fileInfo == null || parent == null || root == null || parent == root || !(parent is Folder parentFolder) || fileInfo.FullName.StartsWith(root.Path)) - return false; - - try { - // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. - if (!Lookup.IsEnabledForItem(parent, out var isSoleProvider)) - return false; - - if (fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(Path.GetFileName(fileInfo.FullName).ToLowerInvariant())) { - Logger.LogDebug("Excluded folder at path {Path}", fileInfo.FullName); - return true; - } - - if (!fileInfo.IsDirectory && Plugin.Instance.IgnoredFileExtensions.Contains(fileInfo.Extension.ToLowerInvariant())) { - Logger.LogDebug("Skipped excluded file at path {Path}", fileInfo.FullName); - return false; - } - - var fullPath = fileInfo.FullName; - var mediaFolder = ApiManager.FindMediaFolder(fullPath, parentFolder, root); - var partialPath = fullPath.Substring(mediaFolder.Path.Length); - var shouldIgnore = Plugin.Instance.Configuration.LibraryFilteringMode ?? isSoleProvider; - if (fileInfo.IsDirectory) - return ScanDirectory(partialPath, fullPath, LibraryManager.GetInheritedContentType(parentFolder), shouldIgnore); - else - return ScanFile(partialPath, fullPath, shouldIgnore); - } - catch (System.Exception ex) { - if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) - { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - Plugin.Instance.CaptureException(ex); - } - return false; - } - } - - private bool ScanDirectory(string partialPath, string fullPath, string libraryType, bool shouldIgnore) - { - var season = ApiManager.GetSeasonInfoByPath(fullPath) - .GetAwaiter() - .GetResult(); - - // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given folder path. - if (season == null) { - // If we're in strict mode, then check the sub-directories if we have a <Show>/<Season>/<Episodes> structure. - if (shouldIgnore && partialPath[1..].Split(Path.DirectorySeparatorChar).Length == 1) { - try { - var entries = FileSystem.GetDirectories(fullPath, false).ToList(); - Logger.LogDebug("Unable to find shoko series for {Path}, trying {DirCount} sub-directories.", partialPath, entries.Count); - foreach (var entry in entries) { - season = ApiManager.GetSeasonInfoByPath(entry.FullName) - .GetAwaiter() - .GetResult(); - if (season != null) - { - Logger.LogDebug("Found shoko series {SeriesName} for sub-directory of path {Path} (Series={SeriesId})", season.Shoko.Name, partialPath, season.Id); - break; - } - } - } - catch (DirectoryNotFoundException) { } - } - if (season == null) { - if (shouldIgnore) - Logger.LogInformation("Ignored unknown folder at path {Path}", partialPath); - else - Logger.LogWarning("Skipped unknown folder at path {Path}", partialPath); - return shouldIgnore; - } - } - - API.Info.ShowInfo show = null; - // Filter library if we enabled the option. - if (Plugin.Instance.Configuration.FilterOnLibraryTypes) switch (libraryType) { - case "tvshows": - if (season.AniDB.Type == SeriesType.Movie) { - Logger.LogInformation("Library separation is enabled, ignoring series. (Series={SeriesId})", season.Id); - return true; - } - - // If we're using series grouping, pre-load the group now to help reduce load times later. - show = ApiManager.GetShowInfoForSeries(season.Id, Ordering.GroupFilterType.Others) - .GetAwaiter() - .GetResult(); - break; - case "movies": - if (season.AniDB.Type != SeriesType.Movie) { - Logger.LogInformation("Library separation is enabled, ignoring series. (Series={SeriesId})", season.Id); - return true; - } - - // If we're using series grouping, pre-load the group now to help reduce load times later. - show = ApiManager.GetShowInfoForSeries(season.Id, Ordering.GroupFilterType.Movies) - .GetAwaiter() - .GetResult(); - break; - } - // If we're using series grouping, pre-load the group now to help reduce load times later. - else - show = ApiManager.GetShowInfoForSeries(season.Id) - .GetAwaiter() - .GetResult(); - - if (show != null) - Logger.LogInformation("Found shoko group {GroupName} (Series={SeriesId},Group={GroupId})", show.Name, season.Id, show.Id); - else - Logger.LogInformation("Found shoko series {SeriesName} (Series={SeriesId})", season.Shoko.Name, season.Id); - - return false; - } - - private bool ScanFile(string partialPath, string fullPath, bool shouldIgnore) - { - var (file, series, _) = ApiManager.GetFileInfoByPath(fullPath, null) - .GetAwaiter() - .GetResult(); - - // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given file path. - if (file == null) { - if (shouldIgnore) - Logger.LogInformation("Ignored unknown file at path {Path}", partialPath); - else - Logger.LogWarning("Skipped unknown file at path {Path}", partialPath); - return shouldIgnore; - } - - Logger.LogInformation("Found {EpisodeCount} shoko episode(s) for {SeriesName} (Series={SeriesId},File={FileId})", file.EpisodeList.Count, series.Shoko.Name, series.Id, file.Id); - - // We're going to post process this file later, but we don't want to include it in our library for now. - if (file.ExtraType != null) { - Logger.LogInformation("File was assigned an extra type, ignoring file. (Series={SeriesId},File={FileId})", series.Id, file.Id); - return true; - } - - return false; - } - } -} \ No newline at end of file diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 7308dad1..bcec597d 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; @@ -23,6 +24,8 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages public override Guid Id => Guid.Parse("5216ccbf-d24a-4eb3-8a7e-7da4230b7052"); + public string VirtualRoot => Path.Combine(DataFolderPath, "VFS"); + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) { Instance = this; diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index f77811ca..c3e0001a 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -10,11 +10,12 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator /// <inheritdoc /> public void RegisterServices(IServiceCollection serviceCollection) { - serviceCollection.AddSingleton<Shokofin.API.ShokoAPIClient>(); - serviceCollection.AddSingleton<Shokofin.API.ShokoAPIManager>(); + serviceCollection.AddSingleton<API.ShokoAPIClient>(); + serviceCollection.AddSingleton<API.ShokoAPIManager>(); serviceCollection.AddSingleton<IIdLookup, IdLookup>(); - serviceCollection.AddSingleton<Shokofin.Sync.UserDataSyncManager>(); - serviceCollection.AddSingleton<Shokofin.MergeVersions.MergeVersionsManager>(); + serviceCollection.AddSingleton<Sync.UserDataSyncManager>(); + serviceCollection.AddSingleton<MergeVersions.MergeVersionsManager>(); + serviceCollection.AddSingleton<Resolvers.ShokoResolveManager>(); } } } \ No newline at end of file diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index cb2449a6..92efb2a7 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -42,7 +42,6 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell { var list = new List<RemoteImageInfo>(); try { - var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Utils.Ordering.GroupFilterType.Others : Utils.Ordering.GroupFilterType.Default; switch (item) { case Episode episode: { if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs new file mode 100644 index 00000000..bc2a42f2 --- /dev/null +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -0,0 +1,517 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Emby.Naming.Common; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Models; +using Shokofin.Utils; + +using ApiFile = Shokofin.API.Models.File; +using File = System.IO.File; +using TvSeries = MediaBrowser.Controller.Entities.TV.Series; + +#nullable enable +namespace Shokofin.Resolvers; + +public class ShokoResolveManager +{ + private readonly ShokoAPIManager ApiManager; + + private readonly ShokoAPIClient ApiClient; + + private readonly IIdLookup Lookup; + + private readonly ILibraryManager LibraryManager; + + private readonly IFileSystem FileSystem; + + private readonly ILogger<ShokoResolveManager> Logger; + + private readonly NamingOptions _namingOptions; + + private readonly IMemoryCache DataCache = new MemoryCache(new MemoryCacheOptions() { + ExpirationScanFrequency = TimeSpan.FromMinutes(50), + }); + + private static readonly TimeSpan DefaultTTL = TimeSpan.FromMinutes(60); + + public ShokoResolveManager(ShokoAPIManager apiManager, ShokoAPIClient apiClient, IIdLookup lookup, ILibraryManager libraryManager, IFileSystem fileSystem, ILogger<ShokoResolveManager> logger, NamingOptions namingOptions) + { + ApiManager = apiManager; + ApiClient = apiClient; + Lookup = lookup; + LibraryManager = libraryManager; + FileSystem = fileSystem; + Logger = logger; + _namingOptions = namingOptions; + LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; + } + + ~ShokoResolveManager() + { + LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; + DataCache.Dispose(); + } + + #region Changes Tracking + + private 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)) { + DataCache.Remove(folder.Id.ToString()); + var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(folder); + if (Directory.Exists(vfsPath)) { + Logger.LogDebug("Removing VFS directory for folder"); + Directory.Delete(vfsPath, true); + } + } + } + + #endregion + + #region Generate Structure + + private bool GenerateFullStructureForMediaFolder(Folder mediaFolder) + { + if (DataCache.TryGetValue<bool>(mediaFolder.Id.ToString(), out var isVFS)) + return isVFS; + + Logger.LogDebug("Looking for match for media folder at {Path}", mediaFolder.Path); + + // check if we should introduce the VFS for the folder + var allPaths = FileSystem.GetFilePaths(mediaFolder.Path, true) + .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + .Select(path => path[mediaFolder.Path.Length..]); + ApiFile? file = null; + int importFolderId = 0; + string partialPathSegment = string.Empty; + foreach (var partialPath in allPaths) { + var files = ApiClient.GetFileByPath(partialPath) + .GetAwaiter() + .GetResult(); + file = files.FirstOrDefault(); + if (file == null) + continue; + + var fileId = file.Id.ToString(); + var fileLocations = file.Locations + .Where(location => location.Path.EndsWith(partialPath)) + .ToList(); + if (fileLocations.Count == 0) + continue; + + var fileLocation = fileLocations[0]; + importFolderId = fileLocation.ImportFolderId; + partialPathSegment = fileLocation.Path[..^partialPath.Length]; + break; + } + + DataCache.Set(mediaFolder.Id.ToString(), file != null, DefaultTTL); + if (file == null) + return false; + + Logger.LogDebug("Found a match for media library at {Path} (ImportFolder={FolderId},RelativePath={RelativePath})", mediaFolder.Path, importFolderId, partialPathSegment); + var filterType = !Plugin.Instance.Configuration.FilterOnLibraryTypes ? ( + Ordering.GroupFilterType.Default + ) : ( + LibraryManager.GetInheritedContentType(mediaFolder) switch { + "movies" => Ordering.GroupFilterType.Movies, + _ => Ordering.GroupFilterType.Others, + } + ); + Logger.LogDebug("Looking for files within import folder… (ImportFolder={FolderId},RelativePath={RelativePath})", importFolderId, partialPathSegment); + var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + var allFilesForImportFolder = ApiClient.GetFilesForImportFolder(importFolderId) + .GetAwaiter() + .GetResult() + .AsParallel() + .Where(file => file.Locations.Any(location => location.ImportFolderId == importFolderId && (partialPathSegment.Length == 0 || location.Path.StartsWith(partialPathSegment)))) + .SelectMany(file => { + var location = file.Locations + .Where(location => location.ImportFolderId == importFolderId && (partialPathSegment.Length == 0 || location.Path.StartsWith(partialPathSegment))) + .First(); + + var sourceLocation = Path.Join(mediaFolder.Path, location.Path[partialPathSegment.Length..]); + if (!File.Exists(sourceLocation)) + return Array.Empty<(string sourceLocation, string symbolicLink)>(); + return file.CrossReferences + .AsParallel() + .Select(xref => { + var season = ApiManager.GetSeasonInfoForSeries(xref.Series.Shoko.ToString()) + .GetAwaiter() + .GetResult(); + if (season == null) + return (sourceLocation: string.Empty, symbolicLink: string.Empty); + var fileName = $"shoko-file-{file.Id}{Path.GetExtension(sourceLocation)}"; + var showFolder = $"shoko-series-{season.Id}"; + var isGrouped = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup || Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroupPlus; + switch (filterType) { + case Ordering.GroupFilterType.Movies: { + var isMovieSeason = season.AniDB.Type == SeriesType.Movie; + if (!isMovieSeason) + return (sourceLocation: string.Empty, symbolicLink: string.Empty); + + return (sourceLocation, symbolicLink: Path.Combine(vfsPath, showFolder, fileName)); + } + case Ordering.GroupFilterType.Others: { + var isMovieSeason = season.AniDB.Type == SeriesType.Movie; + if (isMovieSeason) + return (sourceLocation: string.Empty, symbolicLink: string.Empty); + + goto default; + } + default: + case Ordering.GroupFilterType.Default: { + var isMovieSeason = season.AniDB.Type == SeriesType.Movie; + if (isMovieSeason) + return (sourceLocation, symbolicLink: Path.Combine(vfsPath, showFolder, fileName)); + + var fileInfo = ApiManager.GetFileInfo(file.Id.ToString(), season.Id) + .GetAwaiter() + .GetResult(); + if (fileInfo == null) + return (sourceLocation: string.Empty, symbolicLink: string.Empty); + + var show = ApiManager.GetShowInfoForSeries(xref.Series.Shoko.ToString(), filterType) + .GetAwaiter() + .GetResult(); + season = show?.SeasonList.First(s => s.Id == season.Id); + if (show == null || season == null) + return (sourceLocation: string.Empty, symbolicLink: string.Empty); + + var episode = fileInfo.EpisodeList.FirstOrDefault(); + var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); + var seasonFolder = $"Season {seasonNumber} [shoko-series-{season.Id}]"; + showFolder = $"grouped-by-{show.DefaultSeason?.Id ?? xref.Series.Shoko.ToString()}"; + + return (sourceLocation, symbolicLink: Path.Combine(vfsPath, showFolder, seasonFolder, fileName)); + } + } + }) + .ToArray(); + }) + .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation)) + .OrderBy(tuple => tuple.sourceLocation) + .ThenBy(tuple => tuple.symbolicLink) + .ToList(); + + var skipped = 0; + var created = 0; + foreach (var (sourceLocation, symbolicLink) in allFilesForImportFolder) { + if (File.Exists(symbolicLink)) { + skipped++; + continue; + } + + var symbolicDirectory = Path.GetDirectoryName(symbolicLink); + if (!string.IsNullOrEmpty(symbolicDirectory) && !Directory.Exists(symbolicDirectory)) + Directory.CreateDirectory(symbolicDirectory); + + created++; + File.CreateSymbolicLink(symbolicLink, sourceLocation); + + // TODO: Check for subtitle files. + } + var toBeRemoved = FileSystem.GetFilePaths(vfsPath, true) + .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + .Except(allFilesForImportFolder.Select(tuple => tuple.symbolicLink).ToHashSet()) + .ToList(); + foreach (var symbolicLink in toBeRemoved) { + // TODO: Check for subtitle files. + + File.Delete(symbolicLink); + var symbolicDirectory = Path.GetDirectoryName(symbolicLink); + if (!string.IsNullOrEmpty(symbolicDirectory) && Directory.Exists(symbolicDirectory) && !Directory.EnumerateFileSystemEntries(symbolicDirectory).Any()) + Directory.Delete(symbolicDirectory); + } + + Logger.LogDebug( + "Created {CreatedCount}, skipped {SkippedCount}, and removed {RemovedCount} symbolic links for media folder at {Path}", + created, + skipped, + toBeRemoved.Count, + mediaFolder.Path + ); + + return true; + } + + #endregion + + #region Ignore Rule + + public bool ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) + { + if (Plugin.Instance.Configuration.EXPERIMENTAL_EnableResolver) + return false; + + // Everything in the root folder is ignored by us. + var root = LibraryManager.RootFolder; + if (fileInfo == null || parent == null || root == null || parent == root || fileInfo.FullName.StartsWith(root.Path)) + return false; + + try { + // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. + if (!Lookup.IsEnabledForItem(parent, out var isSoleProvider)) + return false; + + if (fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(Path.GetFileName(fileInfo.FullName).ToLowerInvariant())) { + Logger.LogDebug("Excluded folder at path {Path}", fileInfo.FullName); + return true; + } + + if (!fileInfo.IsDirectory && Plugin.Instance.IgnoredFileExtensions.Contains(fileInfo.Extension.ToLowerInvariant())) { + Logger.LogDebug("Skipped excluded file at path {Path}", fileInfo.FullName); + return false; + } + + var fullPath = fileInfo.FullName; + var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent, root); + + var shouldIgnore = Plugin.Instance.Configuration.LibraryFilteringMode ?? Plugin.Instance.Configuration.EXPERIMENTAL_EnableResolver || isSoleProvider; + var ordering = !Plugin.Instance.Configuration.FilterOnLibraryTypes ? ( + Ordering.GroupFilterType.Default + ) : ( + LibraryManager.GetInheritedContentType(parent) switch { + "movies" => Ordering.GroupFilterType.Movies, + _ => Ordering.GroupFilterType.Others, + } + ); + if (fileInfo.IsDirectory) + return ScanDirectory(partialPath, fullPath, ordering, shouldIgnore); + else + return ScanFile(partialPath, fullPath, ordering, shouldIgnore); + } + catch (System.Exception ex) { + if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) + { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + Plugin.Instance.CaptureException(ex); + } + return false; + } + } + + private bool ScanDirectory(string partialPath, string fullPath, Ordering.GroupFilterType filterType, bool shouldIgnore) + { + var season = ApiManager.GetSeasonInfoByPath(fullPath) + .GetAwaiter() + .GetResult(); + + // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given folder path. + if (season == null) { + // If we're in strict mode, then check the sub-directories if we have a <Show>/<Season>/<Episodes> structure. + if (shouldIgnore && partialPath[1..].Split(Path.DirectorySeparatorChar).Length == 1) { + try { + var entries = FileSystem.GetDirectories(fullPath, false).ToList(); + Logger.LogDebug("Unable to find shoko series for {Path}, trying {DirCount} sub-directories.", partialPath, entries.Count); + foreach (var entry in entries) { + season = ApiManager.GetSeasonInfoByPath(entry.FullName) + .GetAwaiter() + .GetResult(); + if (season != null) + { + Logger.LogDebug("Found shoko series {SeriesName} for sub-directory of path {Path} (Series={SeriesId})", season.Shoko.Name, partialPath, season.Id); + break; + } + } + } + catch (DirectoryNotFoundException) { } + } + if (season == null) { + if (shouldIgnore) + Logger.LogInformation("Ignored unknown folder at path {Path}", partialPath); + else + Logger.LogWarning("Skipped unknown folder at path {Path}", partialPath); + return shouldIgnore; + } + } + + // Filter library if we enabled the option. + if (filterType != Ordering.GroupFilterType.Default) { + var isShowLibrary = filterType == Ordering.GroupFilterType.Others; + var isMovieSeason = season.AniDB.Type == SeriesType.Movie; + if (isMovieSeason == isShowLibrary) { + Logger.LogInformation("Library separation is enabled, ignoring shoko series. (Series={SeriesId})", season.Id); + return true; + } + } + + var show = ApiManager.GetShowInfoForSeries(season.Id, filterType) + .GetAwaiter() + .GetResult()!; + + if (!string.IsNullOrEmpty(show.Id)) + Logger.LogInformation("Found shoko group {GroupName} (Series={SeriesId},Group={GroupId})", show.Name, season.Id, show.Id); + else + Logger.LogInformation("Found shoko series {SeriesName} (Series={SeriesId})", season.Shoko.Name, season.Id); + + return false; + } + + private bool ScanFile(string partialPath, string fullPath, Ordering.GroupFilterType filterType, bool shouldIgnore) + { + var (file, season, _) = ApiManager.GetFileInfoByPath(fullPath, filterType) + .GetAwaiter() + .GetResult(); + + // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given file path. + if (file == null || season == null) { + if (shouldIgnore) + Logger.LogInformation("Ignored unknown file at path {Path}", partialPath); + else + Logger.LogWarning("Skipped unknown file at path {Path}", partialPath); + return shouldIgnore; + } + + Logger.LogInformation("Found {EpisodeCount} shoko episode(s) for {SeriesName} (Series={SeriesId},File={FileId})", file.EpisodeList.Count, season.Shoko.Name, season.Id, file.Id); + + // We're going to post process this file later, but we don't want to include it in our library for now. + if (file.ExtraType != null) { + Logger.LogInformation("File was assigned an extra type, ignoring file. (Series={SeriesId},File={FileId})", season.Id, file.Id); + return true; + } + + return false; + } + + #endregion + + #region Resolvers + + public BaseItem? ResolveSingle(Folder? parent, string? collectionType, FileSystemMetadata fileInfo) + { + // Disable resolver. + if (!Plugin.Instance.Configuration.EXPERIMENTAL_EnableResolver) + return null; + + // Everything in the root folder is ignored by us. + var root = LibraryManager.RootFolder; + if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || fileInfo == null || parent == null || root == null || parent == root || fileInfo.FullName.StartsWith(root.Path)) + return null; + + // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. + if (!Lookup.IsEnabledForItem(parent, out var isSoleProvider)) + return null; + + var fullPath = fileInfo.FullName; + var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent, root); + if (mediaFolder == root) + return null; + + if (parent.Id == mediaFolder.Id && fileInfo.IsDirectory) { + var isVFS = GenerateFullStructureForMediaFolder(mediaFolder); + if (!isVFS) + return null; + + if (!int.TryParse(fileInfo.Name.Split('-').LastOrDefault(), out var seriesId)) + return null; + + return new TvSeries() + { + Path = fileInfo.FullName, + }; + } + + return null; + } + + public MultiItemResolverResult? ResolveMultiple(Folder? parent, string? collectionType, List<FileSystemMetadata> fileInfoList) + { + // Disable resolver. + if (!Plugin.Instance.Configuration.EXPERIMENTAL_EnableResolver) + return new(); + + var root = LibraryManager.RootFolder; + if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || root == null || parent == null || parent == root) + return new(); + + // Redirect children of a VFS managed folder to the VFS series. + if (parent.GetParent() == root) { + var isVFS = GenerateFullStructureForMediaFolder(parent); + if (!isVFS) + return new(); + + var filterType = !Plugin.Instance.Configuration.FilterOnLibraryTypes ? ( + Ordering.GroupFilterType.Default + ) : ( + collectionType switch { + "movies" => Ordering.GroupFilterType.Movies, + _ => Ordering.GroupFilterType.Others, + } + ); + var items = FileSystem.GetDirectories(ShokoAPIManager.GetVirtualRootForMediaFolder(parent)) + .AsParallel() + .SelectMany(dirInfo => { + if (!int.TryParse(dirInfo.Name.Split('-').LastOrDefault(), out var seriesId)) + return Array.Empty<BaseItem>(); + + var season = ApiManager.GetSeasonInfoForSeries(seriesId.ToString()) + .GetAwaiter() + .GetResult(); + if (season == null) + return Array.Empty<BaseItem>(); + + if ((collectionType == CollectionType.Movies || collectionType == null) && season.AniDB.Type == SeriesType.Movie) { + return FileSystem.GetFiles(dirInfo.FullName) + .AsParallel() + .Select(fileInfo => { + if (!int.TryParse(Path.GetFileNameWithoutExtension(fileInfo.Name).Split('[').LastOrDefault()?.Split(']').FirstOrDefault()?.Split('-').LastOrDefault(), out var fileId)) + return null; + + // This will hopefully just re-use the pre-cached entries from the cache, but it may + // also get it from remote if the cache was empty for whatever reason. + var file = ApiManager.GetFileInfo(fileId.ToString(), seriesId.ToString()) + .GetAwaiter() + .GetResult(); + + // Abort if the file was not recognised. + if (file == null || file.ExtraType != null) + return null; + + return new Movie() + { + Path = fileInfo.FullName, + ProviderIds = new() { + { "Shoko File", fileId.ToString() }, + } + } as BaseItem; + }) + .ToArray(); + } + + return new BaseItem[1] { + new TvSeries() { + Path = dirInfo.FullName, + }, + }; + }) + .OfType<BaseItem>() + .ToList(); + + // TODO: uncomment the code snippet once the PR is in stable JF. + // return new() { Items = items, ExtraFiles = new() }; + + // TODO: Remove these two hacks once we have proper support for adding multiple series at once. + if (items.Where(i => i is Movie).ToList().Count == 0 && items.Count > 0) { + fileInfoList.Clear(); + fileInfoList.AddRange(items.Select(s => FileSystem.GetFileSystemInfo(s.Path))); + } + return new() { Items = items.Where(i => i is Movie).ToList(), ExtraFiles = items.OfType<TvSeries>().Select(s => FileSystem.GetFileSystemInfo(s.Path)).ToList() }; + } + + return null; + } + + #endregion +} \ No newline at end of file diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs new file mode 100644 index 00000000..0e2c5733 --- /dev/null +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Model.IO; + +#nullable enable +namespace Shokofin.Resolvers; +#pragma warning disable CS8766 + +public class ShokoResolver : IItemResolver, IMultiItemResolver, IResolverIgnoreRule +{ + private readonly ShokoResolveManager ResolveManager; + + public ResolverPriority Priority => ResolverPriority.Plugin; + + public ShokoResolver(ShokoResolveManager resolveManager) + { + ResolveManager = resolveManager; + } + + public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent) + => ResolveManager.ShouldFilterItem(parent as Folder, fileInfo); + + public BaseItem? ResolvePath(ItemResolveArgs args) + => ResolveManager.ResolveSingle(args.Parent, args.CollectionType, args.FileInfo); + + public MultiItemResolverResult? ResolveMultiple(Folder parent, List<FileSystemMetadata> files, string collectionType, IDirectoryService directoryService) + => ResolveManager.ResolveMultiple(parent, collectionType, files); +} diff --git a/Shokofin/StringExtensions.cs b/Shokofin/StringExtensions.cs new file mode 100644 index 00000000..7d7fbb0d --- /dev/null +++ b/Shokofin/StringExtensions.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; + +#nullable enable +namespace Shokofin; + +public static class StringExtensions +{ + public static void Deconstruct(this IList<string> list, out string first) + { + first = list.Count > 0 ? list[0] : ""; + } + + public static void Deconstruct(this IList<string> list, out string first, out string second) + { + first = list.Count > 0 ? list[0] : ""; + second = list.Count > 1 ? list[1] : ""; + } + + public static void Deconstruct(this IList<string> list, out string first, out string second, out string third) + { + first = list.Count > 0 ? list[0] : ""; + second = list.Count > 1 ? list[1] : ""; + third = list.Count > 2 ? list[2] : ""; + } + + public static void Deconstruct(this IList<string> list, out string first, out string second, out string third, out string forth) + { + first = list.Count > 0 ? list[0] : ""; + second = list.Count > 1 ? list[1] : ""; + third = list.Count > 2 ? list[2] : ""; + forth = list.Count > 3 ? list[3] : ""; + } + + public static void Deconstruct(this IList<string> list, out string first, out string second, out string third, out string forth, out string fifth) + { + first = list.Count > 0 ? list[0] : ""; + second = list.Count > 1 ? list[1] : ""; + third = list.Count > 2 ? list[2] : ""; + forth = list.Count > 3 ? list[3] : ""; + fifth = list.Count > 4 ? list[4] : ""; + } +} \ No newline at end of file From 0e696612e361bccbc3f0df8806a63d5ae8d6d004 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 24 Mar 2024 05:05:16 +0100 Subject: [PATCH 0587/1103] fix: use english title for episode title if we're using the main title. --- Shokofin/Utils/TextUtil.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index c17dbb4c..a796583a 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -309,7 +309,7 @@ public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title // Display the main title. case DisplayLanguageType.Main: { var getSeriesTitle = () => GetTitleByType(seriesTitles, TitleType.Main) ?? seriesTitle; - var getEpisodeTitle = () => episodeTitle; + var getEpisodeTitle = () => GetTitleByLanguages(episodeTitles, "en", mainTitleLanguage) ?? episodeTitle; return ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); } } From bd4898c794475896f8b6af80f73b5f35c28bb76d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 24 Mar 2024 05:12:10 +0100 Subject: [PATCH 0588/1103] fix: remove reference to uncommited changes --- Shokofin/Resolvers/ShokoResolveManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index bc2a42f2..b20777ae 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -154,7 +154,7 @@ private bool GenerateFullStructureForMediaFolder(Folder mediaFolder) return (sourceLocation: string.Empty, symbolicLink: string.Empty); var fileName = $"shoko-file-{file.Id}{Path.GetExtension(sourceLocation)}"; var showFolder = $"shoko-series-{season.Id}"; - var isGrouped = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup || Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroupPlus; + var isGrouped = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; switch (filterType) { case Ordering.GroupFilterType.Movies: { var isMovieSeason = season.AniDB.Type == SeriesType.Movie; From 5817893a2b8a7ad4e2e37539d27555a8b1624494 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 24 Mar 2024 04:12:58 +0000 Subject: [PATCH 0589/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 63408c7f..fbc3ce18 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.36", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.36/shoko_3.0.1.36.zip", + "checksum": "35b2bb0aec419d475d0bf4330dd948cd", + "timestamp": "2024-03-24T04:12:56Z" + }, { "version": "3.0.1.35", "changelog": "NA\n", From db47d80f151f84f376e85b91c8863787109e7cd3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 25 Mar 2024 00:50:07 +0100 Subject: [PATCH 0590/1103] refactor: let's start fixing stuff by first breaking it, then fixing it afterwards. also, don't mind the code for the incomplete collection manager. I still haven't finished the minute details yet. --- Shokofin/API/Info/ShowInfo.cs | 21 +- Shokofin/API/Models/Group.cs | 2 - Shokofin/API/ShokoAPIClient.cs | 5 +- Shokofin/API/ShokoAPIManager.cs | 111 ++++- Shokofin/Collections/CollectionManager.cs | 447 +++++++++++++++++ Shokofin/Configuration/configPage.html | 15 +- Shokofin/Plugin.cs | 9 +- Shokofin/PluginServiceRegistrator.cs | 1 + Shokofin/Providers/BoxSetProvider.cs | 1 + Shokofin/Providers/EpisodeProvider.cs | 6 +- Shokofin/Providers/ExtraMetadataProvider.cs | 250 ++++------ Shokofin/Providers/MovieProvider.cs | 24 +- Shokofin/Providers/SeasonProvider.cs | 124 +---- Shokofin/Providers/SeriesProvider.cs | 10 +- Shokofin/Resolvers/ShokoResolveManager.cs | 510 ++++++++++++-------- Shokofin/StringExtensions.cs | 26 + Shokofin/Tasks/PostScanTask.cs | 28 +- Shokofin/Utils/OrderingUtil.cs | 91 +--- 18 files changed, 1059 insertions(+), 622 deletions(-) create mode 100644 Shokofin/Collections/CollectionManager.cs diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index 0898786e..949e065a 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -12,9 +12,14 @@ public class ShowInfo { public string? Id; + public string? ParentId; + public string Name; - public bool IsStandalone; + public bool IsStandalone => + Shoko == null; + + public Group? Shoko; public string[] Tags; @@ -33,8 +38,8 @@ public class ShowInfo public ShowInfo(Series series) { Id = null; + ParentId = series.IDs.ParentGroup.ToString(); Name = series.Name; - IsStandalone = true; Tags = System.Array.Empty<string>(); Genres = System.Array.Empty<string>(); Studios = System.Array.Empty<string>(); @@ -48,7 +53,8 @@ public ShowInfo(Group group) { Id = group.IDs.Shoko.ToString(); Name = group.Name; - IsStandalone = false; + Shoko = group; + ParentId = group.IDs.ParentGroup?.ToString(); Tags = System.Array.Empty<string>(); Genres = System.Array.Empty<string>(); Studios = System.Array.Empty<string>(); @@ -68,8 +74,9 @@ public ShowInfo(SeasonInfo seasonInfo) if (seasonInfo.OthersList.Count > 0) seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); + Id = null; + ParentId = seasonInfo.Shoko.IDs.ParentGroup.ToString(); Name = seasonInfo.Shoko.Name; - IsStandalone = true; Tags = seasonInfo.Tags; Genres = seasonInfo.Genres; Studios = seasonInfo.Studios; @@ -140,7 +147,8 @@ public ShowInfo(Group group, List<SeasonInfo> seriesList, Ordering.GroupFilterTy Id = groupId; Name = seriesList.Count > 0 ? seriesList[foundIndex].Shoko.Name : group.Name; - IsStandalone = false; + Shoko = group; + ParentId = group.IDs.ParentGroup?.ToString(); Tags = seriesList.SelectMany(s => s.Tags).Distinct().ToArray(); Genres = seriesList.SelectMany(s => s.Genres).Distinct().ToArray(); Studios = seriesList.SelectMany(s => s.Studios).Distinct().ToArray(); @@ -151,6 +159,9 @@ public ShowInfo(Group group, List<SeasonInfo> seriesList, Ordering.GroupFilterTy } public SeasonInfo? GetSeriesInfoBySeasonNumber(int seasonNumber) { + if (Plugin.Instance.Configuration.SeriesGrouping is Ordering.GroupType.Default && seasonNumber is 123 or 124) + return SeasonList.FirstOrDefault(); + if (seasonNumber == 0 || !(SeasonOrderDictionary.TryGetValue(seasonNumber, out var seasonInfo) && seasonInfo != null)) return null; diff --git a/Shokofin/API/Models/Group.cs b/Shokofin/API/Models/Group.cs index 889e64e0..527d030c 100644 --- a/Shokofin/API/Models/Group.cs +++ b/Shokofin/API/Models/Group.cs @@ -22,8 +22,6 @@ public class Group public class GroupIDs : IDs { - public int? DefaultSeries { get; set; } - public int MainSeries { get; set; } public int? ParentGroup { get; set; } diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 4ef8c18f..62e85d08 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -185,9 +185,10 @@ public Task<List<File>> GetFileByPath(string path) return Get<List<File>>($"/api/v3/File/PathEndsWith?path={Uri.EscapeDataString(path)}&includeDataFrom=AniDB&limit=1"); } - public Task<ListResult<File>> GetFilesForSeries(string seriesId) + public async Task<IReadOnlyList<File>> GetFilesForSeries(string seriesId) { - return Get<ListResult<File>>($"/api/v3/Series/{seriesId}/File?pageSize=0&include=XRefs&includeDataFrom=AniDB"); + var listResult = await Get<ListResult<File>>($"/api/v3/Series/{seriesId}/File?pageSize=0&include=XRefs&includeDataFrom=AniDB"); + return listResult.List; } public async Task<IReadOnlyList<File>> GetFilesForImportFolder(int importFolderId) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 8d6e92d7..a00445d3 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -169,9 +169,9 @@ public void Clear() Logger.LogDebug("Clearing data…"); Dispose(); Logger.LogDebug("Initialising new cache…"); - DataCache = (new MemoryCache((new MemoryCacheOptions() { + DataCache = new MemoryCache(new MemoryCacheOptions() { ExpirationScanFrequency = ExpirationScanFrequency, - }))); + }); Logger.LogDebug("Cleanup complete."); } @@ -302,7 +302,7 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) var pathSet = new HashSet<string>(); var episodeIds = new HashSet<string>(); - foreach (var file in (await APIClient.GetFilesForSeries(seriesId) ?? new ()).List) { + foreach (var file in await APIClient.GetFilesForSeries(seriesId)) { if (file.CrossReferences.Count == 1) foreach (var fileLocation in file.Locations) pathSet.Add((Path.GetDirectoryName(fileLocation.Path) ?? "") + Path.DirectorySeparatorChar); @@ -318,17 +318,58 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) #endregion #region File Info - public async Task<(FileInfo?, SeasonInfo?, ShowInfo?)> GetFileInfoByPath(string path, Ordering.GroupFilterType? filterGroupByType) + internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnumerable<string> episodeIds) + { + PathToFileIdAndSeriesIdDictionary.TryAdd(path, (fileId, seriesId)); + PathToEpisodeIdsDictionary.TryAdd(path, episodeIds.ToList()); + } + + public async Task<(FileInfo?, SeasonInfo?, ShowInfo?)> GetFileInfoByPath(string path, Ordering.GroupFilterType filterGroupByType) { // Use pointer for fast lookup. if (PathToFileIdAndSeriesIdDictionary.ContainsKey(path)) { var (fI, sI) = PathToFileIdAndSeriesIdDictionary[path]; var fileInfo = await GetFileInfo(fI, sI); + if (fileInfo == null) + return (null, null, null); + var seasonInfo = await GetSeasonInfoForSeries(sI); - var showInfo = filterGroupByType.HasValue ? await GetShowInfoForSeries(sI, filterGroupByType.Value) : null; + if (seasonInfo == null) + return (null, null, null); + + var showInfo = await GetShowInfoForSeries(sI, filterGroupByType); + if (showInfo == null) + return (null, null, null); + return new(fileInfo, seasonInfo, showInfo); } + // Fast-path for VFS. + if (path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { + var (seriesSegment, fileSegment) = Path.GetFileNameWithoutExtension(path).Split('[').TakeLast(2).Select(a => a.Split(']').First()).ToList(); + if (!int.TryParse(seriesSegment.Split('-').LastOrDefault(), out var seriesIdRaw)) + return (null, null, null); + if (!int.TryParse(fileSegment.Split('-').LastOrDefault(), out var fileIdRaw)) + return (null, null, null); + + var sI = seriesIdRaw.ToString(); + var fI = fileIdRaw.ToString(); + var fileInfo = await GetFileInfo(fI, sI); + if (fileInfo == null) + return (null, null, null); + + var seasonInfo = await GetSeasonInfoForSeries(sI); + if (seasonInfo == null) + return (null, null, null); + + var showInfo = await GetShowInfoForSeries(sI, filterGroupByType); + if (showInfo == null) + return (null, null, null); + + AddFileLookupIds(path, fI, sI, fileInfo.EpisodeList.Select(episode => episode.Id)); + return (fileInfo, seasonInfo, showInfo); + } + // Strip the path and search for a match. var partialPath = StripMediaFolder(path); var result = await APIClient.GetFileByPath(partialPath); @@ -366,12 +407,9 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) continue; // Find the show info. - ShowInfo? showInfo = null; - if (filterGroupByType.HasValue) { - showInfo = await GetShowInfoForSeries(seriesId, filterGroupByType.Value); - if (showInfo == null) - return (null, null, null); - } + var showInfo = await GetShowInfoForSeries(seriesId, filterGroupByType); + if (showInfo == null || showInfo.SeasonList.Count == 0) + return (null, null, null); // Find the season info. var seasonInfo = await GetSeasonInfoForSeries(seriesId); @@ -386,8 +424,7 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) EpisodeIdToEpisodePathDictionary.TryAdd(episodeInfo.Id, path); // Add pointers for faster lookup. - PathToFileIdAndSeriesIdDictionary.TryAdd(path, (fileId, seriesId)); - PathToEpisodeIdsDictionary.TryAdd(path, fileInfo.EpisodeList.Select(episode => episode.Id).ToList()); + AddFileLookupIds(path, fileId, seriesId, fileInfo.EpisodeList.Select(episode => episode.Id)); // Return the result. return new(fileInfo, seasonInfo, showInfo); @@ -626,7 +663,7 @@ private async Task<SeasonInfo> CreateSeriesInfo(Series series, string seriesId) seasonInfo = new SeasonInfo(series, episodes, cast, relations, genres, tags); foreach (var episode in episodes) - EpisodeIdToSeriesIdDictionary[episode.Id] = seriesId; + EpisodeIdToSeriesIdDictionary.TryAdd(episode.Id, seriesId); DataCache.Set<SeasonInfo>(cacheKey, seasonInfo, DefaultTimeSpan); return seasonInfo; } @@ -688,6 +725,19 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId, out s if (PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) return seriesId; + // Fast-path for VFS. + if (path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { + var seriesSegment = Path.GetFileName(path).Split('[').Last().Split(']').First(); + if (!int.TryParse(seriesSegment.Split('-').LastOrDefault(), out var seriesIdRaw)) + return null; + + seriesId = seriesIdRaw.ToString(); + PathToSeriesIdDictionary[path] = seriesId; + SeriesIdToPathDictionary.TryAdd(seriesId, path); + + return seriesId; + } + var partialPath = StripMediaFolder(path); Logger.LogDebug("Looking for shoko series matching path {Path}", partialPath); var result = await APIClient.GetSeriesPathEndsWith(partialPath); @@ -720,7 +770,7 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId, out s #endregion #region Show Info - public async Task<ShowInfo?> GetShowInfoByPath(string path, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + public async Task<ShowInfo?> GetShowInfoByPath(string path, Ordering.GroupFilterType filterByType) { if (PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) { if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var tuple)) { @@ -740,7 +790,24 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId, out s return await GetShowInfoForSeries(seriesId, filterByType); } - public async Task<ShowInfo?> GetShowInfoForSeries(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + public async Task<ShowInfo?> GetShowInfoForEpisode(string episodeId, Ordering.GroupFilterType filterByType) + { + if (string.IsNullOrEmpty(episodeId)) + return null; + + if (EpisodeIdToSeriesIdDictionary.TryGetValue(episodeId, out var seriesId)) + return await GetShowInfoForSeries(seriesId, filterByType); + + var series = await APIClient.GetSeriesFromEpisode(episodeId); + if (series == null) + return null; + + seriesId = series.IDs.Shoko.ToString(); + EpisodeIdToSeriesIdDictionary.TryAdd(episodeId, seriesId); + return await GetShowInfoForSeries(seriesId, filterByType); + } + + public async Task<ShowInfo?> GetShowInfoForSeries(string seriesId, Ordering.GroupFilterType filterByType) { if (string.IsNullOrEmpty(seriesId)) return null; @@ -764,7 +831,7 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId, out s return await CreateShowInfo(group, group.IDs.Shoko.ToString(), filterByType); } - private async Task<ShowInfo?> GetShowInfoForGroup(string groupId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + private async Task<ShowInfo?> GetShowInfoForGroup(string groupId, Ordering.GroupFilterType filterByType) { if (string.IsNullOrEmpty(groupId)) return null; @@ -812,7 +879,7 @@ private async Task<ShowInfo> CreateShowInfo(Group group, string groupId, Orderin return showInfo; } - private async Task<ShowInfo?> GetOrCreateShowInfoForStandaloneSeries(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + private async Task<ShowInfo?> GetOrCreateShowInfoForStandaloneSeries(string seriesId, Ordering.GroupFilterType filterByType) { var cacheKey = $"show:{filterByType}:by-series-id:{seriesId}"; if (DataCache.TryGetValue<ShowInfo>(cacheKey, out var showInfo)) { @@ -847,7 +914,7 @@ private async Task<ShowInfo> CreateShowInfo(Group group, string groupId, Orderin #endregion #region Collection Info - public async Task<CollectionInfo?> GetCollectionInfoByPath(string path, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + public async Task<CollectionInfo?> GetCollectionInfoByPath(string path, Ordering.GroupFilterType filterByType) { if (PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) { if (SeriesIdToCollectionIdDictionary.TryGetValue(seriesId, out var groupId)) { @@ -867,7 +934,7 @@ private async Task<ShowInfo> CreateShowInfo(Group group, string groupId, Orderin return await GetCollectionInfoForGroup(seriesId, filterByType); } - public async Task<CollectionInfo?> GetCollectionInfoBySeriesName(string seriesName, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + public async Task<CollectionInfo?> GetCollectionInfoBySeriesName(string seriesName, Ordering.GroupFilterType filterByType) { if (NameToSeriesIdDictionary.TryGetValue(seriesName, out var seriesId)) { if (SeriesIdToCollectionIdDictionary.TryGetValue(seriesId, out var groupId)) { @@ -887,7 +954,7 @@ private async Task<ShowInfo> CreateShowInfo(Group group, string groupId, Orderin return await GetCollectionInfoForSeries(seriesId, filterByType); } - public async Task<CollectionInfo?> GetCollectionInfoForGroup(string groupId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + public async Task<CollectionInfo?> GetCollectionInfoForGroup(string groupId, Ordering.GroupFilterType filterByType) { if (string.IsNullOrEmpty(groupId)) return null; @@ -901,7 +968,7 @@ private async Task<ShowInfo> CreateShowInfo(Group group, string groupId, Orderin return await CreateCollectionInfo(group, groupId, filterByType); } - public async Task<CollectionInfo?> GetCollectionInfoForSeries(string seriesId, Ordering.GroupFilterType filterByType = Ordering.GroupFilterType.Default) + public async Task<CollectionInfo?> GetCollectionInfoForSeries(string seriesId, Ordering.GroupFilterType filterByType) { if (string.IsNullOrEmpty(seriesId)) return null; diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs new file mode 100644 index 00000000..1cf89ed2 --- /dev/null +++ b/Shokofin/Collections/CollectionManager.cs @@ -0,0 +1,447 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Collections; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Info; +using Shokofin.Utils; + +#nullable enable +namespace Shokofin.Collections; + +public class CollectionManager +{ + private readonly ILibraryManager LibraryManager; + + private readonly ICollectionManager Collection; + + private readonly ILogger<CollectionManager> Logger; + + private readonly IIdLookup Lookup; + + private readonly ShokoAPIManager ApiManager; + + public CollectionManager(ILibraryManager libraryManager, ICollectionManager collectionManager, ILogger<CollectionManager> logger, IIdLookup lookup, ShokoAPIManager apiManager) + { + LibraryManager = libraryManager; + Collection = collectionManager; + Logger = logger; + Lookup = lookup; + ApiManager = apiManager; + } + + public async Task ReconstructCollections(IProgress<double> progress, CancellationToken cancellationToken) + { + try + { + switch (Plugin.Instance.Configuration.BoxSetGrouping) + { + default: + case Ordering.GroupType.Default: + case Ordering.GroupType.MergeFriendly: + break; + case Ordering.GroupType.ShokoSeries: + await ReconstructMovieSeriesCollections(progress, cancellationToken); + break; + case Ordering.GroupType.ShokoGroup: + await ReconstructMovieGroupCollections(progress, cancellationToken); + break; + case Ordering.GroupType.ShokoGroupPlus: + await ReconstructSharedCollections(progress, cancellationToken); + break; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + Plugin.Instance.CaptureException(ex); + } + } + + private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, CancellationToken cancellationToken) + { + // Get all movies + + // Clean up the movies + await CleanupMovies(); + + await CleanupGroupCollections(); + + var movies = GetMovies(); + Logger.LogInformation("Reconstructing collections for {MovieCount} movies using Shoko Series.", movies.Count); + + // create a tree-map of how it's supposed to be. + var config = Plugin.Instance.Configuration; + var filterByType = config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Movies : Ordering.GroupFilterType.Default; + var movieDict = new Dictionary<Movie, (FileInfo, SeasonInfo, ShowInfo)>(); + foreach (var movie in movies) + { + if (!Lookup.TryGetEpisodeIdsFor(movie, out var episodeIds)) + continue; + + var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(movie.Path, filterByType); + if (fileInfo == null || seasonInfo == null || showInfo == null) + continue; + + movieDict.Add(movie, (fileInfo, seasonInfo, showInfo)); + } + + var seriesDict = movieDict.Values + .Select(tuple => tuple.Item2) + .DistinctBy(seasonInfo => seasonInfo.Id) + .ToDictionary(seasonInfo => seasonInfo.Id); + var groupsDict = await Task + .WhenAll( + seriesDict.Values + .Select(seasonInfo => seasonInfo.Shoko.IDs.ParentGroup.ToString()) + .Distinct() + .Select(groupId => ApiManager.GetCollectionInfoForGroup(groupId, Ordering.GroupFilterType.Default)) + ) + .ContinueWith(task => task.Result.ToDictionary(x => x!.Id, x => x!)); + + var finalGroups = new Dictionary<string, CollectionInfo>(); + foreach (var initialGroup in groupsDict.Values) + { + var currentGroup = initialGroup; + if (finalGroups.ContainsKey(currentGroup.Id)) + continue; + + finalGroups.Add(currentGroup.Id, currentGroup); + if (currentGroup.IsTopLevel) + continue; + + while (!currentGroup.IsTopLevel && !finalGroups.ContainsKey(currentGroup.ParentId!)) + { + currentGroup = await ApiManager.GetCollectionInfoForGroup(currentGroup.ParentId!, Ordering.GroupFilterType.Default); + if (currentGroup == null) + break; + finalGroups.Add(currentGroup.Id, currentGroup); + } + } + + var existingCollections = GetSeriesCollections(); + var toCheck = new Dictionary<string, BoxSet>(); + var toRemove = new Dictionary<Guid, BoxSet>(); + var toAdd = finalGroups.Keys + .Where(groupId => !existingCollections.ContainsKey(groupId)) + .ToHashSet(); + var idToGuidDict = new Dictionary<string, Guid>(); + + foreach (var (groupId, collectionList) in existingCollections) { + if (finalGroups.ContainsKey(groupId)) { + idToGuidDict.Add(groupId, collectionList[0].Id); + toCheck.Add(groupId, collectionList[0]); + foreach (var collection in collectionList.Skip(1)) + toRemove.Add(collection.Id, collection); + } + else { + foreach (var collection in collectionList) + toRemove.Add(collection.Id, collection); + } + } + + foreach (var (id, boxSet) in toRemove) { + // Remove the item from all parents. + foreach (var parent in boxSet.GetParents().OfType<BoxSet>()) { + if (toRemove.ContainsKey(parent.Id)) + continue; + await Collection.RemoveFromCollectionAsync(parent.Id, new[] { id }); + } + + // Remove all children + var children = boxSet.GetChildren(null, true, new()).Select(x => x.Id); + await Collection.RemoveFromCollectionAsync(id, children); + + // Remove the item. + LibraryManager.DeleteItem(boxSet, new() { DeleteFileLocation = false, DeleteFromExternalProvider = false }); + } + + // Add the missing collections. + foreach (var missingId in toAdd) + { + var collectionInfo = finalGroups[missingId]; + var collection = await Collection.CreateCollectionAsync(new() { + Name = collectionInfo.Name, + ProviderIds = new() { { "Shoko Group", missingId } }, + }); + toCheck.Add(missingId, collection); + } + + // Check the collections. + foreach (var (groupId, collection) in toCheck) + { + var collectionInfo = finalGroups[groupId]; + // Check if the collection have the correct children + + } + } + + private async Task ReconstructMovieGroupCollections(IProgress<double> progress, CancellationToken cancellationToken) + { + + // Clean up the movies + await CleanupMovies(); + + await CleanupSeriesCollections(); + + var movies = GetMovies(); + Logger.LogInformation("Reconstructing collections for {MovieCount} movies using Shoko Groups.", movies.Count); + + // create a tree-map of how it's supposed to be. + + // create a tree-map of how it's supposed to be. + var config = Plugin.Instance.Configuration; + var filterByType = config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Movies : Ordering.GroupFilterType.Default; + var movieDict = new Dictionary<Movie, (FileInfo, SeasonInfo, ShowInfo)>(); + foreach (var movie in movies) + { + if (!Lookup.TryGetEpisodeIdsFor(movie, out var episodeIds)) + continue; + + var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(movie.Path, filterByType); + if (fileInfo == null || seasonInfo == null || showInfo == null) + continue; + + movieDict.Add(movie, (fileInfo, seasonInfo, showInfo)); + } + + var seriesDict = movieDict.Values + .Select(tuple => tuple.Item2) + .DistinctBy(seasonInfo => seasonInfo.Id) + .ToDictionary(seasonInfo => seasonInfo.Id); + var groupsDict = await Task + .WhenAll( + seriesDict.Values + .Select(seasonInfo => seasonInfo.Shoko.IDs.ParentGroup.ToString()) + .Distinct() + .Select(groupId => ApiManager.GetCollectionInfoForGroup(groupId, Ordering.GroupFilterType.Default)) + ) + .ContinueWith(task => task.Result.ToDictionary(x => x!.Id, x => x!)); + + var finalGroups = new Dictionary<string, CollectionInfo>(); + foreach (var initialGroup in groupsDict.Values) + { + var currentGroup = initialGroup; + if (finalGroups.ContainsKey(currentGroup.Id)) + continue; + + finalGroups.Add(currentGroup.Id, currentGroup); + if (currentGroup.IsTopLevel) + continue; + + while (!currentGroup.IsTopLevel && !finalGroups.ContainsKey(currentGroup.ParentId!)) + { + currentGroup = await ApiManager.GetCollectionInfoForGroup(currentGroup.ParentId!, Ordering.GroupFilterType.Default); + if (currentGroup == null) + break; + finalGroups.Add(currentGroup.Id, currentGroup); + } + } + + var existingCollections = GetGroupCollections(); + var toCheck = new Dictionary<string, BoxSet>(); + var toRemove = new Dictionary<Guid, (string GroupId, BoxSet Collection)>(); + var toAdd = finalGroups.Keys + .Where(groupId => !existingCollections.ContainsKey(groupId)) + .ToHashSet(); + var idToGuidDict = new Dictionary<string, Guid>(); + + foreach (var (groupId, collectionList) in existingCollections) { + if (finalGroups.ContainsKey(groupId)) { + idToGuidDict.Add(groupId, collectionList[0].Id); + toCheck.Add(groupId, collectionList[0]); + foreach (var collection in collectionList.Skip(1)) + toRemove.Add(collection.Id, (groupId, collection)); + } + else { + foreach (var collection in collectionList) + toRemove.Add(collection.Id, (groupId, collection)); + } + } + + var toRemoveSet = toRemove.Keys.ToHashSet(); + foreach (var (id, (groupId, boxSet)) in toRemove) + await RemoveCollection(boxSet, toRemoveSet, groupId: groupId); + + // Add the missing collections. + foreach (var missingId in toAdd) + { + var collectionInfo = finalGroups[missingId]; + var collection = await Collection.CreateCollectionAsync(new() { + Name = collectionInfo.Name, + ProviderIds = new() { { "Shoko Group", missingId } }, + }); + toCheck.Add(missingId, collection); + } + + // Check the collections. + foreach (var (groupId, collection) in toCheck) + { + var collectionInfo = finalGroups[groupId]; + // Check if the collection have the correct children + + } + } + + private async Task ReconstructSharedCollections(IProgress<double> progress, CancellationToken cancellationToken) + { + // Get all movies + + // Clean up the movies + await CleanupMovies(); + + await CleanupSeriesCollections(); + + // Get all shows + var movies = GetMovies(); + var shows = GetShows(); + Logger.LogInformation("Reconstructing collections for {MovieCount} movies and {ShowCount} shows using Shoko Groups.", movies.Count, shows.Count); + + // create a tree-map of how it's supposed to be. + + var collections = GetSeriesCollections(); + + // check which nodes are correct, which nodes is not correct, and which are missing. + + // fix the nodes that are not correct. + + // add the missing nodes. + } + + private async Task CleanupMovies() + { + // Check the movies with a shoko series id set, and remove the collection name from them. + var movies = GetMovies(); + foreach (var movie in movies) + { + if (string.IsNullOrEmpty(movie.CollectionName)) + continue; + + if (!Lookup.TryGetEpisodeIdFor(movie, out var episodeId) || + !Lookup.TryGetSeriesIdFor(movie, out var seriesId)) + continue; + + Logger.LogTrace("Removing movie {MovieName} from collection {CollectionName}. (Episode={EpisodeId},Series={SeriesId})", movie.Name, movie.CollectionName, episodeId, seriesId); + movie.CollectionName = string.Empty; + await LibraryManager.UpdateItemAsync(movie, movie.GetParent(), ItemUpdateType.None, CancellationToken.None).ConfigureAwait(false); + } + } + + private async Task CleanupSeriesCollections() + { + var collectionDict = GetSeriesCollections(); + var collectionMap = collectionDict.Values + .SelectMany(x => x.Select(y => y.Id)) + .ToHashSet(); + + Logger.LogInformation("Going to remove {CollectionCount} collection items for {SeriesCount} Shoko Series", collectionMap.Count, collectionDict.Count); + + + foreach (var (seriesId, collectionList) in collectionDict) + foreach (var collection in collectionList) + await RemoveCollection(collection, collectionMap, seriesId: seriesId); + } + + private async Task CleanupGroupCollections() + { + + var collectionDict = GetGroupCollections(); + var collectionMap = collectionDict.Values + .SelectMany(x => x.Select(y => y.Id)) + .ToHashSet(); + + Logger.LogInformation("Going to remove {CollectionCount} collection items for {GroupCount} Shoko Groups", collectionMap.Count, collectionDict.Count); + + foreach (var (groupId, collectionList) in collectionDict) + foreach (var collection in collectionList) + await RemoveCollection(collection, collectionMap, groupId: groupId); + } + + private async Task RemoveCollection(BoxSet boxSet, ISet<Guid> allBoxSets, string? seriesId = null, string? groupId = null) + { + var parents = boxSet.GetParents().OfType<BoxSet>().ToList(); + var children = boxSet.GetChildren(null, true, new()).Select(x => x.Id).ToList(); + Logger.LogTrace("Removing collection {CollectionName} with {ParentCount} parents and {ChildCount} children. (Collection={CollectionId},Series={SeriesId},Group={GroupId})", boxSet.Name, parents.Count, children.Count, boxSet.Id, seriesId, groupId); + + // Remove the item from all parents. + foreach (var parent in parents) { + if (allBoxSets.Contains(parent.Id)) + continue; + await Collection.RemoveFromCollectionAsync(parent.Id, new[] { boxSet.Id }); + } + + // Remove all children + await Collection.RemoveFromCollectionAsync(boxSet.Id, children); + + // Remove the item. + LibraryManager.DeleteItem(boxSet, new() { DeleteFileLocation = false, DeleteFromExternalProvider = false }); + } + + private IReadOnlyList<Movie> GetMovies() + { + return LibraryManager.GetItemList(new() + { + IncludeItemTypes = new[] { BaseItemKind.Movie }, + HasAnyProviderId = new Dictionary<string, string> { { "Shoko File", "" } }, + IsVirtualItem = false, + Recursive = true, + }) + .Where(Lookup.IsEnabledForItem) + .Cast<Movie>() + .ToList(); + } + + private IReadOnlyList<Series> GetShows() + { + return LibraryManager.GetItemList(new() + { + IncludeItemTypes = new[] { BaseItemKind.Series }, + HasAnyProviderId = new Dictionary<string, string> { { "Shoko Series", "" } }, + IsVirtualItem = false, + Recursive = true, + }) + .Where(Lookup.IsEnabledForItem) + .Cast<Series>() + .ToList(); + } + + private IReadOnlyDictionary<string, IReadOnlyList<BoxSet>> GetSeriesCollections() + { + return LibraryManager.GetItemList(new() + { + IncludeItemTypes = new[] { BaseItemKind.BoxSet }, + HasAnyProviderId = new Dictionary<string, string> { { "Shoko Series", "" } }, + IsVirtualItem = false, + Recursive = true, + }) + .Cast<BoxSet>() + .Select(x => x.ProviderIds.TryGetValue("Shoko Series", out var seriesId) && !string.IsNullOrEmpty(seriesId) ? new { SeriesId = seriesId, BoxSet = x } : null) + .Where(x => x != null) + .GroupBy(x => x!.SeriesId, x => x!.BoxSet) + .ToDictionary(x => x.Key, x => x.ToList() as IReadOnlyList<BoxSet>); + } + + private IReadOnlyDictionary<string, IReadOnlyList<BoxSet>> GetGroupCollections() + { + return LibraryManager.GetItemList(new() + { + IncludeItemTypes = new[] { BaseItemKind.BoxSet }, + + HasAnyProviderId = new Dictionary<string, string> { { "Shoko Group", "" } }, + IsVirtualItem = false, + Recursive = true, + }) + .Cast<BoxSet>() + .Select(x => x.ProviderIds.TryGetValue("Shoko Group", out var groupId) && !string.IsNullOrEmpty(groupId) ? new { GroupId = groupId, BoxSet = x } : null) + .Where(x => x != null) + .GroupBy(x => x!.GroupId, x => x!.BoxSet) + .ToDictionary(x => x.Key, x => x.ToList() as IReadOnlyList<BoxSet>); + } +} \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 9e7c9603..4773bf6e 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -185,13 +185,14 @@ <h3>Library Settings</h3> <div class="fieldDescription selectFieldDescription"><div>Determines how to group Series together and divide them into Seasons.</div><div><strong>Warning:</strong> Modifying this setting requires the deletion and re-creation of any libraries using this plugin.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">Why can't I just refresh all metadata?</summary>Currently, refreshing and replacing all metadata does not remove all the metadata stored by Jellyfin. Additionally, if two or more show's entries have been merged, they cannot then be unmerged. Recreation of the library is the only way to ensure that you do not have mixed metadata in your libraries.</details><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What data is kept when recreating the libraries?</summary>The user data for each file is still kept after deleting a library. User data includes any ratings, play history, watch status and the last selected audio/subtitle track for a file.</details></div> </div> <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="BoxSetGrouping">Box-set/Movie grouping:</label> + <label class="selectLabel" for="BoxSetGrouping">Collections:</label> <select is="emby-select" id="BoxSetGrouping" name="BoxSetGrouping" class="emby-select-withcolor emby-select"> - <option value="Default" selected>Do not create Box-sets for Movies</option> - <option value="ShokoSeries">Create Box-sets based upon Shoko's Series entries</option> - <option value="ShokoGroup">Create Box-sets based upon Shoko's Groups and Series entries</option> + <option value="Default" selected>Do not create Collections</option> + <option value="ShokoSeries">Create Collections for Movies based upon Shoko's Series entries</option> + <option value="ShokoGroup">Create Collections for Movies based upon Shoko's Groups and Series entries</option> + <option value="ShokoGroupPlus">Create Collections for Movies and Series based upon Shoko's Groups and Series entries</option> </select> - <div class="fieldDescription">Determines how to group Movies together into Box-sets.</div> + <div class="fieldDescription">Determines how to group entities into Collections.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> @@ -387,9 +388,9 @@ <h3>Experimental Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_EnableResolver" /> - <span>Enable Custom Resolver</span> + <span>Virtual File System</span> </label> - <div class="fieldDescription checkboxFieldDescription"><div>Enables the custom resolver for the plugin.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What is this setting for?</summary>Enabling this setting should in theory make it so you won't have to think about file structure imcompatibilities, since it will override your existing file structure and replace it with a layer of symbolic links instead. <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 class="fieldDescription checkboxFieldDescription"><div>Enables the use of the Virtual File System for any media libraries managed by the plugin.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What does it do?</summary>Enabling this setting should in theory make it so you won't have to think about file structure imcompatibilities, 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="SeasonOrdering">Season ordering:</label> diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index bcec597d..c2efaaf4 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -18,12 +18,15 @@ namespace Shokofin; public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { - public static string MetadataProviderName = "Shoko"; + public const string MetadataProviderName = "Shoko"; - public override string Name => "Shoko"; + public override string Name => MetadataProviderName; public override Guid Id => Guid.Parse("5216ccbf-d24a-4eb3-8a7e-7da4230b7052"); + /// <summary> + /// "Virtual" File System Root Directory. + /// </summary> public string VirtualRoot => Path.Combine(DataFolderPath, "VFS"); public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) @@ -153,7 +156,7 @@ public IEnumerable<PluginPageInfo> GetPages() { Name = "ShokoController.js", EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configController.js", - } + }, }; } diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index c3e0001a..6141f01e 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -15,6 +15,7 @@ public void RegisterServices(IServiceCollection serviceCollection) serviceCollection.AddSingleton<IIdLookup, IdLookup>(); serviceCollection.AddSingleton<Sync.UserDataSyncManager>(); serviceCollection.AddSingleton<MergeVersions.MergeVersionsManager>(); + serviceCollection.AddSingleton<Collections.CollectionManager>(); serviceCollection.AddSingleton<Resolvers.ShokoResolveManager>(); } } diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 486b144c..6d0f5e8d 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -38,6 +38,7 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat return Plugin.Instance.Configuration.BoxSetGrouping switch { Ordering.GroupType.ShokoGroup => await GetShokoGroupedMetadata(info), + Ordering.GroupType.ShokoGroupPlus => await GetShokoGroupedMetadata(info), _ => await GetDefaultMetadata(info), }; } diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index d6d17b65..ba545971 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -40,7 +40,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell try { var result = new MetadataResult<Episode>(); var config = Plugin.Instance.Configuration; - Ordering.GroupFilterType? filterByType = config.SeriesGrouping == Ordering.GroupType.ShokoGroup ? config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default : null; + var filterByType = config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; // Fetch the episode, series and group info (and file info, but that's not really used (yet)) Info.FileInfo fileInfo = null; @@ -60,7 +60,9 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell if (seasonInfo == null) return result; - showInfo = filterByType.HasValue ? (await ApiManager.GetShowInfoForSeries(seasonInfo.Id, filterByType.Value)) : null; + showInfo = await ApiManager.GetShowInfoForSeries(seasonInfo.Id, filterByType); + if (showInfo == null || showInfo.SeasonList.Count == 0) + return result; } else { (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index 8ae1dbde..1bbcad7e 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -91,7 +91,7 @@ public bool IsActionForIdOfTypeLocked(string type, string id, string action) private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs e) { - if (e == null || e.Item == null || e.Parent == null || !(e.UpdateReason.HasFlag(ItemUpdateType.MetadataImport) || e.UpdateReason.HasFlag(ItemUpdateType.MetadataDownload))) + if (e == null || e.Item == null || e.Parent == null || e.UpdateReason.HasFlag(ItemUpdateType.None)) return; switch (e.Item) { @@ -171,7 +171,7 @@ private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs e) private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs e) { - if (e == null || e.Item == null || e.Parent == null || !(e.UpdateReason.HasFlag(ItemUpdateType.MetadataImport) || e.UpdateReason.HasFlag(ItemUpdateType.MetadataDownload))) + if (e == null || e.Item == null || e.Parent == null || e.UpdateReason.HasFlag(ItemUpdateType.None)) return; switch (e.Item) { @@ -253,7 +253,7 @@ private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs e) private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs e) { - if (e == null || e.Item == null || e.Parent == null || !(e.UpdateReason.HasFlag(ItemUpdateType.MetadataImport) || e.UpdateReason.HasFlag(ItemUpdateType.MetadataDownload))) + if (e == null || e.Item == null || e.Parent == null) return; if (e.Item.IsVirtualItem) @@ -267,11 +267,8 @@ private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs e) RemoveExtras(series, seriesId); - if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { - foreach (var season in series.Children.OfType<Season>()) { - OnLibraryManagerItemRemoved(this, new ItemChangeEventArgs { Item = season, Parent = series, UpdateReason = ItemUpdateType.None }); - } - } + foreach (var season in series.Children.OfType<Season>()) + OnLibraryManagerItemRemoved(this, new ItemChangeEventArgs { Item = season, Parent = series, UpdateReason = ItemUpdateType.None }); return; } @@ -305,154 +302,71 @@ private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs e) private void UpdateSeries(Series series, string seriesId) { // Provide metadata for a series using Shoko's Group feature - if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { - var showInfo = ApiManager.GetShowInfoForSeries(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) - .GetAwaiter() - .GetResult(); - if (showInfo == null) { - Logger.LogWarning("Unable to find group info for series. (Series={SeriesID})", seriesId); - return; - } - - // Get the existing seasons and episode ids - var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); + var showInfo = ApiManager.GetShowInfoForSeries(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) + .GetAwaiter() + .GetResult(); + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for series. (Series={SeriesID})", seriesId); + return; + } - // Add missing seasons - foreach (var (seasonNumber, season) in CreateMissingSeasons(showInfo, series, seasons)) - seasons.TryAdd(seasonNumber, season); + // Get the existing seasons and episode ids + var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); - // Handle specials when grouped. - if (seasons.TryGetValue(0, out var zeroSeason)) { - foreach (var seasonInfo in showInfo.SeasonList) { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) - episodeIds.Add(episodeId); - foreach (var episodeInfo in seasonInfo.SpecialsList) { - if (episodeIds.Contains(episodeInfo.Id)) - continue; + // Add missing seasons + foreach (var (seasonNumber, season) in CreateMissingSeasons(showInfo, series, seasons)) + seasons.TryAdd(seasonNumber, season); - AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, zeroSeason); - } - } - } - - // Add missing episodes - foreach (var pair in showInfo.SeasonOrderDictionary) { - var seasonNumber= pair.Key; - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; - - var seasonInfo = pair.Value; + // Handle specials when grouped. + if (seasons.TryGetValue(0, out var zeroSeason)) { + foreach (var seasonInfo in showInfo.SeasonList) { foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) episodeIds.Add(episodeId); - foreach (var episodeInfo in seasonInfo.EpisodeList) { + + foreach (var episodeInfo in seasonInfo.SpecialsList) { if (episodeIds.Contains(episodeInfo.Id)) continue; - AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); + AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, zeroSeason); } } - - // We add the extras to the season if we're using Shoko Groups. - AddExtras(series, showInfo.DefaultSeason); - - foreach (var pair in showInfo.SeasonOrderDictionary) { - if (!seasons.TryGetValue(pair.Key, out var season) || season == null) - continue; - - AddExtras(season, pair.Value); - } } - // Provide metadata for other series - else { - var seasonInfo = ApiManager.GetSeasonInfoForSeries(seriesId) - .GetAwaiter() - .GetResult(); - if (seasonInfo == null) { - Logger.LogWarning("Unable to find series info. (Series={SeriesID})", seriesId); - return; - } - // Get the existing seasons and episode ids - var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); - - // Compute the season numbers for each episode in the series in advance, since we need to filter out the missing seasons - var episodeInfoToSeasonNumberDirectory = seasonInfo.RawEpisodeList.ToDictionary(e => e, e => Ordering.GetSeasonNumber(null, seasonInfo, e)); - - // Add missing seasons - var allKnownSeasonNumbers = episodeInfoToSeasonNumberDirectory.Values.Distinct().ToList(); - foreach (var (seasonNumber, season) in CreateMissingSeasons(seasonInfo, series, seasons, allKnownSeasonNumbers)) - seasons.Add(seasonNumber, season); + // Add missing episodes + foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; - // Add missing episodes foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) episodeIds.Add(episodeId); - foreach (var episodeInfo in seasonInfo.RawEpisodeList) { - if (episodeInfo.ExtraType != null) - continue; + foreach (var episodeInfo in seasonInfo.EpisodeList) { if (episodeIds.Contains(episodeInfo.Id)) continue; - var seasonNumber = episodeInfoToSeasonNumberDirectory[episodeInfo]; - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; - - AddVirtualEpisode(null, seasonInfo, episodeInfo, season); + AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); } + } + + AddExtras(series, showInfo.DefaultSeason); + + foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; - // We add the extras to the series if not. - AddExtras(series, seasonInfo); + AddExtras(season, seasonInfo); } } private void UpdateSeason(Season season, Series series, string seriesId, bool deleted = false) { var seasonNumber = season.IndexNumber!.Value; - var seriesGrouping = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; - Info.ShowInfo showInfo = null; - Info.SeasonInfo seasonInfo = null; - // Provide metadata for a season using Shoko's Group feature - if (seriesGrouping) { - showInfo = ApiManager.GetShowInfoForSeries(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) - .GetAwaiter() - .GetResult(); - if (showInfo == null) { - Logger.LogWarning("Unable to find group info for series. (Series={SeriesId})", seriesId); - return; - } - - if (seasonNumber == 0) { - if (deleted) { - season = AddVirtualSeason(0, series); - } - } - else { - seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); - if (seasonInfo == null) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00} in group for series. (Group={GroupId})", seasonNumber, showInfo.Id); - return; - } - - if (deleted) { - var offset = seasonNumber - showInfo.SeasonNumberBaseDictionary[seasonInfo]; - season = AddVirtualSeason(seasonInfo, offset, seasonNumber, series); - } - } - } - // Provide metadata for other seasons - else { - seasonInfo = ApiManager.GetSeasonInfoForSeries(seriesId) - .GetAwaiter() - .GetResult(); - if (seasonInfo == null) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00}. (Series={SeriesId})", seasonNumber, seriesId); - return; - } - - if (deleted) { - var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && seasonInfo.TvDB != null; - season = seasonNumber == 1 && (!mergeFriendly) ? AddVirtualSeason(seasonInfo, 0, 1, series) : AddVirtualSeason(seasonNumber, series); - } + var showInfo = ApiManager.GetShowInfoForSeries(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) + .GetAwaiter() + .GetResult(); + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for season. (Series={SeriesId})", seriesId); + return; } // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. @@ -465,35 +379,39 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de existingEpisodes.Add(episodeId); } - // Handle specials when grouped. + // Special handling of specials (pun intended). if (seasonNumber == 0) { - if (seriesGrouping) { - foreach (var sI in showInfo.SeasonList) { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(sI.Id)) - existingEpisodes.Add(episodeId); - foreach (var episodeInfo in sI.SpecialsList) { - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; - - AddVirtualEpisode(showInfo, sI, episodeInfo, season); - } - } - } - else { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) + if (deleted) + season = AddVirtualSeason(0, series); + + foreach (var sI in showInfo.SeasonList) { + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(sI.Id)) existingEpisodes.Add(episodeId); - foreach (var episodeInfo in seasonInfo.SpecialsList) { + + foreach (var episodeInfo in sI.SpecialsList) { if (existingEpisodes.Contains(episodeInfo.Id)) continue; - AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); + AddVirtualEpisode(showInfo, sI, episodeInfo, season); } } } + // Every other "season". else { + var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); + if (seasonInfo == null) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00} in group for series. (Group={GroupId})", seasonNumber, showInfo.Id); + return; + } + + var offset = seasonNumber - showInfo.SeasonNumberBaseDictionary[seasonInfo]; + if (deleted) + season = AddVirtualSeason(seasonInfo, offset, seasonNumber, series); + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) existingEpisodes.Add(episodeId); - foreach (var episodeInfo in seasonInfo.EpisodeList) { + + foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList).Concat(seasonInfo.OthersList)) { var episodeParentIndex = episodeInfo.IsSpecial ? 0 : Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); if (episodeParentIndex != seasonNumber) continue; @@ -504,8 +422,7 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); } - // We add the extras to the season if we're using Shoko Groups. - if (seriesGrouping) { + if (offset == 0) { AddExtras(season, seasonInfo); } } @@ -513,16 +430,17 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de private void UpdateEpisode(Episode episode, string episodeId) { - Info.ShowInfo showInfo = null; - Info.SeasonInfo seasonInfo = ApiManager.GetSeasonInfoForEpisode(episodeId) - .GetAwaiter() - .GetResult(); - Info.EpisodeInfo episodeInfo = seasonInfo.EpisodeList.FirstOrDefault(e => e.Id == episodeId); - if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) - showInfo = ApiManager.GetShowInfoForSeries(seasonInfo.Id, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) + var showInfo = ApiManager.GetShowInfoForEpisode(episodeId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) .GetAwaiter() .GetResult(); - + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for episode. (Episode={EpisodeId})", episode); + return; + } + var seasonInfo = ApiManager.GetSeasonInfoForEpisode(episodeId) + .GetAwaiter() + .GetResult(); + var episodeInfo = seasonInfo.EpisodeList.FirstOrDefault(e => e.Id == episodeId); var episodeIds = ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id); if (!episodeIds.Contains(episodeId)) AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, episode.Season); @@ -769,7 +687,7 @@ private void RemoveDuplicateEpisodes(Episode episode, string episodeId) #endregion #region Extras - private void AddExtras(Folder parent, Info.SeasonInfo seasonInfo) + private void AddExtras(BaseItem parent, Info.SeasonInfo seasonInfo) { if (seasonInfo.ExtrasList.Count == 0) return; @@ -780,15 +698,9 @@ private void AddExtras(Folder parent, Info.SeasonInfo seasonInfo) if (!Lookup.TryGetPathForEpisodeId(episodeInfo.Id, out var episodePath)) continue; - switch (episodeInfo.ExtraType) { - default: - break; - case MediaBrowser.Model.Entities.ExtraType.ThemeSong: - case MediaBrowser.Model.Entities.ExtraType.ThemeVideo: - if (!parent.SupportsThemeMedia) - continue; - break; - } + if (episodeInfo.ExtraType is MediaBrowser.Model.Entities.ExtraType.ThemeSong or MediaBrowser.Model.Entities.ExtraType.ThemeVideo && + !parent.SupportsThemeMedia) + continue; var item = LibraryManager.FindByPath(episodePath, false); if (item != null && item is Video video) { @@ -798,7 +710,7 @@ private void AddExtras(Folder parent, Info.SeasonInfo seasonInfo) video.ExtraType = episodeInfo.ExtraType; video.ProviderIds.TryAdd("Shoko Episode", episodeInfo.Id); video.ProviderIds.TryAdd("Shoko Series", seasonInfo.Id); - LibraryManager.UpdateItemAsync(video, null, ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); + LibraryManager.UpdateItemAsync(video, null, ItemUpdateType.None, CancellationToken.None).ConfigureAwait(false); if (!parent.ExtraIds.Contains(video.Id)) { needsUpdate = true; extraIds.Add(video.Id); @@ -829,7 +741,7 @@ private void AddExtras(Folder parent, Info.SeasonInfo seasonInfo) } } - public void RemoveExtras(Folder parent, string seriesId) + public void RemoveExtras(BaseItem parent, string seriesId) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { IsVirtualItem = false, diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index b118b2ba..e6b1c5ce 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -36,10 +36,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio try { var result = new MetadataResult<Movie>(); - var includeGroup = Plugin.Instance.Configuration.BoxSetGrouping == Ordering.GroupType.ShokoGroup; - var config = Plugin.Instance.Configuration; - Ordering.GroupFilterType? filterByType = config.BoxSetGrouping == Ordering.GroupType.ShokoGroup ? config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Movies : Ordering.GroupFilterType.Default : null; - var (file, series, group) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); + var (file, series, _) = await ApiManager.GetFileInfoByPath(info.Path, Ordering.GroupFilterType.Movies); var episode = file?.EpisodeList.FirstOrDefault(); // if file is null then series and episode is also null. @@ -48,7 +45,6 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio return result; } - var collectionName = GetCollectionName(series, group, info.MetadataLanguage); var ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); Logger.LogInformation("Found movie {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", displayTitle, file.Id, episode.Id, series.Id); @@ -57,13 +53,11 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio var rating = isMultiEntry ? episode.AniDB.Rating.ToFloat(10) : series.AniDB.Rating.ToFloat(10); result.Item = new Movie { - IndexNumber = Ordering.GetMovieIndexNumber(group, series, episode), Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, - CollectionName = collectionName, // 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(series)), + Overview = isMultiEntry && !isMainEntry ? Text.GetDescription(episode) : Text.GetDescription(series), ProductionYear = episode.AniDB.AirDate?.Year, Tags = series.Tags.ToArray(), Genres = series.Genres.ToArray(), @@ -73,8 +67,6 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio result.Item.SetProviderId("Shoko File", file.Id); result.Item.SetProviderId("Shoko Episode", episode.Id); result.Item.SetProviderId("Shoko Series", series.Id); - if (config.AddAniDBId) - result.Item.SetProviderId("AniDB", series.AniDB.Id.ToString()); result.HasMetadata = true; @@ -91,18 +83,6 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio } } - private static string GetCollectionName(API.Info.SeasonInfo series, API.Info.ShowInfo group, string metadataLanguage) - { - return Plugin.Instance.Configuration.BoxSetGrouping switch { - Ordering.GroupType.ShokoGroup => - Text.GetSeriesTitle(group.DefaultSeason.AniDB.Titles, group.DefaultSeason.Shoko.Name, metadataLanguage), - Ordering.GroupType.ShokoSeries => - Text.GetSeriesTitle(series.AniDB.Titles, series.Shoko.Name, metadataLanguage), - _ => null, - }; - } - - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 33a8e745..b3831331 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -81,113 +81,33 @@ private static MetadataResult<Season> GetDefaultMetadata(SeasonInfo info) private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo info) { var result = new MetadataResult<Season>(); - - int offset = 0; - int seasonNumber = 1; - API.Info.SeasonInfo season; - // All previously known seasons - if (info.ProviderIds.TryGetValue("Shoko Series", out var seriesId) && info.ProviderIds.TryGetValue("Shoko Season Offset", out var offsetText) && int.TryParse(offsetText, out offset)) { - season = await ApiManager.GetSeasonInfoForSeries(seriesId); - - if (season == null) { - Logger.LogWarning("Unable to find series info for Season. (Series={SeriesId})", seriesId); - return result; - } - - if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { - var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var show = await ApiManager.GetShowInfoForSeries(seriesId, filterByType); - if (show == null) { - Logger.LogWarning("Unable to find group info for Season. (Series={SeriesId})", season.Id); - return result; - } - - if (!show.SeasonNumberBaseDictionary.TryGetValue(season, out seasonNumber)) { - Logger.LogWarning("Unable to find season number for Season. (Series={SeriesId},Group={GroupId})", season.Id, show.Id); - return result; - } - seasonNumber = seasonNumber < 0 ? seasonNumber - offset : seasonNumber + offset; - - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, show.Name, season.Id, show.Id); - } - else { - seasonNumber += offset; - - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId})", seasonNumber, season.Shoko.Name, season.Id); - } + var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; + if (!info.SeriesProviderIds.TryGetValue("Shoko Series", out var seriesId) || !info.IndexNumber.HasValue) { + Logger.LogDebug("Unable refresh Season {SeasonNumber} {SeasonName}", info.IndexNumber, info.Name); + return result; } - // New physical seasons - else if (info.Path != null) { - season = await ApiManager.GetSeasonInfoByPath(info.Path); - - if (season == null) { - Logger.LogWarning("Unable to find series info for Season by path {Path}.", info.Path); - return result; - } - - if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { - var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var show = await ApiManager.GetShowInfoForSeries(season.Id, filterByType); - if (show == null) { - Logger.LogWarning("Unable to find group info for Season by path {Path}. (Series={SeriesId})", info.Path, season.Id); - return result; - } - - if (!show.SeasonNumberBaseDictionary.TryGetValue(season, out seasonNumber)) { - Logger.LogWarning("Unable to find season number for Season by path {Path}. (Series={SeriesId},Group={GroupId})", info.Path, season.Id, show.Id); - return result; - } - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, show.Name, season.Id, show.Id); - } - else { - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId})", seasonNumber, season.Shoko.Name, season.Id); - } + var seasonNumber = info.IndexNumber.Value; + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId, filterByType); + if (showInfo == null) { + Logger.LogWarning("Unable to find show info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); + return result; } - // New virtual seasons - else if (info.SeriesProviderIds.TryGetValue("Shoko Series", out seriesId) && info.IndexNumber.HasValue) { - seasonNumber = info.IndexNumber.Value; - if (Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup) { - var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - var show = await ApiManager.GetShowInfoForSeries(seriesId, filterByType); - if (show == null) { - Logger.LogWarning("Unable to find group info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); - return result; - } - - season = show.GetSeriesInfoBySeasonNumber(seasonNumber); - if (season == null || !show.SeasonNumberBaseDictionary.TryGetValue(season, out var baseSeasonNumber)) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Group={GroupId})", seasonNumber, show.Id); - return result; - } - offset = Math.Abs(seasonNumber - baseSeasonNumber); - - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, show.Name, season.Id, show.Id); - } - else { - season = await ApiManager.GetSeasonInfoForSeries(seriesId); - offset = seasonNumber - 1; - - if (season == null) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); - return result; - } - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId})", seasonNumber, season.Shoko.Name, season.Id); - } - } - // Everything else. - else { - Logger.LogDebug("Unable refresh Season {SeasonNumber} {SeasonName}", info.IndexNumber, info.Name); + var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); + if (seasonInfo == null || !showInfo.SeasonNumberBaseDictionary.TryGetValue(seasonInfo, out var baseSeasonNumber)) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Series={SeriesId},Group={GroupId})", seasonNumber, seriesId, showInfo.Id); return result; } - result.Item = CreateMetadata(season, seasonNumber, offset, info.MetadataLanguage); + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, showInfo.Name, seriesId, showInfo.Id); - result.HasMetadata = true; + var offset = Math.Abs(seasonNumber - baseSeasonNumber); + result.Item = CreateMetadata(seasonInfo, seasonNumber, offset, info.MetadataLanguage); + result.HasMetadata = true; result.ResetPeople(); - foreach (var person in season.Staff) + foreach (var person in seasonInfo.Staff) result.AddPerson(person); return result; @@ -205,24 +125,24 @@ public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber var sortTitle = $"S{seasonNumber} - {seasonInfo.Shoko.Name}"; if (offset > 0) { - string type = ""; + string type = string.Empty; switch (offset) { default: break; - case -1: case 1: if (seasonInfo.AlternateEpisodesList.Count > 0) type = "Alternate Stories"; else type = "Other Episodes"; break; - case -2: case 2: type = "Other Episodes"; break; } - displayTitle += $" ({type})"; - alternateTitle += $" ({type})"; + if (!string.IsNullOrEmpty(type)) { + displayTitle += $" ({type})"; + alternateTitle += $" ({type})"; + } } Season season; diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index b93f1e45..29d1fe9d 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -27,20 +28,25 @@ public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> private readonly ShokoAPIManager ApiManager; private readonly IFileSystem FileSystem; + + private readonly ILibraryManager LibraryManager; - public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager, IFileSystem fileSystem) + public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager, IFileSystem fileSystem, ILibraryManager libraryManager) { Logger = logger; HttpClientFactory = httpClientFactory; ApiManager = apiManager; FileSystem = fileSystem; + LibraryManager = libraryManager; } public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) { try { var result = new MetadataResult<Series>(); - var filterLibrary = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; + var baseItem = LibraryManager.FindByPath(info.Path, true); + var collectionType = LibraryManager.GetInheritedContentType(baseItem); + var filterLibrary = collectionType == CollectionType.TvShows && !Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Default : Ordering.GroupFilterType.Others; var show = await ApiManager.GetShowInfoByPath(info.Path, filterLibrary); if (show == null) { try { diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index b20777ae..ae114f64 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; using Emby.Naming.Common; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -81,125 +82,89 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) #region Generate Structure - private bool GenerateFullStructureForMediaFolder(Folder mediaFolder) + private async Task<IReadOnlyList<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>> GetImportFolderFiles(int importFolderId, string importFolderSubPath, string mediaFolderPath) { - if (DataCache.TryGetValue<bool>(mediaFolder.Id.ToString(), out var isVFS)) - return isVFS; + Logger.LogDebug("Looking for recognised files within media folder… (ImportFolder={FolderId},RelativePath={RelativePath})", importFolderId, importFolderSubPath); + var allFilesForImportFolder = (await ApiClient.GetFilesForImportFolder(importFolderId)) + .AsParallel() + .SelectMany(file => + { + var location = file.Locations + .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length == 0 || location.Path.StartsWith(importFolderSubPath))) + .FirstOrDefault(); + if (location == null || file.CrossReferences.Count == 0) + return Array.Empty<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>(); + + var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); + if (!File.Exists(sourceLocation)) + return Array.Empty<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>(); + + return file.CrossReferences + .Select(xref => (sourceLocation, fileId: file.Id.ToString(), seriesId: xref.Series.Shoko.ToString(), episodeIds: xref.Episodes.Select(e => e.Shoko.ToString()).ToArray())); + }) + .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation)) + .ToList(); + Logger.LogDebug("Found {FileCount} files to use within media folder at {Path} (ImportFolder={FolderId},RelativePath={RelativePath})", allFilesForImportFolder.Count, mediaFolderPath, importFolderId, importFolderSubPath); + return allFilesForImportFolder; + } - Logger.LogDebug("Looking for match for media folder at {Path}", mediaFolder.Path); + private async Task<string?> GenerateStructureForFolder(Folder mediaFolder, string folderPath) + { + if (DataCache.TryGetValue<string?>(folderPath, out var vfsPath) || DataCache.TryGetValue(mediaFolder.Path, out vfsPath)) + return vfsPath; + + Logger.LogDebug("Looking for match for folder at {Path}.", folderPath); - // check if we should introduce the VFS for the folder - var allPaths = FileSystem.GetFilePaths(mediaFolder.Path, true) + // Check if we should introduce the VFS for the media folder. + var allPaths = FileSystem.GetFilePaths(folderPath, true) .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) - .Select(path => path[mediaFolder.Path.Length..]); - ApiFile? file = null; + .Take(100) + .ToList(); int importFolderId = 0; - string partialPathSegment = string.Empty; - foreach (var partialPath in allPaths) { - var files = ApiClient.GetFileByPath(partialPath) + string importFolderSubPath = string.Empty; + foreach (var path in allPaths) { + var partialPath = path[mediaFolder.Path.Length..]; + var partialFolderPath = path[folderPath.Length..]; + var file = ApiClient.GetFileByPath(partialPath) .GetAwaiter() - .GetResult(); - file = files.FirstOrDefault(); + .GetResult() + .FirstOrDefault(); if (file == null) continue; var fileId = file.Id.ToString(); var fileLocations = file.Locations - .Where(location => location.Path.EndsWith(partialPath)) + .Where(location => location.Path.EndsWith(partialFolderPath)) .ToList(); if (fileLocations.Count == 0) continue; var fileLocation = fileLocations[0]; importFolderId = fileLocation.ImportFolderId; - partialPathSegment = fileLocation.Path[..^partialPath.Length]; + importFolderSubPath = fileLocation.Path[..^partialFolderPath.Length]; break; } - DataCache.Set(mediaFolder.Id.ToString(), file != null, DefaultTTL); - if (file == null) - return false; + if (importFolderId != 0) { + Logger.LogDebug("Failed to find a match for folder at {Path} after {Amount} attempts.", folderPath, allPaths.Count); - Logger.LogDebug("Found a match for media library at {Path} (ImportFolder={FolderId},RelativePath={RelativePath})", mediaFolder.Path, importFolderId, partialPathSegment); - var filterType = !Plugin.Instance.Configuration.FilterOnLibraryTypes ? ( - Ordering.GroupFilterType.Default - ) : ( - LibraryManager.GetInheritedContentType(mediaFolder) switch { - "movies" => Ordering.GroupFilterType.Movies, - _ => Ordering.GroupFilterType.Others, - } - ); - Logger.LogDebug("Looking for files within import folder… (ImportFolder={FolderId},RelativePath={RelativePath})", importFolderId, partialPathSegment); - var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); - var allFilesForImportFolder = ApiClient.GetFilesForImportFolder(importFolderId) - .GetAwaiter() - .GetResult() - .AsParallel() - .Where(file => file.Locations.Any(location => location.ImportFolderId == importFolderId && (partialPathSegment.Length == 0 || location.Path.StartsWith(partialPathSegment)))) - .SelectMany(file => { - var location = file.Locations - .Where(location => location.ImportFolderId == importFolderId && (partialPathSegment.Length == 0 || location.Path.StartsWith(partialPathSegment))) - .First(); + DataCache.Set<string?>(folderPath, null, DefaultTTL); + return null; + } - var sourceLocation = Path.Join(mediaFolder.Path, location.Path[partialPathSegment.Length..]); - if (!File.Exists(sourceLocation)) - return Array.Empty<(string sourceLocation, string symbolicLink)>(); - return file.CrossReferences - .AsParallel() - .Select(xref => { - var season = ApiManager.GetSeasonInfoForSeries(xref.Series.Shoko.ToString()) - .GetAwaiter() - .GetResult(); - if (season == null) - return (sourceLocation: string.Empty, symbolicLink: string.Empty); - var fileName = $"shoko-file-{file.Id}{Path.GetExtension(sourceLocation)}"; - var showFolder = $"shoko-series-{season.Id}"; - var isGrouped = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; - switch (filterType) { - case Ordering.GroupFilterType.Movies: { - var isMovieSeason = season.AniDB.Type == SeriesType.Movie; - if (!isMovieSeason) - return (sourceLocation: string.Empty, symbolicLink: string.Empty); - - return (sourceLocation, symbolicLink: Path.Combine(vfsPath, showFolder, fileName)); - } - case Ordering.GroupFilterType.Others: { - var isMovieSeason = season.AniDB.Type == SeriesType.Movie; - if (isMovieSeason) - return (sourceLocation: string.Empty, symbolicLink: string.Empty); - - goto default; - } - default: - case Ordering.GroupFilterType.Default: { - var isMovieSeason = season.AniDB.Type == SeriesType.Movie; - if (isMovieSeason) - return (sourceLocation, symbolicLink: Path.Combine(vfsPath, showFolder, fileName)); - - var fileInfo = ApiManager.GetFileInfo(file.Id.ToString(), season.Id) - .GetAwaiter() - .GetResult(); - if (fileInfo == null) - return (sourceLocation: string.Empty, symbolicLink: string.Empty); - - var show = ApiManager.GetShowInfoForSeries(xref.Series.Shoko.ToString(), filterType) - .GetAwaiter() - .GetResult(); - season = show?.SeasonList.First(s => s.Id == season.Id); - if (show == null || season == null) - return (sourceLocation: string.Empty, symbolicLink: string.Empty); - - var episode = fileInfo.EpisodeList.FirstOrDefault(); - var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); - var seasonFolder = $"Season {seasonNumber} [shoko-series-{season.Id}]"; - showFolder = $"grouped-by-{show.DefaultSeason?.Id ?? xref.Series.Shoko.ToString()}"; - - return (sourceLocation, symbolicLink: Path.Combine(vfsPath, showFolder, seasonFolder, fileName)); - } - } - }) - .ToArray(); - }) + Logger.LogDebug("Found a match for folder at {Path} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path})", folderPath, importFolderId, importFolderSubPath, mediaFolder.Path); + + vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + DataCache.Set(folderPath, vfsPath, DefaultTTL); + var allFiles = await GetImportFolderFiles(importFolderId, importFolderSubPath, folderPath); + await GenerateSymbolicLinks(mediaFolder, allFiles); + + return vfsPath; + } + + private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)> files) + { + var allPathsForVFS = (await Task.WhenAll(files.AsParallel().Select((tuple) => GenerateLocationForFile(mediaFolder, tuple.sourceLocation, tuple.fileId, tuple.seriesId, tuple.episodeIds)).ToList())) .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation)) .OrderBy(tuple => tuple.sourceLocation) .ThenBy(tuple => tuple.symbolicLink) @@ -207,7 +172,7 @@ private bool GenerateFullStructureForMediaFolder(Folder mediaFolder) var skipped = 0; var created = 0; - foreach (var (sourceLocation, symbolicLink) in allFilesForImportFolder) { + foreach (var (sourceLocation, symbolicLink) in allPathsForVFS) { if (File.Exists(symbolicLink)) { skipped++; continue; @@ -222,9 +187,10 @@ private bool GenerateFullStructureForMediaFolder(Folder mediaFolder) // TODO: Check for subtitle files. } + var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); var toBeRemoved = FileSystem.GetFilePaths(vfsPath, true) .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) - .Except(allFilesForImportFolder.Select(tuple => tuple.symbolicLink).ToHashSet()) + .Except(allPathsForVFS.Select(tuple => tuple.symbolicLink).ToHashSet()) .ToList(); foreach (var symbolicLink in toBeRemoved) { // TODO: Check for subtitle files. @@ -242,8 +208,102 @@ private bool GenerateFullStructureForMediaFolder(Folder mediaFolder) toBeRemoved.Count, mediaFolder.Path ); + } + + private async Task<(string sourceLocation, string symbolicLink)> GenerateLocationForFile(Folder mediaFolder, string sourceLocation, string fileId, string seriesId, string[] episodeIds) + { + var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); + var filterType = Ordering.GetGroupFilterTypeForCollection(collectionType); + var season = await ApiManager.GetSeasonInfoForSeries(seriesId); + if (season == null) + return (sourceLocation: string.Empty, symbolicLink: string.Empty); + var isGrouped = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; + var isMovieSeason = season.AniDB.Type == SeriesType.Movie; + switch (collectionType) { + default: { + if (isMovieSeason && collectionType == null) + goto case CollectionType.Movies; + + var show = await ApiManager.GetShowInfoForSeries(seriesId, filterType); + if (show == null) + return (sourceLocation: string.Empty, symbolicLink: string.Empty); + + var file = await ApiManager.GetFileInfo(fileId, seriesId); + var episode = file?.EpisodeList.FirstOrDefault(); + if (file == null || episode == null) + return (sourceLocation: string.Empty, symbolicLink: string.Empty); + + // In the off-chance that we accidentially ended up with two + // instances of the season while fetching in parallel, then we're + // switching to the correct reference of the season for the show + // we're doing. Let's just hope we won't have to also need to switch + // the episode… + season = show.SeasonList.FirstOrDefault(s => s.Id == seriesId); + episode = season?.RawEpisodeList.FirstOrDefault(e => e.Id == episode.Id); + if (season == null || episode == null) + return (sourceLocation: string.Empty, symbolicLink: string.Empty); + + var defaultSeason = show.DefaultSeason ?? season; + var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); + var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); + var (_, _, _, isSpecial) = Ordering.GetSpecialPlacement(show, season, episode); + + var showName = defaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters(); + if (string.IsNullOrEmpty(showName)) + showName = $"Shoko Series {defaultSeason.Id}"; + if (defaultSeason.AniDB.AirDate.HasValue) + showName += $" ({defaultSeason.AniDB.AirDate.Value.Year})"; + + var episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber}"; + var paths = new List<string>() + { + vfsPath, + $"{showName} [shoko-series-{defaultSeason.Id}]", + $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}", + }; + if (file.ExtraType != null) + { + episodeName = episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode == "en")?.Value ?? $"Episode {episode.AniDB.Type} {episode.AniDB.EpisodeNumber}"; + var extrasFolder = file.ExtraType switch { + ExtraType.BehindTheScenes => "behind the scenes", + ExtraType.Clip => "clips", + ExtraType.DeletedScene => "deleted scene", + ExtraType.Interview => "interviews", + ExtraType.Sample => "samples", + ExtraType.Scene => "scenes", + ExtraType.ThemeSong => "theme-music", + ExtraType.ThemeVideo => "backdrops", + ExtraType.Trailer => "trailers", + ExtraType.Unknown => "others", + _ => "extras", + }; + paths.Add(extrasFolder); + } + + var fileName = $"{episodeName} [shoko-series-{seriesId}] [shoko-file-{fileId}{Path.GetExtension(sourceLocation)}]"; + paths.Add(fileName); + var symbolicLink = Path.Combine(paths.ToArray()); + ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, episodeIds); + return (sourceLocation, symbolicLink); + } + case CollectionType.TvShows: { + if (isMovieSeason && Plugin.Instance.Configuration.FilterOnLibraryTypes) + return (sourceLocation: string.Empty, symbolicLink: string.Empty); + + goto default; + } + case CollectionType.Movies: { + if (!isMovieSeason) + return (sourceLocation: string.Empty, symbolicLink: string.Empty); - return true; + var fileName = $"Movie File [shoko-series-{seriesId}] [shoko-file-{fileId}{Path.GetExtension(sourceLocation)}]"; + var symbolicLink = Path.Combine(vfsPath, $"Shoko Series {seriesId} [shoko-series-{seriesId}]", fileName); + + ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, episodeIds); + return (sourceLocation, symbolicLink); + } + } } #endregion @@ -252,15 +312,16 @@ private bool GenerateFullStructureForMediaFolder(Folder mediaFolder) public bool ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) { - if (Plugin.Instance.Configuration.EXPERIMENTAL_EnableResolver) - return false; - // Everything in the root folder is ignored by us. var root = LibraryManager.RootFolder; if (fileInfo == null || parent == null || root == null || parent == root || fileInfo.FullName.StartsWith(root.Path)) return false; try { + // Assume anything within the VFS is already okay. + if (fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) + return false; + // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. if (!Lookup.IsEnabledForItem(parent, out var isSoleProvider)) return false; @@ -279,20 +340,14 @@ public bool ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent, root); var shouldIgnore = Plugin.Instance.Configuration.LibraryFilteringMode ?? Plugin.Instance.Configuration.EXPERIMENTAL_EnableResolver || isSoleProvider; - var ordering = !Plugin.Instance.Configuration.FilterOnLibraryTypes ? ( - Ordering.GroupFilterType.Default - ) : ( - LibraryManager.GetInheritedContentType(parent) switch { - "movies" => Ordering.GroupFilterType.Movies, - _ => Ordering.GroupFilterType.Others, - } - ); + var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); + var filterType = Ordering.GetGroupFilterTypeForCollection(collectionType); if (fileInfo.IsDirectory) - return ScanDirectory(partialPath, fullPath, ordering, shouldIgnore); + return ScanDirectory(partialPath, fullPath, collectionType, filterType, shouldIgnore); else - return ScanFile(partialPath, fullPath, ordering, shouldIgnore); + return ScanFile(partialPath, fullPath, filterType, shouldIgnore); } - catch (System.Exception ex) { + catch (Exception ex) { if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); @@ -302,7 +357,7 @@ public bool ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) } } - private bool ScanDirectory(string partialPath, string fullPath, Ordering.GroupFilterType filterType, bool shouldIgnore) + private bool ScanDirectory(string partialPath, string fullPath, string? collectionType, Ordering.GroupFilterType filterType, bool shouldIgnore) { var season = ApiManager.GetSeasonInfoByPath(fullPath) .GetAwaiter() @@ -338,13 +393,20 @@ private bool ScanDirectory(string partialPath, string fullPath, Ordering.GroupFi } // Filter library if we enabled the option. - if (filterType != Ordering.GroupFilterType.Default) { - var isShowLibrary = filterType == Ordering.GroupFilterType.Others; - var isMovieSeason = season.AniDB.Type == SeriesType.Movie; - if (isMovieSeason == isShowLibrary) { - Logger.LogInformation("Library separation is enabled, ignoring shoko series. (Series={SeriesId})", season.Id); - return true; - } + var isMovieSeason = season.AniDB.Type == SeriesType.Movie; + switch (collectionType) { + case CollectionType.TvShows: + if (isMovieSeason && Plugin.Instance.Configuration.FilterOnLibraryTypes) { + Logger.LogInformation("Found movie in show library and library separation is enabled, ignoring shoko series. (Series={SeriesId})", season.Id); + return true; + } + break; + case CollectionType.Movies: + if (!isMovieSeason) { + Logger.LogInformation("Found show in movie library, ignoring shoko series. (Series={SeriesId})", season.Id); + return true; + } + break; } var show = ApiManager.GetShowInfoForSeries(season.Id, filterType) @@ -400,117 +462,143 @@ private bool ScanFile(string partialPath, string fullPath, Ordering.GroupFilterT if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || fileInfo == null || parent == null || root == null || parent == root || fileInfo.FullName.StartsWith(root.Path)) return null; - // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. - if (!Lookup.IsEnabledForItem(parent, out var isSoleProvider)) - return null; + try { + if (!Lookup.IsEnabledForItem(parent)) + return null; - var fullPath = fileInfo.FullName; - var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent, root); - if (mediaFolder == root) - return null; + var fullPath = fileInfo.FullName; + var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent, root); + if (mediaFolder == root) + return null; - if (parent.Id == mediaFolder.Id && fileInfo.IsDirectory) { - var isVFS = GenerateFullStructureForMediaFolder(mediaFolder); - if (!isVFS) + // We're most likely already within the VFS, so abort here. + if (!fullPath.StartsWith(Plugin.Instance.VirtualRoot)) return null; - if (!int.TryParse(fileInfo.Name.Split('-').LastOrDefault(), out var seriesId)) + var searchPath = Path.Combine(mediaFolder.Path, parent.Path[(mediaFolder.Path.Length + 1)..].Split(Path.DirectorySeparatorChar).Skip(1).Join(Path.DirectorySeparatorChar)); + var vfsPath = GenerateStructureForFolder(mediaFolder, searchPath) + .GetAwaiter() + .GetResult(); + if (string.IsNullOrEmpty(vfsPath)) return null; - return new TvSeries() + if (parent.Id == mediaFolder.Id && fileInfo.IsDirectory) { + if (!int.TryParse(fileInfo.Name.Split('-').LastOrDefault(), out var seriesId)) + return null; + + return new TvSeries() + { + Path = fileInfo.FullName, + }; + } + + // TODO: Redirect to the base item in the VFS if needed. + + return null; + } + catch (Exception ex) { + if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) { - Path = fileInfo.FullName, - }; + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + Plugin.Instance.CaptureException(ex); + } + return null; } - - return null; } public MultiItemResolverResult? ResolveMultiple(Folder? parent, string? collectionType, List<FileSystemMetadata> fileInfoList) { // Disable resolver. if (!Plugin.Instance.Configuration.EXPERIMENTAL_EnableResolver) - return new(); + return null; var root = LibraryManager.RootFolder; if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || root == null || parent == null || parent == root) - return new(); - - // Redirect children of a VFS managed folder to the VFS series. - if (parent.GetParent() == root) { - var isVFS = GenerateFullStructureForMediaFolder(parent); - if (!isVFS) - return new(); - - var filterType = !Plugin.Instance.Configuration.FilterOnLibraryTypes ? ( - Ordering.GroupFilterType.Default - ) : ( - collectionType switch { - "movies" => Ordering.GroupFilterType.Movies, - _ => Ordering.GroupFilterType.Others, - } - ); - var items = FileSystem.GetDirectories(ShokoAPIManager.GetVirtualRootForMediaFolder(parent)) - .AsParallel() - .SelectMany(dirInfo => { - if (!int.TryParse(dirInfo.Name.Split('-').LastOrDefault(), out var seriesId)) - return Array.Empty<BaseItem>(); - - var season = ApiManager.GetSeasonInfoForSeries(seriesId.ToString()) - .GetAwaiter() - .GetResult(); - if (season == null) - return Array.Empty<BaseItem>(); - - if ((collectionType == CollectionType.Movies || collectionType == null) && season.AniDB.Type == SeriesType.Movie) { - return FileSystem.GetFiles(dirInfo.FullName) - .AsParallel() - .Select(fileInfo => { - if (!int.TryParse(Path.GetFileNameWithoutExtension(fileInfo.Name).Split('[').LastOrDefault()?.Split(']').FirstOrDefault()?.Split('-').LastOrDefault(), out var fileId)) - return null; - - // This will hopefully just re-use the pre-cached entries from the cache, but it may - // also get it from remote if the cache was empty for whatever reason. - var file = ApiManager.GetFileInfo(fileId.ToString(), seriesId.ToString()) - .GetAwaiter() - .GetResult(); - - // Abort if the file was not recognised. - if (file == null || file.ExtraType != null) - return null; - - return new Movie() - { - Path = fileInfo.FullName, - ProviderIds = new() { - { "Shoko File", fileId.ToString() }, - } - } as BaseItem; - }) - .ToArray(); - } + return null; - return new BaseItem[1] { - new TvSeries() { - Path = dirInfo.FullName, - }, - }; - }) - .OfType<BaseItem>() - .ToList(); + try { + if (!Lookup.IsEnabledForItem(parent)) + return null; + + // Redirect children of a VFS managed media folder to the VFS. + if (parent.GetParent() == root) { + var vfsPath = GenerateStructureForFolder(parent, parent.Path) + .GetAwaiter() + .GetResult(); + if (string.IsNullOrEmpty(vfsPath)) + return null; - // TODO: uncomment the code snippet once the PR is in stable JF. - // return new() { Items = items, ExtraFiles = new() }; + var filterType = Ordering.GetGroupFilterTypeForCollection(collectionType); + var items = FileSystem.GetDirectories(vfsPath) + .AsParallel() + .SelectMany(dirInfo => { + if (!int.TryParse(dirInfo.Name.Split('-').LastOrDefault(), out var seriesId)) + return Array.Empty<BaseItem>(); - // TODO: Remove these two hacks once we have proper support for adding multiple series at once. - if (items.Where(i => i is Movie).ToList().Count == 0 && items.Count > 0) { - fileInfoList.Clear(); - fileInfoList.AddRange(items.Select(s => FileSystem.GetFileSystemInfo(s.Path))); + var season = ApiManager.GetSeasonInfoForSeries(seriesId.ToString()) + .GetAwaiter() + .GetResult(); + if (season == null) + return Array.Empty<BaseItem>(); + + if ((collectionType == CollectionType.Movies || collectionType == null) && season.AniDB.Type == SeriesType.Movie) { + return FileSystem.GetFiles(dirInfo.FullName) + .AsParallel() + .Select(fileInfo => { + if (!int.TryParse(Path.GetFileNameWithoutExtension(fileInfo.Name).Split('[').LastOrDefault()?.Split(']').FirstOrDefault()?.Split('-').LastOrDefault(), out var fileId)) + return null; + + // This will hopefully just re-use the pre-cached entries from the cache, but it may + // also get it from remote if the cache was empty for whatever reason. + var file = ApiManager.GetFileInfo(fileId.ToString(), seriesId.ToString()) + .GetAwaiter() + .GetResult(); + + // Abort if the file was not recognised. + if (file == null || file.ExtraType != null) + return null; + + return new Movie() + { + Path = fileInfo.FullName, + ProviderIds = new() { + { "Shoko File", fileId.ToString() }, + } + } as BaseItem; + }) + .ToArray(); + } + + return new BaseItem[1] { + new TvSeries() { + Path = dirInfo.FullName, + }, + }; + }) + .OfType<BaseItem>() + .ToList(); + + // TODO: uncomment the code snippet once the PR is in stable JF. + // return new() { Items = items, ExtraFiles = new() }; + + // TODO: Remove these two hacks once we have proper support for adding multiple series at once. + if (items.Where(i => i is Movie).ToList().Count == 0 && items.Count > 0) { + fileInfoList.Clear(); + fileInfoList.AddRange(items.Select(s => FileSystem.GetFileSystemInfo(s.Path))); + } + return new() { Items = items.Where(i => i is Movie).ToList(), ExtraFiles = items.OfType<TvSeries>().Select(s => FileSystem.GetFileSystemInfo(s.Path)).ToList() }; } - return new() { Items = items.Where(i => i is Movie).ToList(), ExtraFiles = items.OfType<TvSeries>().Select(s => FileSystem.GetFileSystemInfo(s.Path)).ToList() }; - } - return null; + return null; + } + catch (Exception ex) { + if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) + { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + Plugin.Instance.CaptureException(ex); + } + return null; + } } #endregion diff --git a/Shokofin/StringExtensions.cs b/Shokofin/StringExtensions.cs index 7d7fbb0d..6869c4b3 100644 --- a/Shokofin/StringExtensions.cs +++ b/Shokofin/StringExtensions.cs @@ -39,4 +39,30 @@ public static void Deconstruct(this IList<string> list, out string first, out st forth = list.Count > 3 ? list[3] : ""; fifth = list.Count > 4 ? list[4] : ""; } + + public static string Join(this IEnumerable<string> list, char separator) + => string.Join(separator, list); + + public static string Join(this IEnumerable<string> list, string? separator) + => string.Join(separator, list); + + public static string Join(this IEnumerable<string> list, char separator, int startIndex, int count) + => string.Join(separator, list, startIndex, count); + + public static string Join(this IEnumerable<string> list, string? separator, int startIndex, int count) + => string.Join(separator, list, startIndex, count); + + public static string ReplaceInvalidPathCharacters(this string path) + => path + .Replace(@"*", "\u1F7AF") // 🞯 (LIGHT FIVE SPOKED ASTERISK) + .Replace(@"|", "\uFF5C") // | (FULLWIDTH VERTICAL LINE) + .Replace(@"\", "\u29F9") // ⧹ (BIG REVERSE SOLIDUS) + .Replace(@"/", "\u29F8") // ⧸ (BIG SOLIDUS) + .Replace(@":", "\u0589") // ։ (ARMENIAN FULL STOP) + .Replace("\"", "\u2033") // ″ (DOUBLE PRIME) + .Replace(@">", "\u203a") // › (SINGLE RIGHT-POINTING ANGLE QUOTATION MARK) + .Replace(@"<", "\u2039") // ‹ (SINGLE LEFT-POINTING ANGLE QUOTATION MARK) + .Replace(@"?", "\uff1f") // ? (FULL WIDTH QUESTION MARK) + .Replace(@".", "\u2024") // ․ (ONE DOT LEADER) + .Trim(); } \ No newline at end of file diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs index fc263870..57f433f7 100644 --- a/Shokofin/Tasks/PostScanTask.cs +++ b/Shokofin/Tasks/PostScanTask.cs @@ -1,5 +1,7 @@ +using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Library; using Shokofin.API; +using Shokofin.Collections; using Shokofin.MergeVersions; using System; using System.Threading; @@ -13,17 +15,37 @@ public class PostScanTask : ILibraryPostScanTask private readonly MergeVersionsManager VersionsManager; - public PostScanTask(ShokoAPIManager apiManager, MergeVersionsManager versionsManager) + private readonly CollectionManager CollectionManager; + + public PostScanTask(ShokoAPIManager apiManager, MergeVersionsManager versionsManager, CollectionManager collectionManager) { ApiManager = apiManager; VersionsManager = versionsManager; + CollectionManager = collectionManager; } public async Task Run(IProgress<double> progress, CancellationToken token) { // Merge versions now if the setting is enabled. - if (Plugin.Instance.Configuration.EXPERIMENTAL_AutoMergeVersions) - await VersionsManager.MergeAll(progress, token); + if (Plugin.Instance.Configuration.EXPERIMENTAL_AutoMergeVersions) { + // Setup basic progress tracking + var baseProgress = 0d; + var simpleProgress = new ActionableProgress<double>(); + simpleProgress.RegisterAction(value => progress.Report(baseProgress + (value / 2d))); + + // Merge versions. + await VersionsManager.MergeAll(simpleProgress, token); + + // Reconstruct collections. + baseProgress = 50; + await CollectionManager.ReconstructCollections(simpleProgress, token); + + progress.Report(100d); + } + else { + // Reconstruct collections. + await CollectionManager.ReconstructCollections(progress, token); + } // Clear the cache now, since we don't need it anymore. ApiManager.Clear(); diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index e6bf8b63..a1bcc5d8 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -1,4 +1,5 @@ using System.Linq; +using MediaBrowser.Model.Entities; using Shokofin.API.Info; using Shokofin.API.Models; @@ -38,6 +39,12 @@ public enum GroupType /// Group movies based on Shoko's series. /// </summary> ShokoSeries = 3, + + /// <summary> + /// Group both movies and shows into collections based on shoko's + /// groups. + /// </summary> + ShokoGroupPlus = 4, } /// <summary> @@ -78,7 +85,7 @@ public enum SpecialOrderType { AfterSeason = 2, /// <summary> - /// Use a mix of <see cref="Shokofin.Utils.Ordering.SpecialOrderType.InBetweenSeasonByOtherData" /> and <see cref="Shokofin.Utils.Ordering.SpecialOrderType.InBetweenSeasonByAirDate" />. + /// Use a mix of <see cref="InBetweenSeasonByOtherData" /> and <see cref="InBetweenSeasonByAirDate" />. /// </summary> InBetweenSeasonMixed = 3, @@ -93,67 +100,12 @@ public enum SpecialOrderType { InBetweenSeasonByOtherData = 5, } - /// <summary> - /// Get index number for a movie in a box-set. - /// </summary> - /// <returns>Absolute index.</returns> - public static int GetMovieIndexNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) - { - switch (Plugin.Instance.Configuration.BoxSetGrouping) { - default: - case GroupType.Default: - return 1; - case GroupType.ShokoSeries: - return episode.AniDB.EpisodeNumber; - case GroupType.ShokoGroup: { - int offset = 0; - foreach (SeasonInfo s in group.SeasonList) { - var sizes = s.Shoko.Sizes.Total; - if (s != series) { - if (episode.AniDB.Type == EpisodeType.Special) { - var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); - if (index == -1) - throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); - return offset - (index + 1); - } - switch (episode.AniDB.Type) { - case EpisodeType.Normal: - // offset += 0; // it's not needed, so it's just here as a comment instead. - break; - case EpisodeType.Special: - offset += sizes?.Episodes ?? 0; - goto case EpisodeType.Normal; - case EpisodeType.Unknown: - offset += sizes?.Specials ?? 0; - goto case EpisodeType.Special; - // Add them to the bottom of the list if we didn't filter them out properly. - case EpisodeType.Parody: - offset += sizes?.Others ?? 0; - goto case EpisodeType.Unknown; - case EpisodeType.OpeningSong: - offset += sizes?.Parodies ?? 0; - goto case EpisodeType.Parody; - case EpisodeType.Trailer: - offset += sizes?.Credits ?? 0; - goto case EpisodeType.OpeningSong; - default: - offset += sizes?.Trailers ?? 0; - goto case EpisodeType.Trailer; - } - return offset + episode.AniDB.EpisodeNumber; - } - else { - if (episode.AniDB.Type == EpisodeType.Special) { - offset -= series.SpecialsList.Count; - } - offset += (sizes?.Episodes ?? 0) + (sizes?.Parodies ?? 0) + (sizes?.Others ?? 0); - } - } - break; - } - } - return 0; - } + public static GroupFilterType GetGroupFilterTypeForCollection(string collectionType) + => collectionType switch { + CollectionType.Movies => GroupFilterType.Movies, + CollectionType.TvShows => Plugin.Instance.Configuration.FilterOnLibraryTypes ? GroupFilterType.Others : GroupFilterType.Default, + _ => GroupFilterType.Others, + }; /// <summary> /// Get index number for an episode in a series. @@ -169,7 +121,7 @@ public static int GetEpisodeNumber(ShowInfo group, SeasonInfo series, EpisodeInf var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); if (index == -1) throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Series={series.Id},Episode={episode.Id})"); - return (index + 1); + return index + 1; } return episode.AniDB.EpisodeNumber; case GroupType.MergeFriendly: { @@ -181,14 +133,14 @@ public static int GetEpisodeNumber(ShowInfo group, SeasonInfo series, EpisodeInf case GroupType.ShokoGroup: { int offset = 0; if (episode.AniDB.Type == EpisodeType.Special) { - var seriesIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); - if (seriesIndex == -1) + var seasonIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); + if (seasonIndex == -1) throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); if (index == -1) throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); - offset = group.SeasonList.GetRange(0, seriesIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); - return offset + (index + 1); + offset = group.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); + return offset + index + 1; } var sizes = series.Shoko.Sizes.Total; switch (episode.AniDB.Type) { @@ -332,7 +284,6 @@ public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo if (!group.SeasonNumberBaseDictionary.TryGetValue(series, out var seasonNumber)) throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id})"); - var offset = 0; switch (episode.AniDB.Type) { default: @@ -347,7 +298,7 @@ public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo } } - return seasonNumber + (seasonNumber < 0 ? -offset : offset); + return seasonNumber + offset; } } } @@ -384,7 +335,7 @@ public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo return ExtraType.Clip; // Music videos if (title.Contains("music video", System.StringComparison.OrdinalIgnoreCase)) - return ExtraType.Clip; + return ExtraType.ThemeVideo; // Behind the Scenes if (title.Contains("making of", System.StringComparison.CurrentCultureIgnoreCase)) return ExtraType.BehindTheScenes; From 467a1cab21d33b505c6578346b6db8abd34a5d31 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 24 Mar 2024 23:50:45 +0000 Subject: [PATCH 0591/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index fbc3ce18..a5d4f3a1 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.37", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.37/shoko_3.0.1.37.zip", + "checksum": "124cc5a700383aca5a3f6e563a09fdd4", + "timestamp": "2024-03-24T23:50:44Z" + }, { "version": "3.0.1.36", "changelog": "NA\n", From c828a9c81836a533f079e3d113507ec8806a683d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 26 Mar 2024 16:59:42 +0100 Subject: [PATCH 0592/1103] refactor: disable and remove sentry --- Shokofin/Collections/CollectionManager.cs | 1 - Shokofin/Configuration/PluginConfiguration.cs | 3 - Shokofin/Configuration/SentryConfiguration.cs | 7 -- Shokofin/Configuration/configController.js | 36 +----- Shokofin/Configuration/configPage.html | 19 --- Shokofin/Plugin.cs | 111 +----------------- Shokofin/Providers/BoxSetProvider.cs | 1 - Shokofin/Providers/EpisodeProvider.cs | 1 - Shokofin/Providers/ImageProvider.cs | 1 - Shokofin/Providers/MovieProvider.cs | 1 - Shokofin/Providers/SeasonProvider.cs | 1 - Shokofin/Providers/SeriesProvider.cs | 1 - Shokofin/Resolvers/ShokoResolveManager.cs | 3 - Shokofin/Shokofin.csproj | 10 -- Shokofin/Sync/UserDataSyncManager.cs | 1 - Shokofin/Web/WebController.cs | 1 - 16 files changed, 2 insertions(+), 196 deletions(-) delete mode 100644 Shokofin/Configuration/SentryConfiguration.cs diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index 1cf89ed2..bdb2dda5 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -61,7 +61,6 @@ public async Task ReconstructCollections(IProgress<double> progress, Cancellatio catch (Exception ex) { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - Plugin.Instance.CaptureException(ex); } } diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index d16606e7..f17fdec0 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -86,8 +86,6 @@ public virtual string PrettyHost public string[] IgnoredFolders { get; set; } - public bool? SentryEnabled { get; set; } - public bool? LibraryFilteringMode { get; set; } #region Experimental features @@ -139,7 +137,6 @@ public PluginConfiguration() UserList = Array.Empty<UserConfiguration>(); IgnoredFileExtensions = new [] { ".nfo", ".jpg", ".jpeg", ".png" }; IgnoredFolders = new [] { ".streams", "@recently-snapshot" }; - SentryEnabled = null; LibraryFilteringMode = null; EXPERIMENTAL_EnableResolver = false; EXPERIMENTAL_AutoMergeVersions = false; diff --git a/Shokofin/Configuration/SentryConfiguration.cs b/Shokofin/Configuration/SentryConfiguration.cs deleted file mode 100644 index c741c64f..00000000 --- a/Shokofin/Configuration/SentryConfiguration.cs +++ /dev/null @@ -1,7 +0,0 @@ - -namespace Shokofin.Configuration; - -public static class SentryConfiguration -{ - public const string DSN = "%SENTRY_DSN%"; -} \ No newline at end of file diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 6aba07ae..374d554e 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -176,7 +176,6 @@ async function defaultSubmit(form) { config.HideProgrammingTags = form.querySelector("#HideProgrammingTags").checked; // Advanced settings - config.SentryEnabled = form.querySelector("#SentryEnabled").checked; config.PublicHost = publicHost; config.IgnoredFileExtensions = ignoredFileExtensions; form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); @@ -296,19 +295,6 @@ async function resetConnectionSettings(form) { return config; } -async function disableSentry(form) { - const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - form.querySelector("#SentryEnabled").checked = false; - - // Connection settings - config.SentryEnabled = false; - - const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - Dashboard.processPluginConfigurationUpdateResult(result); - - return config; -} - async function syncSettings(form) { const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); let publicHost = form.querySelector("#PublicHost").value; @@ -355,7 +341,6 @@ async function syncSettings(form) { config.HideProgrammingTags = form.querySelector("#HideProgrammingTags").checked; // Advanced settings - config.SentryEnabled = form.querySelector("#SentryEnabled").checked; config.PublicHost = publicHost; config.IgnoredFileExtensions = ignoredFileExtensions; form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); @@ -441,22 +426,7 @@ export default function (page) { const userSelector = form.querySelector("#UserSelector"); // Refresh the view after we changed the settings, so the view reflect the new settings. const refreshSettings = (config) => { - if (config.SentryEnabled == null) { - form.querySelector("#Host").removeAttribute("disabled"); - form.querySelector("#Username").removeAttribute("disabled"); - form.querySelector("#ConsentSection").removeAttribute("hidden"); - form.querySelector("#ConnectionSetContainer").setAttribute("hidden", ""); - form.querySelector("#ConnectionResetContainer").setAttribute("hidden", ""); - form.querySelector("#ConnectionSection").setAttribute("hidden", ""); - form.querySelector("#MetadataSection").setAttribute("hidden", ""); - form.querySelector("#ProviderSection").setAttribute("hidden", ""); - form.querySelector("#LibrarySection").setAttribute("hidden", ""); - form.querySelector("#UserSection").setAttribute("hidden", ""); - form.querySelector("#TagSection").setAttribute("hidden", ""); - form.querySelector("#AdvancedSection").setAttribute("hidden", ""); - form.querySelector("#ExperimentalSection").setAttribute("hidden", ""); - } - else if (config.ApiKey) { + if (config.ApiKey) { form.querySelector("#Host").setAttribute("disabled", ""); form.querySelector("#Username").setAttribute("disabled", ""); form.querySelector("#Password").value = ""; @@ -557,7 +527,6 @@ export default function (page) { form.querySelector("#HideProgrammingTags").checked = config.HideProgrammingTags; // Advanced settings - form.querySelector("#SentryEnabled").checked = config.SentryEnabled == null ? true : config.SentryEnabled; form.querySelector("#PublicHost").value = config.PublicHost; form.querySelector("#IgnoredFileExtensions").value = config.IgnoredFileExtensions.join(" "); form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); @@ -607,9 +576,6 @@ export default function (page) { Dashboard.showLoadingMsg(); syncUserSettings(form).then(refreshSettings).catch(onError); break; - case "disable-sentry": - Dashboard.showLoadingMsg(); - disableSentry(form).then(refreshSettings).catch(onError); } return false; }); diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 4773bf6e..53bb727c 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -7,18 +7,6 @@ <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="ConsentSection" class="verticalSection verticalSection-extrabottompadding" hidden> - <legend> - <h3>Improve Plugin Stability</h3> - </legend> - <div class="fieldDescription verticalSection-extrabottompadding">By enabling Sentry crash-reporting, you're helping us fix bugs and enhance the plugin's functionality. When a crash happens, Sentry collects data about the error, which may include paths to your videos involved in the operation. While some users may consider these paths sensitive, we want to assure you that this data is used strictly for diagnostic purposes.<br/><br/>Please be aware that enabling crash reporting is entirely optional. You are in control and can choose to enable or disable the feature at any time.<br/><br/>By choosing to enable this feature, you're not just improving your own experience but also helping us refine the plugin for all users. Your support is greatly appreciated.</div> - <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> - <span>Yes, Enable Crash Reporting</span> - </button> - <button is="emby-button" type="submit" name="disable-sentry" class="raised block emby-button"> - <span>No, Disable Crash Reporting</span> - </button> - </fieldset> <fieldset id="ConnectionSection" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Connection Settings</h3> @@ -357,13 +345,6 @@ <h3>Tag Settings</h3> <legend> <h3>Advanced Settings</h3> </legend> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="SentryEnabled" /> - <span>Enable Crash Reporting.</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Improve the plugin's stability by allowing Sentry to report any crashes, which helps us improve the plugin's stability. This may include paths to your videos involved in the operation, which some users may consider sensitive. We assure you that this data is used solely for diagnostic purposes. Participation is entirely optional but greatly appreciated.</div> - </div> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="text" id="PublicHost" label="Public Shoko host URL:" /> <div class="fieldDescription">This is the public URL leading to where Shoko is running. It is 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. It should include both the protocol and the IP/DNS name.</div> diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index c2efaaf4..e79eed00 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -11,7 +11,6 @@ using MediaBrowser.Model.Serialization; using Shokofin.API.Models; using Shokofin.Configuration; -using Sentry; #nullable enable namespace Shokofin; @@ -33,108 +32,18 @@ public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) { Instance = this; ConfigurationChanged += OnConfigChanged; - RefreshSentry(); IgnoredFileExtensions = this.Configuration.IgnoredFileExtensions.ToHashSet(); IgnoredFolders = this.Configuration.IgnoredFolders.ToHashSet(); } - ~Plugin() - { - if (SentryReference != null) { - SentrySdk.EndSession(); - SentryReference.Dispose(); - SentryReference = null; - } - } - public void OnConfigChanged(object? sender, BasePluginConfiguration e) { - if (!(e is PluginConfiguration config)) + if (e is not PluginConfiguration config) return; - RefreshSentry(); IgnoredFileExtensions = config.IgnoredFileExtensions.ToHashSet(); IgnoredFolders = config.IgnoredFolders.ToHashSet(); } - private void RefreshSentry() - { - if (IsSentryEnabled) { - if (SentryReference == null && SentryConfiguration.DSN.StartsWith("https://")) { - SentryReference = SentrySdk.Init(options => { - var release = Assembly.GetAssembly(typeof(Plugin))?.GetName().Version?.ToString() ?? "1.0.0.0"; - var environment = release.EndsWith(".0") ? "stable" : "dev"; - - // Cut off the build number for stable releases. - if (environment == "stable") - release = release[..^2]; - - // Assign the DSN key and release version. - options.Dsn = SentryConfiguration.DSN; - options.Environment = environment; - options.Release = release; - options.AutoSessionTracking = false; - - // Additional tags for easier filtering in Sentry. - var jellyfinRelease = Assembly.GetAssembly(typeof(Jellyfin.Data.Entities.Preference))?.GetName().Version?.ToString() ?? "0.0.0.0"; - options.DefaultTags.Add("jellyfin.release", jellyfinRelease); - - // Disable auto-exception captures. - options.DisableUnobservedTaskExceptionCapture(); - options.DisableAppDomainUnhandledExceptionCapture(); - options.CaptureFailedRequests = false; - - // Filter exceptions. - options.AddExceptionFilter(new SentryExceptionFilter(ex => - { - switch (ex) { - // Ignore any and all http request exceptions and - // task cancellation exceptions. - case TaskCanceledException: - case HttpRequestException: - return true; - - case ApiException apiEx: - // Server is not ready to accept requests yet. - if (ex.Message.Contains("The Server is not running.")) - return true; - break; - } - - // If we need more filtering in the future then add them - // above this comment. - - return false; - })); - }); - - SentrySdk.StartSession(); - } - } - else { - if (SentryReference != null) - { - SentrySdk.EndSession(); - SentryReference.Dispose(); - SentryReference = null; - } - } - } - - public bool IsSentryEnabled - { - get => Configuration.SentryEnabled ?? true; - } - - public void CaptureException(Exception ex) - { - if (SentryReference == null) - return; - - SentrySdk.CaptureException(ex); - } - - private IDisposable? SentryReference { get; set; } - public HashSet<string> IgnoredFileExtensions; public HashSet<string> IgnoredFolders; @@ -159,22 +68,4 @@ public IEnumerable<PluginPageInfo> GetPages() }, }; } - - /// <summary> - /// An IException filter class to convert a function to a filter. It's weird - /// they don't have a method that just accepts a pure function and converts - /// it internally, but oh well. ¯\_(ツ)_/¯ - /// </summary> - private class SentryExceptionFilter : Sentry.Extensibility.IExceptionFilter - { - private Func<Exception, bool> _action; - - public SentryExceptionFilter(Func<Exception, bool> action) - { - _action = action; - } - - public bool Filter(Exception ex) => - _action(ex); - } } diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 6d0f5e8d..99b2d97b 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -44,7 +44,6 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat } catch (Exception ex) { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - Plugin.Instance.CaptureException(ex); return new MetadataResult<BoxSet>(); } } diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index ba545971..6aaf14f1 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -84,7 +84,6 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell } catch (Exception ex) { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - Plugin.Instance.CaptureException(ex); return new MetadataResult<Episode>(); } } diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 92efb2a7..ab57fe23 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -109,7 +109,6 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell } catch (Exception ex) { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - Plugin.Instance.CaptureException(ex); return list; } } diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index e6b1c5ce..540fc0c0 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -78,7 +78,6 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio } catch (Exception ex) { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - Plugin.Instance.CaptureException(ex); return new MetadataResult<Movie>(); } } diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index b3831331..3aef84c8 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -56,7 +56,6 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat } catch (Exception ex) { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - Plugin.Instance.CaptureException(ex); return new MetadataResult<Season>(); } } diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 29d1fe9d..da88269c 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -126,7 +126,6 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat } catch (Exception ex) { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - Plugin.Instance.CaptureException(ex); return new MetadataResult<Series>(); } } diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index ae114f64..6552aaa3 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -351,7 +351,6 @@ public bool ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - Plugin.Instance.CaptureException(ex); } return false; } @@ -500,7 +499,6 @@ private bool ScanFile(string partialPath, string fullPath, Ordering.GroupFilterT if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - Plugin.Instance.CaptureException(ex); } return null; } @@ -595,7 +593,6 @@ private bool ScanFile(string partialPath, string fullPath, Ordering.GroupFilterT if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - Plugin.Instance.CaptureException(ex); } return null; } diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj index 07737a1d..e5440106 100644 --- a/Shokofin/Shokofin.csproj +++ b/Shokofin/Shokofin.csproj @@ -2,23 +2,13 @@ <PropertyGroup> <TargetFramework>net6.0</TargetFramework> <OutputType>Library</OutputType> - <!-- Update the sentry version here. --> - <SentryVersion>3.31.0</SentryVersion> </PropertyGroup> <ItemGroup> <PackageReference Include="Jellyfin.Controller" Version="10.8.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> - <PackageReference Include="Sentry" Version="$(SentryVersion)" /> </ItemGroup> - <Target Name="CopySentryDLLToOutputPath" AfterTargets="Build"> - <ItemGroup> - <SentryPackage Include="$(NuGetPackageRoot)\sentry\$(SentryVersion)\lib\$(TargetFramework)\Sentry.dll" /> - </ItemGroup> - <Copy SourceFiles="@(SentryPackage)" DestinationFolder="$(OutputPath)" /> - </Target> - <ItemGroup> <None Remove="Configuration\configController.js" /> <None Remove="Configuration\configPage.html" /> diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index 95bfa531..e58aba6a 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -276,7 +276,6 @@ public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) } catch (Exception ex) { Logger.LogError(ex, "Threw unexpectedly; {ErrorMessage}", ex.Message); - Plugin.Instance.CaptureException(ex); return; } } diff --git a/Shokofin/Web/WebController.cs b/Shokofin/Web/WebController.cs index 7e0b03c8..6d56301c 100644 --- a/Shokofin/Web/WebController.cs +++ b/Shokofin/Web/WebController.cs @@ -57,7 +57,6 @@ public async Task<ActionResult<ApiKey>> PostAsync([FromBody] ApiLoginRequest bod } catch (Exception ex) { Logger.LogError(ex, "Failed to create an API-key for user {Username} — unable to complete the request.", body.username); - Plugin.Instance.CaptureException(ex); return new StatusCodeResult(StatusCodes.Status500InternalServerError); } } From c4c9ed61c4a08947fe4d94b93639f144555180dd Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 26 Mar 2024 17:00:23 +0100 Subject: [PATCH 0593/1103] fix: use password type for password field in the connection settings section in the plugin settings. --- 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 53bb727c..664268c9 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -21,7 +21,7 @@ <h3>Connection Settings</h3> </div> <div id="ConnectionSetContainer"> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" 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" class="raised button-submit block emby-button"> From 033f3cc8ebc486629aadaf4bdb787ef13efd10cd Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 26 Mar 2024 17:03:43 +0100 Subject: [PATCH 0594/1103] fix: remove sentry from GHA --- .github/workflows/ReplaceSentryDSN.ps1 | 10 ---------- .github/workflows/release-daily.yml | 15 --------------- .github/workflows/release.yml | 15 --------------- 3 files changed, 40 deletions(-) delete mode 100644 .github/workflows/ReplaceSentryDSN.ps1 diff --git a/.github/workflows/ReplaceSentryDSN.ps1 b/.github/workflows/ReplaceSentryDSN.ps1 deleted file mode 100644 index 11e75691..00000000 --- a/.github/workflows/ReplaceSentryDSN.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -Param( - [string] $dsn = "%SENTRY_DSN%" -) - -$filename = "./Shokofin/Configuration/SentryConfiguration.cs" -$searchString = "%SENTRY_DSN%" - -(Get-Content $filename) | ForEach-Object { - $_ -replace $searchString, $dsn -} | Set-Content $filename diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index b232d4ec..c40a482d 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -16,10 +16,6 @@ jobs: ref: ${{ github.ref }} fetch-depth: 0 - - name: Replace Sentry DSN key - shell: pwsh - run: ./.github/workflows/ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} - - name: Get previous release version id: previoustag uses: "WyriHaximus/github-action-get-previous-tag@v1" @@ -63,14 +59,3 @@ jobs: commit_message: "misc: update unstable manifest" file_pattern: manifest-unstable.json skip_fetch: true - - - name: Push Sentry release "${{ env.NEW_VERSION }}" - uses: getsentry/action-release@v1.2.1 - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ secrets.SENTRY_ORG }} - SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - # SENTRY_URL: https://sentry.io/ - with: - environment: 'dev' - version: ${{ env.NEW_VERSION }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4dca05ca..57288091 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,10 +18,6 @@ jobs: ref: ${{ github.ref }} fetch-depth: 0 - - name: Replace Sentry DSN key - shell: pwsh - run: ./.github/workflows/ReplaceSentryDSN.ps1 -dsn ${{ secrets.SENTRY_DSN }} - - name: Get release version id: currenttag uses: "WyriHaximus/github-action-get-previous-tag@v1" @@ -62,14 +58,3 @@ jobs: commit_message: "misc: update stable manifest" file_pattern: manifest.json skip_fetch: true - - - name: Push Sentry release "${{ steps.currenttag.outputs.tag }}" - uses: getsentry/action-release@v1.2.1 - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ secrets.SENTRY_ORG }} - SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - # SENTRY_URL: https://sentry.io/ - with: - environment: 'stable' - version: ${{ steps.currenttag.outputs.tag }} From 54237da3bf23f3fde6564c71d01aa482cf48fcdf Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 26 Mar 2024 17:06:49 +0100 Subject: [PATCH 0595/1103] fix: remove sentry from JRPM build config --- build.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/build.yaml b/build.yaml index 5cbe2f16..3093c286 100644 --- a/build.yaml +++ b/build.yaml @@ -8,7 +8,6 @@ description: > A plugin to provide metadata from Shoko Server for your locally organized anime library in Jellyfin. category: "Metadata" artifacts: -- "Sentry.dll" - "Shokofin.dll" changelog: > NA From 01f9eb5402eb6107aa18ed8c99073595e962fe22 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 26 Mar 2024 16:07:22 +0000 Subject: [PATCH 0596/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index a5d4f3a1..4c808a9b 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.38", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.38/shoko_3.0.1.38.zip", + "checksum": "cd1c5d495c7f57894d5258999eb63786", + "timestamp": "2024-03-26T16:07:21Z" + }, { "version": "3.0.1.37", "changelog": "NA\n", From 20478f2fc0117aef7108e5a0cb3c73e9d1c58cc2 Mon Sep 17 00:00:00 2001 From: Mikal S <7761729+revam@users.noreply.github.com> Date: Tue, 26 Mar 2024 17:09:39 +0100 Subject: [PATCH 0597/1103] Update README.md [skip ci] --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d5c6d2a9..c9a8ba77 100644 --- a/README.md +++ b/README.md @@ -65,17 +65,17 @@ Learn more about Shoko at https://shokoanime.com/. - [ ] Studio provider for image and details -- [/] Library integration +- [X] Library integration - - [/] Support for different library types + - [X] Support for different library types - [X] Show library - [X] Movie library - - [ ] Mixed show/movie library. + - [X] Mixed show/movie library¹. - Coming soon™-ish + ¹ _You need at least one movie in your library for this to currently work as expected. This is an issue with Jellyfin 10.8._ - [/] Supports adding local trailers From 355febe31402e7cec689c061277c8b1e31b2bd35 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 26 Mar 2024 17:28:00 +0100 Subject: [PATCH 0598/1103] refactor: remove show/season grouping option --- Shokofin/API/Info/ShowInfo.cs | 3 - Shokofin/API/ShokoAPIManager.cs | 4 +- Shokofin/Configuration/PluginConfiguration.cs | 3 - Shokofin/Configuration/configPage.html | 12 +- Shokofin/Providers/EpisodeProvider.cs | 131 ++++++---------- Shokofin/Providers/ExtraMetadataProvider.cs | 12 -- Shokofin/Providers/ImageProvider.cs | 18 +-- Shokofin/Providers/SeasonProvider.cs | 96 ++++-------- Shokofin/Providers/SeriesProvider.cs | 71 ++++----- Shokofin/Resolvers/ShokoResolveManager.cs | 1 - Shokofin/Utils/OrderingUtil.cs | 146 ++++++------------ Shokofin/Utils/TextUtil.cs | 20 +-- 12 files changed, 162 insertions(+), 355 deletions(-) diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index 949e065a..df16f76f 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -159,9 +159,6 @@ public ShowInfo(Group group, List<SeasonInfo> seriesList, Ordering.GroupFilterTy } public SeasonInfo? GetSeriesInfoBySeasonNumber(int seasonNumber) { - if (Plugin.Instance.Configuration.SeriesGrouping is Ordering.GroupType.Default && seasonNumber is 123 or 124) - return SeasonList.FirstOrDefault(); - if (seasonNumber == 0 || !(SeasonOrderDictionary.TryGetValue(seasonNumber, out var seasonInfo) && seasonInfo != null)) return null; diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index a00445d3..939af866 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -824,7 +824,7 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId, out s return null; // Create a standalone group for each series in a group with sub-groups. - var onlyStandalone = Plugin.Instance.Configuration.SeriesGrouping != Ordering.GroupType.ShokoGroup; + var onlyStandalone = Plugin.Instance.Configuration.BoxSetGrouping != Ordering.GroupType.ShokoGroup; if (onlyStandalone || group.Sizes.SubGroups > 0) return await GetOrCreateShowInfoForStandaloneSeries(seriesId, filterByType); @@ -1001,7 +1001,7 @@ private async Task<ShowInfo> CreateShowInfo(Group group, string groupId, Orderin Logger.LogTrace("Creating info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); - var onlyStandalone = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; + var onlyStandalone = Plugin.Instance.Configuration.BoxSetGrouping == Ordering.GroupType.ShokoGroup; var groups = await APIClient.GetGroupsInGroup(groupId); var multiSeasonShows = await Task.WhenAll(groups .Where(group => !onlyStandalone && group.Sizes.SubGroups == 0) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index f17fdec0..5b693f33 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -56,8 +56,6 @@ public virtual string PrettyHost public TextSourceType DescriptionSource { get; set; } - public SeriesAndBoxSetGroupType SeriesGrouping { get; set; } - public OrderType SeasonOrdering { get; set; } public bool MarkSpecialsWhenGrouped { get; set; } @@ -127,7 +125,6 @@ public PluginConfiguration() TitleAlternateType = DisplayLanguageType.Origin; TitleAllowAny = false; DescriptionSource = TextSourceType.Default; - SeriesGrouping = SeriesAndBoxSetGroupType.Default; SeasonOrdering = OrderType.Default; SpecialsPlacement = SpecialOrderType.AfterSeason; MarkSpecialsWhenGrouped = true; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 664268c9..e0c2f770 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -162,16 +162,6 @@ <h3>Library Settings</h3> </details> </div> </div> - - <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="SeriesGrouping">Series/Season grouping:</label> - <select is="emby-select" id="SeriesGrouping" name="SeriesGrouping" class="emby-select-withcolor emby-select"> - <option value="Default" selected>Do not group Series into Seasons</option> - <option value="MergeFriendly">Group series into Seasons using the TvDB/TMDB data stored in Shoko</option> - <option value="ShokoGroup">Group series into Seasons based on Shoko's Groups</option> - </select> - <div class="fieldDescription selectFieldDescription"><div>Determines how to group Series together and divide them into Seasons.</div><div><strong>Warning:</strong> Modifying this setting requires the deletion and re-creation of any libraries using this plugin.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">Why can't I just refresh all metadata?</summary>Currently, refreshing and replacing all metadata does not remove all the metadata stored by Jellyfin. Additionally, if two or more show's entries have been merged, they cannot then be unmerged. Recreation of the library is the only way to ensure that you do not have mixed metadata in your libraries.</details><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What data is kept when recreating the libraries?</summary>The user data for each file is still kept after deleting a library. User data includes any ratings, play history, watch status and the last selected audio/subtitle track for a file.</details></div> - </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="BoxSetGrouping">Collections:</label> <select is="emby-select" id="BoxSetGrouping" name="BoxSetGrouping" class="emby-select-withcolor emby-select"> @@ -185,7 +175,7 @@ <h3>Library Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="FilterOnLibraryTypes" /> - <span>Enable library separation</span> + <span>Library separation</span> </label> <div class="fieldDescription checkboxFieldDescription">This setting can be used to have one shared root folder on your disk for two libraries in Shoko — one library for movies and one for shows. Enabling this will cause the plugin to actively filter out movies from the show library and everything but movies from the movies library. Also, if you've selected to use Shoko's Group feature to create Series/Seasons then it will also exclude the Movies from within the series — i.e. the "season" for the movie won't appear — even if they share a group in Shoko.</div> </div> diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 6aaf14f1..183dfd86 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -97,9 +97,6 @@ public static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Info.FileInfo file, string metadataLanguage, Season season, System.Guid episodeId) { var config = Plugin.Instance.Configuration; - var maybeMergeFriendly = config.SeriesGrouping == Ordering.GroupType.MergeFriendly && series.TvDB != null; - var mergeFriendly = maybeMergeFriendly && episode.TvDB != null; - string displayTitle, alternateTitle, description; if (config.TitleAddForMultipleEpisodes && file != null && file.EpisodeList.Count > 1) { var displayTitles = new List<string>(file.EpisodeList.Count); @@ -129,9 +126,9 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie description = Text.GetDescription(file.EpisodeList); } else { - string defaultEpisodeTitle = mergeFriendly ? episode.TvDB.Title : episode.Shoko.Name; + string defaultEpisodeTitle = episode.Shoko.Name; if (series.AniDB.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) { - string defaultSeriesTitle = mergeFriendly ? series.TvDB.Title : series.Shoko.Name; + string defaultSeriesTitle = series.Shoko.Name; ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); } else { @@ -177,90 +174,48 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie Episode result; var (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber, isSpecial) = Ordering.GetSpecialPlacement(group, series, episode); - if (mergeFriendly) { - if (season != null) { - result = new Episode { - Name = displayTitle, - OriginalTitle = alternateTitle, - IndexNumber = episodeNumber, - ParentIndexNumber = isSpecial ? 0 : seasonNumber, - AirsAfterSeasonNumber = airsAfterSeasonNumber, - AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, - AirsBeforeSeasonNumber = airsBeforeSeasonNumber, - Id = episodeId, - IsVirtualItem = true, - SeasonId = season.Id, - SeriesId = season.Series.Id, - Overview = description, - CommunityRating = episode.TvDB.Rating?.ToFloat(10), - PremiereDate = episode.TvDB.AirDate, - SeriesName = season.Series.Name, - SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, - SeasonName = season.Name, - DateLastSaved = DateTime.UtcNow, - RunTimeTicks = episode.AniDB.Duration.Ticks, - }; - result.PresentationUniqueKey = result.GetPresentationUniqueKey(); - } - else { - result = new Episode { - Name = displayTitle, - OriginalTitle = alternateTitle, - IndexNumber = episodeNumber, - ParentIndexNumber = isSpecial ? 0 : seasonNumber, - AirsAfterSeasonNumber = airsAfterSeasonNumber, - AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, - AirsBeforeSeasonNumber = airsBeforeSeasonNumber, - CommunityRating = episode.TvDB.Rating?.ToFloat(10), - PremiereDate = episode.TvDB.AirDate, - Overview = description, - }; - } + var rating = series.AniDB.Restricted && series.AniDB.Type != SeriesType.TV ? "XXX" : null; + if (season != null) { + result = new Episode { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = episodeNumber, + ParentIndexNumber = isSpecial ? 0 : seasonNumber, + AirsAfterSeasonNumber = airsAfterSeasonNumber, + AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, + AirsBeforeSeasonNumber = airsBeforeSeasonNumber, + Id = episodeId, + IsVirtualItem = true, + SeasonId = season.Id, + SeriesId = season.Series.Id, + Overview = description, + CommunityRating = episode.AniDB.Rating.ToFloat(10), + PremiereDate = episode.AniDB.AirDate, + SeriesName = season.Series.Name, + SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, + SeasonName = season.Name, + OfficialRating = rating, + CustomRating = rating, + DateLastSaved = DateTime.UtcNow, + RunTimeTicks = episode.AniDB.Duration.Ticks, + }; + result.PresentationUniqueKey = result.GetPresentationUniqueKey(); } else { - var rating = series.AniDB.Restricted && series.AniDB.Type != SeriesType.TV ? "XXX" : null; - if (season != null) { - result = new Episode { - Name = displayTitle, - OriginalTitle = alternateTitle, - IndexNumber = episodeNumber, - ParentIndexNumber = isSpecial ? 0 : seasonNumber, - AirsAfterSeasonNumber = airsAfterSeasonNumber, - AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, - AirsBeforeSeasonNumber = airsBeforeSeasonNumber, - Id = episodeId, - IsVirtualItem = true, - SeasonId = season.Id, - SeriesId = season.Series.Id, - Overview = description, - CommunityRating = episode.AniDB.Rating.ToFloat(10), - PremiereDate = episode.AniDB.AirDate, - SeriesName = season.Series.Name, - SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, - SeasonName = season.Name, - OfficialRating = rating, - CustomRating = rating, - DateLastSaved = DateTime.UtcNow, - RunTimeTicks = episode.AniDB.Duration.Ticks, - }; - result.PresentationUniqueKey = result.GetPresentationUniqueKey(); - } - else { - result = new Episode { - Name = displayTitle, - OriginalTitle = alternateTitle, - IndexNumber = episodeNumber, - ParentIndexNumber = isSpecial ? 0 : seasonNumber, - AirsAfterSeasonNumber = airsAfterSeasonNumber, - AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, - AirsBeforeSeasonNumber = airsBeforeSeasonNumber, - PremiereDate = episode.AniDB.AirDate, - Overview = description, - OfficialRating = rating, - CustomRating = rating, - CommunityRating = episode.AniDB.Rating.ToFloat(10), - }; - } + result = new Episode { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = episodeNumber, + ParentIndexNumber = isSpecial ? 0 : seasonNumber, + AirsAfterSeasonNumber = airsAfterSeasonNumber, + AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, + AirsBeforeSeasonNumber = airsBeforeSeasonNumber, + PremiereDate = episode.AniDB.AirDate, + Overview = description, + OfficialRating = rating, + CustomRating = rating, + CommunityRating = episode.AniDB.Rating.ToFloat(10), + }; } if (file != null) { @@ -269,7 +224,7 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie result.IndexNumberEnd = episodeNumberEnd; } - AddProviderIds(result, episodeId: episode.Id, fileId: file?.Id, anidbId: episode.AniDB.Id.ToString(), tvdbId: mergeFriendly || config.SeriesGrouping == Ordering.GroupType.Default ? episode.TvDB?.Id.ToString() : null); + AddProviderIds(result, episodeId: episode.Id, fileId: file?.Id, anidbId: episode.AniDB.Id.ToString()); return result; } diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index 1bbcad7e..a2040936 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -473,18 +473,6 @@ private void UpdateEpisode(Episode episode, string episodeId) #region Seasons - private IEnumerable<(int, Season)> CreateMissingSeasons(Info.SeasonInfo seasonInfo, Series series, Dictionary<int, Season> existingSeasons, List<int> allSeasonNumbers) - { - var missingSeasonNumbers = allSeasonNumbers.Except(existingSeasons.Keys).ToList(); - var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && seasonInfo.TvDB != null; - foreach (var seasonNumber in missingSeasonNumbers) { - var season = seasonNumber == 1 && !mergeFriendly ? AddVirtualSeason(seasonInfo, 0, 1, series) : AddVirtualSeason(seasonNumber, series); - if (season == null) - continue; - yield return (seasonNumber, season); - } - } - private IEnumerable<(int, Season)> CreateMissingSeasons(Info.ShowInfo showInfo, Series series, Dictionary<int, Season> seasons) { bool hasSpecials = false; diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index ab57fe23..2c109d55 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -60,16 +60,14 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell AddImagesForSeries(ref list, seriesImages); } // Also attach any images linked to the "seasons" (AKA series within the group). - if (Plugin.Instance.Configuration.SeriesGrouping == Utils.Ordering.GroupType.ShokoGroup) { - list = list - .Concat( - series.GetSeasons(null, new(true)) - .Cast<Season>() - .SelectMany(season => GetImages(season, cancellationToken).Result) - ) - .DistinctBy(image => image.Url) - .ToList(); - } + list = list + .Concat( + series.GetSeasons(null, new(true)) + .Cast<Season>() + .SelectMany(season => GetImages(season, cancellationToken).Result) + ) + .DistinctBy(image => image.Url) + .ToList(); Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId})", list.Count, series.Name, seriesId); } break; diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 3aef84c8..6848794a 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -33,83 +33,45 @@ public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvid public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) { try { - switch (Plugin.Instance.Configuration.SeriesGrouping) { - default: - if (!info.IndexNumber.HasValue) - return new MetadataResult<Season>(); - - if (info.IndexNumber.Value == 1) - return await GetShokoGroupedMetadata(info); - - return GetDefaultMetadata(info); - case Ordering.GroupType.MergeFriendly: - if (!info.IndexNumber.HasValue) - return new MetadataResult<Season>(); + if (!info.IndexNumber.HasValue || info.IndexNumber.HasValue && info.IndexNumber.Value == 0) + return null; + + var result = new MetadataResult<Season>(); + var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; + if (!info.SeriesProviderIds.TryGetValue("Shoko Series", out var seriesId) || !info.IndexNumber.HasValue) { + Logger.LogDebug("Unable refresh Season {SeasonNumber} {SeasonName}", info.IndexNumber, info.Name); + return result; + } - return GetDefaultMetadata(info); - case Ordering.GroupType.ShokoGroup: - if (info.IndexNumber.HasValue && info.IndexNumber.Value == 0) - return GetDefaultMetadata(info); + var seasonNumber = info.IndexNumber.Value; + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId, filterByType); + if (showInfo == null) { + Logger.LogWarning("Unable to find show info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); + return result; + } - return await GetShokoGroupedMetadata(info); + var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); + if (seasonInfo == null || !showInfo.SeasonNumberBaseDictionary.TryGetValue(seasonInfo, out var baseSeasonNumber)) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Series={SeriesId},Group={GroupId})", seasonNumber, seriesId, showInfo.Id); + return result; } - } - catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - return new MetadataResult<Season>(); - } - } - private static MetadataResult<Season> GetDefaultMetadata(SeasonInfo info) - { - var result = new MetadataResult<Season>(); - - var seasonName = GetSeasonName(info.IndexNumber.Value, info.Name); - result.Item = new Season { - Name = seasonName, - IndexNumber = info.IndexNumber, - SortName = seasonName, - ForcedSortName = seasonName - }; + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, showInfo.Name, seriesId, showInfo.Id); - result.HasMetadata = true; + var offset = Math.Abs(seasonNumber - baseSeasonNumber); - return result; - } + result.Item = CreateMetadata(seasonInfo, seasonNumber, offset, info.MetadataLanguage); + result.HasMetadata = true; + result.ResetPeople(); + foreach (var person in seasonInfo.Staff) + result.AddPerson(person); - private async Task<MetadataResult<Season>> GetShokoGroupedMetadata(SeasonInfo info) - { - var result = new MetadataResult<Season>(); - var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; - if (!info.SeriesProviderIds.TryGetValue("Shoko Series", out var seriesId) || !info.IndexNumber.HasValue) { - Logger.LogDebug("Unable refresh Season {SeasonNumber} {SeasonName}", info.IndexNumber, info.Name); - return result; - } - - var seasonNumber = info.IndexNumber.Value; - var showInfo = await ApiManager.GetShowInfoForSeries(seriesId, filterByType); - if (showInfo == null) { - Logger.LogWarning("Unable to find show info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); return result; } - - var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); - if (seasonInfo == null || !showInfo.SeasonNumberBaseDictionary.TryGetValue(seasonInfo, out var baseSeasonNumber)) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Series={SeriesId},Group={GroupId})", seasonNumber, seriesId, showInfo.Id); - return result; + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<Season>(); } - - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, showInfo.Name, seriesId, showInfo.Id); - - var offset = Math.Abs(seasonNumber - baseSeasonNumber); - - result.Item = CreateMetadata(seasonInfo, seasonNumber, offset, info.MetadataLanguage); - result.HasMetadata = true; - result.ResetPeople(); - foreach (var person in seasonInfo.Staff) - result.AddPerson(person); - - return result; } public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage) diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index da88269c..cf7c8b91 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -68,53 +68,34 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat } var season = show.DefaultSeason; - var mergeFriendly = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.MergeFriendly && season.TvDB != null; - var defaultSeriesTitle = mergeFriendly ? season.TvDB.Title : season.Shoko.Name; + var defaultSeriesTitle = season.Shoko.Name; var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(season.AniDB.Titles, show.Name, info.MetadataLanguage); Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, season.Id, show.Id); - if (mergeFriendly) { - result.Item = new Series { - Name = displayTitle, - OriginalTitle = alternateTitle, - Overview = Text.GetDescription(season), - PremiereDate = season.TvDB.AirDate, - EndDate = season.TvDB.EndDate, - ProductionYear = season.TvDB.AirDate?.Year, - Status = !season.TvDB.EndDate.HasValue || season.TvDB.EndDate.Value > DateTime.UtcNow ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = season.Tags.ToArray(), - Genres = season.Genres.ToArray(), - Studios = season.Studios.ToArray(), - CommunityRating = season.TvDB.Rating?.ToFloat(10), - }; - AddProviderIds(result.Item, season.Id, show.Id, season.AniDB.Id.ToString(), season.TvDB.Id.ToString(), season.Shoko.IDs.TMDB.FirstOrDefault().ToString()); - } - else { - var premiereDate = show.SeasonList - .Select(s => s.AniDB.AirDate) - .Where(s => s != null) - .OrderBy(s => s) - .FirstOrDefault(); - var endDate = show.SeasonList.Any(s => s.AniDB.EndDate == null) ? null : show.SeasonList - .Select(s => s.AniDB.AirDate) - .OrderBy(s => s) - .LastOrDefault(); - result.Item = new Series { - Name = displayTitle, - OriginalTitle = alternateTitle, - Overview = Text.GetDescription(season), - PremiereDate = premiereDate, - ProductionYear = premiereDate?.Year, - EndDate = endDate, - Status = !endDate.HasValue || endDate.Value > DateTime.UtcNow ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = show.Tags.ToArray(), - Genres = show.Genres.ToArray(), - Studios = show.Studios.ToArray(), - OfficialRating = season.AniDB.Restricted ? "XXX" : null, - CustomRating = season.AniDB.Restricted ? "XXX" : null, - CommunityRating = mergeFriendly ? season.TvDB.Rating.ToFloat(10) : season.AniDB.Rating.ToFloat(10), - }; - AddProviderIds(result.Item, season.Id, show.Id, season.AniDB.Id.ToString()); - } + var premiereDate = show.SeasonList + .Select(s => s.AniDB.AirDate) + .Where(s => s != null) + .OrderBy(s => s) + .FirstOrDefault(); + var endDate = show.SeasonList.Any(s => s.AniDB.EndDate == null) ? null : show.SeasonList + .Select(s => s.AniDB.AirDate) + .OrderBy(s => s) + .LastOrDefault(); + result.Item = new Series { + Name = displayTitle, + OriginalTitle = alternateTitle, + Overview = Text.GetDescription(season), + PremiereDate = premiereDate, + ProductionYear = premiereDate?.Year, + EndDate = endDate, + Status = !endDate.HasValue || endDate.Value > DateTime.UtcNow ? SeriesStatus.Continuing : SeriesStatus.Ended, + Tags = show.Tags.ToArray(), + Genres = show.Genres.ToArray(), + Studios = show.Studios.ToArray(), + OfficialRating = season.AniDB.Restricted ? "XXX" : null, + CustomRating = season.AniDB.Restricted ? "XXX" : null, + CommunityRating = season.AniDB.Rating.ToFloat(10), + }; + AddProviderIds(result.Item, season.Id, show.Id, season.AniDB.Id.ToString()); result.HasMetadata = true; diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 6552aaa3..873459be 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -218,7 +218,6 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string var season = await ApiManager.GetSeasonInfoForSeries(seriesId); if (season == null) return (sourceLocation: string.Empty, symbolicLink: string.Empty); - var isGrouped = Plugin.Instance.Configuration.SeriesGrouping == Ordering.GroupType.ShokoGroup; var isMovieSeason = season.AniDB.Type == SeriesType.Movie; switch (collectionType) { default: { diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index a1bcc5d8..35118564 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -113,59 +113,39 @@ public static GroupFilterType GetGroupFilterTypeForCollection(string collectionT /// <returns>Absolute index.</returns> public static int GetEpisodeNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) { - switch (Plugin.Instance.Configuration.SeriesGrouping) - { + int offset = 0; + if (episode.AniDB.Type == EpisodeType.Special) { + var seasonIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); + if (seasonIndex == -1) + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); + var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); + if (index == -1) + throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); + offset = group.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); + return offset + index + 1; + } + var sizes = series.Shoko.Sizes.Total; + switch (episode.AniDB.Type) { + case EpisodeType.Other: + case EpisodeType.Unknown: + case EpisodeType.Normal: + // offset += 0; // it's not needed, so it's just here as a comment instead. + break; + // Add them to the bottom of the list if we didn't filter them out properly. + case EpisodeType.Parody: + offset += sizes?.Episodes ?? 0; + goto case EpisodeType.Normal; + case EpisodeType.OpeningSong: + offset += sizes?.Parodies ?? 0; + goto case EpisodeType.Parody; + case EpisodeType.Trailer: + offset += sizes?.Credits ?? 0; + goto case EpisodeType.OpeningSong; default: - case GroupType.Default: - if (episode.AniDB.Type == EpisodeType.Special) { - var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); - if (index == -1) - throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Series={series.Id},Episode={episode.Id})"); - return index + 1; - } - return episode.AniDB.EpisodeNumber; - case GroupType.MergeFriendly: { - var episodeNumber = episode?.TvDB?.EpisodeNumber; - if (episodeNumber.HasValue) - return episodeNumber.Value; - goto case GroupType.Default; - } - case GroupType.ShokoGroup: { - int offset = 0; - if (episode.AniDB.Type == EpisodeType.Special) { - var seasonIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); - if (seasonIndex == -1) - throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); - var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); - if (index == -1) - throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); - offset = group.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); - return offset + index + 1; - } - var sizes = series.Shoko.Sizes.Total; - switch (episode.AniDB.Type) { - case EpisodeType.Other: - case EpisodeType.Unknown: - case EpisodeType.Normal: - // offset += 0; // it's not needed, so it's just here as a comment instead. - break; - // Add them to the bottom of the list if we didn't filter them out properly. - case EpisodeType.Parody: - offset += sizes?.Episodes ?? 0; - goto case EpisodeType.Normal; - case EpisodeType.OpeningSong: - offset += sizes?.Parodies ?? 0; - goto case EpisodeType.Parody; - case EpisodeType.Trailer: - offset += sizes?.Credits ?? 0; - goto case EpisodeType.OpeningSong; - default: - offset += sizes?.Trailers ?? 0; - goto case EpisodeType.Trailer; - } - return offset + episode.AniDB.EpisodeNumber; - } + offset += sizes?.Trailers ?? 0; + goto case EpisodeType.Trailer; } + return offset + episode.AniDB.EpisodeNumber; } public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo group, SeasonInfo series, EpisodeInfo episode) @@ -175,13 +155,7 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo group, Seaso // Return early if we want to exclude them from the normal seasons. if (order == SpecialOrderType.Excluded) { // Check if this should go in the specials season. - var isSpecial = Plugin.Instance.Configuration.SeriesGrouping == GroupType.MergeFriendly && episode.TvDB != null ? ( - episode.TvDB.SeasonNumber == 0 - ) : ( - episode.AniDB.Type == EpisodeType.Special - ); - - return (null, null, null, isSpecial); + return (null, null, null, episode.IsSpecial); } // Abort if episode is not a TvDB special or AniDB special @@ -255,52 +229,24 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo group, Seaso /// <returns></returns> public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) { - switch (Plugin.Instance.Configuration.SeriesGrouping) { + if (!group.SeasonNumberBaseDictionary.TryGetValue(series, out var seasonNumber)) + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id})"); + + var offset = 0; + switch (episode.AniDB.Type) { default: - case GroupType.Default: - return episode.AniDB.Type switch - { - EpisodeType.Normal => 1, - EpisodeType.Special => 1, - EpisodeType.Unknown => 123, - EpisodeType.Other => 124, - EpisodeType.Trailer => 125, - EpisodeType.ThemeSong => 126, - _ => 127, - }; - case GroupType.MergeFriendly: { - int? seasonNumber = null; - if (episode.TvDB != null) { - if (episode.TvDB.SeasonNumber == 0) - seasonNumber = episode.TvDB.AirsAfterSeason ?? episode.TvDB.AirsBeforeSeason ?? 1; - else - seasonNumber = episode.TvDB.SeasonNumber; - } - if (!seasonNumber.HasValue) - goto case GroupType.Default; - return seasonNumber.Value; + break; + case EpisodeType.Unknown: { + offset = 1; + break; } - case GroupType.ShokoGroup: { - if (!group.SeasonNumberBaseDictionary.TryGetValue(series, out var seasonNumber)) - throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id})"); - - var offset = 0; - switch (episode.AniDB.Type) { - default: - break; - case EpisodeType.Unknown: { - offset = 1; - break; - } - case EpisodeType.Other: { - offset = series.AlternateEpisodesList.Count > 0 ? 2 : 1; - break; - } - } - - return seasonNumber + offset; + case EpisodeType.Other: { + offset = series.AlternateEpisodesList.Count > 0 ? 2 : 1; + break; } } + + return seasonNumber + offset; } /// <summary> diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index a796583a..4235458f 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -163,24 +163,18 @@ private static string GetDescription(string aniDbDescription, string otherDescri string overview; switch (Plugin.Instance.Configuration.DescriptionSource) { default: - switch (Plugin.Instance.Configuration.SeriesGrouping) { - default: - goto preferAniDb; - case Ordering.GroupType.MergeFriendly: - goto preferOther; - } - case TextSourceType.PreferOther: - preferOther: overview = otherDescription ?? ""; - if (string.IsNullOrEmpty(overview)) - goto case TextSourceType.OnlyAniDb; - break; case TextSourceType.PreferAniDb: - preferAniDb: overview = Text.SanitizeTextSummary(aniDbDescription); + overview = SanitizeTextSummary(aniDbDescription); if (string.IsNullOrEmpty(overview)) goto case TextSourceType.OnlyOther; break; + case TextSourceType.PreferOther: + overview = otherDescription ?? ""; + if (string.IsNullOrEmpty(overview)) + goto case TextSourceType.OnlyAniDb; + break; case TextSourceType.OnlyAniDb: - overview = Text.SanitizeTextSummary(aniDbDescription); + overview = SanitizeTextSummary(aniDbDescription); break; case TextSourceType.OnlyOther: overview = otherDescription ?? ""; From e0e2ae142893d1737f9181c516a67bcacde80c66 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 26 Mar 2024 16:28:37 +0000 Subject: [PATCH 0599/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 4c808a9b..362f20b4 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.39", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.39/shoko_3.0.1.39.zip", + "checksum": "7cf4b0799238b646121d3b136afe7ef4", + "timestamp": "2024-03-26T16:28:35Z" + }, { "version": "3.0.1.38", "changelog": "NA\n", From 4e7b5f775cb6e07528b8b765c88d4b145525f400 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 26 Mar 2024 23:24:24 +0100 Subject: [PATCH 0600/1103] refactor: lay more groundwork for proper collection support. --- Shokofin/API/Info/CollectionInfo.cs | 13 +- Shokofin/API/Info/SeasonInfo.cs | 3 + Shokofin/API/Info/ShowInfo.cs | 168 +++++--- Shokofin/API/ShokoAPIClient.cs | 4 +- Shokofin/API/ShokoAPIManager.cs | 366 ++++++------------ Shokofin/Collections/CollectionManager.cs | 25 +- Shokofin/Configuration/PluginConfiguration.cs | 21 +- Shokofin/Configuration/configController.js | 25 +- Shokofin/Configuration/configPage.html | 53 +-- Shokofin/IdLookup.cs | 6 +- Shokofin/Providers/BoxSetProvider.cs | 38 +- Shokofin/Providers/EpisodeProvider.cs | 31 +- Shokofin/Providers/ExtraMetadataProvider.cs | 113 +----- Shokofin/Providers/MovieProvider.cs | 22 +- Shokofin/Providers/SeasonProvider.cs | 11 +- Shokofin/Providers/SeriesProvider.cs | 49 +-- Shokofin/Resolvers/ShokoResolveManager.cs | 189 +++++---- Shokofin/Resolvers/ShokoResolver.cs | 4 +- Shokofin/Utils/OrderingUtil.cs | 33 +- Shokofin/Utils/TextUtil.cs | 7 +- 20 files changed, 473 insertions(+), 708 deletions(-) diff --git a/Shokofin/API/Info/CollectionInfo.cs b/Shokofin/API/Info/CollectionInfo.cs index 3c321157..34893563 100644 --- a/Shokofin/API/Info/CollectionInfo.cs +++ b/Shokofin/API/Info/CollectionInfo.cs @@ -22,18 +22,7 @@ public class CollectionInfo public IReadOnlyList<CollectionInfo> SubCollections; - public CollectionInfo(Group group) - { - Id = group.IDs.Shoko.ToString(); - ParentId = group.IDs.ParentGroup?.ToString(); - IsTopLevel = group.IDs.TopLevelGroup == group.IDs.Shoko; - Name = group.Name; - Shoko = group; - Shows = new List<ShowInfo>(); - SubCollections = new List<CollectionInfo>(); - } - - public CollectionInfo(Group group, List<ShowInfo> shows, List<CollectionInfo> subCollections, Ordering.GroupFilterType filterByType) + public CollectionInfo(Group group, List<ShowInfo> shows, List<CollectionInfo> subCollections) { Id = group.IDs.Shoko.ToString(); ParentId = group.IDs.ParentGroup?.ToString(); diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index d70ce51f..08b0286e 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -18,6 +18,8 @@ public class SeasonInfo public Series.TvDB? TvDB; + public SeriesType Type; + public string[] Tags; public string[] Genres; @@ -145,6 +147,7 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li Shoko = series; AniDB = series.AniDBEntity; TvDB = series.TvDBEntityList.FirstOrDefault(); + Type = series.AniDBEntity.Type; Tags = tags; Genres = genres; Studios = studios; diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index df16f76f..f1d251de 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -1,6 +1,8 @@ +using System; using System.Collections.Generic; using System.Linq; +using MediaBrowser.Controller.Entities; using Microsoft.Extensions.Logging; using Shokofin.API.Models; using Shokofin.Utils; @@ -10,61 +12,109 @@ namespace Shokofin.API.Info; public class ShowInfo { - public string? Id; - - public string? ParentId; - + /// <summary> + /// Main Shoko Series Id. + /// </summary> + public string Id; + + /// <summary> + /// Main Shoko Group Id. + /// </summary> + public string? GroupId; + + /// <summary> + /// Shoko Group Id used for Collection Support. + /// </summary> + public string? CollectionId; + + /// <summary> + /// The main name of the show. + /// </summary> public string Name; + /// <summary> + /// Indicates this is a standalone show without a group attached to it. + /// </summary> public bool IsStandalone => Shoko == null; + /// <summary> + /// The Shoko Group, if this is not a standalone show entry. + /// </summary> public Group? Shoko; + /// <summary> + /// First premiere date of the show. + /// </summary> + public DateTime? PremiereDate => + SeasonList + .Select(s => s.AniDB.AirDate) + .Where(s => s != null) + .OrderBy(s => s) + .FirstOrDefault(); + + /// <summary> + /// Ended date of the show. + /// </summary> + public DateTime? EndDate => + SeasonList.Any(s => s.AniDB.EndDate == null) ? null : SeasonList + .Select(s => s.AniDB.AirDate) + .OrderBy(s => s) + .LastOrDefault(); + + /// <summary> + /// Overall content rating of the show. + /// </summary> + public string? ContentRating => + DefaultSeason.AniDB.Restricted ? "XXX" : null; + + /// <summary> + /// Overall community rating of the show. + /// </summary> + public float CommunityRating => + (float)(SeasonList.Aggregate(0f, (total, seasonInfo) => total + seasonInfo.AniDB.Rating.ToFloat(10)) / SeasonList.Count); + + /// <summary> + /// All tags from across all seasons. + /// </summary> public string[] Tags; + /// <summary> + /// All genres from across all seasons. + /// </summary> public string[] Genres; + /// <summary> + /// All studios from across all seasons. + /// </summary> public string[] Studios; + /// <summary> + /// All staff from across all seasons. + /// </summary> + public PersonInfo[] Staff; + + /// <summary> + /// All seasons. + /// </summary> public List<SeasonInfo> SeasonList; + /// <summary> + /// The season order dictionary. + /// </summary> public Dictionary<int, SeasonInfo> SeasonOrderDictionary; + /// <summary> + /// The season number base-number dictionary. + /// </summary> public Dictionary<SeasonInfo, int> SeasonNumberBaseDictionary; - public SeasonInfo? DefaultSeason; - - public ShowInfo(Series series) - { - Id = null; - ParentId = series.IDs.ParentGroup.ToString(); - Name = series.Name; - Tags = System.Array.Empty<string>(); - Genres = System.Array.Empty<string>(); - Studios = System.Array.Empty<string>(); - SeasonList = new(); - SeasonNumberBaseDictionary = new(); - SeasonOrderDictionary = new(); - DefaultSeason = null; - } - - public ShowInfo(Group group) - { - Id = group.IDs.Shoko.ToString(); - Name = group.Name; - Shoko = group; - ParentId = group.IDs.ParentGroup?.ToString(); - Tags = System.Array.Empty<string>(); - Genres = System.Array.Empty<string>(); - Studios = System.Array.Empty<string>(); - SeasonList = new(); - SeasonNumberBaseDictionary = new(); - SeasonOrderDictionary = new(); - DefaultSeason = null; - } + /// <summary> + /// The default season for the show. + /// </summary> + public SeasonInfo DefaultSeason; - public ShowInfo(SeasonInfo seasonInfo) + public ShowInfo(SeasonInfo seasonInfo, string? groupId = null) { var seasonNumberBaseDictionary = new Dictionary<SeasonInfo, int>() { { seasonInfo, 1 } }; var seasonOrderDictionary = new Dictionary<int, SeasonInfo>() { { 1, seasonInfo } }; @@ -74,41 +124,34 @@ public ShowInfo(SeasonInfo seasonInfo) if (seasonInfo.OthersList.Count > 0) seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); - Id = null; - ParentId = seasonInfo.Shoko.IDs.ParentGroup.ToString(); + Id = seasonInfo.Id; + GroupId = groupId ?? seasonInfo.Shoko.IDs.ParentGroup.ToString(); + CollectionId = seasonInfo.Shoko.IDs.ParentGroup.ToString(); Name = seasonInfo.Shoko.Name; Tags = seasonInfo.Tags; Genres = seasonInfo.Genres; Studios = seasonInfo.Studios; + Staff = seasonInfo.Staff; SeasonList = new() { seasonInfo }; SeasonNumberBaseDictionary = seasonNumberBaseDictionary; SeasonOrderDictionary = seasonOrderDictionary; DefaultSeason = seasonInfo; } - public ShowInfo(Group group, List<SeasonInfo> seriesList, Ordering.GroupFilterType filterByType, ILogger logger) + public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool useGroupIdForCollection) { var groupId = group.IDs.Shoko.ToString(); - if (seriesList.Count > 0) switch (filterByType) { - case Ordering.GroupFilterType.Movies: - seriesList = seriesList.Where(s => s.AniDB.Type == SeriesType.Movie).ToList(); - break; - case Ordering.GroupFilterType.Others: - seriesList = seriesList.Where(s => s.AniDB.Type != SeriesType.Movie).ToList(); - break; - } - // Order series list - var orderingType = filterByType == Ordering.GroupFilterType.Movies ? Plugin.Instance.Configuration.MovieOrdering : Plugin.Instance.Configuration.SeasonOrdering; + var orderingType = Plugin.Instance.Configuration.SeasonOrdering; switch (orderingType) { case Ordering.OrderType.Default: break; case Ordering.OrderType.ReleaseDate: - seriesList = seriesList.OrderBy(s => s?.AniDB?.AirDate ?? System.DateTime.MaxValue).ToList(); + seasonList = seasonList.OrderBy(s => s?.AniDB?.AirDate ?? DateTime.MaxValue).ToList(); break; case Ordering.OrderType.Chronological: - seriesList.Sort(new SeriesInfoRelationComparer()); + seasonList.Sort(new SeriesInfoRelationComparer()); break; } @@ -121,7 +164,7 @@ public ShowInfo(Group group, List<SeasonInfo> seriesList, Ordering.GroupFilterTy case Ordering.OrderType.Default: case Ordering.OrderType.Chronological: { int targetId = group.IDs.MainSeries; - foundIndex = seriesList.FindIndex(s => s.Shoko.IDs.Shoko == targetId); + foundIndex = seasonList.FindIndex(s => s.Shoko.IDs.Shoko == targetId); break; } } @@ -129,14 +172,15 @@ public ShowInfo(Group group, List<SeasonInfo> seriesList, Ordering.GroupFilterTy // Fallback to the first series if we can't get a base point for seasons. if (foundIndex == -1) { - logger.LogWarning("Unable to get a base-point for seasons within the group for the filter, so falling back to the first series in the group. This is most likely due to library separation being enabled. (Filter={FilterByType},Group={GroupID})", filterByType.ToString(), groupId); + logger.LogWarning("Unable to get a base-point for seasons within the group for the filter, so falling back to the first series in the group. This is most likely due to library separation being enabled. (Group={GroupID})", groupId); foundIndex = 0; } + var defaultSeason = seasonList[foundIndex]; var seasonOrderDictionary = new Dictionary<int, SeasonInfo>(); var seasonNumberBaseDictionary = new Dictionary<SeasonInfo, int>(); var seasonNumberOffset = 0; - foreach (var (seasonInfo, index) in seriesList.Select((s, i) => (s, i))) { + foreach (var (seasonInfo, index) in seasonList.Select((s, i) => (s, i))) { seasonNumberBaseDictionary.Add(seasonInfo, ++seasonNumberOffset); seasonOrderDictionary.Add(seasonNumberOffset, seasonInfo); if (seasonInfo.AlternateEpisodesList.Count > 0) @@ -145,17 +189,19 @@ public ShowInfo(Group group, List<SeasonInfo> seriesList, Ordering.GroupFilterTy seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); } - Id = groupId; - Name = seriesList.Count > 0 ? seriesList[foundIndex].Shoko.Name : group.Name; + Id = defaultSeason.Id; + GroupId = groupId; + Name = group.Name; Shoko = group; - ParentId = group.IDs.ParentGroup?.ToString(); - Tags = seriesList.SelectMany(s => s.Tags).Distinct().ToArray(); - Genres = seriesList.SelectMany(s => s.Genres).Distinct().ToArray(); - Studios = seriesList.SelectMany(s => s.Studios).Distinct().ToArray(); - SeasonList = seriesList; + CollectionId = useGroupIdForCollection ? groupId : group.IDs.ParentGroup?.ToString(); + Tags = seasonList.SelectMany(s => s.Tags).Distinct().ToArray(); + Genres = seasonList.SelectMany(s => s.Genres).Distinct().ToArray(); + Studios = seasonList.SelectMany(s => s.Studios).Distinct().ToArray(); + Staff = seasonList.SelectMany(s => s.Staff).DistinctBy(p => new { p.Type, p.Name, p.Role }).ToArray(); + SeasonList = seasonList; SeasonNumberBaseDictionary = seasonNumberBaseDictionary; SeasonOrderDictionary = seasonOrderDictionary; - DefaultSeason = seriesList.Count > 0 ? seriesList[foundIndex] : null; + DefaultSeason = defaultSeason; } public SeasonInfo? GetSeriesInfoBySeasonNumber(int seasonNumber) { diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 62e85d08..119d0387 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -260,9 +260,9 @@ public Task<Series> GetSeriesFromEpisode(string id) return Get<Series>($"/api/v3/Episode/{id}/Series?includeDataFrom=AniDB,TvDB"); } - public Task<List<Series>> GetSeriesInGroup(string groupID, int filterID = 0) + public Task<List<Series>> GetSeriesInGroup(string groupID, int filterID = 0, bool recursive = false) { - return Get<List<Series>>($"/api/v3/Filter/{filterID}/Group/{groupID}/Series?recursive=false&includeMissing=true&includeIgnored=false&includeDataFrom=AniDB,TvDB"); + return Get<List<Series>>($"/api/v3/Filter/{filterID}/Group/{groupID}/Series?recursive={recursive}&includeMissing=true&includeIgnored=false&includeDataFrom=AniDB,TvDB"); } public Task<List<Role>> GetSeriesCast(string id) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 939af866..d0406d66 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -1,20 +1,15 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Entities; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Threading; using System.Threading.Tasks; using Shokofin.API.Info; using Shokofin.API.Models; -using Shokofin.Utils; -using CultureInfo = System.Globalization.CultureInfo; -using ItemLookupInfo = MediaBrowser.Controller.Providers.ItemLookupInfo; using Path = System.IO.Path; #nullable enable @@ -42,7 +37,7 @@ public class ShokoAPIManager : IDisposable private readonly ConcurrentDictionary<string, string> SeriesIdToPathDictionary = new(); - private readonly ConcurrentDictionary<string, (string? GroupId, string DefaultSeriesId)> SeriesIdToGroupIdDictionary = new(); + private readonly ConcurrentDictionary<string, string> SeriesIdToDefaultSeriesIdDictionary = new(); private readonly ConcurrentDictionary<string, string?> SeriesIdToCollectionIdDictionary = new(); @@ -113,7 +108,7 @@ public string StripMediaFolder(string fullPath) mediaFolder = MediaFolderList.FirstOrDefault((folder) => fullPath.StartsWith(folder.Path + Path.DirectorySeparatorChar)); } if (mediaFolder != null) { - return fullPath.Substring(mediaFolder.Path.Length); + return fullPath[mediaFolder.Path.Length..]; } // Try to get the media folder by loading the parent and navigating upwards till we reach the root. @@ -139,7 +134,7 @@ public string StripMediaFolder(string fullPath) lock (MediaFolderListLock) { MediaFolderList.Add(mediaFolder); } - return fullPath.Substring(mediaFolder.Path.Length); + return fullPath[mediaFolder.Path.Length..]; } #endregion @@ -147,6 +142,13 @@ public string StripMediaFolder(string fullPath) public void Dispose() { + GC.SuppressFinalize(this); + Clear(false); + } + + public void Clear(bool restore = true) + { + Logger.LogDebug("Clearing data…"); Logger.LogDebug("Disposing data…"); DataCache.Dispose(); EpisodeIdToEpisodePathDictionary.Clear(); @@ -159,19 +161,15 @@ public void Dispose() PathToFileIdAndSeriesIdDictionary.Clear(); PathToSeriesIdDictionary.Clear(); NameToSeriesIdDictionary.Clear(); - SeriesIdToGroupIdDictionary.Clear(); + SeriesIdToDefaultSeriesIdDictionary.Clear(); SeriesIdToCollectionIdDictionary.Clear(); SeriesIdToPathDictionary.Clear(); - } - - public void Clear() - { - Logger.LogDebug("Clearing data…"); - Dispose(); - Logger.LogDebug("Initialising new cache…"); - DataCache = new MemoryCache(new MemoryCacheOptions() { - ExpirationScanFrequency = ExpirationScanFrequency, - }); + if (restore) { + Logger.LogDebug("Initialising new cache…"); + DataCache = new MemoryCache(new MemoryCacheOptions() { + ExpirationScanFrequency = ExpirationScanFrequency, + }); + } Logger.LogDebug("Cleanup complete."); } @@ -262,7 +260,7 @@ private bool KeepTag(Tag tag) private string SelectTagName(Tag tag) { - return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tag.Name); + return System.Globalization.CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tag.Name); } #endregion @@ -324,7 +322,7 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu PathToEpisodeIdsDictionary.TryAdd(path, episodeIds.ToList()); } - public async Task<(FileInfo?, SeasonInfo?, ShowInfo?)> GetFileInfoByPath(string path, Ordering.GroupFilterType filterGroupByType) + public async Task<(FileInfo?, SeasonInfo?, ShowInfo?)> GetFileInfoByPath(string path) { // Use pointer for fast lookup. if (PathToFileIdAndSeriesIdDictionary.ContainsKey(path)) { @@ -337,7 +335,7 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu if (seasonInfo == null) return (null, null, null); - var showInfo = await GetShowInfoForSeries(sI, filterGroupByType); + var showInfo = await GetShowInfoForSeries(sI); if (showInfo == null) return (null, null, null); @@ -362,7 +360,7 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu if (seasonInfo == null) return (null, null, null); - var showInfo = await GetShowInfoForSeries(sI, filterGroupByType); + var showInfo = await GetShowInfoForSeries(sI); if (showInfo == null) return (null, null, null); @@ -406,16 +404,16 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu if (!pathSet.Contains(selectedPath)) continue; - // Find the show info. - var showInfo = await GetShowInfoForSeries(seriesId, filterGroupByType); - if (showInfo == null || showInfo.SeasonList.Count == 0) - return (null, null, null); - // Find the season info. var seasonInfo = await GetSeasonInfoForSeries(seriesId); if (seasonInfo == null) return (null, null, null); + // Find the show info. + var showInfo = await GetShowInfoForSeries(seriesId); + if (showInfo == null || showInfo.SeasonList.Count == 0) + return (null, null, null); + // Find the file info for the series. var fileInfo = await CreateFileInfo(file, fileId, seriesId); @@ -457,16 +455,14 @@ private async Task<FileInfo> CreateFileInfo(File file, string fileId, string ser Logger.LogTrace("Creating info object for file. (File={FileId},Series={SeriesId})", fileId, seriesId); // Find the cross-references for the selected series. - var seriesXRef = file.CrossReferences.FirstOrDefault(xref => xref.Series.Shoko.ToString() == seriesId); - if (seriesXRef == null) + var seriesXRef = file.CrossReferences.FirstOrDefault(xref => xref.Series.Shoko.ToString() == seriesId) ?? throw new Exception($"Unable to find any cross-references for the specified series for the file. (File={fileId},Series={seriesId})"); // Find a list of the episode info for each episode linked to the file for the series. var episodeList = new List<EpisodeInfo>(); foreach (var episodeXRef in seriesXRef.Episodes) { var episodeId = episodeXRef.Shoko.ToString(); - var episodeInfo = await GetEpisodeInfo(episodeId); - if (episodeInfo == null) + var episodeInfo = await GetEpisodeInfo(episodeId) ?? throw new Exception($"Unable to find episode cross-reference for the specified series and episode for the file. (File={fileId},Episode={episodeId},Series={seriesId})"); if (episodeInfo.Shoko.IsHidden) { Logger.LogDebug("Skipped hidden episode linked to file. (File={FileId},Episode={EpisodeId},Series={SeriesId})", fileId, episodeId, seriesId); @@ -580,22 +576,6 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) #endregion #region Season Info - public async Task<SeasonInfo?> GetSeasonInfoBySeriesName(string seriesName) - { - var seriesId = await GetSeriesIdForName(seriesName); - if (string.IsNullOrEmpty(seriesId)) - return null; - - var key = $"season:{seriesId}"; - if (DataCache.TryGetValue<SeasonInfo>(key, out var seasonInfo)) { - Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId); - return seasonInfo; - } - - var series = await APIClient.GetSeries(seriesId); - return await CreateSeriesInfo(series, seriesId); - } - public async Task<SeasonInfo?> GetSeasonInfoByPath(string path) { var seriesId = await GetSeriesIdForPath(path); @@ -609,7 +589,7 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) } var series = await APIClient.GetSeries(seriesId); - return await CreateSeriesInfo(series, seriesId); + return await CreateSeasonInfo(series, seriesId); } public async Task<SeasonInfo?> GetSeasonInfoForSeries(string seriesId) @@ -624,7 +604,7 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) } var series = await APIClient.GetSeries(seriesId); - return await CreateSeriesInfo(series, seriesId); + return await CreateSeasonInfo(series, seriesId); } public async Task<SeasonInfo?> GetSeasonInfoForEpisode(string episodeId) @@ -634,13 +614,13 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) if (series == null) return null; seriesId = series.IDs.Shoko.ToString(); - return await CreateSeriesInfo(series, seriesId); + return await CreateSeasonInfo(series, seriesId); } return await GetSeasonInfoForSeries(seriesId); } - private async Task<SeasonInfo> CreateSeriesInfo(Series series, string seriesId) + private async Task<SeasonInfo> CreateSeasonInfo(Series series, string seriesId) { var cacheKey = $"season:{seriesId}"; if (DataCache.TryGetValue<SeasonInfo>(cacheKey, out var seasonInfo)) { @@ -689,34 +669,13 @@ public bool TryGetSeriesPathForId(string seriesId, out string? path) return SeriesIdToPathDictionary.TryGetValue(seriesId, out path); } - public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId, out string? defaultSeriesId) + public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaultSeriesId) { - if (string.IsNullOrEmpty(seriesId) || !SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var tuple)) { - groupId = null; + if (string.IsNullOrEmpty(seriesId)) { defaultSeriesId = null; return false; } - groupId = tuple.GroupId; - defaultSeriesId = tuple.DefaultSeriesId; - return true; - } - - private async Task<string?> GetSeriesIdForName(string name) - { - // Reuse cached value. - if (NameToSeriesIdDictionary.TryGetValue(name, out var seriesId)) - return seriesId; - - Logger.LogDebug("Looking for shoko series matching name {Name}", name); - var series = await APIClient.GetSeriesByName(name); - Logger.LogTrace("Found {Count} exact matches for name {Name}", series == null ? 0 : 1, name); - if (series == null) - return null; - - seriesId = series.IDs.Shoko.ToString(); - NameToSeriesIdDictionary[name] = seriesId; - SeriesIdToPathDictionary.TryAdd(seriesId, name); - return seriesId; + return SeriesIdToDefaultSeriesIdDictionary.TryGetValue(seriesId, out defaultSeriesId); } private async Task<string?> GetSeriesIdForPath(string path) @@ -770,33 +729,24 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId, out s #endregion #region Show Info - public async Task<ShowInfo?> GetShowInfoByPath(string path, Ordering.GroupFilterType filterByType) + public async Task<ShowInfo?> GetShowInfoByPath(string path) { - if (PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) { - if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var tuple)) { - if (string.IsNullOrEmpty(tuple.GroupId)) - return await GetOrCreateShowInfoForStandaloneSeries(seriesId, filterByType); - - return await GetShowInfoForGroup(tuple.GroupId, filterByType); - } - } - else - { + if (!PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) { seriesId = await GetSeriesIdForPath(path); if (string.IsNullOrEmpty(seriesId)) return null; } - return await GetShowInfoForSeries(seriesId, filterByType); + return await GetShowInfoForSeries(seriesId); } - public async Task<ShowInfo?> GetShowInfoForEpisode(string episodeId, Ordering.GroupFilterType filterByType) + public async Task<ShowInfo?> GetShowInfoForEpisode(string episodeId) { if (string.IsNullOrEmpty(episodeId)) return null; if (EpisodeIdToSeriesIdDictionary.TryGetValue(episodeId, out var seriesId)) - return await GetShowInfoForSeries(seriesId, filterByType); + return await GetShowInfoForSeries(seriesId); var series = await APIClient.GetSeriesFromEpisode(episodeId); if (series == null) @@ -804,171 +754,111 @@ public bool TryGetGroupIdForSeriesId(string seriesId, out string? groupId, out s seriesId = series.IDs.Shoko.ToString(); EpisodeIdToSeriesIdDictionary.TryAdd(episodeId, seriesId); - return await GetShowInfoForSeries(seriesId, filterByType); + return await GetShowInfoForSeries(seriesId); } - public async Task<ShowInfo?> GetShowInfoForSeries(string seriesId, Ordering.GroupFilterType filterByType) + public async Task<ShowInfo?> GetShowInfoForSeries(string seriesId) { if (string.IsNullOrEmpty(seriesId)) return null; - if (SeriesIdToGroupIdDictionary.TryGetValue(seriesId, out var tuple)) { - if (string.IsNullOrEmpty(tuple.GroupId)) - return await GetOrCreateShowInfoForStandaloneSeries(seriesId, filterByType); - - return await GetShowInfoForGroup(tuple.GroupId, filterByType); - } - var group = await APIClient.GetGroupFromSeries(seriesId); if (group == null) return null; - // Create a standalone group for each series in a group with sub-groups. - var onlyStandalone = Plugin.Instance.Configuration.BoxSetGrouping != Ordering.GroupType.ShokoGroup; - if (onlyStandalone || group.Sizes.SubGroups > 0) - return await GetOrCreateShowInfoForStandaloneSeries(seriesId, filterByType); - - return await CreateShowInfo(group, group.IDs.Shoko.ToString(), filterByType); - } - - private async Task<ShowInfo?> GetShowInfoForGroup(string groupId, Ordering.GroupFilterType filterByType) - { - if (string.IsNullOrEmpty(groupId)) + var seasonInfo = await GetSeasonInfoForSeries(seriesId); + if (seasonInfo == null) return null; - if (DataCache.TryGetValue<ShowInfo>($"show:{filterByType}:by-group-id:{groupId}", out var showInfo)) { - Logger.LogTrace("Reusing info object for show {GroupName}. (Group={GroupId})", showInfo.Name, groupId); - return showInfo; - } + // Create a standalone group if grouping is disabled and/or for each series in a group with sub-groups. + if (!Plugin.Instance.Configuration.UseGroupsForShows || group.Sizes.SubGroups > 0) + return GetOrCreateShowInfoForSeasonInfo(seasonInfo); - var group = await APIClient.GetGroup(groupId); - return await CreateShowInfo(group, groupId, filterByType); + // If we found a movie, and we're assiging movies as stand-alone shows, and we didn't create a stand-alone show + // above, then attach the stand-alone show to the parent group of the group that might other + if (seasonInfo.Type == SeriesType.Movie && Plugin.Instance.Configuration.SeparateMovies) + return GetOrCreateShowInfoForSeasonInfo(seasonInfo, group.Size > 0 ? group.IDs.ParentGroup.ToString() : null); + + return await CreateShowInfoForGroup(group, group.IDs.Shoko.ToString()); } - private async Task<ShowInfo> CreateShowInfo(Group group, string groupId, Ordering.GroupFilterType filterByType) + private async Task<ShowInfo?> CreateShowInfoForGroup(Group group, string groupId) { - var cacheKey = $"show:{filterByType}:by-group-id:{groupId}"; - if (DataCache.TryGetValue<ShowInfo>(cacheKey, out var showInfo)) { - Logger.LogTrace("Reusing info object for show {GroupName}. (Group={GroupId})", showInfo.Name, groupId); + var cacheKey = $"show:by-group-id:{groupId}"; + if (DataCache.TryGetValue<ShowInfo?>(cacheKey, out var showInfo)) { + Logger.LogTrace("Reusing info object for show {GroupName}. (Group={GroupId})", showInfo?.Name, groupId); return showInfo; } Logger.LogTrace("Creating info object for show {GroupName}. (Group={GroupId})", group.Name, groupId); - var seriesList = (await APIClient.GetSeriesInGroup(groupId) - .ContinueWith(task => Task.WhenAll(task.Result.Select(s => CreateSeriesInfo(s, s.IDs.Shoko.ToString())))) + var seasonList = (await APIClient.GetSeriesInGroup(groupId) + .ContinueWith(task => Task.WhenAll(task.Result.Select(s => CreateSeasonInfo(s, s.IDs.Shoko.ToString())))) .Unwrap()) .Where(s => s != null) .ToList(); - // Return early if no series matched the filter or if the list was empty. - if (seriesList.Count == 0) { - Logger.LogWarning("Creating an empty show info for filter {Filter}! (Group={GroupId})", filterByType.ToString(), groupId); + var length = seasonList.Count; + if (Plugin.Instance.Configuration.SeparateMovies) { + seasonList = seasonList.Where(s => s.Type != SeriesType.Movie).ToList(); - showInfo = new ShowInfo(group); + // Return early if no series matched the filter or if the list was empty. + if (seasonList.Count == 0) { + Logger.LogWarning("Creating an empty show info for filter! (Group={GroupId})", groupId); - DataCache.Set<ShowInfo>(cacheKey, showInfo, DefaultTimeSpan); - return showInfo; + showInfo = null; + + DataCache.Set(cacheKey, showInfo, DefaultTimeSpan); + return showInfo; + } } - showInfo = new ShowInfo(group, seriesList, filterByType, Logger); + showInfo = new ShowInfo(group, seasonList, Logger, length != seasonList.Count); + + foreach (var seasonInfo in seasonList) { + SeriesIdToDefaultSeriesIdDictionary[seasonInfo.Id] = showInfo.Id; + if (!string.IsNullOrEmpty(showInfo.CollectionId)) + SeriesIdToCollectionIdDictionary[seasonInfo.Id] = showInfo.CollectionId; + } - foreach (var series in seriesList) - SeriesIdToGroupIdDictionary[series.Id] = (groupId, showInfo.DefaultSeason!.Id); - DataCache.Set<ShowInfo>(cacheKey, showInfo, DefaultTimeSpan); + DataCache.Set(cacheKey, showInfo, DefaultTimeSpan); return showInfo; } - private async Task<ShowInfo?> GetOrCreateShowInfoForStandaloneSeries(string seriesId, Ordering.GroupFilterType filterByType) + private ShowInfo GetOrCreateShowInfoForSeasonInfo(SeasonInfo seasonInfo, string? groupId = null) { - var cacheKey = $"show:{filterByType}:by-series-id:{seriesId}"; + var cacheKey = $"show:by-series-id:{seasonInfo.Id}"; if (DataCache.TryGetValue<ShowInfo>(cacheKey, out var showInfo)) { - Logger.LogTrace("Reusing info object for show {GroupName}. (Series={SeriesId})", showInfo.Name, seriesId); + Logger.LogTrace("Reusing info object for show {GroupName}. (Series={SeriesId})", showInfo.Name, seasonInfo.Id); return showInfo; } - var seasonInfo = await GetSeasonInfoForSeries(seriesId); - if (seasonInfo == null) - return null; - - var shouldAbort = filterByType switch { - Ordering.GroupFilterType.Movies => seasonInfo.AniDB.Type != SeriesType.Movie, - Ordering.GroupFilterType.Others => seasonInfo.AniDB.Type == SeriesType.Movie, - _ => false, - }; - if (shouldAbort) { - Logger.LogWarning("Creating an empty show info for filter {Filter}! (Series={SeriesId})", filterByType.ToString(), seriesId); - - showInfo = new ShowInfo(seasonInfo.Shoko); - - DataCache.Set<ShowInfo>(cacheKey, showInfo, DefaultTimeSpan); - return showInfo; - } - - showInfo = new ShowInfo(seasonInfo); - SeriesIdToGroupIdDictionary[seriesId] = (null, seriesId); - DataCache.Set<ShowInfo>(cacheKey, showInfo, DefaultTimeSpan); + showInfo = new ShowInfo(seasonInfo, groupId); + SeriesIdToDefaultSeriesIdDictionary[seasonInfo.Id] = showInfo.Id; + if (!string.IsNullOrEmpty(showInfo.CollectionId)) + SeriesIdToCollectionIdDictionary[seasonInfo.Id] = showInfo.CollectionId; + showInfo = DataCache.Set(cacheKey, showInfo, DefaultTimeSpan); return showInfo; } #endregion #region Collection Info - public async Task<CollectionInfo?> GetCollectionInfoByPath(string path, Ordering.GroupFilterType filterByType) - { - if (PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) { - if (SeriesIdToCollectionIdDictionary.TryGetValue(seriesId, out var groupId)) { - if (string.IsNullOrEmpty(groupId)) - return null; - - return await GetCollectionInfoForGroup(groupId, filterByType); - } - } - else - { - seriesId = await GetSeriesIdForPath(path); - if (string.IsNullOrEmpty(seriesId)) - return null; - } - - return await GetCollectionInfoForGroup(seriesId, filterByType); - } - - public async Task<CollectionInfo?> GetCollectionInfoBySeriesName(string seriesName, Ordering.GroupFilterType filterByType) - { - if (NameToSeriesIdDictionary.TryGetValue(seriesName, out var seriesId)) { - if (SeriesIdToCollectionIdDictionary.TryGetValue(seriesId, out var groupId)) { - if (string.IsNullOrEmpty(groupId)) - return null; - - return await GetCollectionInfoForGroup(groupId, filterByType); - } - } - else - { - seriesId = await GetSeriesIdForName(seriesName); - if (string.IsNullOrEmpty(seriesId)) - return null; - } - - return await GetCollectionInfoForSeries(seriesId, filterByType); - } - - public async Task<CollectionInfo?> GetCollectionInfoForGroup(string groupId, Ordering.GroupFilterType filterByType) + public async Task<CollectionInfo?> GetCollectionInfoForGroup(string groupId) { if (string.IsNullOrEmpty(groupId)) return null; - if (DataCache.TryGetValue<CollectionInfo>($"collection:{filterByType}:by-group-id:{groupId}", out var seasonInfo)) { + if (DataCache.TryGetValue<CollectionInfo>($"collection:by-group-id:{groupId}", out var seasonInfo)) { Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", seasonInfo.Name, groupId); return seasonInfo; } var group = await APIClient.GetGroup(groupId); - return await CreateCollectionInfo(group, groupId, filterByType); + return await CreateCollectionInfo(group, groupId); } - public async Task<CollectionInfo?> GetCollectionInfoForSeries(string seriesId, Ordering.GroupFilterType filterByType) + public async Task<CollectionInfo?> GetCollectionInfoForSeries(string seriesId) { if (string.IsNullOrEmpty(seriesId)) return null; @@ -977,23 +867,19 @@ private async Task<ShowInfo> CreateShowInfo(Group group, string groupId, Orderin if (string.IsNullOrEmpty(groupId)) return null; - return await GetCollectionInfoForGroup(groupId, filterByType); + return await GetCollectionInfoForGroup(groupId); } var group = await APIClient.GetGroupFromSeries(seriesId); if (group == null) return null; - return await CreateCollectionInfo(group, group.IDs.Shoko.ToString(), filterByType); + return await CreateCollectionInfo(group, group.IDs.Shoko.ToString()); } - private async Task<CollectionInfo?> CreateCollectionInfo(Group group, string groupId, Ordering.GroupFilterType filterByType) + private async Task<CollectionInfo> CreateCollectionInfo(Group group, string groupId) { - // Only create a collection - if (group.Sizes.SubGroups == 0) - return null; - - var cacheKey = $"collection:{filterByType}:by-group-id:{groupId}"; + var cacheKey = $"collection:by-group-id:{groupId}"; if (DataCache.TryGetValue<CollectionInfo>(cacheKey, out var collectionInfo)) { Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", collectionInfo.Name, groupId); return collectionInfo; @@ -1001,50 +887,44 @@ private async Task<ShowInfo> CreateShowInfo(Group group, string groupId, Orderin Logger.LogTrace("Creating info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); - var onlyStandalone = Plugin.Instance.Configuration.BoxSetGrouping == Ordering.GroupType.ShokoGroup; - var groups = await APIClient.GetGroupsInGroup(groupId); - var multiSeasonShows = await Task.WhenAll(groups - .Where(group => !onlyStandalone && group.Sizes.SubGroups == 0) - .Select(group => CreateShowInfo(group, group.IDs.Shoko.ToString(groupId), filterByType))); - var singleSeasonShows = (await APIClient.GetSeriesInGroup(groupId) - .ContinueWith(task => Task.WhenAll(task.Result.Select(s => GetOrCreateShowInfoForStandaloneSeries(s.IDs.Shoko.ToString(), filterByType)))) - .Unwrap()) - .OfType<ShowInfo>() - .ToList(); - var showList = multiSeasonShows.Concat(singleSeasonShows).ToList(); - var groupList = groups - .Where(group => onlyStandalone || group.Sizes.SubGroups > 0) - .Select(s => CreateCollectionInfo(s, s.IDs.Shoko.ToString(), filterByType)) - .OfType<CollectionInfo>() - .ToList(); + Logger.LogTrace("Fetching show info objects for collection {GroupName}. (Group={GroupId})", group.Name, groupId); + var showGroupIds = new HashSet<string>(); + var collectionIds = new HashSet<string>(); + var showDict = new Dictionary<string, ShowInfo>(); + foreach (var series in await APIClient.GetSeriesInGroup(groupId, recursive: true)) { + var showInfo = await GetShowInfoForSeries(series.IDs.Shoko.ToString()); + if (showInfo == null) + continue; - // Return early if no series matched the filter or if the list was empty. - if (showList.Count == 0 && groupList.Count == 0) { - Logger.LogWarning("Creating an empty collection info for filter {Filter}! (Group={GroupId})", filterByType.ToString(), groupId); + if (!string.IsNullOrEmpty(showInfo.GroupId)) + showGroupIds.Add(showInfo.GroupId); - collectionInfo = new CollectionInfo(group); + if (string.IsNullOrEmpty(showInfo.CollectionId)) + continue; - DataCache.Set<CollectionInfo>(cacheKey, collectionInfo, DefaultTimeSpan); - return collectionInfo; + collectionIds.Add(showInfo.CollectionId); + if (showInfo.CollectionId == groupId) + showDict.TryAdd(showInfo.Id, showInfo); } - collectionInfo = new CollectionInfo(group, showList, groupList, filterByType); + var groupList = new List<CollectionInfo>(); + if (group.Sizes.SubGroups > 0) { + Logger.LogTrace("Fetching sub-collection info objects for collection {GroupName}. (Group={GroupId})", group.Name, groupId); + foreach (var subGroup in await APIClient.GetGroupsInGroup(groupId)) { + if (showGroupIds.Contains(subGroup.IDs.Shoko.ToString()) && !collectionIds.Contains(subGroup.IDs.Shoko.ToString())) + continue; + var subCollectionInfo = await CreateCollectionInfo(subGroup, subGroup.IDs.Shoko.ToString()); + + groupList.Add(subCollectionInfo); + } + } - foreach (var showInfo in showList) - foreach (var seasonInfo in showInfo.SeasonList) - SeriesIdToCollectionIdDictionary[seasonInfo.Id] = groupId; + Logger.LogTrace("Finalising info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); + var showList = showDict.Values.ToList(); + collectionInfo = new CollectionInfo(group, showList, groupList); DataCache.Set<CollectionInfo>(cacheKey, collectionInfo, DefaultTimeSpan); return collectionInfo; } - #endregion - #region Post Process Library Changes - - public Task PostProcess(IProgress<double> progress, CancellationToken token) - { - Clear(); - return Task.CompletedTask; - } - #endregion } diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index bdb2dda5..48979e49 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -41,19 +41,14 @@ public async Task ReconstructCollections(IProgress<double> progress, Cancellatio { try { - switch (Plugin.Instance.Configuration.BoxSetGrouping) + switch (Plugin.Instance.Configuration.CollectionGrouping) { default: - case Ordering.GroupType.Default: - case Ordering.GroupType.MergeFriendly: break; - case Ordering.GroupType.ShokoSeries: + case Ordering.CollectionCreationType.ShokoSeries: await ReconstructMovieSeriesCollections(progress, cancellationToken); break; - case Ordering.GroupType.ShokoGroup: - await ReconstructMovieGroupCollections(progress, cancellationToken); - break; - case Ordering.GroupType.ShokoGroupPlus: + case Ordering.CollectionCreationType.ShokoGroup: await ReconstructSharedCollections(progress, cancellationToken); break; } @@ -78,14 +73,13 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, // create a tree-map of how it's supposed to be. var config = Plugin.Instance.Configuration; - var filterByType = config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Movies : Ordering.GroupFilterType.Default; var movieDict = new Dictionary<Movie, (FileInfo, SeasonInfo, ShowInfo)>(); foreach (var movie in movies) { if (!Lookup.TryGetEpisodeIdsFor(movie, out var episodeIds)) continue; - var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(movie.Path, filterByType); + var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(movie.Path); if (fileInfo == null || seasonInfo == null || showInfo == null) continue; @@ -101,7 +95,7 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, seriesDict.Values .Select(seasonInfo => seasonInfo.Shoko.IDs.ParentGroup.ToString()) .Distinct() - .Select(groupId => ApiManager.GetCollectionInfoForGroup(groupId, Ordering.GroupFilterType.Default)) + .Select(groupId => ApiManager.GetCollectionInfoForGroup(groupId)) ) .ContinueWith(task => task.Result.ToDictionary(x => x!.Id, x => x!)); @@ -118,7 +112,7 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, while (!currentGroup.IsTopLevel && !finalGroups.ContainsKey(currentGroup.ParentId!)) { - currentGroup = await ApiManager.GetCollectionInfoForGroup(currentGroup.ParentId!, Ordering.GroupFilterType.Default); + currentGroup = await ApiManager.GetCollectionInfoForGroup(currentGroup.ParentId!); if (currentGroup == null) break; finalGroups.Add(currentGroup.Id, currentGroup); @@ -197,14 +191,13 @@ private async Task ReconstructMovieGroupCollections(IProgress<double> progress, // create a tree-map of how it's supposed to be. var config = Plugin.Instance.Configuration; - var filterByType = config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Movies : Ordering.GroupFilterType.Default; var movieDict = new Dictionary<Movie, (FileInfo, SeasonInfo, ShowInfo)>(); foreach (var movie in movies) { if (!Lookup.TryGetEpisodeIdsFor(movie, out var episodeIds)) continue; - var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(movie.Path, filterByType); + var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(movie.Path); if (fileInfo == null || seasonInfo == null || showInfo == null) continue; @@ -220,7 +213,7 @@ private async Task ReconstructMovieGroupCollections(IProgress<double> progress, seriesDict.Values .Select(seasonInfo => seasonInfo.Shoko.IDs.ParentGroup.ToString()) .Distinct() - .Select(groupId => ApiManager.GetCollectionInfoForGroup(groupId, Ordering.GroupFilterType.Default)) + .Select(groupId => ApiManager.GetCollectionInfoForGroup(groupId)) ) .ContinueWith(task => task.Result.ToDictionary(x => x!.Id, x => x!)); @@ -237,7 +230,7 @@ private async Task ReconstructMovieGroupCollections(IProgress<double> progress, while (!currentGroup.IsTopLevel && !finalGroups.ContainsKey(currentGroup.ParentId!)) { - currentGroup = await ApiManager.GetCollectionInfoForGroup(currentGroup.ParentId!, Ordering.GroupFilterType.Default); + currentGroup = await ApiManager.GetCollectionInfoForGroup(currentGroup.ParentId!); if (currentGroup == null) break; finalGroups.Add(currentGroup.Id, currentGroup); diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 5b693f33..996834f1 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -4,7 +4,7 @@ using TextSourceType = Shokofin.Utils.Text.TextSourceType; using DisplayLanguageType = Shokofin.Utils.Text.DisplayLanguageType; -using SeriesAndBoxSetGroupType = Shokofin.Utils.Ordering.GroupType; +using CollectionCreationType = Shokofin.Utils.Ordering.CollectionCreationType; using OrderType = Shokofin.Utils.Ordering.OrderType; using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; @@ -56,18 +56,22 @@ public virtual string PrettyHost public TextSourceType DescriptionSource { get; set; } + public bool VirtualFileSystem { get; set; } + + public bool UseGroupsForShows { get; set; } + + public bool SeparateMovies { get; set; } + public OrderType SeasonOrdering { get; set; } public bool MarkSpecialsWhenGrouped { get; set; } public SpecialOrderType SpecialsPlacement { get; set; } - public SeriesAndBoxSetGroupType BoxSetGrouping { get; set; } + public CollectionCreationType CollectionGrouping { get; set; } public OrderType MovieOrdering { get; set; } - public bool FilterOnLibraryTypes { get; set; } - public DisplayLanguageType TitleMainType { get; set; } public DisplayLanguageType TitleAlternateType { get; set; } @@ -88,8 +92,6 @@ public virtual string PrettyHost #region Experimental features - public bool EXPERIMENTAL_EnableResolver { get; set; } - public bool EXPERIMENTAL_AutoMergeVersions { get; set; } public bool EXPERIMENTAL_SplitThenMergeMovies { get; set; } @@ -125,17 +127,18 @@ public PluginConfiguration() TitleAlternateType = DisplayLanguageType.Origin; TitleAllowAny = false; DescriptionSource = TextSourceType.Default; + VirtualFileSystem = true; + UseGroupsForShows = true; + SeparateMovies = true; SeasonOrdering = OrderType.Default; SpecialsPlacement = SpecialOrderType.AfterSeason; MarkSpecialsWhenGrouped = true; - BoxSetGrouping = SeriesAndBoxSetGroupType.Default; + CollectionGrouping = CollectionCreationType.None; MovieOrdering = OrderType.Default; - FilterOnLibraryTypes = false; UserList = Array.Empty<UserConfiguration>(); IgnoredFileExtensions = new [] { ".nfo", ".jpg", ".jpeg", ".png" }; IgnoredFolders = new [] { ".streams", "@recently-snapshot" }; LibraryFilteringMode = null; - EXPERIMENTAL_EnableResolver = false; EXPERIMENTAL_AutoMergeVersions = false; EXPERIMENTAL_SplitThenMergeMovies = true; EXPERIMENTAL_SplitThenMergeEpisodes = false; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 374d554e..8de1f52f 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -159,10 +159,10 @@ async function defaultSubmit(form) { config.AddTMDBId = form.querySelector("#AddTMDBId").checked; // Library settings + config.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; config.LibraryFilteringMode = filteringMode; - config.SeriesGrouping = form.querySelector("#SeriesGrouping").value; - config.BoxSetGrouping = form.querySelector("#BoxSetGrouping").value; - config.FilterOnLibraryTypes = form.querySelector("#FilterOnLibraryTypes").checked; + config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; + config.SeparateMovies = form.querySelector("#SeparateMovies").checked; config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; @@ -184,7 +184,6 @@ async function defaultSubmit(form) { // Experimental settings config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; - config.EXPERIMENTAL_EnableResolver = form.querySelector("#EXPERIMENTAL_EnableResolver").checked; config.EXPERIMENTAL_AutoMergeVersions = form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked; config.EXPERIMENTAL_SplitThenMergeMovies = form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked; config.EXPERIMENTAL_SplitThenMergeEpisodes = form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked; @@ -324,10 +323,10 @@ async function syncSettings(form) { config.AddTMDBId = form.querySelector("#AddTMDBId").checked; // Library settings + config.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; config.LibraryFilteringMode = filteringMode; - config.SeriesGrouping = form.querySelector("#SeriesGrouping").value; - config.BoxSetGrouping = form.querySelector("#BoxSetGrouping").value; - config.FilterOnLibraryTypes = form.querySelector("#FilterOnLibraryTypes").checked; + config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; + config.SeparateMovies = form.querySelector("#SeparateMovies").checked; config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; @@ -349,7 +348,6 @@ async function syncSettings(form) { // Experimental settings config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; - config.EXPERIMENTAL_EnableResolver = form.querySelector("#EXPERIMENTAL_EnableResolver").checked; config.EXPERIMENTAL_AutoMergeVersions = form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked; config.EXPERIMENTAL_SplitThenMergeMovies = form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked; config.EXPERIMENTAL_SplitThenMergeEpisodes = form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked; @@ -481,6 +479,10 @@ export default function (page) { form.querySelector("#SyncUserDataInitialSkipEventCount").disabled = disabled; }); + form.querySelector("#VirtualFileSystem").addEventListener("change", function () { + form.querySelector("#LibraryFilteringMode").disabled = this.checked; + }); + page.addEventListener("viewshow", async function () { Dashboard.showLoadingMsg(); try { @@ -507,10 +509,10 @@ export default function (page) { form.querySelector("#AddTMDBId").checked = config.AddTMDBId; // Library settings + form.querySelector("#VirtualFileSystem").checked = config.VirtualFileSystem || true; form.querySelector("#LibraryFilteringMode").value = `${config.LibraryFilteringMode != null ? config.LibraryFilteringMode : null}`; - form.querySelector("#SeriesGrouping").value = config.SeriesGrouping; - form.querySelector("#BoxSetGrouping").value = config.BoxSetGrouping; - form.querySelector("#FilterOnLibraryTypes").checked = config.FilterOnLibraryTypes; + form.querySelector("#CollectionGrouping").value = config.CollectionGrouping; + form.querySelector("#SeparateMovies").checked = config.SeparateMovies || true; form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" ? "AfterSeason" : config.SpecialsPlacement; form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; @@ -533,7 +535,6 @@ export default function (page) { // Experimental settings form.querySelector("#SeasonOrdering").value = config.SeasonOrdering; - form.querySelector("#EXPERIMENTAL_EnableResolver").checked = config.EXPERIMENTAL_EnableResolver || false; form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked = config.EXPERIMENTAL_AutoMergeVersions || false; form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked = config.EXPERIMENTAL_SplitThenMergeMovies || true; form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked = config.EXPERIMENTAL_SplitThenMergeEpisodes || false; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index e0c2f770..954ce6b0 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -139,15 +139,32 @@ <h3>Plugin Compatibility Settings</h3> <h3>Library Settings</h3> </legend> <div class="fieldDescription verticalSection-extrabottompadding">If you wish to set the Series/Grouping setting below to "Don't group Series into Seasons", you must disable "Automatically merge series that are spread across multiple folders" in the settings for all Libraries that depend on Shokofin for their metadata.<br>On the other hand, if you want to have Series and Grouping be determined by Shoko, or TvDB/TMDB - you must enable the "Automatically merge series that are spread across multiple folders" setting for all Libraries that use Shokofin for their metadata.<br>See the settings under each individual Library here: <a href="#!/library.html">Library Settings</a></div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="VirtualFileSystem" /> + <span>Virtual File System (VFS)</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>Enables the use of the Virtual File System for any media libraries managed by the plugin.</div> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">What does it do?</summary> + Enabling this setting should in theory make it so you won't have to think about file structure imcompatibilities, 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">Filtering mode:</label> - <select is="emby-select" id="LibraryFilteringMode" name="LibraryFilteringMode" class="emby-select-withcolor emby-select"> + <select is="emby-select" id="LibraryFilteringMode" name="LibraryFilteringMode" class="emby-select-withcolor emby-select" disabled> <option value="true" selected>Strict</option> <option value="null">Auto</option> <option value="false">Unrestricted</option> </select> <div class="fieldDescription"> - <div>Choose how the plugin filters out videos in your library.</div> + <div>Choose how the plugin filters out videos in your 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 strict filtering entail?</summary> + Strict filtering means the plugin will filter out any and all unrecognized videos from the active 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 active library. @@ -162,23 +179,22 @@ <h3>Library Settings</h3> </details> </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.</div> + </div> <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="BoxSetGrouping">Collections:</label> - <select is="emby-select" id="BoxSetGrouping" name="BoxSetGrouping" class="emby-select-withcolor emby-select"> - <option value="Default" selected>Do not create Collections</option> + <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="ShokoSeries">Create Collections for Movies based upon Shoko's Series entries</option> - <option value="ShokoGroup">Create Collections for Movies based upon Shoko's Groups and Series entries</option> - <option value="ShokoGroupPlus">Create Collections for Movies and Series based upon Shoko's Groups and Series entries</option> + <option value="ShokoGroup">Create Collections for Movies and Series based upon Shoko's Groups</option> </select> <div class="fieldDescription">Determines how to group entities into Collections.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="FilterOnLibraryTypes" /> - <span>Library separation</span> - </label> - <div class="fieldDescription checkboxFieldDescription">This setting can be used to have one shared root folder on your disk for two libraries in Shoko — one library for movies and one for shows. Enabling this will cause the plugin to actively filter out movies from the show library and everything but movies from the movies library. Also, if you've selected to use Shoko's Group feature to create Series/Seasons then it will also exclude the Movies from within the series — i.e. the "season" for the movie won't appear — even if they share a group in Shoko.</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"> @@ -193,7 +209,7 @@ <h3>Library Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> - <span>Mark specials</span> + <span>Mark Specials</span> </label> <div class="fieldDescription checkboxFieldDescription">Add a number to the title of each Special episode</div> </div> @@ -356,13 +372,6 @@ <h3>Advanced 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_EnableResolver" /> - <span>Virtual File System</span> - </label> - <div class="fieldDescription checkboxFieldDescription"><div>Enables the use of the Virtual File System for any media libraries managed by the plugin.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What does it do?</summary>Enabling this setting should in theory make it so you won't have to think about file structure imcompatibilities, 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="SeasonOrdering">Season ordering:</label> <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select"> diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index 2ba19bf0..af86c5cd 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -173,8 +173,8 @@ public bool TryGetSeriesIdFor(Series series, out string seriesId) if (TryGetSeriesIdFor(series.Path, out seriesId)) { // Set the "Shoko Group" and "Shoko Series" provider ids for the series, since it haven't been set again. It doesn't matter if it's not saved to the database, since we only need it while running the following code. - if (ApiManager.TryGetGroupIdForSeriesId(seriesId, out var groupId, out seriesId)) { - SeriesProvider.AddProviderIds(series, seriesId, groupId); + if (ApiManager.TryGetDefaultSeriesIdForSeriesId(seriesId, out var defaultSeriesId)) { + SeriesProvider.AddProviderIds(series, defaultSeriesId); } // Same as above, but only set the "Shoko Series" id. else { @@ -217,7 +217,7 @@ public bool TryGetSeriesIdFor(BoxSet boxSet, out string seriesId) } if (TryGetSeriesIdFor(boxSet.Path, out seriesId)) { - if (ApiManager.TryGetGroupIdForSeriesId(seriesId, out var _, out var defaultSeriesId)) { + if (ApiManager.TryGetDefaultSeriesIdForSeriesId(seriesId, out var defaultSeriesId)) { seriesId = defaultSeriesId; } return true; diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 99b2d97b..f187547c 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -35,10 +35,9 @@ public BoxSetProvider(IHttpClientFactory httpClientFactory, ILogger<BoxSetProvid public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) { try { - return Plugin.Instance.Configuration.BoxSetGrouping switch + return Plugin.Instance.Configuration.CollectionGrouping switch { - Ordering.GroupType.ShokoGroup => await GetShokoGroupedMetadata(info), - Ordering.GroupType.ShokoGroupPlus => await GetShokoGroupedMetadata(info), + Ordering.CollectionCreationType.ShokoGroup => await GetShokoGroupedMetadata(info), _ => await GetDefaultMetadata(info), }; } @@ -53,18 +52,10 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info) var result = new MetadataResult<BoxSet>(); // First try to re-use any existing series id. - API.Info.SeasonInfo? season = null; - if (info.ProviderIds.TryGetValue("Shoko Series", out var seriesId)) - season = await ApiManager.GetSeasonInfoForSeries(seriesId); - - // Then try to look ir up by path. - if (season == null) - season = await ApiManager.GetSeasonInfoByPath(info.Path); - - // Then try to look it up using the name. - if (season == null && TryGetBoxSetName(info, out var boxSetName)) - season = await ApiManager.GetSeasonInfoBySeriesName(boxSetName); + if (!info.ProviderIds.TryGetValue("Shoko Series", out var seriesId)) + return result; + var season = await ApiManager.GetSeasonInfoForSeries(seriesId); if (season == null) { Logger.LogWarning("Unable to find movie box-set info for name {Name} and path {Path}", info.Name, info.Path); return result; @@ -98,23 +89,12 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info) private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo info) { + // Filter out all manually created collections. We don't help those. var result = new MetadataResult<BoxSet>(); - var config = Plugin.Instance.Configuration; - Ordering.GroupFilterType filterByType = config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Movies : Ordering.GroupFilterType.Default; - - // First try to re-use any existing group id. - API.Info.CollectionInfo? collection = null; - if (info.ProviderIds.TryGetValue("Shoko Group", out var groupId)) - collection = await ApiManager.GetCollectionInfoForGroup(groupId, filterByType); - - // Then try to look it up by path. - if (collection == null) - collection = await ApiManager.GetCollectionInfoByPath(info.Path, filterByType); - - // Then try to look it up using the name. - if (collection == null && TryGetBoxSetName(info, out var boxSetName)) - collection = await ApiManager.GetCollectionInfoBySeriesName(boxSetName, filterByType); + if (!info.ProviderIds.TryGetValue("Shoko Group", out var groupId)) + return result; + var collection = await ApiManager.GetCollectionInfoForGroup(groupId); if (collection == null) { Logger.LogWarning("Unable to find collection info for name {Name} and path {Path}", info.Name, info.Path); return result; diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 183dfd86..71054581 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -40,7 +40,6 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell try { var result = new MetadataResult<Episode>(); var config = Plugin.Instance.Configuration; - var filterByType = config.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; // Fetch the episode, series and group info (and file info, but that's not really used (yet)) Info.FileInfo fileInfo = null; @@ -60,12 +59,12 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell if (seasonInfo == null) return result; - showInfo = await ApiManager.GetShowInfoForSeries(seasonInfo.Id, filterByType); + showInfo = await ApiManager.GetShowInfoForSeries(seasonInfo.Id); if (showInfo == null || showInfo.SeasonList.Count == 0) return result; } else { - (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(info.Path, filterByType); + (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(info.Path); episodeInfo = fileInfo?.EpisodeList.FirstOrDefault(); } @@ -76,7 +75,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell } result.Item = CreateMetadata(showInfo, seasonInfo, episodeInfo, fileInfo, info.MetadataLanguage); - Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},Group={GroupId})", result.Item.Name, fileInfo?.Id, episodeInfo.Id, seasonInfo.Id, showInfo?.Id); + Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},Group={GroupId})", result.Item.Name, fileInfo?.Id, episodeInfo.Id, seasonInfo.Id, showInfo?.GroupId); result.HasMetadata = true; @@ -106,7 +105,7 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie string defaultEpisodeTitle = episodeInfo.Shoko.Name; if ( // Movies - (series.AniDB.Type == SeriesType.Movie && (episodeInfo.AniDB.Type == EpisodeType.Normal || episodeInfo.AniDB.Type == EpisodeType.Special)) || + (series.Type == SeriesType.Movie && (episodeInfo.AniDB.Type == EpisodeType.Normal || episodeInfo.AniDB.Type == EpisodeType.Special)) || // OVAs (series.AniDB.Type == SeriesType.OVA && episodeInfo.AniDB.Type == EpisodeType.Normal && episodeInfo.AniDB.EpisodeNumber == 1 && episodeInfo.Shoko.Name == "OVA") ) { @@ -127,7 +126,12 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie } else { string defaultEpisodeTitle = episode.Shoko.Name; - if (series.AniDB.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) { + if ( + // Movies + (series.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) || + // OVAs + (series.AniDB.Type == SeriesType.OVA && episode.AniDB.Type == EpisodeType.Normal && episode.AniDB.EpisodeNumber == 1 && episode.Shoko.Name == "OVA") + ) { string defaultSeriesTitle = series.Shoko.Name; ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); } @@ -137,9 +141,6 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie description = Text.GetDescription(episode); } - var episodeNumber = Ordering.GetEpisodeNumber(group, series, episode); - var seasonNumber = Ordering.GetSeasonNumber(group, series, episode); - if (config.MarkSpecialsWhenGrouped) switch (episode.AniDB.Type) { case EpisodeType.Unknown: case EpisodeType.Other: @@ -172,9 +173,11 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie break; } - Episode result; + var episodeNumber = Ordering.GetEpisodeNumber(group, series, episode); + var seasonNumber = Ordering.GetSeasonNumber(group, series, episode); var (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber, isSpecial) = Ordering.GetSpecialPlacement(group, series, episode); - var rating = series.AniDB.Restricted && series.AniDB.Type != SeriesType.TV ? "XXX" : null; + + Episode result; if (season != null) { result = new Episode { Name = displayTitle, @@ -194,8 +197,7 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie SeriesName = season.Series.Name, SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, SeasonName = season.Name, - OfficialRating = rating, - CustomRating = rating, + OfficialRating = group.ContentRating, DateLastSaved = DateTime.UtcNow, RunTimeTicks = episode.AniDB.Duration.Ticks, }; @@ -212,8 +214,7 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie AirsBeforeSeasonNumber = airsBeforeSeasonNumber, PremiereDate = episode.AniDB.AirDate, Overview = description, - OfficialRating = rating, - CustomRating = rating, + OfficialRating = group.ContentRating, CommunityRating = episode.AniDB.Rating.ToFloat(10), }; } diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index a2040936..33c225b0 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -51,6 +51,7 @@ public Task RunAsync() public void Dispose() { + GC.SuppressFinalize(this); LibraryManager.ItemAdded -= OnLibraryManagerItemAdded; LibraryManager.ItemUpdated -= OnLibraryManagerItemUpdated; LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; @@ -262,11 +263,9 @@ private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs e) switch (e.Item) { // Clean up after removing a series. case Series series: { - if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + if (!Lookup.TryGetSeriesIdFor(series, out var _)) return; - RemoveExtras(series, seriesId); - foreach (var season in series.Children.OfType<Season>()) OnLibraryManagerItemRemoved(this, new ItemChangeEventArgs { Item = season, Parent = series, UpdateReason = ItemUpdateType.None }); @@ -278,10 +277,7 @@ private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs e) if (!(Lookup.TryGetSeriesIdFor(season.Series, out var seriesId) && (e.Parent is Series series))) return; - if (e.UpdateReason == ItemUpdateType.None) - RemoveExtras(season, seriesId); - else - UpdateSeason(season, series, seriesId, true); + UpdateSeason(season, series, seriesId, true); return; } @@ -302,7 +298,7 @@ private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs e) private void UpdateSeries(Series series, string seriesId) { // Provide metadata for a series using Shoko's Group feature - var showInfo = ApiManager.GetShowInfoForSeries(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) + var showInfo = ApiManager.GetShowInfoForSeries(seriesId) .GetAwaiter() .GetResult(); if (showInfo == null || showInfo.SeasonList.Count == 0) { @@ -347,21 +343,12 @@ private void UpdateSeries(Series series, string seriesId) AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); } } - - AddExtras(series, showInfo.DefaultSeason); - - foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; - - AddExtras(season, seasonInfo); - } } private void UpdateSeason(Season season, Series series, string seriesId, bool deleted = false) { var seasonNumber = season.IndexNumber!.Value; - var showInfo = ApiManager.GetShowInfoForSeries(seriesId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) + var showInfo = ApiManager.GetShowInfoForSeries(seriesId) .GetAwaiter() .GetResult(); if (showInfo == null || showInfo.SeasonList.Count == 0) { @@ -400,7 +387,7 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de else { var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); if (seasonInfo == null) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00} in group for series. (Group={GroupId})", seasonNumber, showInfo.Id); + Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); return; } @@ -421,16 +408,12 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); } - - if (offset == 0) { - AddExtras(season, seasonInfo); - } } } private void UpdateEpisode(Episode episode, string episodeId) { - var showInfo = ApiManager.GetShowInfoForEpisode(episodeId, Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default) + var showInfo = ApiManager.GetShowInfoForEpisode(episodeId) .GetAwaiter() .GetResult(); if (showInfo == null || showInfo.SeasonList.Count == 0) { @@ -633,9 +616,9 @@ private bool EpisodeExists(string episodeId, string seriesId, string groupId) return false; } - private void AddVirtualEpisode(Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, MediaBrowser.Controller.Entities.TV.Season season) + private void AddVirtualEpisode(Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, Season season) { - var groupId = showInfo?.Id ?? null; + var groupId = showInfo?.GroupId ?? null; if (EpisodeExists(episodeInfo.Id, seasonInfo.Id, groupId)) return; @@ -672,84 +655,6 @@ private void RemoveDuplicateEpisodes(Episode episode, string episodeId) Logger.LogInformation("Removed {Count:00} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", existingVirtualItems.Count, episode.Name, episodeId); } - #endregion - #region Extras - - private void AddExtras(BaseItem parent, Info.SeasonInfo seasonInfo) - { - if (seasonInfo.ExtrasList.Count == 0) - return; - - var needsUpdate = false; - var extraIds = new List<Guid>(); - foreach (var episodeInfo in seasonInfo.ExtrasList) { - if (!Lookup.TryGetPathForEpisodeId(episodeInfo.Id, out var episodePath)) - continue; - - if (episodeInfo.ExtraType is MediaBrowser.Model.Entities.ExtraType.ThemeSong or MediaBrowser.Model.Entities.ExtraType.ThemeVideo && - !parent.SupportsThemeMedia) - continue; - - var item = LibraryManager.FindByPath(episodePath, false); - if (item != null && item is Video video) { - video.ParentId = Guid.Empty; - video.OwnerId = parent.Id; - video.Name = episodeInfo.Shoko.Name; - video.ExtraType = episodeInfo.ExtraType; - video.ProviderIds.TryAdd("Shoko Episode", episodeInfo.Id); - video.ProviderIds.TryAdd("Shoko Series", seasonInfo.Id); - LibraryManager.UpdateItemAsync(video, null, ItemUpdateType.None, CancellationToken.None).ConfigureAwait(false); - if (!parent.ExtraIds.Contains(video.Id)) { - needsUpdate = true; - extraIds.Add(video.Id); - } - } - else { - Logger.LogInformation("Adding {ExtraType} {VideoName} to parent {ParentName} (Series={SeriesId})", episodeInfo.ExtraType, episodeInfo.Shoko.Name, parent.Name, seasonInfo.Id); - video = new Video { - Id = LibraryManager.GetNewItemId($"{parent.Id} {episodeInfo.ExtraType} {episodeInfo.Id}", typeof (Video)), - Name = episodeInfo.Shoko.Name, - Path = episodePath, - ExtraType = episodeInfo.ExtraType, - ParentId = Guid.Empty, - OwnerId = parent.Id, - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow, - }; - video.ProviderIds.Add("Shoko Episode", episodeInfo.Id); - video.ProviderIds.Add("Shoko Series", seasonInfo.Id); - LibraryManager.CreateItem(video, null); - needsUpdate = true; - extraIds.Add(video.Id); - } - } - if (needsUpdate) { - parent.ExtraIds = parent.ExtraIds.Concat(extraIds).Distinct().ToArray(); - LibraryManager.UpdateItemAsync(parent, parent.GetParent(), ItemUpdateType.None, CancellationToken.None).ConfigureAwait(false); - } - } - - public void RemoveExtras(BaseItem parent, string seriesId) - { - var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IsVirtualItem = false, - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Video }, - HasOwnerId = true, - HasAnyProviderId = new Dictionary<string, string> { ["Shoko Series"] = seriesId}, - DtoOptions = new DtoOptions(true), - }, true); - - var deleteOptions = new DeleteOptions { - DeleteFileLocation = false, - }; - - foreach (var video in searchList) - LibraryManager.DeleteItem(video, deleteOptions); - - if (searchList.Count > 0) - Logger.LogInformation("Removed {Count:00} extras from parent {ParentName}. (Series={SeriesId})", searchList.Count, parent.Name, seriesId); - } - #endregion } } diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 540fc0c0..9c287e85 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -36,7 +36,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio try { var result = new MetadataResult<Movie>(); - var (file, series, _) = await ApiManager.GetFileInfoByPath(info.Path, Ordering.GroupFilterType.Movies); + var (file, season, _) = await ApiManager.GetFileInfoByPath(info.Path); var episode = file?.EpisodeList.FirstOrDefault(); // if file is null then series and episode is also null. @@ -45,33 +45,33 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio return result; } - var ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, series.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); - Logger.LogInformation("Found movie {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", displayTitle, file.Id, episode.Id, series.Id); + var ( displayTitle, alternateTitle ) = Text.GetMovieTitles(season.AniDB.Titles, episode.AniDB.Titles, season.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); + Logger.LogInformation("Found movie {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", displayTitle, file.Id, episode.Id, season.Id); - bool isMultiEntry = series.Shoko.Sizes.Total.Episodes > 1; + 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) : series.AniDB.Rating.ToFloat(10); + 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(series), + Overview = isMultiEntry && !isMainEntry ? Text.GetDescription(episode) : Text.GetDescription(season), ProductionYear = episode.AniDB.AirDate?.Year, - Tags = series.Tags.ToArray(), - Genres = series.Genres.ToArray(), - Studios = series.Studios.ToArray(), + Tags = season.Tags.ToArray(), + Genres = season.Genres.ToArray(), + Studios = season.Studios.ToArray(), CommunityRating = rating, }; result.Item.SetProviderId("Shoko File", file.Id); result.Item.SetProviderId("Shoko Episode", episode.Id); - result.Item.SetProviderId("Shoko Series", series.Id); + result.Item.SetProviderId("Shoko Series", season.Id); result.HasMetadata = true; result.ResetPeople(); - foreach (var person in series.Staff) + foreach (var person in season.Staff) result.AddPerson(person); return result; diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 6848794a..9754cd93 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -33,18 +33,17 @@ public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvid public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) { try { + var result = new MetadataResult<Season>(); if (!info.IndexNumber.HasValue || info.IndexNumber.HasValue && info.IndexNumber.Value == 0) - return null; + return result; - var result = new MetadataResult<Season>(); - var filterByType = Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Others : Ordering.GroupFilterType.Default; if (!info.SeriesProviderIds.TryGetValue("Shoko Series", out var seriesId) || !info.IndexNumber.HasValue) { Logger.LogDebug("Unable refresh Season {SeasonNumber} {SeasonName}", info.IndexNumber, info.Name); return result; } var seasonNumber = info.IndexNumber.Value; - var showInfo = await ApiManager.GetShowInfoForSeries(seriesId, filterByType); + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); if (showInfo == null) { Logger.LogWarning("Unable to find show info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); return result; @@ -52,11 +51,11 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); if (seasonInfo == null || !showInfo.SeasonNumberBaseDictionary.TryGetValue(seasonInfo, out var baseSeasonNumber)) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Series={SeriesId},Group={GroupId})", seasonNumber, seriesId, showInfo.Id); + Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Series={SeriesId},Group={GroupId})", seasonNumber, seriesId, showInfo.GroupId); return result; } - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, showInfo.Name, seriesId, showInfo.Id); + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, showInfo.Name, seriesId, showInfo.GroupId); var offset = Math.Abs(seasonNumber - baseSeasonNumber); diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index cf7c8b91..ffd98509 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -6,7 +6,6 @@ using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -28,32 +27,26 @@ public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> private readonly ShokoAPIManager ApiManager; private readonly IFileSystem FileSystem; - - private readonly ILibraryManager LibraryManager; - public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager, IFileSystem fileSystem, ILibraryManager libraryManager) + public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager, IFileSystem fileSystem) { Logger = logger; HttpClientFactory = httpClientFactory; ApiManager = apiManager; FileSystem = fileSystem; - LibraryManager = libraryManager; } public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) { try { var result = new MetadataResult<Series>(); - var baseItem = LibraryManager.FindByPath(info.Path, true); - var collectionType = LibraryManager.GetInheritedContentType(baseItem); - var filterLibrary = collectionType == CollectionType.TvShows && !Plugin.Instance.Configuration.FilterOnLibraryTypes ? Ordering.GroupFilterType.Default : Ordering.GroupFilterType.Others; - var show = await ApiManager.GetShowInfoByPath(info.Path, filterLibrary); + var show = await ApiManager.GetShowInfoByPath(info.Path); if (show == null) { try { // Look for the "season" directories to probe for the group information var entries = FileSystem.GetDirectories(info.Path, false); foreach (var entry in entries) { - show = await ApiManager.GetShowInfoByPath(entry.FullName, filterLibrary); + show = await ApiManager.GetShowInfoByPath(entry.FullName); if (show != null) break; } @@ -68,41 +61,33 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat } var season = show.DefaultSeason; - var defaultSeriesTitle = season.Shoko.Name; + var defaultSeriesTitle = show.Name; var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(season.AniDB.Titles, show.Name, info.MetadataLanguage); - Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, season.Id, show.Id); - var premiereDate = show.SeasonList - .Select(s => s.AniDB.AirDate) - .Where(s => s != null) - .OrderBy(s => s) - .FirstOrDefault(); - var endDate = show.SeasonList.Any(s => s.AniDB.EndDate == null) ? null : show.SeasonList - .Select(s => s.AniDB.AirDate) - .OrderBy(s => s) - .LastOrDefault(); + Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, show.Id, show.GroupId); + var premiereDate = show.PremiereDate; + var endDate = show.EndDate; result.Item = new Series { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.GetDescription(season), + Overview = Text.GetDescription(show), PremiereDate = premiereDate, ProductionYear = premiereDate?.Year, EndDate = endDate, Status = !endDate.HasValue || endDate.Value > DateTime.UtcNow ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = show.Tags.ToArray(), - Genres = show.Genres.ToArray(), - Studios = show.Studios.ToArray(), - OfficialRating = season.AniDB.Restricted ? "XXX" : null, - CustomRating = season.AniDB.Restricted ? "XXX" : null, - CommunityRating = season.AniDB.Rating.ToFloat(10), + Tags = show.Tags, + Genres = show.Genres, + Studios = show.Studios, + OfficialRating = show.ContentRating, + CustomRating = show.ContentRating, + CommunityRating = show.CommunityRating, }; - AddProviderIds(result.Item, season.Id, show.Id, season.AniDB.Id.ToString()); - result.HasMetadata = true; - result.ResetPeople(); - foreach (var person in season.Staff) + foreach (var person in show.Staff) result.AddPerson(person); + AddProviderIds(result.Item, show.Id, show.GroupId, season.AniDB.Id.ToString()); + return result; } catch (Exception ex) { diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 873459be..5a46d2b0 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -67,6 +67,7 @@ public ShokoResolveManager(ShokoAPIManager apiManager, ShokoAPIClient apiClient, 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 Folder folder && folder.ParentId == Guid.Empty && !string.IsNullOrEmpty(folder.Path) && !folder.Path.StartsWith(root.Path)) { DataCache.Remove(folder.Id.ToString()); @@ -214,80 +215,69 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string { var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); - var filterType = Ordering.GetGroupFilterTypeForCollection(collectionType); var season = await ApiManager.GetSeasonInfoForSeries(seriesId); if (season == null) return (sourceLocation: string.Empty, symbolicLink: string.Empty); - var isMovieSeason = season.AniDB.Type == SeriesType.Movie; - switch (collectionType) { - default: { - if (isMovieSeason && collectionType == null) - goto case CollectionType.Movies; - var show = await ApiManager.GetShowInfoForSeries(seriesId, filterType); - if (show == null) - return (sourceLocation: string.Empty, symbolicLink: string.Empty); - - var file = await ApiManager.GetFileInfo(fileId, seriesId); - var episode = file?.EpisodeList.FirstOrDefault(); - if (file == null || episode == null) - return (sourceLocation: string.Empty, symbolicLink: string.Empty); - - // In the off-chance that we accidentially ended up with two - // instances of the season while fetching in parallel, then we're - // switching to the correct reference of the season for the show - // we're doing. Let's just hope we won't have to also need to switch - // the episode… - season = show.SeasonList.FirstOrDefault(s => s.Id == seriesId); - episode = season?.RawEpisodeList.FirstOrDefault(e => e.Id == episode.Id); - if (season == null || episode == null) - return (sourceLocation: string.Empty, symbolicLink: string.Empty); + var show = await ApiManager.GetShowInfoForSeries(seriesId); + if (show == null) + return (sourceLocation: string.Empty, symbolicLink: string.Empty); - var defaultSeason = show.DefaultSeason ?? season; - var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); - var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); - var (_, _, _, isSpecial) = Ordering.GetSpecialPlacement(show, season, episode); + var file = await ApiManager.GetFileInfo(fileId, seriesId); + var episode = file?.EpisodeList.FirstOrDefault(); + if (file == null || episode == null) + return (sourceLocation: string.Empty, symbolicLink: string.Empty); - var showName = defaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters(); - if (string.IsNullOrEmpty(showName)) - showName = $"Shoko Series {defaultSeason.Id}"; - if (defaultSeason.AniDB.AirDate.HasValue) - showName += $" ({defaultSeason.AniDB.AirDate.Value.Year})"; + // In the off-chance that we accidentially ended up with two + // instances of the season while fetching in parallel, then we're + // switching to the correct reference of the season for the show + // we're doing. Let's just hope we won't have to also need to switch + // the episode… + season = show.SeasonList.FirstOrDefault(s => s.Id == seriesId); + episode = season?.RawEpisodeList.FirstOrDefault(e => e.Id == episode.Id); - var episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber}"; - var paths = new List<string>() - { - vfsPath, - $"{showName} [shoko-series-{defaultSeason.Id}]", - $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}", - }; - if (file.ExtraType != null) - { - episodeName = episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode == "en")?.Value ?? $"Episode {episode.AniDB.Type} {episode.AniDB.EpisodeNumber}"; - var extrasFolder = file.ExtraType switch { - ExtraType.BehindTheScenes => "behind the scenes", - ExtraType.Clip => "clips", - ExtraType.DeletedScene => "deleted scene", - ExtraType.Interview => "interviews", - ExtraType.Sample => "samples", - ExtraType.Scene => "scenes", - ExtraType.ThemeSong => "theme-music", - ExtraType.ThemeVideo => "backdrops", - ExtraType.Trailer => "trailers", - ExtraType.Unknown => "others", - _ => "extras", - }; - paths.Add(extrasFolder); - } + if (season == null || episode == null) + return (sourceLocation: string.Empty, symbolicLink: string.Empty); + var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters(); + if (string.IsNullOrEmpty(showName)) + showName = $"Shoko Series {show.Id}"; + else if (show.DefaultSeason.AniDB.AirDate.HasValue) + showName += $" ({show.DefaultSeason.AniDB.AirDate.Value.Year})"; + + var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); + var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); + var isSpecial = episode.IsSpecial; + + var paths = new List<string>() + { + vfsPath, + $"{showName} [shoko-series-{show.Id}]", + $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}", + }; + var episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber}"; + if (file.ExtraType != null) + { + episodeName = episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode == "en")?.Value ?? $"Episode {episode.AniDB.Type} {episode.AniDB.EpisodeNumber}"; + var extrasFolder = file.ExtraType switch { + ExtraType.BehindTheScenes => "behind the scenes", + ExtraType.Clip => "clips", + ExtraType.DeletedScene => "deleted scene", + ExtraType.Interview => "interviews", + ExtraType.Sample => "samples", + ExtraType.Scene => "scenes", + ExtraType.ThemeSong => "theme-music", + ExtraType.ThemeVideo => "backdrops", + ExtraType.Trailer => "trailers", + ExtraType.Unknown => "others", + _ => "extras", + }; + paths.Add(extrasFolder); + } - var fileName = $"{episodeName} [shoko-series-{seriesId}] [shoko-file-{fileId}{Path.GetExtension(sourceLocation)}]"; - paths.Add(fileName); - var symbolicLink = Path.Combine(paths.ToArray()); - ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, episodeIds); - return (sourceLocation, symbolicLink); - } + var isMovieSeason = season.Type == SeriesType.Movie; + switch (collectionType) { case CollectionType.TvShows: { - if (isMovieSeason && Plugin.Instance.Configuration.FilterOnLibraryTypes) + if (isMovieSeason && Plugin.Instance.Configuration.SeparateMovies) return (sourceLocation: string.Empty, symbolicLink: string.Empty); goto default; @@ -296,9 +286,20 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string if (!isMovieSeason) return (sourceLocation: string.Empty, symbolicLink: string.Empty); - var fileName = $"Movie File [shoko-series-{seriesId}] [shoko-file-{fileId}{Path.GetExtension(sourceLocation)}]"; - var symbolicLink = Path.Combine(vfsPath, $"Shoko Series {seriesId} [shoko-series-{seriesId}]", fileName); + // Remove the season directory from the path. + paths.RemoveAt(2); + + paths.Add( $"Movie [shoko-series-{seriesId}] [shoko-file-{fileId}{Path.GetExtension(sourceLocation)}]"); + var symbolicLink = Path.Combine(paths.ToArray()); + ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, episodeIds); + return (sourceLocation, symbolicLink); + } + default: { + if (isMovieSeason && collectionType == null) + goto case CollectionType.Movies; + paths.Add($"{episodeName} [shoko-series-{seriesId}] [shoko-file-{fileId}{Path.GetExtension(sourceLocation)}]"); + var symbolicLink = Path.Combine(paths.ToArray()); ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, episodeIds); return (sourceLocation, symbolicLink); } @@ -309,7 +310,7 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string #region Ignore Rule - public bool ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) + public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) { // Everything in the root folder is ignored by us. var root = LibraryManager.RootFolder; @@ -338,13 +339,12 @@ public bool ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) var fullPath = fileInfo.FullName; var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent, root); - var shouldIgnore = Plugin.Instance.Configuration.LibraryFilteringMode ?? Plugin.Instance.Configuration.EXPERIMENTAL_EnableResolver || isSoleProvider; + var shouldIgnore = Plugin.Instance.Configuration.LibraryFilteringMode ?? Plugin.Instance.Configuration.VirtualFileSystem || isSoleProvider; var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); - var filterType = Ordering.GetGroupFilterTypeForCollection(collectionType); if (fileInfo.IsDirectory) - return ScanDirectory(partialPath, fullPath, collectionType, filterType, shouldIgnore); + return await ScanDirectory(partialPath, fullPath, collectionType, shouldIgnore); else - return ScanFile(partialPath, fullPath, filterType, shouldIgnore); + return await ScanFile(partialPath, fullPath, shouldIgnore); } catch (Exception ex) { if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) @@ -355,11 +355,9 @@ public bool ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) } } - private bool ScanDirectory(string partialPath, string fullPath, string? collectionType, Ordering.GroupFilterType filterType, bool shouldIgnore) + private async Task<bool> ScanDirectory(string partialPath, string fullPath, string? collectionType, bool shouldIgnore) { - var season = ApiManager.GetSeasonInfoByPath(fullPath) - .GetAwaiter() - .GetResult(); + var season = await ApiManager.GetSeasonInfoByPath(fullPath); // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given folder path. if (season == null) { @@ -369,9 +367,7 @@ private bool ScanDirectory(string partialPath, string fullPath, string? collecti var entries = FileSystem.GetDirectories(fullPath, false).ToList(); Logger.LogDebug("Unable to find shoko series for {Path}, trying {DirCount} sub-directories.", partialPath, entries.Count); foreach (var entry in entries) { - season = ApiManager.GetSeasonInfoByPath(entry.FullName) - .GetAwaiter() - .GetResult(); + season = await ApiManager.GetSeasonInfoByPath(entry.FullName); if (season != null) { Logger.LogDebug("Found shoko series {SeriesName} for sub-directory of path {Path} (Series={SeriesId})", season.Shoko.Name, partialPath, season.Id); @@ -391,10 +387,10 @@ private bool ScanDirectory(string partialPath, string fullPath, string? collecti } // Filter library if we enabled the option. - var isMovieSeason = season.AniDB.Type == SeriesType.Movie; + var isMovieSeason = season.Type == SeriesType.Movie; switch (collectionType) { case CollectionType.TvShows: - if (isMovieSeason && Plugin.Instance.Configuration.FilterOnLibraryTypes) { + if (isMovieSeason && Plugin.Instance.Configuration.SeparateMovies) { Logger.LogInformation("Found movie in show library and library separation is enabled, ignoring shoko series. (Series={SeriesId})", season.Id); return true; } @@ -407,23 +403,18 @@ private bool ScanDirectory(string partialPath, string fullPath, string? collecti break; } - var show = ApiManager.GetShowInfoForSeries(season.Id, filterType) - .GetAwaiter() - .GetResult()!; - - if (!string.IsNullOrEmpty(show.Id)) - Logger.LogInformation("Found shoko group {GroupName} (Series={SeriesId},Group={GroupId})", show.Name, season.Id, show.Id); + var show = await ApiManager.GetShowInfoForSeries(season.Id)!; + if (!string.IsNullOrEmpty(show!.GroupId)) + Logger.LogInformation("Found shoko group {GroupName} (Series={SeriesId},Group={GroupId})", show.Name, season.Id, show.GroupId); else Logger.LogInformation("Found shoko series {SeriesName} (Series={SeriesId})", season.Shoko.Name, season.Id); return false; } - private bool ScanFile(string partialPath, string fullPath, Ordering.GroupFilterType filterType, bool shouldIgnore) + private async Task<bool> ScanFile(string partialPath, string fullPath, bool shouldIgnore) { - var (file, season, _) = ApiManager.GetFileInfoByPath(fullPath, filterType) - .GetAwaiter() - .GetResult(); + var (file, season, _) = await ApiManager.GetFileInfoByPath(fullPath); // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given file path. if (file == null || season == null) { @@ -451,13 +442,12 @@ private bool ScanFile(string partialPath, string fullPath, Ordering.GroupFilterT public BaseItem? ResolveSingle(Folder? parent, string? collectionType, FileSystemMetadata fileInfo) { - // Disable resolver. - if (!Plugin.Instance.Configuration.EXPERIMENTAL_EnableResolver) + if (!Plugin.Instance.Configuration.VirtualFileSystem) return null; - // Everything in the root folder is ignored by us. var root = LibraryManager.RootFolder; - if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || fileInfo == null || parent == null || root == null || parent == root || fileInfo.FullName.StartsWith(root.Path)) + if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || + fileInfo == null || parent == null || root == null || parent == root || fileInfo.FullName.StartsWith(root.Path)) return null; try { @@ -505,12 +495,12 @@ private bool ScanFile(string partialPath, string fullPath, Ordering.GroupFilterT public MultiItemResolverResult? ResolveMultiple(Folder? parent, string? collectionType, List<FileSystemMetadata> fileInfoList) { - // Disable resolver. - if (!Plugin.Instance.Configuration.EXPERIMENTAL_EnableResolver) + if (!Plugin.Instance.Configuration.VirtualFileSystem) return null; var root = LibraryManager.RootFolder; - if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || root == null || parent == null || parent == root) + if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || + root == null || parent == null || parent == root) return null; try { @@ -525,7 +515,6 @@ private bool ScanFile(string partialPath, string fullPath, Ordering.GroupFilterT if (string.IsNullOrEmpty(vfsPath)) return null; - var filterType = Ordering.GetGroupFilterTypeForCollection(collectionType); var items = FileSystem.GetDirectories(vfsPath) .AsParallel() .SelectMany(dirInfo => { @@ -538,7 +527,7 @@ private bool ScanFile(string partialPath, string fullPath, Ordering.GroupFilterT if (season == null) return Array.Empty<BaseItem>(); - if ((collectionType == CollectionType.Movies || collectionType == null) && season.AniDB.Type == SeriesType.Movie) { + if ((collectionType == CollectionType.Movies || collectionType == null) && season.Type == SeriesType.Movie) { return FileSystem.GetFiles(dirInfo.FullName) .AsParallel() .Select(fileInfo => { diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs index 0e2c5733..3e9f5acb 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -21,7 +21,9 @@ public ShokoResolver(ShokoResolveManager resolveManager) } public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent) - => ResolveManager.ShouldFilterItem(parent as Folder, fileInfo); + => ResolveManager.ShouldFilterItem(parent as Folder, fileInfo) + .GetAwaiter() + .GetResult(); public BaseItem? ResolvePath(ItemResolveArgs args) => ResolveManager.ResolveSingle(args.Parent, args.CollectionType, args.FileInfo); diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index 35118564..b83ef056 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -9,42 +9,26 @@ namespace Shokofin.Utils { public class Ordering { - public enum GroupFilterType { - Default = 0, - Movies = 1, - Others = 2, - } - /// <summary> /// Group series or movie box-sets /// </summary> - public enum GroupType + public enum CollectionCreationType { /// <summary> /// No grouping. All series will have their own entry. /// </summary> - Default = 0, - - /// <summary> - /// Don't group, but make series merge-friendly by using the season numbers from TvDB. - /// </summary> - MergeFriendly = 1, - - /// <summary> - /// Group series based on Shoko's default group filter. - /// </summary> - ShokoGroup = 2, + None = 0, /// <summary> /// Group movies based on Shoko's series. /// </summary> - ShokoSeries = 3, - + ShokoSeries = 1, + /// <summary> /// Group both movies and shows into collections based on shoko's /// groups. /// </summary> - ShokoGroupPlus = 4, + ShokoGroup = 2, } /// <summary> @@ -100,13 +84,6 @@ public enum SpecialOrderType { InBetweenSeasonByOtherData = 5, } - public static GroupFilterType GetGroupFilterTypeForCollection(string collectionType) - => collectionType switch { - CollectionType.Movies => GroupFilterType.Movies, - CollectionType.TvShows => Plugin.Instance.Configuration.FilterOnLibraryTypes ? GroupFilterType.Others : GroupFilterType.Default, - _ => GroupFilterType.Others, - }; - /// <summary> /// Get index number for an episode in a series. /// </summary> diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index 4235458f..450f9a12 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -149,8 +149,11 @@ public enum DisplayTitleType { FullTitle = 3, } - public static string GetDescription(SeasonInfo series) - => GetDescription(series.AniDB.Description, series.TvDB?.Description); + public static string GetDescription(ShowInfo show) + => GetDescription(show.DefaultSeason); + + public static string GetDescription(SeasonInfo season) + => GetDescription(season.AniDB.Description, season.TvDB?.Description); public static string GetDescription(EpisodeInfo episode) => GetDescription(episode.AniDB.Description, episode.TvDB?.Description); From a55a1122eca87c648282052a9a9b59415aa6dd87 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 26 Mar 2024 22:25:04 +0000 Subject: [PATCH 0601/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 362f20b4..2f0b4309 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.40", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.40/shoko_3.0.1.40.zip", + "checksum": "0a9a104b5dbeace65a6cb7c8ed3ac11e", + "timestamp": "2024-03-26T22:25:02Z" + }, { "version": "3.0.1.39", "changelog": "NA\n", From a0b2327ba1736e14297469cb156c6a4424d08be8 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 26 Mar 2024 23:30:34 +0100 Subject: [PATCH 0602/1103] fix: remove remaining code for sentry warning in the plugin settings. --- Shokofin/Configuration/configController.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 8de1f52f..25a998fd 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -428,7 +428,6 @@ export default function (page) { form.querySelector("#Host").setAttribute("disabled", ""); form.querySelector("#Username").setAttribute("disabled", ""); form.querySelector("#Password").value = ""; - form.querySelector("#ConsentSection").setAttribute("hidden", ""); form.querySelector("#ConnectionSetContainer").setAttribute("hidden", ""); form.querySelector("#ConnectionResetContainer").removeAttribute("hidden"); form.querySelector("#ConnectionSection").removeAttribute("hidden"); @@ -443,7 +442,6 @@ export default function (page) { else { form.querySelector("#Host").removeAttribute("disabled"); form.querySelector("#Username").removeAttribute("disabled"); - form.querySelector("#ConsentSection").setAttribute("hidden", ""); form.querySelector("#ConnectionSetContainer").removeAttribute("hidden"); form.querySelector("#ConnectionResetContainer").setAttribute("hidden", ""); form.querySelector("#ConnectionSection").removeAttribute("hidden"); From c560a99df553b7aa029128f7ff9b25a6f4b28d3d Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 26 Mar 2024 22:31:06 +0000 Subject: [PATCH 0603/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 2f0b4309..38524bb1 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.41", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.41/shoko_3.0.1.41.zip", + "checksum": "d8ac8a5b5b53ad2cda4082c2e8d3012b", + "timestamp": "2024-03-26T22:31:04Z" + }, { "version": "3.0.1.40", "changelog": "NA\n", From 59e5b763e7d6baa5dbb30c2c07a7d605823774bb Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 26 Mar 2024 23:45:18 +0100 Subject: [PATCH 0604/1103] refactor: remove support for tvdb ids --- Shokofin/Configuration/PluginConfiguration.cs | 3 --- Shokofin/Configuration/configController.js | 3 --- Shokofin/Configuration/configPage.html | 7 ------- Shokofin/Providers/EpisodeProvider.cs | 4 +--- Shokofin/Providers/SeriesProvider.cs | 9 +++------ 5 files changed, 4 insertions(+), 22 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 996834f1..b9957131 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -50,8 +50,6 @@ public virtual string PrettyHost public bool AddAniDBId { get; set; } - public bool AddTvDBId { get; set; } - public bool AddTMDBId { get; set; } public TextSourceType DescriptionSource { get; set; } @@ -121,7 +119,6 @@ public PluginConfiguration() SynopsisRemoveSummary = true; SynopsisCleanMultiEmptyLines = true; AddAniDBId = true; - AddTvDBId = true; AddTMDBId = true; TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 25a998fd..14adc630 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -155,7 +155,6 @@ async function defaultSubmit(form) { // Provider settings config.AddAniDBId = form.querySelector("#AddAniDBId").checked; - config.AddTvDBId = form.querySelector("#AddTvDBId").checked; config.AddTMDBId = form.querySelector("#AddTMDBId").checked; // Library settings @@ -319,7 +318,6 @@ async function syncSettings(form) { // Provider settings config.AddAniDBId = form.querySelector("#AddAniDBId").checked; - config.AddTvDBId = form.querySelector("#AddTvDBId").checked; config.AddTMDBId = form.querySelector("#AddTMDBId").checked; // Library settings @@ -503,7 +501,6 @@ export default function (page) { // Provider settings form.querySelector("#AddAniDBId").checked = config.AddAniDBId; - form.querySelector("#AddTvDBId").checked = config.AddTvDBId; form.querySelector("#AddTMDBId").checked = config.AddTMDBId; // Library settings diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 954ce6b0..b6e1b6ba 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -116,13 +116,6 @@ <h3>Plugin Compatibility Settings</h3> </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="AddTvDBId" /> - <span>Add TvDB IDs</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this will add the TvDB 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" /> diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 71054581..12c991ae 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -230,7 +230,7 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie return result; } - private static void AddProviderIds(IHasProviderIds item, string episodeId, string fileId = null, string anidbId = null, string tvdbId = null, string tmdbId = null) + private static void AddProviderIds(IHasProviderIds item, string episodeId, string fileId = null, string anidbId = null, string tmdbId = null) { var config = Plugin.Instance.Configuration; item.SetProviderId("Shoko Episode", episodeId); @@ -238,8 +238,6 @@ private static void AddProviderIds(IHasProviderIds item, string episodeId, strin item.SetProviderId("Shoko File", fileId); if (config.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") item.SetProviderId("AniDB", anidbId); - if (config.AddTvDBId && !string.IsNullOrEmpty(tvdbId) && tvdbId != "0") - item.SetProviderId(MetadataProvider.Tvdb, tvdbId); if (config.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") item.SetProviderId(MetadataProvider.Tmdb, tmdbId); } diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index ffd98509..5bc495a7 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -96,12 +96,11 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat } } - public static void AddProviderIds(IHasProviderIds item, string seriesId, string groupId = null, string anidbId = null, string tvdbId = null, string tmdbId = null) + public static void AddProviderIds(IHasProviderIds item, string seriesId, string groupId = null, string anidbId = null, string tmdbId = null) { - // NOTE: These next two lines will remain here till _someone_ fix the series merging for providers other then TvDB and ImDB in Jellyfin. + // NOTE: These next line will remain here till _someone_ fix the series merging for providers other then TvDB and ImDB in Jellyfin. // NOTE: #2 Will fix this once JF 10.9 is out, as it contains a change that will help in this situation. - if (string.IsNullOrEmpty(tvdbId)) - item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{seriesId}"); + item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{seriesId}"); var config = Plugin.Instance.Configuration; item.SetProviderId("Shoko Series", seriesId); @@ -109,8 +108,6 @@ public static void AddProviderIds(IHasProviderIds item, string seriesId, string item.SetProviderId("Shoko Group", groupId); if (config.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") item.SetProviderId("AniDB", anidbId); - if (config.AddTvDBId &&!string.IsNullOrEmpty(tvdbId) && tvdbId != "0") - item.SetProviderId(MetadataProvider.Tvdb, tvdbId); if (config.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") item.SetProviderId(MetadataProvider.Tmdb, tmdbId); } From fd4df1f249178c95a310cf2b9cc0a33ad746a0ab Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 27 Mar 2024 00:23:07 +0100 Subject: [PATCH 0605/1103] refactor: more changes to the settings --- Shokofin/Configuration/PluginConfiguration.cs | 3 - Shokofin/Configuration/configController.js | 45 ++----------- Shokofin/Configuration/configPage.html | 64 ++++++++----------- Shokofin/Plugin.cs | 6 +- Shokofin/Resolvers/ShokoResolveManager.cs | 22 ++++--- 5 files changed, 49 insertions(+), 91 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index b9957131..181e952e 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -82,8 +82,6 @@ public virtual string PrettyHost public UserConfiguration[] UserList { get; set; } - public string[] IgnoredFileExtensions { get; set; } - public string[] IgnoredFolders { get; set; } public bool? LibraryFilteringMode { get; set; } @@ -133,7 +131,6 @@ public PluginConfiguration() CollectionGrouping = CollectionCreationType.None; MovieOrdering = OrderType.Default; UserList = Array.Empty<UserConfiguration>(); - IgnoredFileExtensions = new [] { ".nfo", ".jpg", ".jpeg", ".png" }; IgnoredFolders = new [] { ".streams", "@recently-snapshot" }; LibraryFilteringMode = null; EXPERIMENTAL_AutoMergeVersions = false; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 14adc630..34684052 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -8,38 +8,6 @@ const Messages = { 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 filterIgnoredExtensions(value) { - // We convert to a set to filter out duplicate values. - const filteredSet = new Set( - value - // Split the values at every space, tab, comma. - .split(/[\s,]+/g) - // Sanitize inputs. - .map(str => { - // Trim the start and end and convert to lower-case. - str = str.trim().toLowerCase(); - - // Add a dot if it's missing. - if (str[0] !== ".") - str = "." + str; - - return str; - }), - ); - - // Filter out empty values. - if (filteredSet.has("")) - filteredSet.delete(""); - - // 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. @@ -137,7 +105,6 @@ async function defaultSubmit(form) { publicHost = publicHost.slice(0, -1); form.querySelector("#PublicHost").value = publicHost; } - const ignoredFileExtensions = filterIgnoredExtensions(form.querySelector("#IgnoredFileExtensions").value); const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); const filteringModeRaw = form.querySelector("#LibraryFilteringMode").value; const filteringMode = filteringModeRaw === "true" ? true : filteringModeRaw === "false" ? false : null; @@ -176,8 +143,6 @@ async function defaultSubmit(form) { // Advanced settings config.PublicHost = publicHost; - config.IgnoredFileExtensions = ignoredFileExtensions; - form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); config.IgnoredFolders = ignoredFolders; form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); @@ -300,7 +265,6 @@ async function syncSettings(form) { publicHost = publicHost.slice(0, -1); form.querySelector("#PublicHost").value = publicHost; } - const ignoredFileExtensions = filterIgnoredExtensions(form.querySelector("#IgnoredFileExtensions").value); const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); const filteringModeRaw = form.querySelector("#LibraryFilteringMode").value; const filteringMode = filteringModeRaw === "true" ? true : filteringModeRaw === "false" ? false : null; @@ -339,8 +303,6 @@ async function syncSettings(form) { // Advanced settings config.PublicHost = publicHost; - config.IgnoredFileExtensions = ignoredFileExtensions; - form.querySelector("#IgnoredFileExtensions").value = ignoredFileExtensions.join(" "); config.IgnoredFolders = ignoredFolders; form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); @@ -477,6 +439,12 @@ export default function (page) { form.querySelector("#VirtualFileSystem").addEventListener("change", function () { form.querySelector("#LibraryFilteringMode").disabled = this.checked; + if (this.checked) { + form.querySelector("#LibraryFilteringModeContainer").setAttribute("hidden", ""); + } + else { + form.querySelector("#LibraryFilteringModeContainer").removeAttribute("hidden"); + } }); page.addEventListener("viewshow", async function () { @@ -525,7 +493,6 @@ export default function (page) { // Advanced settings form.querySelector("#PublicHost").value = config.PublicHost; - form.querySelector("#IgnoredFileExtensions").value = config.IgnoredFileExtensions.join(" "); form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); // Experimental settings diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index b6e1b6ba..8c75921b 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -131,21 +131,41 @@ <h3>Plugin Compatibility Settings</h3> <legend> <h3>Library Settings</h3> </legend> - <div class="fieldDescription verticalSection-extrabottompadding">If you wish to set the Series/Grouping setting below to "Don't group Series into Seasons", you must disable "Automatically merge series that are spread across multiple folders" in the settings for all Libraries that depend on Shokofin for their metadata.<br>On the other hand, if you want to have Series and Grouping be determined by Shoko, or TvDB/TMDB - you must enable the "Automatically merge series that are spread across multiple folders" setting for all Libraries that use Shokofin for their metadata.<br>See the settings under each individual Library here: <a href="#!/library.html">Library Settings</a></div> + <div class="fieldDescription verticalSection-extrabottompadding">Everything related to how the plugin manages the library.</div> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="CollectionGrouping">Create 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="ShokoSeries">Create Collections for Movies based upon Shoko's Series entries</option> + <option value="ShokoGroup">Create Collections for Movies and Series based upon Shoko's Groups</option> + </select> + <div class="fieldDescription">Determines how to group entities into Collections.</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"> + <option value="AfterSeason">Always place Specials after the normal Episodes (Default)</option> + <option value="InBetweenSeasonByAirDate">Use release dates to place Specials</option> + <option value="InBetweenSeasonByOtherData">Loosely use the TvDB/TMDB data available in Shoko to place Specials</option> + <option value="InBetweenSeasonMixed">Either loosely use the TvDB/TMDB data available in Shoko or fallback to using release dates to place Specials</option> + <option value="Excluded">Exclude Specials from the Seasons</option> + </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"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="VirtualFileSystem" /> - <span>Virtual File System (VFS)</span> + <span>Virtual File System™ (VFS)</span> </label> <div class="fieldDescription checkboxFieldDescription"> - <div>Enables the use of the Virtual File System for any media libraries managed by the plugin.</div> + <div>Enables the use of the Virtual File System™ for any media libraries managed by the plugin.</div> <details style="margin-top: 0.5em"> - <summary style="margin-bottom: 0.25em">What does it do?</summary> + <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 imcompatibilities, 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"> + <div id="LibraryFilteringModeContainer" class="selectContainer selectContainer-withDescription" hidden> <label class="selectLabel" for="LibraryFilteringMode">Filtering mode:</label> <select is="emby-select" id="LibraryFilteringMode" name="LibraryFilteringMode" class="emby-select-withcolor emby-select" disabled> <option value="true" selected>Strict</option> @@ -158,10 +178,6 @@ <h3>Library Settings</h3> <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 active 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 active library. - </details> <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. @@ -175,34 +191,14 @@ <h3>Library Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SeparateMovies" /> - <span>Separate Movies From Shows</span> + <span>Separate movies from shows</span> </label> - <div class="fieldDescription checkboxFieldDescription">This filters out Movies from the Shows in your library.</div> - </div> - <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"> - <option value="None" selected>Do not create Collections</option> - <option value="ShokoSeries">Create Collections for Movies based upon Shoko's Series entries</option> - <option value="ShokoGroup">Create Collections for Movies and Series based upon Shoko's Groups</option> - </select> - <div class="fieldDescription">Determines how to group entities into Collections.</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"> - <option value="AfterSeason">Always place Specials after the normal Episodes (Default)</option> - <option value="InBetweenSeasonByAirDate">Use release dates to place Specials</option> - <option value="InBetweenSeasonByOtherData">Loosely use the TvDB/TMDB data available in Shoko to place Specials</option> - <option value="InBetweenSeasonMixed">Either loosely use the TvDB/TMDB data available in Shoko or fallback to using release dates to place Specials</option> - <option value="Excluded">Exclude Specials from the Seasons</option> - </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 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"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> - <span>Mark Specials</span> + <span>Mark specials</span> </label> <div class="fieldDescription checkboxFieldDescription">Add a number to the title of each Special episode</div> </div> @@ -348,10 +344,6 @@ <h3>Advanced Settings</h3> <input is="emby-input" type="text" id="PublicHost" label="Public Shoko host URL:" /> <div class="fieldDescription">This is the public URL leading to where Shoko is running. It is 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. It should include both the protocol and the IP/DNS name.</div> </div> - <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="IgnoredFileExtensions" label="Ignored file extensions:" /> - <div class="fieldDescription">A space separated list of file extensions which will be ignored during the library scan.</div> - </div> <div class="inputContainer inputContainer-withDescription"> <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 the library scan.</div> diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index e79eed00..8a0e5167 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -32,20 +32,16 @@ public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) { Instance = this; ConfigurationChanged += OnConfigChanged; - IgnoredFileExtensions = this.Configuration.IgnoredFileExtensions.ToHashSet(); - IgnoredFolders = this.Configuration.IgnoredFolders.ToHashSet(); + IgnoredFolders = Configuration.IgnoredFolders.ToHashSet(); } public void OnConfigChanged(object? sender, BasePluginConfiguration e) { if (e is not PluginConfiguration config) return; - IgnoredFileExtensions = config.IgnoredFileExtensions.ToHashSet(); IgnoredFolders = config.IgnoredFolders.ToHashSet(); } - public HashSet<string> IgnoredFileExtensions; - public HashSet<string> IgnoredFolders; #pragma warning disable 8618 diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 5a46d2b0..9e17f679 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -312,9 +312,11 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) { - // Everything in the root folder is ignored by us. + if (parent == null || fileInfo == null) + return false; + var root = LibraryManager.RootFolder; - if (fileInfo == null || parent == null || root == null || parent == root || fileInfo.FullName.StartsWith(root.Path)) + if (root == null || parent == root) return false; try { @@ -326,12 +328,12 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file if (!Lookup.IsEnabledForItem(parent, out var isSoleProvider)) return false; - if (fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(Path.GetFileName(fileInfo.FullName).ToLowerInvariant())) { + if (fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(Path.GetFileName(fileInfo.FullName).ToLowerInvariant())) { Logger.LogDebug("Excluded folder at path {Path}", fileInfo.FullName); return true; } - if (!fileInfo.IsDirectory && Plugin.Instance.IgnoredFileExtensions.Contains(fileInfo.Extension.ToLowerInvariant())) { + if (!fileInfo.IsDirectory && !_namingOptions.VideoFileExtensions.Contains(fileInfo.Extension.ToLowerInvariant())) { Logger.LogDebug("Skipped excluded file at path {Path}", fileInfo.FullName); return false; } @@ -445,9 +447,11 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou if (!Plugin.Instance.Configuration.VirtualFileSystem) return null; + if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || parent == null || fileInfo == null) + return null; + var root = LibraryManager.RootFolder; - if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || - fileInfo == null || parent == null || root == null || parent == root || fileInfo.FullName.StartsWith(root.Path)) + if (root == null || parent == root) return null; try { @@ -498,9 +502,11 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou if (!Plugin.Instance.Configuration.VirtualFileSystem) return null; + if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || parent == null) + return null; + var root = LibraryManager.RootFolder; - if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || - root == null || parent == null || parent == root) + if (root == null || parent == root) return null; try { From 8d3e71a9e5c5840600ee27a9da502e9097ff0c2b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 26 Mar 2024 23:24:07 +0000 Subject: [PATCH 0606/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 38524bb1..593a181a 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.42", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.42/shoko_3.0.1.42.zip", + "checksum": "e14caade748eb88bb699f961465fc9bb", + "timestamp": "2024-03-26T23:24:05Z" + }, { "version": "3.0.1.41", "changelog": "NA\n", From 34fbbffe97fa0e3fc1ab8d4f1991031eb7056171 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 27 Mar 2024 03:21:47 +0100 Subject: [PATCH 0607/1103] refactor: even more changes to the settings --- Shokofin/Configuration/PluginConfiguration.cs | 2 +- Shokofin/Configuration/configController.js | 33 ++++++++++++-- Shokofin/Configuration/configPage.html | 45 +++++++++++++++---- 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 181e952e..ae786d7e 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -123,7 +123,7 @@ public PluginConfiguration() TitleAllowAny = false; DescriptionSource = TextSourceType.Default; VirtualFileSystem = true; - UseGroupsForShows = true; + UseGroupsForShows = false; SeparateMovies = true; SeasonOrdering = OrderType.Default; SpecialsPlacement = SpecialOrderType.AfterSeason; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 34684052..29615c3c 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -127,6 +127,8 @@ async function defaultSubmit(form) { // Library settings config.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; config.LibraryFilteringMode = filteringMode; + config.UseGroupsForShows = form.querySelector("#UseGroupsForShows").checked; + config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; config.SeparateMovies = form.querySelector("#SeparateMovies").checked; config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; @@ -147,7 +149,6 @@ async function defaultSubmit(form) { form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); // Experimental settings - config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; config.EXPERIMENTAL_AutoMergeVersions = form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked; config.EXPERIMENTAL_SplitThenMergeMovies = form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked; config.EXPERIMENTAL_SplitThenMergeEpisodes = form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked; @@ -287,6 +288,8 @@ async function syncSettings(form) { // Library settings config.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; config.LibraryFilteringMode = filteringMode; + config.UseGroupsForShows = form.querySelector("#UseGroupsForShows").checked; + config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; config.SeparateMovies = form.querySelector("#SeparateMovies").checked; config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; @@ -307,7 +310,6 @@ async function syncSettings(form) { form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); // Experimental settings - config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; config.EXPERIMENTAL_AutoMergeVersions = form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked; config.EXPERIMENTAL_SplitThenMergeMovies = form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked; config.EXPERIMENTAL_SplitThenMergeEpisodes = form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked; @@ -447,6 +449,16 @@ export default function (page) { } }); + form.querySelector("#UseGroupsForShows").addEventListener("change", function () { + form.querySelector("#SeasonOrdering").disabled = this.checked; + if (this.checked) { + form.querySelector("#SeasonOrderingContainer").setAttribute("hidden", ""); + } + else { + form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); + } + }); + page.addEventListener("viewshow", async function () { Dashboard.showLoadingMsg(); try { @@ -474,6 +486,22 @@ export default function (page) { // Library settings form.querySelector("#VirtualFileSystem").checked = config.VirtualFileSystem || true; form.querySelector("#LibraryFilteringMode").value = `${config.LibraryFilteringMode != null ? config.LibraryFilteringMode : null}`; + form.querySelector("#LibraryFilteringMode").disabled = form.querySelector("#VirtualFileSystem").checked || true; + if (form.querySelector("#VirtualFileSystem").checked) { + form.querySelector("#LibraryFilteringModeContainer").setAttribute("hidden", ""); + } + else { + form.querySelector("#LibraryFilteringModeContainer").removeAttribute("hidden"); + } + form.querySelector("#UseGroupsForShows").checked = config.UseGroupsForShows || false; + form.querySelector("#SeasonOrdering").value = config.SeasonOrdering; + form.querySelector("#SeasonOrdering").disabled = form.querySelector("#UseGroupsForShows").checked; + if (form.querySelector("#UseGroupsForShows").checked) { + form.querySelector("#SeasonOrderingContainer").setAttribute("hidden", ""); + } + else { + form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); + } form.querySelector("#CollectionGrouping").value = config.CollectionGrouping; form.querySelector("#SeparateMovies").checked = config.SeparateMovies || true; form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" ? "AfterSeason" : config.SpecialsPlacement; @@ -496,7 +524,6 @@ export default function (page) { form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); // Experimental settings - form.querySelector("#SeasonOrdering").value = config.SeasonOrdering; form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked = config.EXPERIMENTAL_AutoMergeVersions || false; form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked = config.EXPERIMENTAL_SplitThenMergeMovies || true; form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked = config.EXPERIMENTAL_SplitThenMergeEpisodes || false; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 8c75921b..852e30bb 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -188,6 +188,42 @@ <h3>Library Settings</h3> </details> </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> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>This will use Shoko's group feature to group together AniDB anime entries into show entries.</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 infomation look up the + <a href="https://docs.shokoanime.com/server/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 sepeatated 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 infomation look up the + <a href="https://docs.shokoanime.com/server/management">Shoko docs</a> on how to manage your groups. + </details> + </div> + </div> + <div id="SeasonOrderingContainer" class="selectContainer selectContainer-withDescription" 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.</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" /> @@ -357,15 +393,6 @@ <h3>Advanced 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="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="SeasonOrdering">Season ordering:</label> - <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select"> - <option value="Default" selected>Let Shoko decide.</option> - <option value="ReleaseDate">Order season by release date.</option> - <option value="Chronological">Order seasons in chronological order.</option> - </select> - <div class="fieldDescription">Determines how to order Seasons within Shows.</div> - </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_AutoMergeVersions" /> From 45c2fc5b3b57e1184a1f4c439404fce240377231 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 27 Mar 2024 03:24:48 +0100 Subject: [PATCH 0608/1103] fix: reverse the expression --- Shokofin/Configuration/configController.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 29615c3c..492ac293 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -450,12 +450,12 @@ export default function (page) { }); form.querySelector("#UseGroupsForShows").addEventListener("change", function () { - form.querySelector("#SeasonOrdering").disabled = this.checked; + form.querySelector("#SeasonOrdering").disabled = !this.checked; if (this.checked) { - form.querySelector("#SeasonOrderingContainer").setAttribute("hidden", ""); + form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); } else { - form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); + form.querySelector("#SeasonOrderingContainer").setAttribute("hidden", ""); } }); @@ -495,12 +495,12 @@ export default function (page) { } form.querySelector("#UseGroupsForShows").checked = config.UseGroupsForShows || false; form.querySelector("#SeasonOrdering").value = config.SeasonOrdering; - form.querySelector("#SeasonOrdering").disabled = form.querySelector("#UseGroupsForShows").checked; + form.querySelector("#SeasonOrdering").disabled = !form.querySelector("#UseGroupsForShows").checked; if (form.querySelector("#UseGroupsForShows").checked) { - form.querySelector("#SeasonOrderingContainer").setAttribute("hidden", ""); + form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); } else { - form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); + form.querySelector("#SeasonOrderingContainer").setAttribute("hidden", ""); } form.querySelector("#CollectionGrouping").value = config.CollectionGrouping; form.querySelector("#SeparateMovies").checked = config.SeparateMovies || true; From fb8453444110a42deee743ac8383627e00ce4570 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 27 Mar 2024 02:27:32 +0000 Subject: [PATCH 0609/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 593a181a..f178b3ea 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.43", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.43/shoko_3.0.1.43.zip", + "checksum": "7fde63175bf69e223aac31a3f6b5ecb7", + "timestamp": "2024-03-27T02:27:31Z" + }, { "version": "3.0.1.42", "changelog": "NA\n", From a67fe75a0650eefcef6e7244b489f508d8bde9df Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 27 Mar 2024 03:49:36 +0100 Subject: [PATCH 0610/1103] =?UTF-8?q?misc:=20even=20more=20changes=20to=20?= =?UTF-8?q?the=20settings=20=E2=80=94=20the=202nd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/Configuration/configPage.html | 47 +++++++++++++------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 852e30bb..a0e22c9a 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -131,26 +131,25 @@ <h3>Plugin Compatibility Settings</h3> <legend> <h3>Library Settings</h3> </legend> - <div class="fieldDescription verticalSection-extrabottompadding">Everything related to how the plugin manages the library.</div> <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="CollectionGrouping">Create 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="ShokoSeries">Create Collections for Movies based upon Shoko's Series entries</option> - <option value="ShokoGroup">Create Collections for Movies and Series based upon Shoko's Groups</option> + <option value="None" selected>Do not create collections</option> + <option value="ShokoSeries">Create collections for movies based upon Shoko's series</option> + <option value="ShokoGroup">Create collections for movies and shows based upon Shoko's groups and series</option> </select> - <div class="fieldDescription">Determines how to group entities into Collections.</div> + <div class="fieldDescription">Determines how to group entities into collections.</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"> - <option value="AfterSeason">Always place Specials after the normal Episodes (Default)</option> - <option value="InBetweenSeasonByAirDate">Use release dates to place Specials</option> - <option value="InBetweenSeasonByOtherData">Loosely use the TvDB/TMDB data available in Shoko to place Specials</option> - <option value="InBetweenSeasonMixed">Either loosely use the TvDB/TMDB data available in Shoko or fallback to using release dates to place Specials</option> - <option value="Excluded">Exclude Specials from the Seasons</option> + <option value="AfterSeason">Always place specials after the normal episodes (Default)</option> + <option value="InBetweenSeasonByAirDate">Use release dates to place specials</option> + <option value="InBetweenSeasonByOtherData">Loosely use the TvDB/TMDB data available in Shoko to place specials</option> + <option value="InBetweenSeasonMixed">Either loosely use the TvDB/TMDB data available in Shoko or fallback to using release dates to place specials</option> + <option value="Excluded">Exclude specials from the seasons</option> </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 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"> <label class="emby-checkbox-label"> @@ -168,23 +167,23 @@ <h3>Library Settings</h3> <div id="LibraryFilteringModeContainer" class="selectContainer selectContainer-withDescription" hidden> <label class="selectLabel" for="LibraryFilteringMode">Filtering mode:</label> <select is="emby-select" id="LibraryFilteringMode" name="LibraryFilteringMode" class="emby-select-withcolor emby-select" disabled> - <option value="true" selected>Strict</option> <option value="null">Auto</option> - <option value="false">Unrestricted</option> + <option value="true" selected>Strict</option> + <option value="false">Disabled</option> </select> <div class="fieldDescription"> <div>Choose how the plugin filters out videos in your 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 strict filtering entail?</summary> - Strict filtering means the plugin will filter out any and all unrecognized videos from the active library. - </details> <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 unrestricted filtering entail?</summary> - Unrestricted filtering means the plugin will not filter out anything from the active library. + <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 disabling filtering entail?</summary> + Disabling the filtering means the plugin will not filter out anything from the library. Use this only if you're having trouble. </details> </div> </div> @@ -218,9 +217,9 @@ <h3>Library Settings</h3> <div id="SeasonOrderingContainer" class="selectContainer selectContainer-withDescription" 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.</option> + <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</option> </select> <div class="fieldDescription">Determines how to order seasons within each show using the Shoko groups.</div> </div> @@ -378,7 +377,7 @@ <h3>Advanced Settings</h3> </legend> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="text" id="PublicHost" label="Public Shoko host URL:" /> - <div class="fieldDescription">This is the public URL leading to where Shoko is running. It is 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. It should include both the protocol and the IP/DNS name.</div> + <div class="fieldDescription">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. If provided, then it should also be possible for Jellyfin to use the URL to access shoko, since this will be needed to grab images from the Shoko instance. It should include both the protocol and the IP/DNS name.</div> </div> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="text" id="IgnoredFolders" label="Ignored folder names:" /> From 9b0188c938a4b180f9280c1a24cbd652e5db14b0 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 27 Mar 2024 02:57:56 +0000 Subject: [PATCH 0611/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index f178b3ea..84129387 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.44", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.44/shoko_3.0.1.44.zip", + "checksum": "cf57ecdb558caa137773d60a8434dcc0", + "timestamp": "2024-03-27T02:57:55Z" + }, { "version": "3.0.1.43", "changelog": "NA\n", From 8af0ce27034cc4cc3a2489b94897edb2484c91e8 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 28 Mar 2024 00:02:08 +0100 Subject: [PATCH 0612/1103] misc: log virtual root --- Shokofin/Plugin.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 8a0e5167..a30cecb9 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -2,14 +2,11 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net.Http; -using System.Reflection; -using System.Threading.Tasks; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; -using Shokofin.API.Models; +using Microsoft.Extensions.Logging; using Shokofin.Configuration; #nullable enable @@ -23,16 +20,20 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages public override Guid Id => Guid.Parse("5216ccbf-d24a-4eb3-8a7e-7da4230b7052"); + private readonly ILogger<Plugin> Logger; + /// <summary> /// "Virtual" File System Root Directory. /// </summary> public string VirtualRoot => Path.Combine(DataFolderPath, "VFS"); - public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger) : base(applicationPaths, xmlSerializer) { Instance = this; ConfigurationChanged += OnConfigChanged; IgnoredFolders = Configuration.IgnoredFolders.ToHashSet(); + Logger = logger; + Logger.LogInformation("Virtual File System Location; {Path}", VirtualRoot); } public void OnConfigChanged(object? sender, BasePluginConfiguration e) From 452365447ee74d44c8b34bebea192cee2521855c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 28 Mar 2024 00:02:28 +0100 Subject: [PATCH 0613/1103] fix collection id for stand-alone movies --- Shokofin/API/Info/ShowInfo.cs | 6 +++--- Shokofin/API/ShokoAPIManager.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index f1d251de..56734626 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -114,7 +114,7 @@ public class ShowInfo /// </summary> public SeasonInfo DefaultSeason; - public ShowInfo(SeasonInfo seasonInfo, string? groupId = null) + public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) { var seasonNumberBaseDictionary = new Dictionary<SeasonInfo, int>() { { seasonInfo, 1 } }; var seasonOrderDictionary = new Dictionary<int, SeasonInfo>() { { 1, seasonInfo } }; @@ -125,8 +125,8 @@ public ShowInfo(SeasonInfo seasonInfo, string? groupId = null) seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); Id = seasonInfo.Id; - GroupId = groupId ?? seasonInfo.Shoko.IDs.ParentGroup.ToString(); - CollectionId = seasonInfo.Shoko.IDs.ParentGroup.ToString(); + GroupId = seasonInfo.Shoko.IDs.ParentGroup.ToString(); + CollectionId = collectionId ?? seasonInfo.Shoko.IDs.ParentGroup.ToString(); Name = seasonInfo.Shoko.Name; Tags = seasonInfo.Tags; Genres = seasonInfo.Genres; diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index d0406d66..0b6d0090 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -825,7 +825,7 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul return showInfo; } - private ShowInfo GetOrCreateShowInfoForSeasonInfo(SeasonInfo seasonInfo, string? groupId = null) + private ShowInfo GetOrCreateShowInfoForSeasonInfo(SeasonInfo seasonInfo, string? collectionId = null) { var cacheKey = $"show:by-series-id:{seasonInfo.Id}"; if (DataCache.TryGetValue<ShowInfo>(cacheKey, out var showInfo)) { @@ -833,7 +833,7 @@ private ShowInfo GetOrCreateShowInfoForSeasonInfo(SeasonInfo seasonInfo, string? return showInfo; } - showInfo = new ShowInfo(seasonInfo, groupId); + showInfo = new ShowInfo(seasonInfo, collectionId); SeriesIdToDefaultSeriesIdDictionary[seasonInfo.Id] = showInfo.Id; if (!string.IsNullOrEmpty(showInfo.CollectionId)) SeriesIdToCollectionIdDictionary[seasonInfo.Id] = showInfo.CollectionId; From 12bdbaf40902b83e72a2fbe9bb09db3e20d79ed5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 28 Mar 2024 00:02:57 +0100 Subject: [PATCH 0614/1103] cleanup: remove uneeded variables from series provider --- Shokofin/Providers/SeriesProvider.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 5bc495a7..fc864a2f 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -60,10 +60,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat } } - var season = show.DefaultSeason; - var defaultSeriesTitle = show.Name; - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(season.AniDB.Titles, show.Name, info.MetadataLanguage); - Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, show.Id, show.GroupId); + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(show.DefaultSeason.AniDB.Titles, show.Name, info.MetadataLanguage); var premiereDate = show.PremiereDate; var endDate = show.EndDate; result.Item = new Series { @@ -86,7 +83,9 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat foreach (var person in show.Staff) result.AddPerson(person); - AddProviderIds(result.Item, show.Id, show.GroupId, season.AniDB.Id.ToString()); + AddProviderIds(result.Item, show.Id, show.GroupId, show.DefaultSeason.AniDB.Id.ToString()); + + Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, show.Id, show.GroupId); return result; } From d1f7cb154e8a44f0e77d4d72620fa44b420d2d78 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 27 Mar 2024 23:03:48 +0000 Subject: [PATCH 0615/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 84129387..39fed5a0 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.45", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.45/shoko_3.0.1.45.zip", + "checksum": "56c3a0ad2b2e86991adbc22f2a96c735", + "timestamp": "2024-03-27T23:03:47Z" + }, { "version": "3.0.1.44", "changelog": "NA\n", From c2b20a7acef4677ca55c62bc44c124393f899739 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 28 Mar 2024 05:35:10 +0100 Subject: [PATCH 0616/1103] refactor: make plugin compatible with stable server + more a bunch of fixes and other changes, that will thereamong make the plugin compatible with stabke Shoko Server again, fix the VFS, and also modernise some of the remaining older style namespace definitions. --- Shokofin/API/Info/EpisodeInfo.cs | 1 + Shokofin/API/Info/ShowInfo.cs | 8 +- Shokofin/API/Models/ComponentVersion.cs | 44 ++ Shokofin/API/ShokoAPIClient.cs | 165 +++++-- Shokofin/API/ShokoAPIManager.cs | 1 - Shokofin/Configuration/PluginConfiguration.cs | 184 +++---- Shokofin/Configuration/configController.js | 20 +- Shokofin/Configuration/configPage.html | 10 +- Shokofin/Providers/ExtraMetadataProvider.cs | 7 +- Shokofin/Providers/SeasonProvider.cs | 2 +- Shokofin/Resolvers/ShokoResolveManager.cs | 151 +++--- Shokofin/Resolvers/ShokoResolver.cs | 11 +- Shokofin/Tasks/ClearPluginCacheTask.cs | 109 ++--- Shokofin/Tasks/ExportUserDataTask.cs | 100 ++-- Shokofin/Tasks/ImportUserDataTask.cs | 100 ++-- Shokofin/Tasks/MergeAllTask.cs | 92 ++-- Shokofin/Tasks/MergeEpisodesTask.cs | 94 ++-- Shokofin/Tasks/MergeMoviesTask.cs | 94 ++-- Shokofin/Tasks/PostScanTask.cs | 72 +-- Shokofin/Tasks/SplitAllTask.cs | 94 ++-- Shokofin/Tasks/SplitEpisodesTask.cs | 94 ++-- Shokofin/Tasks/SplitMoviesTask.cs | 94 ++-- Shokofin/Tasks/SyncUserDataTask.cs | 100 ++-- Shokofin/Utils/OrderingUtil.cs | 455 +++++++++--------- Shokofin/Utils/SeriesInfoRelationComparer.cs | 2 +- Shokofin/Utils/TextUtil.cs | 2 +- Shokofin/Web/ShokoApiController.cs | 102 ++++ Shokofin/Web/WebController.cs | 70 --- 28 files changed, 1230 insertions(+), 1048 deletions(-) create mode 100644 Shokofin/API/Models/ComponentVersion.cs create mode 100644 Shokofin/Web/ShokoApiController.cs delete mode 100644 Shokofin/Web/WebController.cs diff --git a/Shokofin/API/Info/EpisodeInfo.cs b/Shokofin/API/Info/EpisodeInfo.cs index 1ce029fe..fd4a0c1e 100644 --- a/Shokofin/API/Info/EpisodeInfo.cs +++ b/Shokofin/API/Info/EpisodeInfo.cs @@ -23,6 +23,7 @@ public bool IsSpecial { get { + if (ExtraType != null) return false; var order = Plugin.Instance.Configuration.SpecialsPlacement; var allowOtherData = order == SpecialOrderType.InBetweenSeasonByOtherData || order == SpecialOrderType.InBetweenSeasonMixed; return allowOtherData ? (TvDB?.SeasonNumber == 0 || AniDB.Type == EpisodeType.Special) : AniDB.Type == EpisodeType.Special; diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index 56734626..ce33fa9a 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -107,7 +107,7 @@ public class ShowInfo /// <summary> /// The season number base-number dictionary. /// </summary> - public Dictionary<SeasonInfo, int> SeasonNumberBaseDictionary; + public Dictionary<string, int> SeasonNumberBaseDictionary; /// <summary> /// The default season for the show. @@ -116,7 +116,7 @@ public class ShowInfo public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) { - var seasonNumberBaseDictionary = new Dictionary<SeasonInfo, int>() { { seasonInfo, 1 } }; + var seasonNumberBaseDictionary = new Dictionary<string, int>() { { seasonInfo.Id, 1 } }; var seasonOrderDictionary = new Dictionary<int, SeasonInfo>() { { 1, seasonInfo } }; var seasonNumberOffset = 1; if (seasonInfo.AlternateEpisodesList.Count > 0) @@ -178,10 +178,10 @@ public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool u var defaultSeason = seasonList[foundIndex]; var seasonOrderDictionary = new Dictionary<int, SeasonInfo>(); - var seasonNumberBaseDictionary = new Dictionary<SeasonInfo, int>(); + var seasonNumberBaseDictionary = new Dictionary<string, int>(); var seasonNumberOffset = 0; foreach (var (seasonInfo, index) in seasonList.Select((s, i) => (s, i))) { - seasonNumberBaseDictionary.Add(seasonInfo, ++seasonNumberOffset); + seasonNumberBaseDictionary.Add(seasonInfo.Id, ++seasonNumberOffset); seasonOrderDictionary.Add(seasonNumberOffset, seasonInfo); if (seasonInfo.AlternateEpisodesList.Count > 0) seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); diff --git a/Shokofin/API/Models/ComponentVersion.cs b/Shokofin/API/Models/ComponentVersion.cs new file mode 100644 index 00000000..507ce50e --- /dev/null +++ b/Shokofin/API/Models/ComponentVersion.cs @@ -0,0 +1,44 @@ +using System; +using System.Text.Json.Serialization; + +# nullable enable +namespace Shokofin.API.Models; + +public class ComponentVersionSet +{ + /// <summary> + /// Shoko.Server version. + /// </summary> + public ComponentVersion Server { get; set; } = new(); +} + +public class ComponentVersion +{ + /// <summary> + /// Version number. + /// </summary> + public string Version { get; set; } = string.Empty; + + /// <summary> + /// Commit SHA. + /// </summary> + public string? Commit { get; set; } + + /// <summary> + /// Release channel. + /// </summary> + public ReleaseChannel? ReleaseChannel { get; set; } + + /// <summary> + /// Release date. + /// </summary> + public DateTime? ReleaseDate { get; set; } = null; +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ReleaseChannel +{ + Stable = 1, + Dev = 2, + Debug = 3, +} diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 119d0387..339cb024 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -6,6 +6,7 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Shokofin.API.Models; @@ -21,81 +22,110 @@ public class ShokoAPIClient : IDisposable private readonly ILogger<ShokoAPIClient> Logger; + private static DateTime? ServerCommitDate => + Plugin.Instance.Configuration.HostVersion?.ReleaseDate; + + private static readonly DateTime StableCutOffDate = DateTime.Parse("2023-12-16T00:00:00.000Z"); + + private static bool UseStableAPI => + ServerCommitDate.HasValue && ServerCommitDate.Value < StableCutOffDate; + + private IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions() { + ExpirationScanFrequency = ExpirationScanFrequency, + }); + + private static readonly TimeSpan ExpirationScanFrequency = new(0, 25, 0); + + private static readonly TimeSpan DefaultTimeSpan = new(1, 30, 0); + public ShokoAPIClient(ILogger<ShokoAPIClient> logger) { - _httpClient = (new HttpClient()); - _httpClient.Timeout = TimeSpan.FromMinutes(10); + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromMinutes(10), + }; Logger = logger; } #region Base Implementation + public void Clear(bool restore = true) + { + Logger.LogDebug("Clearing data…"); + _cache.Dispose(); + if (restore) { + Logger.LogDebug("Initialising new cache…"); + _cache = new MemoryCache(new MemoryCacheOptions() { + ExpirationScanFrequency = ExpirationScanFrequency, + }); + } + } + public void Dispose() { + GC.SuppressFinalize(this); _httpClient.Dispose(); + Clear(false); } private Task<ReturnType> Get<ReturnType>(string url, string? apiKey = null) => Get<ReturnType>(url, HttpMethod.Get, apiKey); - private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? apiKey = null) + private Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? apiKey = null) { - var response = await Get(url, method, apiKey); - if (response.StatusCode != HttpStatusCode.OK) - throw ApiException.FromResponse(response); - var responseStream = await response.Content.ReadAsStreamAsync(); - var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream); - if (value == null) - throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); - return value; + var defaultKey = apiKey == null; + var key = $"apiKey={(defaultKey ? "default" : apiKey)},method={method},url={url},value"; + return _cache.GetOrCreate(key, async (cachedEntry) => { + var response = await Get(url, method, apiKey); + if (response.StatusCode != HttpStatusCode.OK) + throw ApiException.FromResponse(response); + var responseStream = await response.Content.ReadAsStreamAsync(); + responseStream.Seek(0, System.IO.SeekOrigin.Begin); + var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream) ?? + throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); + return value; + }); } private async Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null) { // Use the default key if no key was provided. - if (apiKey == null) - apiKey = Plugin.Instance.Configuration.ApiKey; + var defaultKey = apiKey == null; + apiKey ??= Plugin.Instance.Configuration.ApiKey; // Check if we have a key to use. - if (string.IsNullOrEmpty(apiKey)) { + if (string.IsNullOrEmpty(apiKey)) throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); + + var version = Plugin.Instance.Configuration.HostVersion; + if (version == null) + { + version = await GetVersion() + ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); + + Plugin.Instance.Configuration.HostVersion = version; + Plugin.Instance.SaveConfiguration(); } try { - Logger.LogTrace("Trying to get {URL}", url); - var remoteUrl = string.Concat(Plugin.Instance.Configuration.Host, url); + var key = $"apiKey={(defaultKey ? "default" : apiKey)},method={method},url={url},httpRequest"; + return await _cache.GetOrCreateAsync(key, async (cachedEntry) => { + if (cachedEntry.Value is HttpResponseMessage message) + return message; - // Because Shoko Server don't support HEAD requests, we spoof it instead. - if (method == HttpMethod.Head) { - var real = await _httpClient.GetAsync(remoteUrl, HttpCompletionOption.ResponseHeadersRead); - var fake = new HttpResponseMessage(real.StatusCode); - fake.ReasonPhrase = real.ReasonPhrase; - fake.RequestMessage = real.RequestMessage; - if (fake.RequestMessage != null) - fake.RequestMessage.Method = HttpMethod.Head; - fake.Version = real.Version; - fake.Content = (new StringContent(String.Empty)); - fake.Content.Headers.Clear(); - foreach (var pair in real.Content.Headers) { - fake.Content.Headers.Add(pair.Key, pair.Value); - } - fake.Headers.Clear(); - foreach (var pair in real.Headers) { - fake.Headers.Add(pair.Key, pair.Value); - } - real.Dispose(); - return fake; - } + Logger.LogTrace("Trying to get {URL}", url); + var remoteUrl = string.Concat(Plugin.Instance.Configuration.Host, url); - using (var requestMessage = new HttpRequestMessage(method, remoteUrl)) { - requestMessage.Content = (new StringContent("")); + using var requestMessage = new HttpRequestMessage(method, remoteUrl); + requestMessage.Content = new StringContent(string.Empty); requestMessage.Headers.Add("apikey", apiKey); var response = await _httpClient.SendAsync(requestMessage); if (response.StatusCode == HttpStatusCode.Unauthorized) throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); + cachedEntry.SlidingExpiration = DefaultTimeSpan; return response; - } + }); } catch (HttpRequestException ex) { @@ -109,12 +139,11 @@ private Task<ReturnType> Post<Type, ReturnType>(string url, Type body, string? a private async Task<ReturnType> Post<Type, ReturnType>(string url, HttpMethod method, Type body, string? apiKey = null) { - var response = await Post<Type>(url, method, body, apiKey); + var response = await Post(url, method, body, apiKey); if (response.StatusCode != HttpStatusCode.OK) throw ApiException.FromResponse(response); var responseStream = await response.Content.ReadAsStreamAsync(); - var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream); - if (value == null) + var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream) ?? throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); return value; } @@ -122,12 +151,20 @@ private async Task<ReturnType> Post<Type, ReturnType>(string url, HttpMethod met private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method, Type body, string? apiKey = null) { // Use the default key if no key was provided. - if (apiKey == null) - apiKey = Plugin.Instance.Configuration.ApiKey; + apiKey ??= Plugin.Instance.Configuration.ApiKey; // Check if we have a key to use. - if (string.IsNullOrEmpty(apiKey)) { + if (string.IsNullOrEmpty(apiKey)) throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); + + var version = Plugin.Instance.Configuration.HostVersion; + if (version == null) + { + version = await GetVersion() + ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); + + Plugin.Instance.Configuration.HostVersion = version; + Plugin.Instance.SaveConfiguration(); } try { @@ -161,6 +198,16 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method public async Task<ApiKey?> GetApiKey(string username, string password, bool forUser = false) { + var version = Plugin.Instance.Configuration.HostVersion; + if (version == null) + { + version = await GetVersion() + ?? throw new HttpRequestException("Unable to connect to Shoko Server to read the version.", null, HttpStatusCode.BadGateway); + + Plugin.Instance.Configuration.HostVersion = version; + Plugin.Instance.SaveConfiguration(); + } + var postData = JsonSerializer.Serialize(new Dictionary<string, string> { {"user", username}, @@ -170,13 +217,34 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method var apiBaseUrl = Plugin.Instance.Configuration.Host; var response = await _httpClient.PostAsync($"{apiBaseUrl}/api/auth", new StringContent(postData, Encoding.UTF8, "application/json")); if (response.StatusCode == HttpStatusCode.OK) - return (await JsonSerializer.DeserializeAsync<ApiKey>(response.Content.ReadAsStreamAsync().Result)); + return await JsonSerializer.DeserializeAsync<ApiKey>(response.Content.ReadAsStreamAsync().Result); + + return null; + } + + public async Task<ComponentVersion?> GetVersion() + { + var apiBaseUrl = Plugin.Instance.Configuration.Host; + var response = await _httpClient.GetAsync($"{apiBaseUrl}/api/v3/Init/Version"); + if (response.StatusCode == HttpStatusCode.OK) { + try { + var componentVersionSet = await JsonSerializer.DeserializeAsync<ComponentVersionSet>(response.Content.ReadAsStreamAsync().Result); + return componentVersionSet?.Server; + } + catch (Exception e) { + Logger.LogTrace("Unable to connect to Shoko Server to read the version. Exception; {e}", e.Message); + return null; + } + } return null; } public Task<File> GetFile(string id) { + if (UseStableAPI) + return Get<File>($"/api/v3/File/{id}?includeXRefs=true&includeDataFrom=AniDB"); + return Get<File>($"/api/v3/File/{id}?include=XRefs&includeDataFrom=AniDB"); } @@ -187,6 +255,9 @@ public Task<List<File>> GetFileByPath(string path) public async Task<IReadOnlyList<File>> GetFilesForSeries(string seriesId) { + if (UseStableAPI) + return await Get<List<File>>($"/api/v3/Series/{seriesId}/File?&includeXRefs=true&includeDataFrom=AniDB"); + var listResult = await Get<ListResult<File>>($"/api/v3/Series/{seriesId}/File?pageSize=0&include=XRefs&includeDataFrom=AniDB"); return listResult.List; } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 0b6d0090..24edee1a 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -149,7 +149,6 @@ public void Dispose() public void Clear(bool restore = true) { Logger.LogDebug("Clearing data…"); - Logger.LogDebug("Disposing data…"); DataCache.Dispose(); EpisodeIdToEpisodePathDictionary.Clear(); EpisodeIdToSeriesIdDictionary.Clear(); diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index ae786d7e..f8d42f0c 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,6 +1,7 @@ using MediaBrowser.Model.Plugins; using System; using System.Text.Json.Serialization; +using Shokofin.API.Models; using TextSourceType = Shokofin.Utils.Text.TextSourceType; using DisplayLanguageType = Shokofin.Utils.Text.DisplayLanguageType; @@ -8,135 +9,138 @@ using OrderType = Shokofin.Utils.Ordering.OrderType; using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; -namespace Shokofin.Configuration +#nullable enable +namespace Shokofin.Configuration; + +public class PluginConfiguration : BasePluginConfiguration { - public class PluginConfiguration : BasePluginConfiguration - { - public string Host { get; set; } + public string Host { get; set; } - public string PublicHost { get; set; } + public ComponentVersion? HostVersion { get; set; } - [JsonIgnore] - public virtual string PrettyHost - => string.IsNullOrEmpty(PublicHost) ? Host : PublicHost; + public string PublicHost { get; set; } - public string Username { get; set; } + [JsonIgnore] + public virtual string PrettyHost + => string.IsNullOrEmpty(PublicHost) ? Host : PublicHost; - public string ApiKey { get; set; } + public string Username { get; set; } - public bool HideArtStyleTags { get; set; } + public string ApiKey { get; set; } - public bool HideMiscTags { get; set; } + public bool HideArtStyleTags { get; set; } - public bool HidePlotTags { get; set; } + public bool HideMiscTags { get; set; } - public bool HideAniDbTags { get; set; } + public bool HidePlotTags { get; set; } - public bool HideSettingTags { get; set; } + public bool HideAniDbTags { get; set; } - public bool HideProgrammingTags { get; set; } - - public bool HideUnverifiedTags { get; set; } + public bool HideSettingTags { get; set; } - public bool TitleAddForMultipleEpisodes { get; set; } + public bool HideProgrammingTags { get; set; } + + public bool HideUnverifiedTags { get; set; } - public bool SynopsisCleanLinks { get; set; } + public bool TitleAddForMultipleEpisodes { get; set; } - public bool SynopsisCleanMiscLines { get; set; } + public bool SynopsisCleanLinks { get; set; } - public bool SynopsisRemoveSummary { get; set; } + public bool SynopsisCleanMiscLines { get; set; } - public bool SynopsisCleanMultiEmptyLines { get; set; } + public bool SynopsisRemoveSummary { get; set; } - public bool AddAniDBId { get; set; } + public bool SynopsisCleanMultiEmptyLines { get; set; } - public bool AddTMDBId { get; set; } + public bool AddAniDBId { get; set; } - public TextSourceType DescriptionSource { get; set; } + public bool AddTMDBId { get; set; } - public bool VirtualFileSystem { get; set; } + public TextSourceType DescriptionSource { get; set; } - public bool UseGroupsForShows { get; set; } + public bool VirtualFileSystem { get; set; } - public bool SeparateMovies { get; set; } + public bool UseGroupsForShows { get; set; } - public OrderType SeasonOrdering { get; set; } + public bool SeparateMovies { get; set; } - public bool MarkSpecialsWhenGrouped { get; set; } + public OrderType SeasonOrdering { get; set; } - public SpecialOrderType SpecialsPlacement { get; set; } + public bool MarkSpecialsWhenGrouped { get; set; } - public CollectionCreationType CollectionGrouping { get; set; } + public SpecialOrderType SpecialsPlacement { get; set; } - public OrderType MovieOrdering { get; set; } + public CollectionCreationType CollectionGrouping { get; set; } - public DisplayLanguageType TitleMainType { get; set; } + public OrderType MovieOrdering { get; set; } - public DisplayLanguageType TitleAlternateType { get; set; } + public DisplayLanguageType TitleMainType { get; set; } - /// <summary> - /// Allow choosing any title in the selected language if no official - /// title is available. - /// </summary> - public bool TitleAllowAny { get; set; } + public DisplayLanguageType TitleAlternateType { get; set; } - public UserConfiguration[] UserList { get; set; } + /// <summary> + /// Allow choosing any title in the selected language if no official + /// title is available. + /// </summary> + public bool TitleAllowAny { get; set; } - public string[] IgnoredFolders { get; set; } + public UserConfiguration[] UserList { get; set; } - public bool? LibraryFilteringMode { get; set; } + public string[] IgnoredFolders { get; set; } - #region Experimental features + public bool? LibraryFilteringMode { get; set; } - public bool EXPERIMENTAL_AutoMergeVersions { get; set; } + #region Experimental features - public bool EXPERIMENTAL_SplitThenMergeMovies { get; set; } + public bool EXPERIMENTAL_AutoMergeVersions { get; set; } - public bool EXPERIMENTAL_SplitThenMergeEpisodes { get; set; } + public bool EXPERIMENTAL_SplitThenMergeMovies { get; set; } - public bool EXPERIMENTAL_MergeSeasons { get; set; } + public bool EXPERIMENTAL_SplitThenMergeEpisodes { get; set; } - #endregion + public bool EXPERIMENTAL_MergeSeasons { get; set; } - public PluginConfiguration() - { - Host = "http://127.0.0.1:8111"; - PublicHost = ""; - Username = "Default"; - ApiKey = ""; - HideArtStyleTags = false; - HideMiscTags = false; - HidePlotTags = true; - HideAniDbTags = true; - HideSettingTags = false; - HideProgrammingTags = true; - HideUnverifiedTags = true; - TitleAddForMultipleEpisodes = true; - SynopsisCleanLinks = true; - SynopsisCleanMiscLines = true; - SynopsisRemoveSummary = true; - SynopsisCleanMultiEmptyLines = true; - AddAniDBId = true; - AddTMDBId = true; - TitleMainType = DisplayLanguageType.Default; - TitleAlternateType = DisplayLanguageType.Origin; - TitleAllowAny = false; - DescriptionSource = TextSourceType.Default; - VirtualFileSystem = true; - UseGroupsForShows = false; - SeparateMovies = true; - SeasonOrdering = OrderType.Default; - SpecialsPlacement = SpecialOrderType.AfterSeason; - MarkSpecialsWhenGrouped = true; - CollectionGrouping = CollectionCreationType.None; - MovieOrdering = OrderType.Default; - UserList = Array.Empty<UserConfiguration>(); - IgnoredFolders = new [] { ".streams", "@recently-snapshot" }; - LibraryFilteringMode = null; - EXPERIMENTAL_AutoMergeVersions = false; - EXPERIMENTAL_SplitThenMergeMovies = true; - EXPERIMENTAL_SplitThenMergeEpisodes = false; - EXPERIMENTAL_MergeSeasons = false; - } + #endregion + + public PluginConfiguration() + { + Host = "http://127.0.0.1:8111"; + HostVersion = null; + PublicHost = ""; + Username = "Default"; + ApiKey = ""; + HideArtStyleTags = false; + HideMiscTags = false; + HidePlotTags = true; + HideAniDbTags = true; + HideSettingTags = false; + HideProgrammingTags = true; + HideUnverifiedTags = true; + TitleAddForMultipleEpisodes = true; + SynopsisCleanLinks = true; + SynopsisCleanMiscLines = true; + SynopsisRemoveSummary = true; + SynopsisCleanMultiEmptyLines = true; + AddAniDBId = true; + AddTMDBId = true; + TitleMainType = DisplayLanguageType.Default; + TitleAlternateType = DisplayLanguageType.Origin; + TitleAllowAny = false; + DescriptionSource = TextSourceType.Default; + VirtualFileSystem = true; + UseGroupsForShows = false; + SeparateMovies = false; + SeasonOrdering = OrderType.Default; + SpecialsPlacement = SpecialOrderType.AfterSeason; + MarkSpecialsWhenGrouped = true; + CollectionGrouping = CollectionCreationType.None; + MovieOrdering = OrderType.Default; + UserList = Array.Empty<UserConfiguration>(); + IgnoredFolders = new [] { ".streams", "@recently-snapshot" }; + LibraryFilteringMode = null; + EXPERIMENTAL_AutoMergeVersions = false; + EXPERIMENTAL_SplitThenMergeMovies = true; + EXPERIMENTAL_SplitThenMergeEpisodes = false; + EXPERIMENTAL_MergeSeasons = false; } } diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 492ac293..7b68e15d 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -97,7 +97,7 @@ function getApiKey(username, password, userKey = false) { } async function defaultSubmit(form) { - const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + let config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); if (config.ApiKey !== "") { let publicHost = form.querySelector("#PublicHost").value; @@ -230,9 +230,10 @@ async function defaultSubmit(form) { 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; - + let result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); Dashboard.processPluginConfigurationUpdateResult(result); } @@ -252,6 +253,7 @@ async function resetConnectionSettings(form) { // Connection settings config.ApiKey = ""; + config.HostVersion = null; const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); Dashboard.processPluginConfigurationUpdateResult(result); @@ -386,6 +388,20 @@ export default function (page) { const userSelector = form.querySelector("#UserSelector"); // Refresh the view after we changed the settings, so the view reflect the new settings. const refreshSettings = (config) => { + if (config.HostVersion) { + let version = `Version ${config.HostVersion.Version}`; + const extraDetails = [ + config.HostVersion.ReleaseChannel || "", + config.HostVersion. + Commit ? config.HostVersion.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 (config.ApiKey) { form.querySelector("#Host").setAttribute("disabled", ""); form.querySelector("#Username").setAttribute("disabled", ""); diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index a0e22c9a..19bdde9f 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -30,6 +30,10 @@ <h3>Connection Settings</h3> <div class="fieldDescription">Establish a connection to Shoko Server 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:" disabled 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"> <span>Reset Connection</span> </button> @@ -160,7 +164,9 @@ <h3>Library Settings</h3> <div>Enables the use of the Virtual File System™ for any media libraries managed by the plugin.</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 imcompatibilities, 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> + 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> @@ -206,7 +212,7 @@ <h3>Library Settings</h3> <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 sepeatated the movies from the shows. In that case the + 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 infomation look up the diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index 33c225b0..1aff10cc 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -277,7 +277,8 @@ private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs e) if (!(Lookup.TryGetSeriesIdFor(season.Series, out var seriesId) && (e.Parent is Series series))) return; - UpdateSeason(season, series, seriesId, true); + if (season.IndexNumber.HasValue) + UpdateSeason(season, series, seriesId, true); return; } @@ -391,7 +392,7 @@ private void UpdateSeason(Season season, Series series, string seriesId, bool de return; } - var offset = seasonNumber - showInfo.SeasonNumberBaseDictionary[seasonInfo]; + var offset = seasonNumber - showInfo.SeasonNumberBaseDictionary[seasonInfo.Id]; if (deleted) season = AddVirtualSeason(seasonInfo, offset, seasonNumber, series); @@ -464,7 +465,7 @@ private void UpdateEpisode(Episode episode, string episodeId) continue; if (pair.Value.SpecialsList.Count > 0) hasSpecials = true; - var offset = pair.Key - showInfo.SeasonNumberBaseDictionary[pair.Value]; + var offset = pair.Key - showInfo.SeasonNumberBaseDictionary[pair.Value.Id]; var season = AddVirtualSeason(pair.Value, offset, pair.Key, series); if (season == null) continue; diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 9754cd93..05e43355 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -50,7 +50,7 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat } var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); - if (seasonInfo == null || !showInfo.SeasonNumberBaseDictionary.TryGetValue(seasonInfo, out var baseSeasonNumber)) { + if (seasonInfo == null || !showInfo.SeasonNumberBaseDictionary.TryGetValue(seasonInfo.Id, out var baseSeasonNumber)) { Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Series={SeriesId},Group={GroupId})", seasonNumber, seriesId, showInfo.GroupId); return result; } diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 9e17f679..5b33afed 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Emby.Naming.Common; using MediaBrowser.Controller.Entities; @@ -39,10 +41,12 @@ public class ShokoResolveManager private readonly NamingOptions _namingOptions; - private readonly IMemoryCache DataCache = new MemoryCache(new MemoryCacheOptions() { - ExpirationScanFrequency = TimeSpan.FromMinutes(50), + private IMemoryCache DataCache = new MemoryCache(new MemoryCacheOptions() { + ExpirationScanFrequency = ExpirationScanFrequency, }); + private static readonly TimeSpan ExpirationScanFrequency = new(0, 25, 0); + private static readonly TimeSpan DefaultTTL = TimeSpan.FromMinutes(60); public ShokoResolveManager(ShokoAPIManager apiManager, ShokoAPIClient apiClient, IIdLookup lookup, ILibraryManager libraryManager, IFileSystem fileSystem, ILogger<ShokoResolveManager> logger, NamingOptions namingOptions) @@ -60,7 +64,19 @@ public ShokoResolveManager(ShokoAPIManager apiManager, ShokoAPIClient apiClient, ~ShokoResolveManager() { LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; + Clear(false); + } + + public void Clear(bool restore = true) + { + Logger.LogDebug("Clearing data…"); DataCache.Dispose(); + if (restore) { + Logger.LogDebug("Initialising new cache…"); + DataCache = new MemoryCache(new MemoryCacheOptions() { + ExpirationScanFrequency = ExpirationScanFrequency, + }); + } } #region Changes Tracking @@ -126,10 +142,10 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) foreach (var path in allPaths) { var partialPath = path[mediaFolder.Path.Length..]; var partialFolderPath = path[folderPath.Length..]; - var file = ApiClient.GetFileByPath(partialPath) + var files = ApiClient.GetFileByPath(partialPath) .GetAwaiter() - .GetResult() - .FirstOrDefault(); + .GetResult(); + var file = files.FirstOrDefault(); if (file == null) continue; @@ -146,7 +162,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) break; } - if (importFolderId != 0) { + if (importFolderId == 0) { Logger.LogDebug("Failed to find a match for folder at {Path} after {Amount} attempts.", folderPath, allPaths.Count); DataCache.Set<string?>(folderPath, null, DefaultTTL); @@ -165,31 +181,44 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)> files) { - var allPathsForVFS = (await Task.WhenAll(files.AsParallel().Select((tuple) => GenerateLocationForFile(mediaFolder, tuple.sourceLocation, tuple.fileId, tuple.seriesId, tuple.episodeIds)).ToList())) - .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation)) - .OrderBy(tuple => tuple.sourceLocation) - .ThenBy(tuple => tuple.symbolicLink) - .ToList(); - var skipped = 0; - var created = 0; - foreach (var (sourceLocation, symbolicLink) in allPathsForVFS) { - if (File.Exists(symbolicLink)) { - skipped++; - continue; - } + var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); + var allPathsForVFS = new ConcurrentBag<(string sourceLocation, string symbolicLink)>(); + var semaphore = new SemaphoreSlim(10); + await Task.WhenAll(files + .AsParallel() + .Select(async (tuple) => { + await semaphore.WaitAsync(); + + try { + var (sourceLocation, symbolicLink) = await GenerateLocationForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId, tuple.episodeIds); + // Skip any source files we weren't meant to have in the library. + if (string.IsNullOrEmpty(sourceLocation)) + return; + + if (File.Exists(symbolicLink)) { + skipped++; + allPathsForVFS.Add((sourceLocation, symbolicLink)); + return; + } - var symbolicDirectory = Path.GetDirectoryName(symbolicLink); - if (!string.IsNullOrEmpty(symbolicDirectory) && !Directory.Exists(symbolicDirectory)) - Directory.CreateDirectory(symbolicDirectory); + // TODO: Check for subtitle files. - created++; - File.CreateSymbolicLink(symbolicLink, sourceLocation); + var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; + if (!Directory.Exists(symbolicDirectory)) + Directory.CreateDirectory(symbolicDirectory); - // TODO: Check for subtitle files. - } - var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); - var toBeRemoved = FileSystem.GetFilePaths(vfsPath, true) + allPathsForVFS.Add((sourceLocation, symbolicLink)); + File.CreateSymbolicLink(symbolicLink, sourceLocation); + } + finally { + semaphore.Release(); + } + }) + .ToList()); + + var toBeRemoved = FileSystem.GetFilePaths(ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder), true) .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) .Except(allPathsForVFS.Select(tuple => tuple.symbolicLink).ToHashSet()) .ToList(); @@ -197,24 +226,29 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string // TODO: Check for subtitle files. File.Delete(symbolicLink); - var symbolicDirectory = Path.GetDirectoryName(symbolicLink); - if (!string.IsNullOrEmpty(symbolicDirectory) && Directory.Exists(symbolicDirectory) && !Directory.EnumerateFileSystemEntries(symbolicDirectory).Any()) - Directory.Delete(symbolicDirectory); + CleanupDirectoryStructure(symbolicLink); } Logger.LogDebug( "Created {CreatedCount}, skipped {SkippedCount}, and removed {RemovedCount} symbolic links for media folder at {Path}", - created, + allPathsForVFS.Count - skipped, skipped, toBeRemoved.Count, mediaFolder.Path ); } - private async Task<(string sourceLocation, string symbolicLink)> GenerateLocationForFile(Folder mediaFolder, string sourceLocation, string fileId, string seriesId, string[] episodeIds) + private static void CleanupDirectoryStructure(string? path) + { + path = Path.GetDirectoryName(path); + while (!string.IsNullOrEmpty(path) && Directory.Exists(path) && !Directory.EnumerateFileSystemEntries(path).Any()) { + Directory.Delete(path); + path = Path.GetDirectoryName(path); + } + } + + private async Task<(string sourceLocation, string symbolicLink)> GenerateLocationForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId, string[] episodeIds) { - var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); - var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); var season = await ApiManager.GetSeasonInfoForSeries(seriesId); if (season == null) return (sourceLocation: string.Empty, symbolicLink: string.Empty); @@ -244,20 +278,12 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string else if (show.DefaultSeason.AniDB.AirDate.HasValue) showName += $" ({show.DefaultSeason.AniDB.AirDate.Value.Year})"; - var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); - var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); var isSpecial = episode.IsSpecial; - - var paths = new List<string>() - { - vfsPath, - $"{showName} [shoko-series-{show.Id}]", - $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}", - }; - var episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber}"; + var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); + var paths = new List<string>() { vfsPath, $"{showName} [shoko-series-{show.Id}]", $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}" }; + var episodeName = episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode == "en")?.Value ?? $"Episode {episode.AniDB.Type} {episode.AniDB.EpisodeNumber}"; if (file.ExtraType != null) { - episodeName = episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode == "en")?.Value ?? $"Episode {episode.AniDB.Type} {episode.AniDB.EpisodeNumber}"; var extrasFolder = file.ExtraType switch { ExtraType.BehindTheScenes => "behind the scenes", ExtraType.Clip => "clips", @@ -273,6 +299,10 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string }; paths.Add(extrasFolder); } + else { + var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); + episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber}"; + } var isMovieSeason = season.Type == SeriesType.Movie; switch (collectionType) { @@ -295,7 +325,7 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string return (sourceLocation, symbolicLink); } default: { - if (isMovieSeason && collectionType == null) + if (isMovieSeason && collectionType == null && Plugin.Instance.Configuration.SeparateMovies) goto case CollectionType.Movies; paths.Add($"{episodeName} [shoko-series-{seriesId}] [shoko-file-{fileId}{Path.GetExtension(sourceLocation)}]"); @@ -316,7 +346,7 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file return false; var root = LibraryManager.RootFolder; - if (root == null || parent == root) + if (root == null || parent == root || parent.ParentId == root.Id) return false; try { @@ -442,7 +472,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou #region Resolvers - public BaseItem? ResolveSingle(Folder? parent, string? collectionType, FileSystemMetadata fileInfo) + public async Task<BaseItem?> ResolveSingle(Folder? parent, string? collectionType, FileSystemMetadata fileInfo) { if (!Plugin.Instance.Configuration.VirtualFileSystem) return null; @@ -467,10 +497,10 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou if (!fullPath.StartsWith(Plugin.Instance.VirtualRoot)) return null; - var searchPath = Path.Combine(mediaFolder.Path, parent.Path[(mediaFolder.Path.Length + 1)..].Split(Path.DirectorySeparatorChar).Skip(1).Join(Path.DirectorySeparatorChar)); - var vfsPath = GenerateStructureForFolder(mediaFolder, searchPath) - .GetAwaiter() - .GetResult(); + var searchPath = mediaFolder.Path != parent.Path + ? Path.Combine(mediaFolder.Path, parent.Path[(mediaFolder.Path.Length + 1)..].Split(Path.DirectorySeparatorChar).Skip(1).Join(Path.DirectorySeparatorChar)) + : mediaFolder.Path; + var vfsPath = await GenerateStructureForFolder(mediaFolder, searchPath); if (string.IsNullOrEmpty(vfsPath)) return null; @@ -497,7 +527,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou } } - public MultiItemResolverResult? ResolveMultiple(Folder? parent, string? collectionType, List<FileSystemMetadata> fileInfoList) + public async Task<MultiItemResolverResult?> ResolveMultiple(Folder? parent, string? collectionType, List<FileSystemMetadata> fileInfoList) { if (!Plugin.Instance.Configuration.VirtualFileSystem) return null; @@ -514,17 +544,17 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou return null; // Redirect children of a VFS managed media folder to the VFS. - if (parent.GetParent() == root) { - var vfsPath = GenerateStructureForFolder(parent, parent.Path) - .GetAwaiter() - .GetResult(); + if (parent.ParentId == root.Id) { + var vfsPath = await GenerateStructureForFolder(parent, parent.Path); if (string.IsNullOrEmpty(vfsPath)) return null; + var createMovies = collectionType == CollectionType.Movies || (collectionType == null && Plugin.Instance.Configuration.SeparateMovies); var items = FileSystem.GetDirectories(vfsPath) .AsParallel() .SelectMany(dirInfo => { - if (!int.TryParse(dirInfo.Name.Split('-').LastOrDefault(), out var seriesId)) + var seriesSegment = dirInfo.Name.Split('[').Last().Split(']').First(); + if (!int.TryParse(seriesSegment.Split('-').LastOrDefault(), out var seriesId)) return Array.Empty<BaseItem>(); var season = ApiManager.GetSeasonInfoForSeries(seriesId.ToString()) @@ -533,7 +563,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou if (season == null) return Array.Empty<BaseItem>(); - if ((collectionType == CollectionType.Movies || collectionType == null) && season.Type == SeriesType.Movie) { + if (createMovies && season.Type == SeriesType.Movie) { return FileSystem.GetFiles(dirInfo.FullName) .AsParallel() .Select(fileInfo => { @@ -541,7 +571,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou return null; // This will hopefully just re-use the pre-cached entries from the cache, but it may - // also get it from remote if the cache was empty for whatever reason. + // also get it from remote if the cache was emptied for whatever reason. var file = ApiManager.GetFileInfo(fileId.ToString(), seriesId.ToString()) .GetAwaiter() .GetResult(); @@ -578,6 +608,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou fileInfoList.Clear(); fileInfoList.AddRange(items.Select(s => FileSystem.GetFileSystemInfo(s.Path))); } + return new() { Items = items.Where(i => i is Movie).ToList(), ExtraFiles = items.OfType<TvSeries>().Select(s => FileSystem.GetFileSystemInfo(s.Path)).ToList() }; } diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs index 3e9f5acb..d169e069 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -22,12 +22,19 @@ public ShokoResolver(ShokoResolveManager resolveManager) public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent) => ResolveManager.ShouldFilterItem(parent as Folder, fileInfo) + .ConfigureAwait(false) .GetAwaiter() .GetResult(); public BaseItem? ResolvePath(ItemResolveArgs args) - => ResolveManager.ResolveSingle(args.Parent, args.CollectionType, args.FileInfo); + => ResolveManager.ResolveSingle(args.Parent, args.CollectionType, args.FileInfo) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); public MultiItemResolverResult? ResolveMultiple(Folder parent, List<FileSystemMetadata> files, string collectionType, IDirectoryService directoryService) - => ResolveManager.ResolveMultiple(parent, collectionType, files); + => ResolveManager.ResolveMultiple(parent, collectionType, files) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); } diff --git a/Shokofin/Tasks/ClearPluginCacheTask.cs b/Shokofin/Tasks/ClearPluginCacheTask.cs index 099c4610..3c38c66e 100644 --- a/Shokofin/Tasks/ClearPluginCacheTask.cs +++ b/Shokofin/Tasks/ClearPluginCacheTask.cs @@ -2,75 +2,72 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; -using MediaBrowser.Model.Globalization; -using Shokofin.Sync; using Shokofin.API; +using Shokofin.Resolvers; -namespace Shokofin.Tasks +#nullable enable +namespace Shokofin.Tasks; + +/// <summary> +/// Class ClearPluginCacheTask. +/// </summary> +public class ClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTask { - /// <summary> - /// Class ClearPluginCacheTask. - /// </summary> - public class ClearPluginCacheTask : IScheduledTask - { - /// <summary> - /// The _library manager. - /// </summary> - private readonly ShokoAPIManager APIManager; + /// <inheritdoc /> + public string Name => "Clear Plugin Cache"; - /// <summary> - /// Initializes a new instance of the <see cref="ClearPluginCacheTask" /> class. - /// </summary> - public ClearPluginCacheTask(ShokoAPIManager apiManager) - { - APIManager = apiManager; - } + /// <inheritdoc /> + public string Description => "For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. Clear the plugin cache."; - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - return new TaskTriggerInfo[0]; - } + /// <inheritdoc /> + public string Category => "Shokofin"; - /// <summary> - /// Returns the task to be executed. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <param name="progress">The progress.</param> - /// <returns>Task.</returns> - public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - APIManager.Clear(); - return Task.CompletedTask; - } + /// <inheritdoc /> + public string Key => "ShokoClearPluginCache"; - /// <inheritdoc /> - public string Name => "Clear Plugin Cache"; + /// <inheritdoc /> + public bool IsHidden => false; - /// <inheritdoc /> - public string Description => "For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. Clear the plugin cache."; + /// <inheritdoc /> + public bool IsEnabled => false; - /// <inheritdoc /> - public string Category => "Shokofin"; + /// <inheritdoc /> + public bool IsLogged => true; - /// <inheritdoc /> - public string Key => "ShokoClearPluginCache"; + private readonly ShokoAPIManager ApiManager; - /// <inheritdoc /> - public bool IsHidden => false; + private readonly ShokoAPIClient ApiClient; - /// <inheritdoc /> - public bool IsEnabled => false; + private readonly ShokoResolveManager ResolveManager; - /// <inheritdoc /> - public bool IsLogged => true; + /// <summary> + /// Initializes a new instance of the <see cref="ClearPluginCacheTask" /> class. + /// </summary> + public ClearPluginCacheTask(ShokoAPIManager apiManager, ShokoAPIClient apiClient, ShokoResolveManager resolveManager) + { + ApiManager = apiManager; + ApiClient = apiClient; + ResolveManager = resolveManager; + } + + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + 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> + public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + ApiClient.Clear(); + ApiManager.Clear(); + ResolveManager.Clear(); + return Task.CompletedTask; } } diff --git a/Shokofin/Tasks/ExportUserDataTask.cs b/Shokofin/Tasks/ExportUserDataTask.cs index 8357a48b..dafe1b41 100644 --- a/Shokofin/Tasks/ExportUserDataTask.cs +++ b/Shokofin/Tasks/ExportUserDataTask.cs @@ -2,73 +2,65 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; -using MediaBrowser.Model.Globalization; using Shokofin.Sync; -namespace Shokofin.Tasks +#nullable enable +namespace Shokofin.Tasks; + +/// <summary> +/// Class ExportUserDataTask. +/// </summary> +public class ExportUserDataTask : IScheduledTask, IConfigurableScheduledTask { - /// <summary> - /// Class ExportUserDataTask. - /// </summary> - public class ExportUserDataTask : IScheduledTask - { - /// <summary> - /// The _library manager. - /// </summary> - private readonly UserDataSyncManager _userSyncManager; + /// <inheritdoc /> + public string Name => "Export User Data"; - /// <summary> - /// Initializes a new instance of the <see cref="ExportUserDataTask" /> class. - /// </summary> - public ExportUserDataTask(UserDataSyncManager userSyncManager) - { - _userSyncManager = userSyncManager; - } + /// <inheritdoc /> + public string Description => "Export the user-data stored in Jellyfin to Shoko. Will not import user-data from Shoko."; - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - return new TaskTriggerInfo[0]; - } + /// <inheritdoc /> + public string Category => "Shokofin"; - /// <summary> - /// Returns the task to be executed. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <param name="progress">The progress.</param> - /// <returns>Task.</returns> - public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - await _userSyncManager.ScanAndSync(SyncDirection.Export, progress, cancellationToken); - } + /// <inheritdoc /> + public string Key => "ShokoExportUserData"; - /// <inheritdoc /> - public string Name => "Export User Data"; + /// <inheritdoc /> + public bool IsHidden => false; - /// <inheritdoc /> - public string Description => "Export the user-data stored in Jellyfin to Shoko. Will not import user-data from Shoko."; + /// <inheritdoc /> + public bool IsEnabled => false; - /// <inheritdoc /> - public string Category => "Shokofin"; + /// <inheritdoc /> + public bool IsLogged => true; - /// <inheritdoc /> - public string Key => "ShokoExportUserData"; + /// <summary> + /// The _library manager. + /// </summary> + private readonly UserDataSyncManager _userSyncManager; - /// <inheritdoc /> - public bool IsHidden => false; + /// <summary> + /// Initializes a new instance of the <see cref="ExportUserDataTask" /> class. + /// </summary> + public ExportUserDataTask(UserDataSyncManager userSyncManager) + { + _userSyncManager = userSyncManager; + } - /// <inheritdoc /> - public bool IsEnabled => false; + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); - /// <inheritdoc /> - public bool IsLogged => true; + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await _userSyncManager.ScanAndSync(SyncDirection.Export, progress, cancellationToken); } } diff --git a/Shokofin/Tasks/ImportUserDataTask.cs b/Shokofin/Tasks/ImportUserDataTask.cs index bec3c6df..6d836366 100644 --- a/Shokofin/Tasks/ImportUserDataTask.cs +++ b/Shokofin/Tasks/ImportUserDataTask.cs @@ -2,73 +2,65 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; -using MediaBrowser.Model.Globalization; using Shokofin.Sync; -namespace Shokofin.Tasks +#nullable enable +namespace Shokofin.Tasks; + +/// <summary> +/// Class ImportUserDataTask. +/// </summary> +public class ImportUserDataTask : IScheduledTask, IConfigurableScheduledTask { - /// <summary> - /// Class ImportUserDataTask. - /// </summary> - public class ImportUserDataTask : IScheduledTask - { - /// <summary> - /// The _library manager. - /// </summary> - private readonly UserDataSyncManager _userSyncManager; + /// <inheritdoc /> + public string Name => "Import User Data"; - /// <summary> - /// Initializes a new instance of the <see cref="ImportUserDataTask" /> class. - /// </summary> - public ImportUserDataTask(UserDataSyncManager userSyncManager) - { - _userSyncManager = userSyncManager; - } + /// <inheritdoc /> + public string Description => "Import the user-data stored in Shoko to Jellyfin. Will not export user-data to Shoko."; - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - return new TaskTriggerInfo[0]; - } + /// <inheritdoc /> + public string Category => "Shokofin"; - /// <summary> - /// Returns the task to be executed. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <param name="progress">The progress.</param> - /// <returns>Task.</returns> - public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - await _userSyncManager.ScanAndSync(SyncDirection.Import, progress, cancellationToken); - } + /// <inheritdoc /> + public string Key => "ShokoImportUserData"; - /// <inheritdoc /> - public string Name => "Import User Data"; + /// <inheritdoc /> + public bool IsHidden => false; - /// <inheritdoc /> - public string Description => "Import the user-data stored in Shoko to Jellyfin. Will not export user-data to Shoko."; + /// <inheritdoc /> + public bool IsEnabled => false; - /// <inheritdoc /> - public string Category => "Shokofin"; + /// <inheritdoc /> + public bool IsLogged => true; - /// <inheritdoc /> - public string Key => "ShokoImportUserData"; + /// <summary> + /// The _library manager. + /// </summary> + private readonly UserDataSyncManager _userSyncManager; - /// <inheritdoc /> - public bool IsHidden => false; + /// <summary> + /// Initializes a new instance of the <see cref="ImportUserDataTask" /> class. + /// </summary> + public ImportUserDataTask(UserDataSyncManager userSyncManager) + { + _userSyncManager = userSyncManager; + } - /// <inheritdoc /> - public bool IsEnabled => false; + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); - /// <inheritdoc /> - public bool IsLogged => true; + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await _userSyncManager.ScanAndSync(SyncDirection.Import, progress, cancellationToken); } } diff --git a/Shokofin/Tasks/MergeAllTask.cs b/Shokofin/Tasks/MergeAllTask.cs index 7a771313..4496625b 100644 --- a/Shokofin/Tasks/MergeAllTask.cs +++ b/Shokofin/Tasks/MergeAllTask.cs @@ -5,64 +5,62 @@ using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; -namespace Shokofin.Tasks +#nullable enable +namespace Shokofin.Tasks; + +/// <summary> +/// Class MergeAllTask. +/// </summary> +public class MergeAllTask : IScheduledTask, IConfigurableScheduledTask { /// <summary> - /// Class MergeAllTask. + /// The merge-versions manager. /// </summary> - public class MergeAllTask : IScheduledTask - { - /// <summary> - /// The merge-versions manager. - /// </summary> - private readonly MergeVersionsManager VersionsManager; + private readonly MergeVersionsManager VersionsManager; - /// <summary> - /// Initializes a new instance of the <see cref="MergeAllTask" /> class. - /// </summary> - public MergeAllTask(MergeVersionsManager userSyncManager) - { - VersionsManager = userSyncManager; - } + /// <summary> + /// Initializes a new instance of the <see cref="MergeAllTask" /> class. + /// </summary> + public MergeAllTask(MergeVersionsManager userSyncManager) + { + VersionsManager = userSyncManager; + } - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - return new TaskTriggerInfo[0]; - } + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + 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> - public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - await VersionsManager.MergeAll(progress, cancellationToken); - } + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await VersionsManager.MergeAll(progress, cancellationToken); + } - /// <inheritdoc /> - public string Name => "Merge both movies and episodes"; + /// <inheritdoc /> + public string Name => "Merge both movies and episodes"; - /// <inheritdoc /> - public string Description => "Merge all movie and episode entries with the same Shoko Episode ID set."; + /// <inheritdoc /> + public string Description => "Merge all movie and episode entries with the same Shoko Episode ID set."; - /// <inheritdoc /> - public string Category => "Shokofin"; + /// <inheritdoc /> + public string Category => "Shokofin"; - /// <inheritdoc /> - public string Key => "ShokoMergeAll"; + /// <inheritdoc /> + public string Key => "ShokoMergeAll"; - /// <inheritdoc /> - public bool IsHidden => false; + /// <inheritdoc /> + public bool IsHidden => false; - /// <inheritdoc /> - public bool IsEnabled => false; + /// <inheritdoc /> + public bool IsEnabled => false; - /// <inheritdoc /> - public bool IsLogged => true; - } + /// <inheritdoc /> + public bool IsLogged => true; } diff --git a/Shokofin/Tasks/MergeEpisodesTask.cs b/Shokofin/Tasks/MergeEpisodesTask.cs index dec3cff0..86e5dcfb 100644 --- a/Shokofin/Tasks/MergeEpisodesTask.cs +++ b/Shokofin/Tasks/MergeEpisodesTask.cs @@ -5,64 +5,62 @@ using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; -namespace Shokofin.Tasks +#nullable enable +namespace Shokofin.Tasks; + +/// <summary> +/// Class MergeEpisodesTask. +/// </summary> +public class MergeEpisodesTask : IScheduledTask, IConfigurableScheduledTask { - /// <summary> - /// Class MergeEpisodesTask. - /// </summary> - public class MergeEpisodesTask : IScheduledTask - { - /// <summary> - /// The merge-versions manager. - /// </summary> - private readonly MergeVersionsManager VersionsManager; + /// <inheritdoc /> + public string Name => "Merge episodes"; - /// <summary> - /// Initializes a new instance of the <see cref="MergeEpisodesTask" /> class. - /// </summary> - public MergeEpisodesTask(MergeVersionsManager userSyncManager) - { - VersionsManager = userSyncManager; - } + /// <inheritdoc /> + public string Description => "Merge all episode entries with the same Shoko Episode ID set."; - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - return new TaskTriggerInfo[0]; - } + /// <inheritdoc /> + public string Category => "Shokofin"; - /// <summary> - /// Returns the task to be executed. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <param name="progress">The progress.</param> - /// <returns>Task.</returns> - public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - await VersionsManager.MergeAllEpisodes(progress, cancellationToken); - } + /// <inheritdoc /> + public string Key => "ShokoMergeEpisodes"; - /// <inheritdoc /> - public string Name => "Merge episodes"; + /// <inheritdoc /> + public bool IsHidden => false; - /// <inheritdoc /> - public string Description => "Merge all episode entries with the same Shoko Episode ID set."; + /// <inheritdoc /> + public bool IsEnabled => false; - /// <inheritdoc /> - public string Category => "Shokofin"; + /// <inheritdoc /> + public bool IsLogged => true; - /// <inheritdoc /> - public string Key => "ShokoMergeEpisodes"; + /// <summary> + /// The merge-versions manager. + /// </summary> + private readonly MergeVersionsManager VersionsManager; - /// <inheritdoc /> - public bool IsHidden => false; + /// <summary> + /// Initializes a new instance of the <see cref="MergeEpisodesTask" /> class. + /// </summary> + public MergeEpisodesTask(MergeVersionsManager userSyncManager) + { + VersionsManager = userSyncManager; + } - /// <inheritdoc /> - public bool IsEnabled => false; + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); - /// <inheritdoc /> - public bool IsLogged => true; + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await VersionsManager.MergeAllEpisodes(progress, cancellationToken); } } diff --git a/Shokofin/Tasks/MergeMoviesTask.cs b/Shokofin/Tasks/MergeMoviesTask.cs index 86c8c267..2d016b3f 100644 --- a/Shokofin/Tasks/MergeMoviesTask.cs +++ b/Shokofin/Tasks/MergeMoviesTask.cs @@ -5,64 +5,62 @@ using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; -namespace Shokofin.Tasks +#nullable enable +namespace Shokofin.Tasks; + +/// <summary> +/// Class MergeMoviesTask. +/// </summary> +public class MergeMoviesTask : IScheduledTask, IConfigurableScheduledTask { - /// <summary> - /// Class MergeMoviesTask. - /// </summary> - public class MergeMoviesTask : IScheduledTask - { - /// <summary> - /// The merge-versions manager. - /// </summary> - private readonly MergeVersionsManager VersionsManager; + /// <inheritdoc /> + public string Name => "Merge movies"; - /// <summary> - /// Initializes a new instance of the <see cref="MergeMoviesTask" /> class. - /// </summary> - public MergeMoviesTask(MergeVersionsManager userSyncManager) - { - VersionsManager = userSyncManager; - } + /// <inheritdoc /> + public string Description => "Merge all movie entries with the same Shoko Episode ID set."; - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - return new TaskTriggerInfo[0]; - } + /// <inheritdoc /> + public string Category => "Shokofin"; - /// <summary> - /// Returns the task to be executed. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <param name="progress">The progress.</param> - /// <returns>Task.</returns> - public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - await VersionsManager.MergeAllMovies(progress, cancellationToken); - } + /// <inheritdoc /> + public string Key => "ShokoMergeMovies"; - /// <inheritdoc /> - public string Name => "Merge movies"; + /// <inheritdoc /> + public bool IsHidden => false; - /// <inheritdoc /> - public string Description => "Merge all movie entries with the same Shoko Episode ID set."; + /// <inheritdoc /> + public bool IsEnabled => false; - /// <inheritdoc /> - public string Category => "Shokofin"; + /// <inheritdoc /> + public bool IsLogged => true; - /// <inheritdoc /> - public string Key => "ShokoMergeMovies"; + /// <summary> + /// The merge-versions manager. + /// </summary> + private readonly MergeVersionsManager VersionsManager; - /// <inheritdoc /> - public bool IsHidden => false; + /// <summary> + /// Initializes a new instance of the <see cref="MergeMoviesTask" /> class. + /// </summary> + public MergeMoviesTask(MergeVersionsManager userSyncManager) + { + VersionsManager = userSyncManager; + } - /// <inheritdoc /> - public bool IsEnabled => false; + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); - /// <inheritdoc /> - public bool IsLogged => true; + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await VersionsManager.MergeAllMovies(progress, cancellationToken); } } diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs index 57f433f7..07a79fa1 100644 --- a/Shokofin/Tasks/PostScanTask.cs +++ b/Shokofin/Tasks/PostScanTask.cs @@ -7,48 +7,48 @@ using System.Threading; using System.Threading.Tasks; -namespace Shokofin.Tasks +#nullable enable +namespace Shokofin.Tasks; + +public class PostScanTask : ILibraryPostScanTask { - public class PostScanTask : ILibraryPostScanTask + private readonly ShokoAPIManager ApiManager; + + private readonly MergeVersionsManager VersionsManager; + + private readonly CollectionManager CollectionManager; + + public PostScanTask(ShokoAPIManager apiManager, MergeVersionsManager versionsManager, CollectionManager collectionManager) { - private readonly ShokoAPIManager ApiManager; + ApiManager = apiManager; + VersionsManager = versionsManager; + CollectionManager = collectionManager; + } - private readonly MergeVersionsManager VersionsManager; + public async Task Run(IProgress<double> progress, CancellationToken token) + { + // Merge versions now if the setting is enabled. + if (Plugin.Instance.Configuration.EXPERIMENTAL_AutoMergeVersions) { + // Setup basic progress tracking + var baseProgress = 0d; + var simpleProgress = new ActionableProgress<double>(); + simpleProgress.RegisterAction(value => progress.Report(baseProgress + (value / 2d))); - private readonly CollectionManager CollectionManager; + // Merge versions. + await VersionsManager.MergeAll(simpleProgress, token); - public PostScanTask(ShokoAPIManager apiManager, MergeVersionsManager versionsManager, CollectionManager collectionManager) - { - ApiManager = apiManager; - VersionsManager = versionsManager; - CollectionManager = collectionManager; - } + // Reconstruct collections. + baseProgress = 50; + await CollectionManager.ReconstructCollections(simpleProgress, token); - public async Task Run(IProgress<double> progress, CancellationToken token) - { - // Merge versions now if the setting is enabled. - if (Plugin.Instance.Configuration.EXPERIMENTAL_AutoMergeVersions) { - // Setup basic progress tracking - var baseProgress = 0d; - var simpleProgress = new ActionableProgress<double>(); - simpleProgress.RegisterAction(value => progress.Report(baseProgress + (value / 2d))); - - // Merge versions. - await VersionsManager.MergeAll(simpleProgress, token); - - // Reconstruct collections. - baseProgress = 50; - await CollectionManager.ReconstructCollections(simpleProgress, token); - - progress.Report(100d); - } - else { - // Reconstruct collections. - await CollectionManager.ReconstructCollections(progress, token); - } - - // Clear the cache now, since we don't need it anymore. - ApiManager.Clear(); + progress.Report(100d); } + else { + // Reconstruct collections. + await CollectionManager.ReconstructCollections(progress, token); + } + + // Clear the cache now, since we don't need it anymore. + ApiManager.Clear(); } } diff --git a/Shokofin/Tasks/SplitAllTask.cs b/Shokofin/Tasks/SplitAllTask.cs index 1e3e04c1..fe7b7846 100644 --- a/Shokofin/Tasks/SplitAllTask.cs +++ b/Shokofin/Tasks/SplitAllTask.cs @@ -5,64 +5,62 @@ using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; -namespace Shokofin.Tasks +#nullable enable +namespace Shokofin.Tasks; + +/// <summary> +/// Class SplitAllTask. +/// </summary> +public class SplitAllTask : IScheduledTask, IConfigurableScheduledTask { - /// <summary> - /// Class SplitAllTask. - /// </summary> - public class SplitAllTask : IScheduledTask - { - /// <summary> - /// The merge-versions manager. - /// </summary> - private readonly MergeVersionsManager VersionsManager; + /// <inheritdoc /> + public string Name => "Split both movies and episodes"; - /// <summary> - /// Initializes a new instance of the <see cref="SplitAllTask" /> class. - /// </summary> - public SplitAllTask(MergeVersionsManager userSyncManager) - { - VersionsManager = userSyncManager; - } + /// <inheritdoc /> + public string Description => "Split all movie and episode entries with a Shoko Episode ID set."; - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - return new TaskTriggerInfo[0]; - } + /// <inheritdoc /> + public string Category => "Shokofin"; - /// <summary> - /// Returns the task to be executed. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <param name="progress">The progress.</param> - /// <returns>Task.</returns> - public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - await VersionsManager.SplitAll(progress, cancellationToken); - } + /// <inheritdoc /> + public string Key => "ShokoSplitAll"; - /// <inheritdoc /> - public string Name => "Split both movies and episodes"; + /// <inheritdoc /> + public bool IsHidden => false; - /// <inheritdoc /> - public string Description => "Split all movie and episode entries with a Shoko Episode ID set."; + /// <inheritdoc /> + public bool IsEnabled => false; - /// <inheritdoc /> - public string Category => "Shokofin"; + /// <inheritdoc /> + public bool IsLogged => true; - /// <inheritdoc /> - public string Key => "ShokoSplitAll"; + /// <summary> + /// The merge-versions manager. + /// </summary> + private readonly MergeVersionsManager VersionsManager; - /// <inheritdoc /> - public bool IsHidden => false; + /// <summary> + /// Initializes a new instance of the <see cref="SplitAllTask" /> class. + /// </summary> + public SplitAllTask(MergeVersionsManager userSyncManager) + { + VersionsManager = userSyncManager; + } - /// <inheritdoc /> - public bool IsEnabled => false; + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); - /// <inheritdoc /> - public bool IsLogged => true; + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await VersionsManager.SplitAll(progress, cancellationToken); } } diff --git a/Shokofin/Tasks/SplitEpisodesTask.cs b/Shokofin/Tasks/SplitEpisodesTask.cs index b419c256..74aa28a1 100644 --- a/Shokofin/Tasks/SplitEpisodesTask.cs +++ b/Shokofin/Tasks/SplitEpisodesTask.cs @@ -5,64 +5,62 @@ using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; -namespace Shokofin.Tasks +#nullable enable +namespace Shokofin.Tasks; + +/// <summary> +/// Class SplitEpisodesTask. +/// </summary> +public class SplitEpisodesTask : IScheduledTask, IConfigurableScheduledTask { - /// <summary> - /// Class SplitEpisodesTask. - /// </summary> - public class SplitEpisodesTask : IScheduledTask - { - /// <summary> - /// The merge-versions manager. - /// </summary> - private readonly MergeVersionsManager VersionsManager; + /// <inheritdoc /> + public string Name => "Split episodes"; - /// <summary> - /// Initializes a new instance of the <see cref="SplitEpisodesTask" /> class. - /// </summary> - public SplitEpisodesTask(MergeVersionsManager userSyncManager) - { - VersionsManager = userSyncManager; - } + /// <inheritdoc /> + public string Description => "Split all episode entries with a Shoko Episode ID set."; - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - return new TaskTriggerInfo[0]; - } + /// <inheritdoc /> + public string Category => "Shokofin"; - /// <summary> - /// Returns the task to be executed. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <param name="progress">The progress.</param> - /// <returns>Task.</returns> - public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - await VersionsManager.SplitAllEpisodes(progress, cancellationToken); - } + /// <inheritdoc /> + public string Key => "ShokoSplitEpisodes"; - /// <inheritdoc /> - public string Name => "Split episodes"; + /// <inheritdoc /> + public bool IsHidden => false; - /// <inheritdoc /> - public string Description => "Split all episode entries with a Shoko Episode ID set."; + /// <inheritdoc /> + public bool IsEnabled => false; - /// <inheritdoc /> - public string Category => "Shokofin"; + /// <inheritdoc /> + public bool IsLogged => true; - /// <inheritdoc /> - public string Key => "ShokoSplitEpisodes"; + /// <summary> + /// The merge-versions manager. + /// </summary> + private readonly MergeVersionsManager VersionsManager; - /// <inheritdoc /> - public bool IsHidden => false; + /// <summary> + /// Initializes a new instance of the <see cref="SplitEpisodesTask" /> class. + /// </summary> + public SplitEpisodesTask(MergeVersionsManager userSyncManager) + { + VersionsManager = userSyncManager; + } - /// <inheritdoc /> - public bool IsEnabled => false; + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); - /// <inheritdoc /> - public bool IsLogged => true; + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await VersionsManager.SplitAllEpisodes(progress, cancellationToken); } } diff --git a/Shokofin/Tasks/SplitMoviesTask.cs b/Shokofin/Tasks/SplitMoviesTask.cs index 53f1d67c..d03fff75 100644 --- a/Shokofin/Tasks/SplitMoviesTask.cs +++ b/Shokofin/Tasks/SplitMoviesTask.cs @@ -5,64 +5,62 @@ using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; -namespace Shokofin.Tasks +#nullable enable +namespace Shokofin.Tasks; + +/// <summary> +/// Class SplitMoviesTask. +/// </summary> +public class SplitMoviesTask : IScheduledTask, IConfigurableScheduledTask { - /// <summary> - /// Class SplitMoviesTask. - /// </summary> - public class SplitMoviesTask : IScheduledTask - { - /// <summary> - /// The merge-versions manager. - /// </summary> - private readonly MergeVersionsManager VersionsManager; + /// <inheritdoc /> + public string Name => "Split movies"; - /// <summary> - /// Initializes a new instance of the <see cref="SplitMoviesTask" /> class. - /// </summary> - public SplitMoviesTask(MergeVersionsManager userSyncManager) - { - VersionsManager = userSyncManager; - } + /// <inheritdoc /> + public string Description => "Split all movie entries with a Shoko Episode ID set."; - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - return new TaskTriggerInfo[0]; - } + /// <inheritdoc /> + public string Category => "Shokofin"; - /// <summary> - /// Returns the task to be executed. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <param name="progress">The progress.</param> - /// <returns>Task.</returns> - public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - await VersionsManager.SplitAllMovies(progress, cancellationToken); - } + /// <inheritdoc /> + public string Key => "ShokoSplitMovies"; - /// <inheritdoc /> - public string Name => "Split movies"; + /// <inheritdoc /> + public bool IsHidden => false; - /// <inheritdoc /> - public string Description => "Split all movie entries with a Shoko Episode ID set."; + /// <inheritdoc /> + public bool IsEnabled => false; - /// <inheritdoc /> - public string Category => "Shokofin"; + /// <inheritdoc /> + public bool IsLogged => true; - /// <inheritdoc /> - public string Key => "ShokoSplitMovies"; + /// <summary> + /// The merge-versions manager. + /// </summary> + private readonly MergeVersionsManager VersionsManager; - /// <inheritdoc /> - public bool IsHidden => false; + /// <summary> + /// Initializes a new instance of the <see cref="SplitMoviesTask" /> class. + /// </summary> + public SplitMoviesTask(MergeVersionsManager userSyncManager) + { + VersionsManager = userSyncManager; + } - /// <inheritdoc /> - public bool IsEnabled => false; + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); - /// <inheritdoc /> - public bool IsLogged => true; + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await VersionsManager.SplitAllMovies(progress, cancellationToken); } } diff --git a/Shokofin/Tasks/SyncUserDataTask.cs b/Shokofin/Tasks/SyncUserDataTask.cs index b3f444d9..a10d2888 100644 --- a/Shokofin/Tasks/SyncUserDataTask.cs +++ b/Shokofin/Tasks/SyncUserDataTask.cs @@ -2,73 +2,65 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Persistence; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; -using MediaBrowser.Model.Globalization; using Shokofin.Sync; -namespace Shokofin.Tasks +#nullable enable +namespace Shokofin.Tasks; + +/// <summary> +/// Class SyncUserDataTask. +/// </summary> +public class SyncUserDataTask : IScheduledTask, IConfigurableScheduledTask { - /// <summary> - /// Class SyncUserDataTask. - /// </summary> - public class SyncUserDataTask : IScheduledTask - { - /// <summary> - /// The _library manager. - /// </summary> - private readonly UserDataSyncManager _userSyncManager; + /// <inheritdoc /> + public string Name => "Sync User Data"; - /// <summary> - /// Initializes a new instance of the <see cref="SyncUserDataTask" /> class. - /// </summary> - public SyncUserDataTask(UserDataSyncManager userSyncManager) - { - _userSyncManager = userSyncManager; - } + /// <inheritdoc /> + public string Description => "Synchronize the user-data stored in Jellyfin with the user-data stored in Shoko. Imports or exports data as needed."; - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - return new TaskTriggerInfo[0]; - } + /// <inheritdoc /> + public string Category => "Shokofin"; - /// <summary> - /// Returns the task to be executed. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <param name="progress">The progress.</param> - /// <returns>Task.</returns> - public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - await _userSyncManager.ScanAndSync(SyncDirection.Sync, progress, cancellationToken); - } + /// <inheritdoc /> + public string Key => "ShokoSyncUserData"; - /// <inheritdoc /> - public string Name => "Sync User Data"; + /// <inheritdoc /> + public bool IsHidden => false; - /// <inheritdoc /> - public string Description => "Synchronize the user-data stored in Jellyfin with the user-data stored in Shoko. Imports or exports data as needed."; + /// <inheritdoc /> + public bool IsEnabled => false; - /// <inheritdoc /> - public string Category => "Shokofin"; + /// <inheritdoc /> + public bool IsLogged => true; - /// <inheritdoc /> - public string Key => "ShokoSyncUserData"; + /// <summary> + /// The _library manager. + /// </summary> + private readonly UserDataSyncManager _userSyncManager; - /// <inheritdoc /> - public bool IsHidden => false; + /// <summary> + /// Initializes a new instance of the <see cref="SyncUserDataTask" /> class. + /// </summary> + public SyncUserDataTask(UserDataSyncManager userSyncManager) + { + _userSyncManager = userSyncManager; + } - /// <inheritdoc /> - public bool IsEnabled => false; + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); - /// <inheritdoc /> - public bool IsLogged => true; + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + await _userSyncManager.ScanAndSync(SyncDirection.Sync, progress, cancellationToken); } } diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index b83ef056..f3b64a9d 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -1,276 +1,285 @@ using System.Linq; -using MediaBrowser.Model.Entities; using Shokofin.API.Info; using Shokofin.API.Models; using ExtraType = MediaBrowser.Model.Entities.ExtraType; -namespace Shokofin.Utils +#nullable enable +namespace Shokofin.Utils; + +public class Ordering { - public class Ordering + /// <summary> + /// Group series or movie box-sets + /// </summary> + public enum CollectionCreationType { /// <summary> - /// Group series or movie box-sets + /// No grouping. All series will have their own entry. /// </summary> - public enum CollectionCreationType - { - /// <summary> - /// No grouping. All series will have their own entry. - /// </summary> - None = 0, - - /// <summary> - /// Group movies based on Shoko's series. - /// </summary> - ShokoSeries = 1, + None = 0, - /// <summary> - /// Group both movies and shows into collections based on shoko's - /// groups. - /// </summary> - ShokoGroup = 2, - } + /// <summary> + /// Group movies based on Shoko's series. + /// </summary> + ShokoSeries = 1, /// <summary> - /// Season or movie ordering when grouping series/box-sets using Shoko's groups. + /// Group both movies and shows into collections based on shoko's + /// groups. /// </summary> - public enum OrderType - { - /// <summary> - /// Let Shoko decide the order. - /// </summary> - Default = 0, + ShokoGroup = 2, + } - /// <summary> - /// Order seasons by release date. - /// </summary> - ReleaseDate = 1, + /// <summary> + /// Season or movie ordering when grouping series/box-sets using Shoko's groups. + /// </summary> + public enum OrderType + { + /// <summary> + /// Let Shoko decide the order. + /// </summary> + Default = 0, - /// <summary> - /// Order seasons based on the chronological order of relations. - /// </summary> - Chronological = 2, - } + /// <summary> + /// Order seasons by release date. + /// </summary> + ReleaseDate = 1, - public enum SpecialOrderType { - /// <summary> - /// Use the default for the type. - /// </summary> - Default = 0, + /// <summary> + /// Order seasons based on the chronological order of relations. + /// </summary> + Chronological = 2, + } - /// <summary> - /// Always exclude the specials from the season. - /// </summary> - Excluded = 1, + public enum SpecialOrderType { + /// <summary> + /// Use the default for the type. + /// </summary> + Default = 0, - /// <summary> - /// Always place the specials after the normal episodes in the season. - /// </summary> - AfterSeason = 2, + /// <summary> + /// Always exclude the specials from the season. + /// </summary> + Excluded = 1, - /// <summary> - /// Use a mix of <see cref="InBetweenSeasonByOtherData" /> and <see cref="InBetweenSeasonByAirDate" />. - /// </summary> - InBetweenSeasonMixed = 3, + /// <summary> + /// Always place the specials after the normal episodes in the season. + /// </summary> + AfterSeason = 2, - /// <summary> - /// Place the specials in-between normal episodes based on the time the episodes aired. - /// </summary> - InBetweenSeasonByAirDate = 4, + /// <summary> + /// Use a mix of <see cref="InBetweenSeasonByOtherData" /> and <see cref="InBetweenSeasonByAirDate" />. + /// </summary> + InBetweenSeasonMixed = 3, - /// <summary> - /// Place the specials in-between normal episodes based upon the data from TvDB or TMDB. - /// </summary> - InBetweenSeasonByOtherData = 5, - } + /// <summary> + /// Place the specials in-between normal episodes based on the time the episodes aired. + /// </summary> + InBetweenSeasonByAirDate = 4, /// <summary> - /// Get index number for an episode in a series. + /// Place the specials in-between normal episodes based upon the data from TvDB or TMDB. /// </summary> - /// <returns>Absolute index.</returns> - public static int GetEpisodeNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) - { - int offset = 0; - if (episode.AniDB.Type == EpisodeType.Special) { - var seasonIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); - if (seasonIndex == -1) - throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); - var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); - if (index == -1) - throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); - offset = group.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); - return offset + index + 1; - } - var sizes = series.Shoko.Sizes.Total; - switch (episode.AniDB.Type) { - case EpisodeType.Other: - case EpisodeType.Unknown: - case EpisodeType.Normal: - // offset += 0; // it's not needed, so it's just here as a comment instead. - break; - // Add them to the bottom of the list if we didn't filter them out properly. - case EpisodeType.Parody: - offset += sizes?.Episodes ?? 0; - goto case EpisodeType.Normal; - case EpisodeType.OpeningSong: - offset += sizes?.Parodies ?? 0; - goto case EpisodeType.Parody; - case EpisodeType.Trailer: - offset += sizes?.Credits ?? 0; - goto case EpisodeType.OpeningSong; - default: - offset += sizes?.Trailers ?? 0; - goto case EpisodeType.Trailer; - } - return offset + episode.AniDB.EpisodeNumber; + InBetweenSeasonByOtherData = 5, + } + + /// <summary> + /// Get index number for an episode in a series. + /// </summary> + /// <returns>Absolute index.</returns> + public static int GetEpisodeNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) + { + int offset = 0; + if (episode.ExtraType == null) { + var seasonIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); + if (seasonIndex == -1) + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); + var index = series.ExtrasList.FindIndex(e => string.Equals(e.Id, episode.Id)); + if (index == -1) + throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); + offset = group.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.ExtrasList.Count); + return offset + index + 1; + } + if (episode.AniDB.Type == EpisodeType.Special) { + var seasonIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); + if (seasonIndex == -1) + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); + var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); + if (index == -1) + throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); + offset = group.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); + return offset + index + 1; + } + var sizes = series.Shoko.Sizes.Total; + switch (episode.AniDB.Type) { + case EpisodeType.Other: + case EpisodeType.Unknown: + case EpisodeType.Normal: + // offset += 0; // it's not needed, so it's just here as a comment instead. + break; + // Add them to the bottom of the list if we didn't filter them out properly. + case EpisodeType.Parody: + offset += sizes?.Episodes ?? 0; + goto case EpisodeType.Normal; + case EpisodeType.OpeningSong: + offset += sizes?.Parodies ?? 0; + goto case EpisodeType.Parody; + case EpisodeType.Trailer: + offset += sizes?.Credits ?? 0; + goto case EpisodeType.OpeningSong; + default: + offset += sizes?.Trailers ?? 0; + goto case EpisodeType.Trailer; } + return offset + episode.AniDB.EpisodeNumber; + } - public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo group, SeasonInfo series, EpisodeInfo episode) - { - var order = Plugin.Instance.Configuration.SpecialsPlacement; + public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo group, SeasonInfo series, EpisodeInfo episode) + { + var order = Plugin.Instance.Configuration.SpecialsPlacement; - // Return early if we want to exclude them from the normal seasons. - if (order == SpecialOrderType.Excluded) { - // Check if this should go in the specials season. - return (null, null, null, episode.IsSpecial); - } + // Return early if we want to exclude them from the normal seasons. + if (order == SpecialOrderType.Excluded) { + // Check if this should go in the specials season. + return (null, null, null, episode.IsSpecial); + } - // Abort if episode is not a TvDB special or AniDB special - if (!episode.IsSpecial) - return (null, null, null, false); + // Abort if episode is not a TvDB special or AniDB special + if (!episode.IsSpecial) + return (null, null, null, false); - int? episodeNumber = null; - int seasonNumber = GetSeasonNumber(group, series, episode); - int? airsBeforeEpisodeNumber = null; - int? airsBeforeSeasonNumber = null; - int? airsAfterSeasonNumber = null; - switch (order) { - default: - airsAfterSeasonNumber = seasonNumber; - break; - case SpecialOrderType.InBetweenSeasonByAirDate: - byAirdate: - // Reset the order if we come from `SpecialOrderType.InBetweenSeasonMixed`. - episodeNumber = null; - if (series.SpecialsAnchors.TryGetValue(episode, out var previousEpisode)) - episodeNumber = GetEpisodeNumber(group, series, previousEpisode); + int? episodeNumber = null; + int seasonNumber = GetSeasonNumber(group, series, episode); + int? airsBeforeEpisodeNumber = null; + int? airsBeforeSeasonNumber = null; + int? airsAfterSeasonNumber = null; + switch (order) { + default: + airsAfterSeasonNumber = seasonNumber; + break; + case SpecialOrderType.InBetweenSeasonByAirDate: + byAirdate: + // Reset the order if we come from `SpecialOrderType.InBetweenSeasonMixed`. + episodeNumber = null; + if (series.SpecialsAnchors.TryGetValue(episode, out var previousEpisode)) + episodeNumber = GetEpisodeNumber(group, series, previousEpisode); - if (episodeNumber.HasValue && episodeNumber.Value < series.EpisodeList.Count) { - airsBeforeEpisodeNumber = episodeNumber.Value + 1; - airsBeforeSeasonNumber = seasonNumber; - } - else { - airsAfterSeasonNumber = seasonNumber; - } + if (episodeNumber.HasValue && episodeNumber.Value < series.EpisodeList.Count) { + airsBeforeEpisodeNumber = episodeNumber.Value + 1; + airsBeforeSeasonNumber = seasonNumber; + } + else { + airsAfterSeasonNumber = seasonNumber; + } + break; + case SpecialOrderType.InBetweenSeasonMixed: + case SpecialOrderType.InBetweenSeasonByOtherData: + // We need to have TvDB/TMDB data in the first place to do this method. + if (episode.TvDB == null) { + if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirdate; break; - case SpecialOrderType.InBetweenSeasonMixed: - case SpecialOrderType.InBetweenSeasonByOtherData: - // We need to have TvDB/TMDB data in the first place to do this method. - if (episode.TvDB == null) { - if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirdate; - break; - } - - episodeNumber = episode.TvDB.AirsBeforeEpisode; - if (!episodeNumber.HasValue) { - if (episode.TvDB.AirsBeforeSeason.HasValue) { - airsBeforeSeasonNumber = seasonNumber; - break; - } - - if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirdate; - airsAfterSeasonNumber = seasonNumber; - break; - } + } - var nextEpisode = series.EpisodeList.FirstOrDefault(e => e.TvDB != null && e.TvDB.SeasonNumber == seasonNumber && e.TvDB.EpisodeNumber == episodeNumber); - if (nextEpisode != null) { - airsBeforeEpisodeNumber = GetEpisodeNumber(group, series, nextEpisode); + episodeNumber = episode.TvDB.AirsBeforeEpisode; + if (!episodeNumber.HasValue) { + if (episode.TvDB.AirsBeforeSeason.HasValue) { airsBeforeSeasonNumber = seasonNumber; break; } if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirdate; + airsAfterSeasonNumber = seasonNumber; break; - } + } + + var nextEpisode = series.EpisodeList.FirstOrDefault(e => e.TvDB != null && e.TvDB.SeasonNumber == seasonNumber && e.TvDB.EpisodeNumber == episodeNumber); + if (nextEpisode != null) { + airsBeforeEpisodeNumber = GetEpisodeNumber(group, series, nextEpisode); + airsBeforeSeasonNumber = seasonNumber; + break; + } - return (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber, true); + if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirdate; + break; } - /// <summary> - /// Get season number for an episode in a series. - /// </summary> - /// <param name="group"></param> - /// <param name="series"></param> - /// <param name="episode"></param> - /// <returns></returns> - public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) - { - if (!group.SeasonNumberBaseDictionary.TryGetValue(series, out var seasonNumber)) - throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id})"); + return (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber, true); + } - var offset = 0; - switch (episode.AniDB.Type) { - default: - break; - case EpisodeType.Unknown: { - offset = 1; - break; - } - case EpisodeType.Other: { - offset = series.AlternateEpisodesList.Count > 0 ? 2 : 1; - break; - } - } + /// <summary> + /// Get season number for an episode in a series. + /// </summary> + /// <param name="group"></param> + /// <param name="series"></param> + /// <param name="episode"></param> + /// <returns></returns> + public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) + { + if (!group.SeasonNumberBaseDictionary.TryGetValue(series.Id, out var seasonNumber)) + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id})"); - return seasonNumber + offset; + var offset = 0; + switch (episode.AniDB.Type) { + default: + break; + case EpisodeType.Unknown: { + offset = 1; + break; + } + case EpisodeType.Other: { + offset = series.AlternateEpisodesList.Count > 0 ? 2 : 1; + break; + } } - /// <summary> - /// Get the extra type for an episode. - /// </summary> - /// <param name="episode"></param> - /// <returns></returns> - public static ExtraType? GetExtraType(Episode.AniDB episode) + return seasonNumber + offset; + } + + /// <summary> + /// Get the extra type for an episode. + /// </summary> + /// <param name="episode"></param> + /// <returns></returns> + public static ExtraType? GetExtraType(Episode.AniDB episode) + { + switch (episode.Type) { - switch (episode.Type) - { - case EpisodeType.Normal: - case EpisodeType.Other: - case EpisodeType.Unknown: + case EpisodeType.Normal: + case EpisodeType.Other: + case EpisodeType.Unknown: + return null; + case EpisodeType.ThemeSong: + case EpisodeType.OpeningSong: + case EpisodeType.EndingSong: + return ExtraType.ThemeVideo; + case EpisodeType.Trailer: + return ExtraType.Trailer; + case EpisodeType.Special: { + var title = Text.GetTitleByLanguages(episode.Titles, "en"); + if (string.IsNullOrEmpty(title)) return null; - case EpisodeType.ThemeSong: - case EpisodeType.OpeningSong: - case EpisodeType.EndingSong: + // Interview + if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) + return ExtraType.Interview; + // Cinema intro/outro + if (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) && + (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase))) + return ExtraType.Clip; + // Music videos + if (title.Contains("music video", System.StringComparison.OrdinalIgnoreCase)) return ExtraType.ThemeVideo; - case EpisodeType.Trailer: - return ExtraType.Trailer; - case EpisodeType.Special: { - var title = Text.GetTitleByLanguages(episode.Titles, "en"); - if (string.IsNullOrEmpty(title)) - return null; - // Interview - if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) - return ExtraType.Interview; - // Cinema intro/outro - if (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) && - (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase))) - return ExtraType.Clip; - // Music videos - if (title.Contains("music video", System.StringComparison.OrdinalIgnoreCase)) - return ExtraType.ThemeVideo; - // Behind the Scenes - if (title.Contains("making of", System.StringComparison.CurrentCultureIgnoreCase)) - return ExtraType.BehindTheScenes; - if (title.Contains("music in", System.StringComparison.CurrentCultureIgnoreCase)) - return ExtraType.BehindTheScenes; - if (title.Contains("advance screening", System.StringComparison.CurrentCultureIgnoreCase)) - return ExtraType.BehindTheScenes; - return null; - } - default: - return ExtraType.Unknown; + // Behind the Scenes + if (title.Contains("making of", System.StringComparison.CurrentCultureIgnoreCase)) + return ExtraType.BehindTheScenes; + if (title.Contains("music in", System.StringComparison.CurrentCultureIgnoreCase)) + return ExtraType.BehindTheScenes; + if (title.Contains("advance screening", System.StringComparison.CurrentCultureIgnoreCase)) + return ExtraType.BehindTheScenes; + return null; } + default: + return ExtraType.Unknown; } } } diff --git a/Shokofin/Utils/SeriesInfoRelationComparer.cs b/Shokofin/Utils/SeriesInfoRelationComparer.cs index 334aeaa1..c288fdcf 100644 --- a/Shokofin/Utils/SeriesInfoRelationComparer.cs +++ b/Shokofin/Utils/SeriesInfoRelationComparer.cs @@ -9,7 +9,7 @@ namespace Shokofin.Utils; public class SeriesInfoRelationComparer : IComparer<SeasonInfo> { - protected static Dictionary<RelationType, int> RelationPriority = new() { + private static readonly Dictionary<RelationType, int> RelationPriority = new() { { RelationType.Prequel, 1 }, { RelationType.MainStory, 2 }, { RelationType.FullStory, 3 }, diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/TextUtil.cs index 450f9a12..ca030fb9 100644 --- a/Shokofin/Utils/TextUtil.cs +++ b/Shokofin/Utils/TextUtil.cs @@ -7,7 +7,7 @@ namespace Shokofin.Utils { - public class Text + public static class Text { private static HashSet<char> PunctuationMarks = new() { // Common punctuation marks diff --git a/Shokofin/Web/ShokoApiController.cs b/Shokofin/Web/ShokoApiController.cs new file mode 100644 index 00000000..3e325c47 --- /dev/null +++ b/Shokofin/Web/ShokoApiController.cs @@ -0,0 +1,102 @@ +using System; +using System.Net.Http; +using System.Net.Mime; +using System.Reflection; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Models; + +#nullable enable +namespace Shokofin.Web; + +/// <summary> +/// Pushbullet notifications controller. +/// </summary> +[ApiController] +[Route("Plugin/Shokofin")] +[Produces(MediaTypeNames.Application.Json)] +public class ShokoApiController : ControllerBase +{ + private readonly ILogger<ShokoApiController> 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; + } + + /// <summary> + /// Try to get the version of the server. + /// </summary> + /// <returns></returns> + [HttpGet("Version")] + public async Task<ActionResult<ComponentVersion>> GetVersionAsync() + { + try { + Logger.LogDebug("Trying to get version from the remote Shoko server."); + var version = await APIClient.GetVersion().ConfigureAwait(false); + if (version == null) { + Logger.LogDebug("Failed to get version from the remote Shoko server."); + return StatusCode(StatusCodes.Status502BadGateway); + } + + Logger.LogDebug("Successfully got version {Version} from the remote Shoko server. (Channel={Channel},Commit={Commit})", version.Version, version.ReleaseChannel, version.Commit?[0..7]); + return version; + } + catch (Exception ex) { + Logger.LogError(ex, "Failed to get version from the remote Shoko server. Exception; {ex}", ex.Message); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + [HttpPost("GetApiKey")] + public async Task<ActionResult<ApiKey>> PostAsync([FromBody] ApiLoginRequest body) + { + try { + Logger.LogDebug("Trying to create an API-key for user {Username}.", body.Username); + var apiKey = await APIClient.GetApiKey(body.Username, body.Password, body.UserKey).ConfigureAwait(false); + if (apiKey == null) { + Logger.LogDebug("Failed to create an API-key for user {Username} — invalid credentials received.", body.Username); + return StatusCode(StatusCodes.Status401Unauthorized); + } + + Logger.LogDebug("Successfully created an API-key for user {Username}.", body.Username); + return apiKey; + } + catch (Exception ex) { + Logger.LogError(ex, "Failed to create an API-key for user {Username} — unable to complete the request.", body.Username); + return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); + } + } +} + +public class ApiLoginRequest +{ + /// <summary> + /// The username to submit to shoko. + /// </summary> + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + /// <summary> + /// The password to submit to shoko. + /// </summary> + [JsonPropertyName("password")] + public string Password { get; set; } = string.Empty; + + /// <summary> + /// If this is a user key. + /// </summary> + [JsonPropertyName("userKey")] + public bool UserKey { get; set; } = false; +} \ No newline at end of file diff --git a/Shokofin/Web/WebController.cs b/Shokofin/Web/WebController.cs deleted file mode 100644 index 6d56301c..00000000 --- a/Shokofin/Web/WebController.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Net.Mime; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Jellyfin.Extensions.Json; -using MediaBrowser.Common.Net; -using MediaBrowser.Model.Serialization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Shokofin.API; -using Shokofin.API.Models; - -namespace Shokofin.Web -{ - - /// <summary> - /// Pushbullet notifications controller. - /// </summary> - [ApiController] - [Route("Plugin/Shokofin")] - [Produces(MediaTypeNames.Application.Json)] - public class WebController : ControllerBase - { - private readonly ILogger<WebController> Logger; - - private readonly ShokoAPIClient APIClient; - - /// <summary> - /// Initializes a new instance of the <see cref="WebController"/> class. - /// </summary> - /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> - public WebController(ILogger<WebController> logger, ShokoAPIClient apiClient) - { - Logger = logger; - APIClient = apiClient; - } - - [HttpPost("GetApiKey")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task<ActionResult<ApiKey>> PostAsync([FromBody] ApiLoginRequest body) - { - try { - Logger.LogDebug("Trying to create an API-key for user {Username}.", body.username); - var apiKey = await APIClient.GetApiKey(body.username, body.password, body.userKey).ConfigureAwait(false); - if (apiKey == null) { - Logger.LogDebug("Failed to create an API-key for user {Username} — invalid credentials received.", body.username); - return new StatusCodeResult(StatusCodes.Status401Unauthorized); - } - - Logger.LogDebug("Successfully created an API-key for user {Username}.", body.username); - return apiKey; - } - catch (Exception ex) { - Logger.LogError(ex, "Failed to create an API-key for user {Username} — unable to complete the request.", body.username); - return new StatusCodeResult(StatusCodes.Status500InternalServerError); - } - } - } - - public class ApiLoginRequest { - public string username { get; set; } - public string password { get; set; } - public bool userKey { get; set; } - } -} \ No newline at end of file From dfc436265f26cb96c4f4f3901c991e6568946975 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 28 Mar 2024 04:52:37 +0000 Subject: [PATCH 0617/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 39fed5a0..9ffc249f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.46", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.46/shoko_3.0.1.46.zip", + "checksum": "d9767d7a426382e3476e377c6d198b1a", + "timestamp": "2024-03-28T04:52:36Z" + }, { "version": "3.0.1.45", "changelog": "NA\n", From 5cfb44732ae788864fefa45796797551fc81064a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 28 Mar 2024 06:01:49 +0100 Subject: [PATCH 0618/1103] fix: fix settings page init --- Shokofin/Configuration/configController.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 7b68e15d..e689efcb 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -490,7 +490,7 @@ export default function (page) { form.querySelector("#TitleMainType").value = config.TitleMainType; form.querySelector("#TitleAlternateType").value = config.TitleAlternateType; form.querySelector("#TitleAllowAny").checked = config.TitleAllowAny; - form.querySelector("#TitleAddForMultipleEpisodes").checked = config.TitleAddForMultipleEpisodes || true; + form.querySelector("#TitleAddForMultipleEpisodes").checked = config.TitleAddForMultipleEpisodes != null ? config.TitleAddForMultipleEpisodes : true; form.querySelector("#DescriptionSource").value = config.DescriptionSource; form.querySelector("#CleanupAniDBDescriptions").checked = config.SynopsisCleanMultiEmptyLines || config.SynopsisCleanLinks; form.querySelector("#MinimalAniDBDescriptions").checked = config.SynopsisRemoveSummary || config.SynopsisCleanMiscLines; @@ -500,9 +500,9 @@ export default function (page) { form.querySelector("#AddTMDBId").checked = config.AddTMDBId; // Library settings - form.querySelector("#VirtualFileSystem").checked = config.VirtualFileSystem || true; + form.querySelector("#VirtualFileSystem").checked = config.VirtualFileSystem != null ? config.VirtualFileSystem : true; form.querySelector("#LibraryFilteringMode").value = `${config.LibraryFilteringMode != null ? config.LibraryFilteringMode : null}`; - form.querySelector("#LibraryFilteringMode").disabled = form.querySelector("#VirtualFileSystem").checked || true; + form.querySelector("#LibraryFilteringMode").disabled = form.querySelector("#VirtualFileSystem").checked; if (form.querySelector("#VirtualFileSystem").checked) { form.querySelector("#LibraryFilteringModeContainer").setAttribute("hidden", ""); } @@ -518,8 +518,8 @@ export default function (page) { else { form.querySelector("#SeasonOrderingContainer").setAttribute("hidden", ""); } - form.querySelector("#CollectionGrouping").value = config.CollectionGrouping; - form.querySelector("#SeparateMovies").checked = config.SeparateMovies || true; + form.querySelector("#CollectionGrouping").value = config.CollectionGrouping || "Default"; + form.querySelector("#SeparateMovies").checked = config.SeparateMovies != null ? config.SeparateMovies : true; form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" ? "AfterSeason" : config.SpecialsPlacement; form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; @@ -541,7 +541,7 @@ export default function (page) { // Experimental settings form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked = config.EXPERIMENTAL_AutoMergeVersions || false; - form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked = config.EXPERIMENTAL_SplitThenMergeMovies || true; + form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked = config.EXPERIMENTAL_SplitThenMergeMovies != null ? config.EXPERIMENTAL_SplitThenMergeMovies : true; form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked = config.EXPERIMENTAL_SplitThenMergeEpisodes || false; form.querySelector("#EXPERIMENTAL_MergeSeasons").checked = config.EXPERIMENTAL_MergeSeasons || false; From f46e1ee3b27615a153b731e08c951cbd21a575db Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 28 Mar 2024 05:02:20 +0000 Subject: [PATCH 0619/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 9ffc249f..7b38fea4 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.47", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.47/shoko_3.0.1.47.zip", + "checksum": "0337e83677298801b4f2a668b8f1b466", + "timestamp": "2024-03-28T05:02:18Z" + }, { "version": "3.0.1.46", "changelog": "NA\n", From 00fa408618ab89261d8dba3ddd0840a230e784ad Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 28 Mar 2024 06:52:33 +0100 Subject: [PATCH 0620/1103] misc: tweak logging & move method --- Shokofin/Resolvers/ShokoResolveManager.cs | 85 ++++++++++++----------- 1 file changed, 44 insertions(+), 41 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 5b33afed..2e54cbd2 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -89,8 +89,9 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) DataCache.Remove(folder.Id.ToString()); var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(folder); if (Directory.Exists(vfsPath)) { - Logger.LogDebug("Removing VFS directory for folder"); + 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); } } } @@ -99,38 +100,12 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) #region Generate Structure - private async Task<IReadOnlyList<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>> GetImportFolderFiles(int importFolderId, string importFolderSubPath, string mediaFolderPath) - { - Logger.LogDebug("Looking for recognised files within media folder… (ImportFolder={FolderId},RelativePath={RelativePath})", importFolderId, importFolderSubPath); - var allFilesForImportFolder = (await ApiClient.GetFilesForImportFolder(importFolderId)) - .AsParallel() - .SelectMany(file => - { - var location = file.Locations - .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length == 0 || location.Path.StartsWith(importFolderSubPath))) - .FirstOrDefault(); - if (location == null || file.CrossReferences.Count == 0) - return Array.Empty<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>(); - - var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); - if (!File.Exists(sourceLocation)) - return Array.Empty<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>(); - - return file.CrossReferences - .Select(xref => (sourceLocation, fileId: file.Id.ToString(), seriesId: xref.Series.Shoko.ToString(), episodeIds: xref.Episodes.Select(e => e.Shoko.ToString()).ToArray())); - }) - .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation)) - .ToList(); - Logger.LogDebug("Found {FileCount} files to use within media folder at {Path} (ImportFolder={FolderId},RelativePath={RelativePath})", allFilesForImportFolder.Count, mediaFolderPath, importFolderId, importFolderSubPath); - return allFilesForImportFolder; - } - private async Task<string?> GenerateStructureForFolder(Folder mediaFolder, string folderPath) { if (DataCache.TryGetValue<string?>(folderPath, out var vfsPath) || DataCache.TryGetValue(mediaFolder.Path, out vfsPath)) return vfsPath; - Logger.LogDebug("Looking for match for folder at {Path}.", folderPath); + Logger.LogInformation("Looking for match for folder at {Path}.", folderPath); // Check if we should introduce the VFS for the media folder. var allPaths = FileSystem.GetFilePaths(folderPath, true) @@ -163,13 +138,13 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) } if (importFolderId == 0) { - Logger.LogDebug("Failed to find a match for folder at {Path} after {Amount} attempts.", folderPath, allPaths.Count); + Logger.LogWarning("Failed to find a match for folder at {Path} after {Amount} attempts.", folderPath, allPaths.Count); DataCache.Set<string?>(folderPath, null, DefaultTTL); return null; } - Logger.LogDebug("Found a match for folder at {Path} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path})", folderPath, importFolderId, importFolderSubPath, mediaFolder.Path); + Logger.LogInformation("Found a match for folder at {Path} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path})", folderPath, importFolderId, importFolderSubPath, mediaFolder.Path); vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); DataCache.Set(folderPath, vfsPath, DefaultTTL); @@ -179,8 +154,36 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) return vfsPath; } - private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)> files) + private async Task<IReadOnlyList<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>> GetImportFolderFiles(int importFolderId, string importFolderSubPath, string mediaFolderPath) { + Logger.LogDebug("Looking for recognised files within media folder… (ImportFolder={FolderId},RelativePath={RelativePath})", importFolderId, importFolderSubPath); + var allFilesForImportFolder = (await ApiClient.GetFilesForImportFolder(importFolderId)) + .AsParallel() + .SelectMany(file => + { + var location = file.Locations + .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length == 0 || location.Path.StartsWith(importFolderSubPath))) + .FirstOrDefault(); + if (location == null || file.CrossReferences.Count == 0) + return Array.Empty<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>(); + + var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); + if (!File.Exists(sourceLocation)) + return Array.Empty<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>(); + + return file.CrossReferences + .Select(xref => (sourceLocation, fileId: file.Id.ToString(), seriesId: xref.Series.Shoko.ToString(), episodeIds: xref.Episodes.Select(e => e.Shoko.ToString()).ToArray())); + }) + .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation)) + .ToList(); + Logger.LogDebug("Found {FileCount} files to use within media folder at {Path} (ImportFolder={FolderId},RelativePath={RelativePath})", allFilesForImportFolder.Count, mediaFolderPath, importFolderId, importFolderSubPath); + return allFilesForImportFolder; + } + + private async Task GenerateSymbolicLinks(Folder mediaFolder, IReadOnlyList<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)> files) + { + Logger.LogInformation("Found {FileCount} recognised files to potentially use within media folder at {Path}", files.Count, mediaFolder.Path); + var skipped = 0; var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); @@ -229,7 +232,7 @@ await Task.WhenAll(files CleanupDirectoryStructure(symbolicLink); } - Logger.LogDebug( + Logger.LogInformation( "Created {CreatedCount}, skipped {SkippedCount}, and removed {RemovedCount} symbolic links for media folder at {Path}", allPathsForVFS.Count - skipped, skipped, @@ -238,15 +241,6 @@ await Task.WhenAll(files ); } - private static void CleanupDirectoryStructure(string? path) - { - path = Path.GetDirectoryName(path); - while (!string.IsNullOrEmpty(path) && Directory.Exists(path) && !Directory.EnumerateFileSystemEntries(path).Any()) { - Directory.Delete(path); - path = Path.GetDirectoryName(path); - } - } - private async Task<(string sourceLocation, string symbolicLink)> GenerateLocationForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId, string[] episodeIds) { var season = await ApiManager.GetSeasonInfoForSeries(seriesId); @@ -336,6 +330,15 @@ private static void CleanupDirectoryStructure(string? path) } } + private static void CleanupDirectoryStructure(string? path) + { + path = Path.GetDirectoryName(path); + while (!string.IsNullOrEmpty(path) && Directory.Exists(path) && !Directory.EnumerateFileSystemEntries(path).Any()) { + Directory.Delete(path); + path = Path.GetDirectoryName(path); + } + } + #endregion #region Ignore Rule From 96c8fdc06e9d50d9177b9f6cb5df1ccf6a795b87 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 28 Mar 2024 06:52:46 +0100 Subject: [PATCH 0621/1103] fix: reverse boolean --- Shokofin/Utils/OrderingUtil.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/OrderingUtil.cs index f3b64a9d..7883d778 100644 --- a/Shokofin/Utils/OrderingUtil.cs +++ b/Shokofin/Utils/OrderingUtil.cs @@ -91,7 +91,7 @@ public enum SpecialOrderType { public static int GetEpisodeNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) { int offset = 0; - if (episode.ExtraType == null) { + if (episode.ExtraType != null) { var seasonIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); if (seasonIndex == -1) throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); From feb4ea8445a289faa4e6c5ca9472fd87ac3c118e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 28 Mar 2024 10:19:53 +0100 Subject: [PATCH 0622/1103] feat: better caching of objects --- Shokofin/API/ShokoAPIClient.cs | 162 +++++++------ Shokofin/API/ShokoAPIManager.cs | 277 +++++++++++----------- Shokofin/Resolvers/ShokoResolveManager.cs | 92 +++---- Shokofin/Utils/GuardedMemoryCache.cs | 167 +++++++++++++ 4 files changed, 438 insertions(+), 260 deletions(-) create mode 100644 Shokofin/Utils/GuardedMemoryCache.cs diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 339cb024..f1892309 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -1,14 +1,17 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Shokofin.API.Models; +using Shokofin.Utils; #nullable enable namespace Shokofin.API; @@ -27,10 +30,15 @@ public class ShokoAPIClient : IDisposable private static readonly DateTime StableCutOffDate = DateTime.Parse("2023-12-16T00:00:00.000Z"); - private static bool UseStableAPI => + private static bool UseOlderSeriesAndFileEndpoints => ServerCommitDate.HasValue && ServerCommitDate.Value < StableCutOffDate; - private IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions() { + private static readonly DateTime ImportFolderCutOffDate = DateTime.Parse("2024-03-28T00:00:00.000Z"); + + private static bool UseOlderImportFolderFileEndpoints => + ServerCommitDate.HasValue && ServerCommitDate.Value < ImportFolderCutOffDate; + + private GuardedMemoryCache _cache = new(new MemoryCacheOptions() { ExpirationScanFrequency = ExpirationScanFrequency, }); @@ -55,7 +63,7 @@ public void Clear(bool restore = true) _cache.Dispose(); if (restore) { Logger.LogDebug("Initialising new cache…"); - _cache = new MemoryCache(new MemoryCacheOptions() { + _cache = new(new MemoryCacheOptions() { ExpirationScanFrequency = ExpirationScanFrequency, }); } @@ -72,67 +80,65 @@ private Task<ReturnType> Get<ReturnType>(string url, string? apiKey = null) => Get<ReturnType>(url, HttpMethod.Get, apiKey); private Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? apiKey = null) - { - var defaultKey = apiKey == null; - var key = $"apiKey={(defaultKey ? "default" : apiKey)},method={method},url={url},value"; - return _cache.GetOrCreate(key, async (cachedEntry) => { - var response = await Get(url, method, apiKey); - if (response.StatusCode != HttpStatusCode.OK) - throw ApiException.FromResponse(response); - var responseStream = await response.Content.ReadAsStreamAsync(); - responseStream.Seek(0, System.IO.SeekOrigin.Begin); - var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream) ?? - throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); - return value; - }); - } + => _cache.GetOrCreateAsync( + $"apiKey={apiKey ?? "default"},method={method},url={url},object", + (_) => Logger.LogTrace("Reusing object for {Method} {URL}", method, url), + async (cachedEntry) => { + var response = await Get(url, method, apiKey); + if (response.StatusCode != HttpStatusCode.OK) + throw ApiException.FromResponse(response); + var responseStream = await response.Content.ReadAsStreamAsync(); + responseStream.Seek(0, System.IO.SeekOrigin.Begin); + var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream) ?? + throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); + cachedEntry.SlidingExpiration = DefaultTimeSpan; + return value; + } + ); private async Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null) - { - // Use the default key if no key was provided. - var defaultKey = apiKey == null; - apiKey ??= Plugin.Instance.Configuration.ApiKey; - - // Check if we have a key to use. - if (string.IsNullOrEmpty(apiKey)) - throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); - - var version = Plugin.Instance.Configuration.HostVersion; - if (version == null) - { - version = await GetVersion() - ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); - - Plugin.Instance.Configuration.HostVersion = version; - Plugin.Instance.SaveConfiguration(); - } - - try { - var key = $"apiKey={(defaultKey ? "default" : apiKey)},method={method},url={url},httpRequest"; - return await _cache.GetOrCreateAsync(key, async (cachedEntry) => { - if (cachedEntry.Value is HttpResponseMessage message) - return message; - - Logger.LogTrace("Trying to get {URL}", url); - var remoteUrl = string.Concat(Plugin.Instance.Configuration.Host, url); - - using var requestMessage = new HttpRequestMessage(method, remoteUrl); - requestMessage.Content = new StringContent(string.Empty); - requestMessage.Headers.Add("apikey", apiKey); - var response = await _httpClient.SendAsync(requestMessage); - if (response.StatusCode == HttpStatusCode.Unauthorized) - throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); - Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); - cachedEntry.SlidingExpiration = DefaultTimeSpan; - return response; - }); - } - catch (HttpRequestException ex) - { - Logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); - throw; - } - } + => await _cache.GetOrCreateAsync( + $"apiKey={apiKey ?? "default"},method={method},url={url},httpRequest", + (response) => Logger.LogTrace("Reusing response for {Method} {URL}", method, url), + async (cachedEntry) => { + // Use the default key if no key was provided. + apiKey ??= Plugin.Instance.Configuration.ApiKey; + + // Check if we have a key to use. + if (string.IsNullOrEmpty(apiKey)) + throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); + + var version = Plugin.Instance.Configuration.HostVersion; + if (version == null) + { + version = await GetVersion() + ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); + + Plugin.Instance.Configuration.HostVersion = version; + Plugin.Instance.SaveConfiguration(); + } + + try { + Logger.LogTrace("Trying to {Method} {URL}", method, url); + var remoteUrl = string.Concat(Plugin.Instance.Configuration.Host, url); + + using var requestMessage = new HttpRequestMessage(method, remoteUrl); + requestMessage.Content = new StringContent(string.Empty); + requestMessage.Headers.Add("apikey", apiKey); + var response = await _httpClient.SendAsync(requestMessage); + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); + Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); + cachedEntry.SlidingExpiration = DefaultTimeSpan; + return response; + } + catch (HttpRequestException ex) + { + Logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); + throw; + } + } + ); private Task<ReturnType> Post<Type, ReturnType>(string url, Type body, string? apiKey = null) => Post<Type, ReturnType>(url, HttpMethod.Post, body, apiKey); @@ -177,15 +183,14 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method if (method == HttpMethod.Head) throw new HttpRequestException("Head requests cannot contain a body."); - using (var requestMessage = new HttpRequestMessage(method, remoteUrl)) { - requestMessage.Content = (new StringContent(JsonSerializer.Serialize<Type>(body), Encoding.UTF8, "application/json")); - requestMessage.Headers.Add("apikey", apiKey); - var response = await _httpClient.SendAsync(requestMessage); - if (response.StatusCode == HttpStatusCode.Unauthorized) - throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); - Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); - return response; - } + using var requestMessage = new HttpRequestMessage(method, remoteUrl); + requestMessage.Content = (new StringContent(JsonSerializer.Serialize<Type>(body), Encoding.UTF8, "application/json")); + requestMessage.Headers.Add("apikey", apiKey); + var response = await _httpClient.SendAsync(requestMessage); + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); + Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); + return response; } catch (HttpRequestException ex) { @@ -242,7 +247,7 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method public Task<File> GetFile(string id) { - if (UseStableAPI) + if (UseOlderSeriesAndFileEndpoints) return Get<File>($"/api/v3/File/{id}?includeXRefs=true&includeDataFrom=AniDB"); return Get<File>($"/api/v3/File/{id}?include=XRefs&includeDataFrom=AniDB"); @@ -255,17 +260,22 @@ public Task<List<File>> GetFileByPath(string path) public async Task<IReadOnlyList<File>> GetFilesForSeries(string seriesId) { - if (UseStableAPI) + if (UseOlderSeriesAndFileEndpoints) return await Get<List<File>>($"/api/v3/Series/{seriesId}/File?&includeXRefs=true&includeDataFrom=AniDB"); var listResult = await Get<ListResult<File>>($"/api/v3/Series/{seriesId}/File?pageSize=0&include=XRefs&includeDataFrom=AniDB"); return listResult.List; } - public async Task<IReadOnlyList<File>> GetFilesForImportFolder(int importFolderId) + public async Task<IReadOnlyList<File>> GetFilesForImportFolder(int importFolderId, string subPath) { - var listResult = await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?pageSize=0&includeXRefs=true"); - return listResult.List; + if (UseOlderImportFolderFileEndpoints) { + var listResult1 = await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?pageSize=0&includeXRefs=true"); + return listResult1.List; + } + + var listResult2 = await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?folderPath={Uri.EscapeDataString(subPath)}&pageSize=0&include=XRefs"); + return listResult2.List; } public async Task<File.UserStats?> GetFileUserStats(string fileId, string? apiKey = null) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 24edee1a..61894adb 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Shokofin.API.Info; using Shokofin.API.Models; +using Shokofin.Utils; using Path = System.IO.Path; @@ -54,7 +55,7 @@ public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient LibraryManager = libraryManager; } - private IMemoryCache DataCache = new MemoryCache(new MemoryCacheOptions() { + private GuardedMemoryCache DataCache = new(new MemoryCacheOptions() { ExpirationScanFrequency = ExpirationScanFrequency, }); @@ -165,7 +166,7 @@ public void Clear(bool restore = true) SeriesIdToPathDictionary.Clear(); if (restore) { Logger.LogDebug("Initialising new cache…"); - DataCache = new MemoryCache(new MemoryCacheOptions() { + DataCache = new(new MemoryCacheOptions() { ExpirationScanFrequency = ExpirationScanFrequency, }); } @@ -512,18 +513,17 @@ public bool TryGetFileIdForPath(string path, out string? fileId) } private EpisodeInfo CreateEpisodeInfo(Episode episode, string episodeId) - { - var cacheKey = $"episode:{episodeId}"; - if (DataCache.TryGetValue<EpisodeInfo>(cacheKey, out var episodeInfo)) - return episodeInfo; - - Logger.LogTrace("Creating info object for episode {EpisodeName}. (Episode={EpisodeId})", episode.Name, episodeId); - - episodeInfo = new EpisodeInfo(episode); - - DataCache.Set<EpisodeInfo>(cacheKey, episodeInfo, DefaultTimeSpan); - return episodeInfo; - } + => DataCache.GetOrCreate( + $"episode:{episodeId}", + (cachedEntry) => { + Logger.LogTrace("Creating info object for episode {EpisodeName}. (Episode={EpisodeId})", episode.Name, episodeId); + + return new EpisodeInfo(episode); + }, + new() { + AbsoluteExpirationRelativeToNow = DefaultTimeSpan, + } + ); public bool TryGetEpisodeIdForPath(string path, out string? episodeId) { @@ -619,33 +619,33 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) return await GetSeasonInfoForSeries(seriesId); } - private async Task<SeasonInfo> CreateSeasonInfo(Series series, string seriesId) - { - var cacheKey = $"season:{seriesId}"; - if (DataCache.TryGetValue<SeasonInfo>(cacheKey, out var seasonInfo)) { - Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId); - return seasonInfo; - } - - Logger.LogTrace("Creating info object for season {SeriesName}. (Series={SeriesId})", series.Name, seriesId); - - var episodes = (await APIClient.GetEpisodesFromSeries(seriesId) ?? new()).List - .Select(e => CreateEpisodeInfo(e, e.IDs.Shoko.ToString())) - .Where(e => !e.Shoko.IsHidden) - .OrderBy(e => e.AniDB.AirDate) - .ToList(); - var cast = await APIClient.GetSeriesCast(seriesId); - var relations = await APIClient.GetSeriesRelations(seriesId); - var genres = await GetGenresForSeries(seriesId); - var tags = await GetTagsForSeries(seriesId); - - seasonInfo = new SeasonInfo(series, episodes, cast, relations, genres, tags); - - foreach (var episode in episodes) - EpisodeIdToSeriesIdDictionary.TryAdd(episode.Id, seriesId); - DataCache.Set<SeasonInfo>(cacheKey, seasonInfo, DefaultTimeSpan); - return seasonInfo; - } + private Task<SeasonInfo> CreateSeasonInfo(Series series, string seriesId) + => DataCache.GetOrCreateAsync( + $"season:{seriesId}", + (seasonInfo) => Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId), + async (cachedEntry) => { + Logger.LogTrace("Creating info object for season {SeriesName}. (Series={SeriesId})", series.Name, seriesId); + + var episodes = (await APIClient.GetEpisodesFromSeries(seriesId) ?? new()).List + .Select(e => CreateEpisodeInfo(e, e.IDs.Shoko.ToString())) + .Where(e => !e.Shoko.IsHidden) + .OrderBy(e => e.AniDB.AirDate) + .ToList(); + var cast = await APIClient.GetSeriesCast(seriesId); + var relations = await APIClient.GetSeriesRelations(seriesId); + var genres = await GetGenresForSeries(seriesId); + var tags = await GetTagsForSeries(seriesId); + + var seasonInfo = new SeasonInfo(series, episodes, cast, relations, genres, tags); + + foreach (var episode in episodes) + EpisodeIdToSeriesIdDictionary.TryAdd(episode.Id, seriesId); + return seasonInfo; + }, + new() { + AbsoluteExpirationRelativeToNow = DefaultTimeSpan, + } + ); #endregion #region Series Helpers @@ -781,64 +781,65 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul return await CreateShowInfoForGroup(group, group.IDs.Shoko.ToString()); } - private async Task<ShowInfo?> CreateShowInfoForGroup(Group group, string groupId) - { - var cacheKey = $"show:by-group-id:{groupId}"; - if (DataCache.TryGetValue<ShowInfo?>(cacheKey, out var showInfo)) { - Logger.LogTrace("Reusing info object for show {GroupName}. (Group={GroupId})", showInfo?.Name, groupId); - return showInfo; - } + private Task<ShowInfo?> CreateShowInfoForGroup(Group group, string groupId) + => DataCache.GetOrCreateAsync( + $"show:by-group-id:{groupId}", + (showInfo) => Logger.LogTrace("Reusing info object for show {GroupName}. (Group={GroupId})", showInfo?.Name, groupId), + async (cachedEntry) => { + Logger.LogTrace("Creating info object for show {GroupName}. (Group={GroupId})", group.Name, groupId); - Logger.LogTrace("Creating info object for show {GroupName}. (Group={GroupId})", group.Name, groupId); + var seasonList = (await APIClient.GetSeriesInGroup(groupId) + .ContinueWith(task => Task.WhenAll(task.Result.Select(s => CreateSeasonInfo(s, s.IDs.Shoko.ToString())))) + .Unwrap()) + .Where(s => s != null) + .ToList(); - var seasonList = (await APIClient.GetSeriesInGroup(groupId) - .ContinueWith(task => Task.WhenAll(task.Result.Select(s => CreateSeasonInfo(s, s.IDs.Shoko.ToString())))) - .Unwrap()) - .Where(s => s != null) - .ToList(); + var length = seasonList.Count; + if (Plugin.Instance.Configuration.SeparateMovies) { + seasonList = seasonList.Where(s => s.Type != SeriesType.Movie).ToList(); - var length = seasonList.Count; - if (Plugin.Instance.Configuration.SeparateMovies) { - seasonList = seasonList.Where(s => s.Type != SeriesType.Movie).ToList(); + // Return early if no series matched the filter or if the list was empty. + if (seasonList.Count == 0) { + Logger.LogWarning("Creating an empty show info for filter! (Group={GroupId})", groupId); - // Return early if no series matched the filter or if the list was empty. - if (seasonList.Count == 0) { - Logger.LogWarning("Creating an empty show info for filter! (Group={GroupId})", groupId); + cachedEntry.AbsoluteExpirationRelativeToNow = DefaultTimeSpan; + return null; + } + } - showInfo = null; + var showInfo = new ShowInfo(group, seasonList, Logger, length != seasonList.Count); + + foreach (var seasonInfo in seasonList) { + SeriesIdToDefaultSeriesIdDictionary[seasonInfo.Id] = showInfo.Id; + if (!string.IsNullOrEmpty(showInfo.CollectionId)) + SeriesIdToCollectionIdDictionary[seasonInfo.Id] = showInfo.CollectionId; + } - DataCache.Set(cacheKey, showInfo, DefaultTimeSpan); return showInfo; + }, + new() { + AbsoluteExpirationRelativeToNow = DefaultTimeSpan, } - } - - showInfo = new ShowInfo(group, seasonList, Logger, length != seasonList.Count); + ); - foreach (var seasonInfo in seasonList) { - SeriesIdToDefaultSeriesIdDictionary[seasonInfo.Id] = showInfo.Id; - if (!string.IsNullOrEmpty(showInfo.CollectionId)) - SeriesIdToCollectionIdDictionary[seasonInfo.Id] = showInfo.CollectionId; - } - - DataCache.Set(cacheKey, showInfo, DefaultTimeSpan); - return showInfo; - } private ShowInfo GetOrCreateShowInfoForSeasonInfo(SeasonInfo seasonInfo, string? collectionId = null) - { - var cacheKey = $"show:by-series-id:{seasonInfo.Id}"; - if (DataCache.TryGetValue<ShowInfo>(cacheKey, out var showInfo)) { - Logger.LogTrace("Reusing info object for show {GroupName}. (Series={SeriesId})", showInfo.Name, seasonInfo.Id); - return showInfo; - } - - showInfo = new ShowInfo(seasonInfo, collectionId); - SeriesIdToDefaultSeriesIdDictionary[seasonInfo.Id] = showInfo.Id; - if (!string.IsNullOrEmpty(showInfo.CollectionId)) - SeriesIdToCollectionIdDictionary[seasonInfo.Id] = showInfo.CollectionId; - showInfo = DataCache.Set(cacheKey, showInfo, DefaultTimeSpan); - return showInfo; - } + => DataCache.GetOrCreate( + $"show:by-series-id:{seasonInfo.Id}", + (showInfo) => Logger.LogTrace("Reusing info object for show {GroupName}. (Series={SeriesId})", showInfo.Name, seasonInfo.Id), + (cachedEntry) => { + Logger.LogTrace("Creating info object for show {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seasonInfo.Id); + + var showInfo = new ShowInfo(seasonInfo, collectionId); + SeriesIdToDefaultSeriesIdDictionary[seasonInfo.Id] = showInfo.Id; + if (!string.IsNullOrEmpty(showInfo.CollectionId)) + SeriesIdToCollectionIdDictionary[seasonInfo.Id] = showInfo.CollectionId; + return showInfo; + }, + new() { + AbsoluteExpirationRelativeToNow = DefaultTimeSpan, + } + ); #endregion #region Collection Info @@ -848,9 +849,9 @@ private ShowInfo GetOrCreateShowInfoForSeasonInfo(SeasonInfo seasonInfo, string? if (string.IsNullOrEmpty(groupId)) return null; - if (DataCache.TryGetValue<CollectionInfo>($"collection:by-group-id:{groupId}", out var seasonInfo)) { - Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", seasonInfo.Name, groupId); - return seasonInfo; + if (DataCache.TryGetValue<CollectionInfo>($"collection:by-group-id:{groupId}", out var collectionInfo)) { + Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", collectionInfo.Name, groupId); + return collectionInfo; } var group = await APIClient.GetGroup(groupId); @@ -876,54 +877,54 @@ private ShowInfo GetOrCreateShowInfoForSeasonInfo(SeasonInfo seasonInfo, string? return await CreateCollectionInfo(group, group.IDs.Shoko.ToString()); } - private async Task<CollectionInfo> CreateCollectionInfo(Group group, string groupId) - { - var cacheKey = $"collection:by-group-id:{groupId}"; - if (DataCache.TryGetValue<CollectionInfo>(cacheKey, out var collectionInfo)) { - Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", collectionInfo.Name, groupId); - return collectionInfo; - } - - Logger.LogTrace("Creating info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); - - Logger.LogTrace("Fetching show info objects for collection {GroupName}. (Group={GroupId})", group.Name, groupId); - var showGroupIds = new HashSet<string>(); - var collectionIds = new HashSet<string>(); - var showDict = new Dictionary<string, ShowInfo>(); - foreach (var series in await APIClient.GetSeriesInGroup(groupId, recursive: true)) { - var showInfo = await GetShowInfoForSeries(series.IDs.Shoko.ToString()); - if (showInfo == null) - continue; - - if (!string.IsNullOrEmpty(showInfo.GroupId)) - showGroupIds.Add(showInfo.GroupId); - - if (string.IsNullOrEmpty(showInfo.CollectionId)) - continue; - - collectionIds.Add(showInfo.CollectionId); - if (showInfo.CollectionId == groupId) - showDict.TryAdd(showInfo.Id, showInfo); - } - - var groupList = new List<CollectionInfo>(); - if (group.Sizes.SubGroups > 0) { - Logger.LogTrace("Fetching sub-collection info objects for collection {GroupName}. (Group={GroupId})", group.Name, groupId); - foreach (var subGroup in await APIClient.GetGroupsInGroup(groupId)) { - if (showGroupIds.Contains(subGroup.IDs.Shoko.ToString()) && !collectionIds.Contains(subGroup.IDs.Shoko.ToString())) - continue; - var subCollectionInfo = await CreateCollectionInfo(subGroup, subGroup.IDs.Shoko.ToString()); - - groupList.Add(subCollectionInfo); + private Task<CollectionInfo> CreateCollectionInfo(Group group, string groupId) + => DataCache.GetOrCreateAsync( + $"collection:by-group-id:{groupId}", + (collectionInfo) => Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", collectionInfo.Name, groupId), + async (cachedEntry) => { + Logger.LogTrace("Creating info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); + Logger.LogTrace("Fetching show info objects for collection {GroupName}. (Group={GroupId})", group.Name, groupId); + + var showGroupIds = new HashSet<string>(); + var collectionIds = new HashSet<string>(); + var showDict = new Dictionary<string, ShowInfo>(); + foreach (var series in await APIClient.GetSeriesInGroup(groupId, recursive: true)) { + var showInfo = await GetShowInfoForSeries(series.IDs.Shoko.ToString()); + if (showInfo == null) + continue; + + if (!string.IsNullOrEmpty(showInfo.GroupId)) + showGroupIds.Add(showInfo.GroupId); + + if (string.IsNullOrEmpty(showInfo.CollectionId)) + continue; + + collectionIds.Add(showInfo.CollectionId); + if (showInfo.CollectionId == groupId) + showDict.TryAdd(showInfo.Id, showInfo); + } + + var groupList = new List<CollectionInfo>(); + if (group.Sizes.SubGroups > 0) { + Logger.LogTrace("Fetching sub-collection info objects for collection {GroupName}. (Group={GroupId})", group.Name, groupId); + foreach (var subGroup in await APIClient.GetGroupsInGroup(groupId)) { + if (showGroupIds.Contains(subGroup.IDs.Shoko.ToString()) && !collectionIds.Contains(subGroup.IDs.Shoko.ToString())) + continue; + var subCollectionInfo = await CreateCollectionInfo(subGroup, subGroup.IDs.Shoko.ToString()); + + groupList.Add(subCollectionInfo); + } + } + + Logger.LogTrace("Finalising info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); + var showList = showDict.Values.ToList(); + var collectionInfo = new CollectionInfo(group, showList, groupList); + return collectionInfo; + }, + new() { + AbsoluteExpirationRelativeToNow = DefaultTimeSpan, } - } - - Logger.LogTrace("Finalising info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); - var showList = showDict.Values.ToList(); - collectionInfo = new CollectionInfo(group, showList, groupList); - DataCache.Set<CollectionInfo>(cacheKey, collectionInfo, DefaultTimeSpan); - return collectionInfo; - } + ); #endregion } diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 2e54cbd2..83ddd0c2 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -18,7 +18,6 @@ using Shokofin.API.Models; using Shokofin.Utils; -using ApiFile = Shokofin.API.Models.File; using File = System.IO.File; using TvSeries = MediaBrowser.Controller.Entities.TV.Series; @@ -41,7 +40,7 @@ public class ShokoResolveManager private readonly NamingOptions _namingOptions; - private IMemoryCache DataCache = new MemoryCache(new MemoryCacheOptions() { + private GuardedMemoryCache DataCache = new(new MemoryCacheOptions() { ExpirationScanFrequency = ExpirationScanFrequency, }); @@ -73,7 +72,7 @@ public void Clear(bool restore = true) DataCache.Dispose(); if (restore) { Logger.LogDebug("Initialising new cache…"); - DataCache = new MemoryCache(new MemoryCacheOptions() { + DataCache = new(new MemoryCacheOptions() { ExpirationScanFrequency = ExpirationScanFrequency, }); } @@ -102,63 +101,65 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) private async Task<string?> GenerateStructureForFolder(Folder mediaFolder, string folderPath) { - if (DataCache.TryGetValue<string?>(folderPath, out var vfsPath) || DataCache.TryGetValue(mediaFolder.Path, out vfsPath)) + // Return early if we've already generated the structure from the import folder itself. + if (DataCache.TryGetValue<string?>(mediaFolder.Path, out var vfsPath)) return vfsPath; - Logger.LogInformation("Looking for match for folder at {Path}.", folderPath); + return await DataCache.GetOrCreateAsync(folderPath, async (cachedEntry) => { + Logger.LogInformation("Looking for match for folder at {Path}.", folderPath); - // Check if we should introduce the VFS for the media folder. - var allPaths = FileSystem.GetFilePaths(folderPath, true) - .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) - .Take(100) - .ToList(); - int importFolderId = 0; - string importFolderSubPath = string.Empty; - foreach (var path in allPaths) { - var partialPath = path[mediaFolder.Path.Length..]; - var partialFolderPath = path[folderPath.Length..]; - var files = ApiClient.GetFileByPath(partialPath) - .GetAwaiter() - .GetResult(); - var file = files.FirstOrDefault(); - if (file == null) - continue; - - var fileId = file.Id.ToString(); - var fileLocations = file.Locations - .Where(location => location.Path.EndsWith(partialFolderPath)) + // Check if we should introduce the VFS for the media folder. + var allPaths = FileSystem.GetFilePaths(folderPath, true) + .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + .Take(100) .ToList(); - if (fileLocations.Count == 0) - continue; + int importFolderId = 0; + string importFolderSubPath = string.Empty; + foreach (var path in allPaths) { + var partialPath = path[mediaFolder.Path.Length..]; + var partialFolderPath = path[folderPath.Length..]; + var files = ApiClient.GetFileByPath(partialPath) + .GetAwaiter() + .GetResult(); + var file = files.FirstOrDefault(); + if (file == null) + continue; + + var fileId = file.Id.ToString(); + var fileLocations = file.Locations + .Where(location => location.Path.EndsWith(partialFolderPath)) + .ToList(); + if (fileLocations.Count == 0) + continue; - var fileLocation = fileLocations[0]; - importFolderId = fileLocation.ImportFolderId; - importFolderSubPath = fileLocation.Path[..^partialFolderPath.Length]; - break; - } + var fileLocation = fileLocations[0]; + importFolderId = fileLocation.ImportFolderId; + importFolderSubPath = fileLocation.Path[..^partialFolderPath.Length]; + break; + } - if (importFolderId == 0) { - Logger.LogWarning("Failed to find a match for folder at {Path} after {Amount} attempts.", folderPath, allPaths.Count); + if (importFolderId == 0) { + Logger.LogWarning("Failed to find a match for folder at {Path} after {Amount} attempts.", folderPath, allPaths.Count); - DataCache.Set<string?>(folderPath, null, DefaultTTL); - return null; - } + cachedEntry.AbsoluteExpirationRelativeToNow = DefaultTTL; + return null; + } - Logger.LogInformation("Found a match for folder at {Path} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path})", folderPath, importFolderId, importFolderSubPath, mediaFolder.Path); + Logger.LogInformation("Found a match for folder at {Path} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path})", folderPath, importFolderId, importFolderSubPath, mediaFolder.Path); - vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); - DataCache.Set(folderPath, vfsPath, DefaultTTL); - var allFiles = await GetImportFolderFiles(importFolderId, importFolderSubPath, folderPath); - await GenerateSymbolicLinks(mediaFolder, allFiles); + vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + var allFiles = await GetImportFolderFiles(importFolderId, importFolderSubPath, folderPath); + await GenerateSymbolicLinks(mediaFolder, allFiles); - return vfsPath; + cachedEntry.AbsoluteExpirationRelativeToNow = DefaultTTL; + return vfsPath; + }); } private async Task<IReadOnlyList<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>> GetImportFolderFiles(int importFolderId, string importFolderSubPath, string mediaFolderPath) { Logger.LogDebug("Looking for recognised files within media folder… (ImportFolder={FolderId},RelativePath={RelativePath})", importFolderId, importFolderSubPath); - var allFilesForImportFolder = (await ApiClient.GetFilesForImportFolder(importFolderId)) - .AsParallel() + var allFilesForImportFolder = (await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath)) .SelectMany(file => { var location = file.Locations @@ -190,7 +191,6 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IReadOnlyList<(stri var allPathsForVFS = new ConcurrentBag<(string sourceLocation, string symbolicLink)>(); var semaphore = new SemaphoreSlim(10); await Task.WhenAll(files - .AsParallel() .Select(async (tuple) => { await semaphore.WaitAsync(); diff --git a/Shokofin/Utils/GuardedMemoryCache.cs b/Shokofin/Utils/GuardedMemoryCache.cs new file mode 100644 index 00000000..570cddb1 --- /dev/null +++ b/Shokofin/Utils/GuardedMemoryCache.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; + +#nullable enable +namespace Shokofin.Utils; + +sealed class GuardedMemoryCache : IDisposable, IMemoryCache +{ + private readonly IMemoryCache Cache; + + private readonly ConcurrentDictionary<object, SemaphoreSlim> Semaphores = new(); + + public GuardedMemoryCache(MemoryCacheOptions options) => Cache = new MemoryCache(options); + + public GuardedMemoryCache(IMemoryCache cache) => Cache = cache; + + public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICacheEntry, TItem> createFactory, MemoryCacheEntryOptions? createOptions = null) + { + if (Cache.TryGetValue<TItem>(key, out var value)) { + foundAction(value); + return value; + } + + var semaphore = GetSemaphore(key); + + semaphore.Wait(); + + try + { + if (Cache.TryGetValue<TItem>(key, out value)) { + foundAction(value); + return value; + } + + using ICacheEntry entry = Cache.CreateEntry(key); + if (createOptions != null) + entry.SetOptions(createOptions); + + value = createFactory(entry); + entry.Value = value; + return value; + } + finally + { + semaphore.Release(); + RemoveSemaphore(key); + } + } + + public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> foundAction, Func<ICacheEntry, Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) + { + if (Cache.TryGetValue<TItem>(key, out var value)) { + foundAction(value); + return value; + } + + var semaphore = GetSemaphore(key); + + await semaphore.WaitAsync(); + + try + { + if (Cache.TryGetValue<TItem>(key, out value)) { + foundAction(value); + return value; + } + + using ICacheEntry entry = Cache.CreateEntry(key); + if (createOptions != null) + entry.SetOptions(createOptions); + + value = await createFactory(entry).ConfigureAwait(false); + entry.Value = value; + return value; + } + finally + { + semaphore.Release(); + RemoveSemaphore(key); + } + } + + public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFactory, MemoryCacheEntryOptions? createOptions = null) + { + if (Cache.TryGetValue<TItem>(key, out var value)) + return value; + + var semaphore = GetSemaphore(key); + + semaphore.Wait(); + + try + { + if (Cache.TryGetValue<TItem>(key, out value)) + return value; + + using ICacheEntry entry = Cache.CreateEntry(key); + if (createOptions != null) + entry.SetOptions(createOptions); + + value = createFactory(entry); + entry.Value = value; + return value; + } + finally + { + semaphore.Release(); + RemoveSemaphore(key); + } + } + + public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) + { + if (Cache.TryGetValue<TItem>(key, out var value)) + return value; + + var semaphore = GetSemaphore(key); + + await semaphore.WaitAsync(); + + try + { + if (Cache.TryGetValue<TItem>(key, out value)) + return value; + + using ICacheEntry entry = Cache.CreateEntry(key); + if (createOptions != null) + entry.SetOptions(createOptions); + + value = await createFactory(entry).ConfigureAwait(false); + entry.Value = value; + return value; + } + finally + { + semaphore.Release(); + RemoveSemaphore(key); + } + } + + public void Dispose() + { + foreach (var semaphore in Semaphores.Values) + semaphore.Release(); + Cache.Dispose(); + } + + SemaphoreSlim GetSemaphore(object key) + => Semaphores.GetOrAdd(key, _ => new SemaphoreSlim(1)); + + void RemoveSemaphore(object key) + { + Semaphores.TryRemove(key, out var _); + } + + public ICacheEntry CreateEntry(object key) + => Cache.CreateEntry(key); + + public void Remove(object key) + => Cache.Remove(key); + + public bool TryGetValue(object key, out object value) + => Cache.TryGetValue(key, out value); +} \ No newline at end of file From f952f62f7bd0ec1ba4d2b3b59b47b62cbdf2df99 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 28 Mar 2024 10:20:24 +0100 Subject: [PATCH 0623/1103] fix: fix series creation that i broke at some point --- Shokofin/Resolvers/ShokoResolveManager.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 83ddd0c2..3f44537f 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -508,7 +508,8 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou return null; if (parent.Id == mediaFolder.Id && fileInfo.IsDirectory) { - if (!int.TryParse(fileInfo.Name.Split('-').LastOrDefault(), out var seriesId)) + var seriesSegment = fileInfo.Name.Split('[').Last().Split(']').First(); + if (!int.TryParse(seriesSegment.Split('-').LastOrDefault(), out var seriesId)) return null; return new TvSeries() From a323527bded081e354b73d4ac26cccd7e660e4d9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 28 Mar 2024 10:22:59 +0100 Subject: [PATCH 0624/1103] misc: track time spent generating symbolic links --- Shokofin/Resolvers/ShokoResolveManager.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 3f44537f..5d1d2471 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -185,6 +185,7 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IReadOnlyList<(stri { Logger.LogInformation("Found {FileCount} recognised files to potentially use within media folder at {Path}", files.Count, mediaFolder.Path); + var start = DateTime.UtcNow; var skipped = 0; var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); @@ -232,12 +233,14 @@ await Task.WhenAll(files CleanupDirectoryStructure(symbolicLink); } + var timeSpent = start - DateTime.UtcNow; Logger.LogInformation( - "Created {CreatedCount}, skipped {SkippedCount}, and removed {RemovedCount} symbolic links for media folder at {Path}", + "Created {CreatedCount}, skipped {SkippedCount}, and removed {RemovedCount} symbolic links for media folder at {Path} in {TimeSpan}", allPathsForVFS.Count - skipped, skipped, toBeRemoved.Count, - mediaFolder.Path + mediaFolder.Path, + timeSpent ); } From 4bc5059b1e81989f6203fe06182d0db6d4335bce Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 28 Mar 2024 10:35:14 +0100 Subject: [PATCH 0625/1103] fix: move file lookup from the first loop when finding locations to the second loop when generating sym-links --- Shokofin/Resolvers/ShokoResolveManager.cs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 5d1d2471..e0bb7d9e 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -158,8 +158,10 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) private async Task<IReadOnlyList<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>> GetImportFolderFiles(int importFolderId, string importFolderSubPath, string mediaFolderPath) { - Logger.LogDebug("Looking for recognised files within media folder… (ImportFolder={FolderId},RelativePath={RelativePath})", importFolderId, importFolderSubPath); + Logger.LogDebug("Looking up recognised files for media folder… (ImportFolder={FolderId},RelativePath={RelativePath})", importFolderId, importFolderSubPath); + var start = DateTime.UtcNow; var allFilesForImportFolder = (await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath)) + .AsParallel() .SelectMany(file => { var location = file.Locations @@ -169,21 +171,26 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) return Array.Empty<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>(); var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); - if (!File.Exists(sourceLocation)) - return Array.Empty<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>(); - return file.CrossReferences .Select(xref => (sourceLocation, fileId: file.Id.ToString(), seriesId: xref.Series.Shoko.ToString(), episodeIds: xref.Episodes.Select(e => e.Shoko.ToString()).ToArray())); }) .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation)) .ToList(); - Logger.LogDebug("Found {FileCount} files to use within media folder at {Path} (ImportFolder={FolderId},RelativePath={RelativePath})", allFilesForImportFolder.Count, mediaFolderPath, importFolderId, importFolderSubPath); + var timeSpent = start - DateTime.UtcNow; + Logger.LogDebug( + "Found ≤{FileCount} files to potentially use within media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath})", + allFilesForImportFolder.Count, + mediaFolderPath, + timeSpent, + importFolderId, + importFolderSubPath + ); return allFilesForImportFolder; } private async Task GenerateSymbolicLinks(Folder mediaFolder, IReadOnlyList<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)> files) { - Logger.LogInformation("Found {FileCount} recognised files to potentially use within media folder at {Path}", files.Count, mediaFolder.Path); + Logger.LogInformation("Creating structure for ≤{FileCount} files to potentially use within media folder at {Path}", files.Count, mediaFolder.Path); var start = DateTime.UtcNow; var skipped = 0; @@ -196,6 +203,9 @@ await Task.WhenAll(files await semaphore.WaitAsync(); try { + if (!File.Exists(tuple.sourceLocation)) + return; + var (sourceLocation, symbolicLink) = await GenerateLocationForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId, tuple.episodeIds); // Skip any source files we weren't meant to have in the library. if (string.IsNullOrEmpty(sourceLocation)) From 0bc37ad9fa05f4cc69e027496144286988f66a82 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 28 Mar 2024 10:37:40 +0100 Subject: [PATCH 0626/1103] fix: reverse time estimation --- Shokofin/Resolvers/ShokoResolveManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index e0bb7d9e..6716ed22 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -176,7 +176,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) }) .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation)) .ToList(); - var timeSpent = start - DateTime.UtcNow; + var timeSpent = DateTime.UtcNow - start; Logger.LogDebug( "Found ≤{FileCount} files to potentially use within media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath})", allFilesForImportFolder.Count, @@ -243,7 +243,7 @@ await Task.WhenAll(files CleanupDirectoryStructure(symbolicLink); } - var timeSpent = start - DateTime.UtcNow; + var timeSpent = DateTime.UtcNow - start; Logger.LogInformation( "Created {CreatedCount}, skipped {SkippedCount}, and removed {RemovedCount} symbolic links for media folder at {Path} in {TimeSpan}", allPathsForVFS.Count - skipped, From fbf6b63ba1ffbdfa4989136007f5e7f3c90884f6 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 28 Mar 2024 09:51:10 +0000 Subject: [PATCH 0627/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 7b38fea4..e001ddf8 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.48", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.48/shoko_3.0.1.48.zip", + "checksum": "1d04cb3a2862ebf6fba879f4048e7ee4", + "timestamp": "2024-03-28T09:51:09Z" + }, { "version": "3.0.1.47", "changelog": "NA\n", From b806492c899937346818c440a2647ee88ae87676 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 28 Mar 2024 10:59:46 +0100 Subject: [PATCH 0628/1103] =?UTF-8?q?refactor:=20use=20setting=20for=20thr?= =?UTF-8?q?ead=20count=20but=20don't=20add=20it=20to=20the=20UI=E2=80=A6?= =?UTF-8?q?=20yet=20at=20least.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/Configuration/PluginConfiguration.cs | 3 +++ Shokofin/Resolvers/ShokoResolveManager.cs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index f8d42f0c..e49a5892 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -60,6 +60,8 @@ public virtual string PrettyHost public bool VirtualFileSystem { get; set; } + public int VirtualFileSystemThreads { get; set; } + public bool UseGroupsForShows { get; set; } public bool SeparateMovies { get; set; } @@ -128,6 +130,7 @@ public PluginConfiguration() TitleAllowAny = false; DescriptionSource = TextSourceType.Default; VirtualFileSystem = true; + VirtualFileSystemThreads = 10; UseGroupsForShows = false; SeparateMovies = false; SeasonOrdering = OrderType.Default; diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 6716ed22..bc910298 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -197,7 +197,7 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IReadOnlyList<(stri var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); var allPathsForVFS = new ConcurrentBag<(string sourceLocation, string symbolicLink)>(); - var semaphore = new SemaphoreSlim(10); + var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); await Task.WhenAll(files .Select(async (tuple) => { await semaphore.WaitAsync(); From cccd47f56cba87a320758c8e1f2f0304a393798a Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 28 Mar 2024 10:00:23 +0000 Subject: [PATCH 0629/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index e001ddf8..fcab2b89 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.49", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.49/shoko_3.0.1.49.zip", + "checksum": "6c7905622a86e2494718ec17e0bbe62c", + "timestamp": "2024-03-28T10:00:21Z" + }, { "version": "3.0.1.48", "changelog": "NA\n", From 7c790886dab37bcaa39c1f0a06aa23eefd80b5c3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 01:28:29 +0100 Subject: [PATCH 0630/1103] refactor: generate one or more links per file Fixed a typo in the generated file name, and divided up the code so it will generate the folder names and file names separately, to allow for multiple folder locations for the same file name. --- Shokofin/Resolvers/ShokoResolveManager.cs | 154 +++++++++++----------- 1 file changed, 78 insertions(+), 76 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index bc910298..aa7661d7 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -203,28 +203,31 @@ await Task.WhenAll(files await semaphore.WaitAsync(); try { + // Skip any source files we that we cannot find. if (!File.Exists(tuple.sourceLocation)) return; - var (sourceLocation, symbolicLink) = await GenerateLocationForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId, tuple.episodeIds); + var (sourceLocation, symbolicLinks) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId, tuple.episodeIds); // Skip any source files we weren't meant to have in the library. if (string.IsNullOrEmpty(sourceLocation)) return; - if (File.Exists(symbolicLink)) { - skipped++; - allPathsForVFS.Add((sourceLocation, symbolicLink)); - return; - } + foreach (var symbolicLink in symbolicLinks) { + if (File.Exists(symbolicLink)) { + skipped++; + allPathsForVFS.Add((sourceLocation, symbolicLink)); + return; + } - // TODO: Check for subtitle files. + // TODO: Check for subtitle files. - var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; - if (!Directory.Exists(symbolicDirectory)) - Directory.CreateDirectory(symbolicDirectory); + var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; + if (!Directory.Exists(symbolicDirectory)) + Directory.CreateDirectory(symbolicDirectory); - allPathsForVFS.Add((sourceLocation, symbolicLink)); - File.CreateSymbolicLink(symbolicLink, sourceLocation); + allPathsForVFS.Add((sourceLocation, symbolicLink)); + File.CreateSymbolicLink(symbolicLink, sourceLocation); + } } finally { semaphore.Release(); @@ -240,6 +243,7 @@ await Task.WhenAll(files // TODO: Check for subtitle files. File.Delete(symbolicLink); + CleanupDirectoryStructure(symbolicLink); } @@ -254,93 +258,91 @@ await Task.WhenAll(files ); } - private async Task<(string sourceLocation, string symbolicLink)> GenerateLocationForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId, string[] episodeIds) + private async Task<(string sourceLocation, string[] symbolicLinks)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId, string[] episodeIds) { var season = await ApiManager.GetSeasonInfoForSeries(seriesId); if (season == null) - return (sourceLocation: string.Empty, symbolicLink: string.Empty); + return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); + + var isMovieSeason = season.Type == SeriesType.Movie; + var shouldAbort = collectionType switch { + CollectionType.TvShows => isMovieSeason && Plugin.Instance.Configuration.SeparateMovies, + CollectionType.Movies => !isMovieSeason, + _ => false, + }; + if (shouldAbort) + return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); var show = await ApiManager.GetShowInfoForSeries(seriesId); if (show == null) - return (sourceLocation: string.Empty, symbolicLink: string.Empty); + return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); var file = await ApiManager.GetFileInfo(fileId, seriesId); var episode = file?.EpisodeList.FirstOrDefault(); if (file == null || episode == null) - return (sourceLocation: string.Empty, symbolicLink: string.Empty); - - // In the off-chance that we accidentially ended up with two - // instances of the season while fetching in parallel, then we're - // switching to the correct reference of the season for the show - // we're doing. Let's just hope we won't have to also need to switch - // the episode… - season = show.SeasonList.FirstOrDefault(s => s.Id == seriesId); - episode = season?.RawEpisodeList.FirstOrDefault(e => e.Id == episode.Id); + return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); if (season == null || episode == null) - return (sourceLocation: string.Empty, symbolicLink: string.Empty); + return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); + + var isSpecial = episode.IsSpecial; + var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); + var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters(); if (string.IsNullOrEmpty(showName)) showName = $"Shoko Series {show.Id}"; else if (show.DefaultSeason.AniDB.AirDate.HasValue) showName += $" ({show.DefaultSeason.AniDB.AirDate.Value.Year})"; - var isSpecial = episode.IsSpecial; - var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); - var paths = new List<string>() { vfsPath, $"{showName} [shoko-series-{show.Id}]", $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}" }; - var episodeName = episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode == "en")?.Value ?? $"Episode {episode.AniDB.Type} {episode.AniDB.EpisodeNumber}"; - if (file.ExtraType != null) - { - var extrasFolder = file.ExtraType switch { - ExtraType.BehindTheScenes => "behind the scenes", - ExtraType.Clip => "clips", - ExtraType.DeletedScene => "deleted scene", - ExtraType.Interview => "interviews", - ExtraType.Sample => "samples", - ExtraType.Scene => "scenes", - ExtraType.ThemeSong => "theme-music", - ExtraType.ThemeVideo => "backdrops", - ExtraType.Trailer => "trailers", - ExtraType.Unknown => "others", - _ => "extras", - }; - paths.Add(extrasFolder); + var folders = new List<string>(); + var episodeName = (episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode == "en")?.Value ?? $"Episode {episode.AniDB.Type} {episodeNumber}").ReplaceInvalidPathCharacters(); + var extrasFolder = file.ExtraType switch { + ExtraType.BehindTheScenes => "behind the scenes", + ExtraType.Clip => "clips", + ExtraType.DeletedScene => "deleted scene", + ExtraType.Interview => "interviews", + ExtraType.Sample => "samples", + ExtraType.Scene => "scenes", + ExtraType.ThemeSong => "theme-music", + ExtraType.ThemeVideo => "backdrops", + ExtraType.Trailer => "trailers", + ExtraType.Unknown => "others", + null => null, + _ => "extras", + }; + + if (isMovieSeason && collectionType != CollectionType.TvShows) { + if (!string.IsNullOrEmpty(extrasFolder)) + { + foreach (var episodeInfo in season.EpisodeList) + folders.Add(Path.Combine(vfsPath, $"{showName} [shoko-series-{show.Id}] [shoko-episode-{episodeInfo.Id}]", extrasFolder)); + } + else { + folders.Add(Path.Combine(vfsPath, $"{showName} [shoko-series-{show.Id}] [shoko-episode-{episode.Id}]")); + episodeName = "Movie"; + } } else { - var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); - episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber}"; - } - - var isMovieSeason = season.Type == SeriesType.Movie; - switch (collectionType) { - case CollectionType.TvShows: { - if (isMovieSeason && Plugin.Instance.Configuration.SeparateMovies) - return (sourceLocation: string.Empty, symbolicLink: string.Empty); - - goto default; - } - case CollectionType.Movies: { - if (!isMovieSeason) - return (sourceLocation: string.Empty, symbolicLink: string.Empty); - - // Remove the season directory from the path. - paths.RemoveAt(2); - - paths.Add( $"Movie [shoko-series-{seriesId}] [shoko-file-{fileId}{Path.GetExtension(sourceLocation)}]"); - var symbolicLink = Path.Combine(paths.ToArray()); - ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, episodeIds); - return (sourceLocation, symbolicLink); + var seasonName = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}"; + if (!string.IsNullOrEmpty(extrasFolder)) + { + folders.Add(Path.Combine(vfsPath, $"{showName} [shoko-series-{show.Id}]", extrasFolder)); + folders.Add(Path.Combine(vfsPath, $"{showName} [shoko-series-{show.Id}]", seasonName, extrasFolder)); } - default: { - if (isMovieSeason && collectionType == null && Plugin.Instance.Configuration.SeparateMovies) - goto case CollectionType.Movies; - - paths.Add($"{episodeName} [shoko-series-{seriesId}] [shoko-file-{fileId}{Path.GetExtension(sourceLocation)}]"); - var symbolicLink = Path.Combine(paths.ToArray()); - ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, episodeIds); - return (sourceLocation, symbolicLink); + else { + folders.Add(Path.Combine(vfsPath, $"{showName} [shoko-series-{show.Id}]", seasonName)); + episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber}"; } } + + var fileName = $"{episodeName} [shoko-series-{seriesId}] [shoko-file-{fileId}]{Path.GetExtension(sourceLocation)}"; + var symbolicLinks = folders + .Select(folderPath => Path.Combine(folderPath, fileName)) + .ToArray(); + + foreach (var symbolicLink in symbolicLinks) + ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, episodeIds); + return (sourceLocation, symbolicLinks); } private static void CleanupDirectoryStructure(string? path) From a86f0e9d3092f09cb1c9ffb7df34a8ab10931d38 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 01:28:48 +0100 Subject: [PATCH 0631/1103] feat: add subtitle file detection --- Shokofin/Resolvers/ShokoResolveManager.cs | 65 +++++++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index aa7661d7..83d9ff6f 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -6,11 +6,13 @@ using System.Threading; using System.Threading.Tasks; using Emby.Naming.Common; +using Emby.Naming.ExternalFiles; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -40,6 +42,8 @@ public class ShokoResolveManager private readonly NamingOptions _namingOptions; + private readonly ExternalPathParser ExternalPathParser; + private GuardedMemoryCache DataCache = new(new MemoryCacheOptions() { ExpirationScanFrequency = ExpirationScanFrequency, }); @@ -48,7 +52,16 @@ public class ShokoResolveManager private static readonly TimeSpan DefaultTTL = TimeSpan.FromMinutes(60); - public ShokoResolveManager(ShokoAPIManager apiManager, ShokoAPIClient apiClient, IIdLookup lookup, ILibraryManager libraryManager, IFileSystem fileSystem, ILogger<ShokoResolveManager> logger, NamingOptions namingOptions) + public ShokoResolveManager( + ShokoAPIManager apiManager, + ShokoAPIClient apiClient, + IIdLookup lookup, + ILibraryManager libraryManager, + IFileSystem fileSystem, + ILogger<ShokoResolveManager> logger, + ILocalizationManager localizationManager, + NamingOptions namingOptions + ) { ApiManager = apiManager; ApiClient = apiClient; @@ -57,6 +70,7 @@ public ShokoResolveManager(ShokoAPIManager apiManager, ShokoAPIClient apiClient, FileSystem = fileSystem; Logger = logger; _namingOptions = namingOptions; + ExternalPathParser = new ExternalPathParser(namingOptions, localizationManager, MediaBrowser.Model.Dlna.DlnaProfileType.Subtitle); LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; } @@ -212,6 +226,8 @@ await Task.WhenAll(files if (string.IsNullOrEmpty(sourceLocation)) return; + var sourcePrefix = Path.GetFileNameWithoutExtension(sourceLocation); + var subtitleLinks = FindSubtitlesForPath(sourceLocation); foreach (var symbolicLink in symbolicLinks) { if (File.Exists(symbolicLink)) { skipped++; @@ -219,14 +235,21 @@ await Task.WhenAll(files return; } - // TODO: Check for subtitle files. - var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; if (!Directory.Exists(symbolicDirectory)) Directory.CreateDirectory(symbolicDirectory); allPathsForVFS.Add((sourceLocation, symbolicLink)); File.CreateSymbolicLink(symbolicLink, sourceLocation); + + if (subtitleLinks.Count > 0) + { + var destinationPrefix = Path.GetFileNameWithoutExtension(symbolicLink); + foreach (var (source, dest) in subtitleLinks.Select(path => (path, destinationPrefix + path[sourcePrefix.Length..]))) { + allPathsForVFS.Add((source, dest)); + File.CreateSymbolicLink(dest, source); + } + } } } finally { @@ -240,10 +263,13 @@ await Task.WhenAll(files .Except(allPathsForVFS.Select(tuple => tuple.symbolicLink).ToHashSet()) .ToList(); foreach (var symbolicLink in toBeRemoved) { - // TODO: Check for subtitle files. + var subtitleLinks = FindSubtitlesForPath(symbolicLink); File.Delete(symbolicLink); + foreach (var subtitleLink in subtitleLinks) + File.Delete(symbolicLink); + CleanupDirectoryStructure(symbolicLink); } @@ -353,6 +379,37 @@ private static void CleanupDirectoryStructure(string? path) path = Path.GetDirectoryName(path); } } + + private IReadOnlyList<string> FindSubtitlesForPath(string sourcePath) + { + var externalPaths = new List<string>(); + var folderPath = Path.GetDirectoryName(sourcePath); + if (string.IsNullOrEmpty(folderPath) || !FileSystem.DirectoryExists(folderPath)) + return externalPaths; + + var files = FileSystem.GetFilePaths(folderPath) + .ToList(); + files.Remove(sourcePath); + + if (files.Count == 0) + return externalPaths; + + var sourcePrefix = Path.GetFileNameWithoutExtension(sourcePath); + foreach (var file in files) { + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file); + if ( + fileNameWithoutExtension.Length >= sourcePrefix.Length && + sourcePrefix.Equals(fileNameWithoutExtension[..sourcePrefix.Length], StringComparison.OrdinalIgnoreCase) && + (fileNameWithoutExtension.Length == sourcePrefix.Length || _namingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[sourcePrefix.Length])) + ) { + var externalPathInfo = ExternalPathParser.ParseFile(file, fileNameWithoutExtension[sourcePrefix.Length..].ToString()); + if (externalPathInfo != null && !string.IsNullOrEmpty(externalPathInfo.Path)) + externalPaths.Add(externalPathInfo.Path); + } + } + + return externalPaths; + } #endregion From ebb7ead6f0f9b714f13a8058731816826c3a23a5 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 29 Mar 2024 00:30:15 +0000 Subject: [PATCH 0632/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index fcab2b89..e22846ea 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.50", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.50/shoko_3.0.1.50.zip", + "checksum": "f25dbf75dc6bed8eae79600b23ff488a", + "timestamp": "2024-03-29T00:30:13Z" + }, { "version": "3.0.1.49", "changelog": "NA\n", From 937b7098cfc7651292488c2ea9f88df8af507fc8 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 01:54:27 +0100 Subject: [PATCH 0633/1103] cleanup: split up external ids --- Shokofin/ExternalIds.cs | 80 -------------------------- Shokofin/ExternalIds/ShokoEpisodeId.cs | 26 +++++++++ Shokofin/ExternalIds/ShokoFileId.cs | 27 +++++++++ Shokofin/ExternalIds/ShokoGroupId.cs | 26 +++++++++ Shokofin/ExternalIds/ShokoSeriesId.cs | 26 +++++++++ 5 files changed, 105 insertions(+), 80 deletions(-) delete mode 100644 Shokofin/ExternalIds.cs create mode 100644 Shokofin/ExternalIds/ShokoEpisodeId.cs create mode 100644 Shokofin/ExternalIds/ShokoFileId.cs create mode 100644 Shokofin/ExternalIds/ShokoGroupId.cs create mode 100644 Shokofin/ExternalIds/ShokoSeriesId.cs diff --git a/Shokofin/ExternalIds.cs b/Shokofin/ExternalIds.cs deleted file mode 100644 index fd6b2ca8..00000000 --- a/Shokofin/ExternalIds.cs +++ /dev/null @@ -1,80 +0,0 @@ -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Entities.Movies; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; - -namespace Shokofin -{ - public class ShokoGroupExternalId : IExternalId - { - public bool Supports(IHasProviderIds item) - => item is Series or BoxSet; - - public string ProviderName - => "Shoko Group"; - - public string Key - => "Shoko Group"; - - public ExternalIdMediaType? Type - => null; - - public virtual string UrlFormatString - => $"{Plugin.Instance.Configuration.PrettyHost}/webui/collection/group/{{0}}"; - } - - public class ShokoSeriesExternalId : IExternalId - { - public bool Supports(IHasProviderIds item) - => item is Series or Season or Movie; - - public string ProviderName - => "Shoko Series"; - - public string Key - => "Shoko Series"; - - public ExternalIdMediaType? Type - => null; - - public virtual string UrlFormatString - => $"{Plugin.Instance.Configuration.PrettyHost}/webui/collection/series/{{0}}"; - } - - public class ShokoEpisodeExternalId : IExternalId - { - public bool Supports(IHasProviderIds item) - => item is Episode or Movie; - - public string ProviderName - => "Shoko Episode"; - - public string Key - => "Shoko Episode"; - - public ExternalIdMediaType? Type - => null; - - public virtual string UrlFormatString - => $"{Plugin.Instance.Configuration.PrettyHost}/webui/redirect/episode/{{0}}"; - } - - public class ShokoFileExternalId : IExternalId - { - public bool Supports(IHasProviderIds item) - => item is Episode or Movie; - - public string ProviderName - => "Shoko File"; - - public string Key - => "Shoko File"; - - public ExternalIdMediaType? Type - => null; - - public virtual string UrlFormatString - => null; - } -} \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoEpisodeId.cs b/Shokofin/ExternalIds/ShokoEpisodeId.cs new file mode 100644 index 00000000..070a8359 --- /dev/null +++ b/Shokofin/ExternalIds/ShokoEpisodeId.cs @@ -0,0 +1,26 @@ +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +#nullable enable +namespace Shokofin.ExternalIds; + +public class ShokoEpisodeId : IExternalId +{ + public bool Supports(IHasProviderIds item) + => item is Episode or Movie; + + public string ProviderName + => "Shoko Episode"; + + public string Key + => "Shoko Episode"; + + public ExternalIdMediaType? Type + => null; + + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyHost}/webui/redirect/episode/{{0}}"; +} \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoFileId.cs b/Shokofin/ExternalIds/ShokoFileId.cs new file mode 100644 index 00000000..2d802278 --- /dev/null +++ b/Shokofin/ExternalIds/ShokoFileId.cs @@ -0,0 +1,27 @@ +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +#nullable enable +namespace Shokofin.ExternalIds; + + +public class ShokoFileId : IExternalId +{ + public bool Supports(IHasProviderIds item) + => item is Episode or Movie; + + public string ProviderName + => "Shoko File"; + + public string Key + => "Shoko File"; + + public ExternalIdMediaType? Type + => null; + + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyHost}/webui/redirect/file/{{0}}"; +} \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoGroupId.cs b/Shokofin/ExternalIds/ShokoGroupId.cs new file mode 100644 index 00000000..211e6975 --- /dev/null +++ b/Shokofin/ExternalIds/ShokoGroupId.cs @@ -0,0 +1,26 @@ +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +#nullable enable +namespace Shokofin.ExternalIds; + +public class ShokoGroupId : IExternalId +{ + public bool Supports(IHasProviderIds item) + => item is Series or BoxSet; + + public string ProviderName + => "Shoko Group"; + + public string Key + => "Shoko Group"; + + public ExternalIdMediaType? Type + => null; + + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyHost}/webui/collection/group/{{0}}"; +} \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoSeriesId.cs b/Shokofin/ExternalIds/ShokoSeriesId.cs new file mode 100644 index 00000000..ae7ad0c4 --- /dev/null +++ b/Shokofin/ExternalIds/ShokoSeriesId.cs @@ -0,0 +1,26 @@ +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +#nullable enable +namespace Shokofin.ExternalIds; + +public class ShokoSeriesId : IExternalId +{ + public bool Supports(IHasProviderIds item) + => item is Series or Season or Movie; + + public string ProviderName + => "Shoko Series"; + + public string Key + => "Shoko Series"; + + public ExternalIdMediaType? Type + => null; + + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyHost}/webui/collection/series/{{0}}"; +} \ No newline at end of file From 772d5ef318ce36614250fc56bfc40c8db75db2d3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 01:54:56 +0100 Subject: [PATCH 0634/1103] cleanup: rename util files --- Shokofin/Utils/{OrderingUtil.cs => Ordering.cs} | 0 Shokofin/Utils/{TextUtil.cs => Text.cs} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename Shokofin/Utils/{OrderingUtil.cs => Ordering.cs} (100%) rename Shokofin/Utils/{TextUtil.cs => Text.cs} (100%) diff --git a/Shokofin/Utils/OrderingUtil.cs b/Shokofin/Utils/Ordering.cs similarity index 100% rename from Shokofin/Utils/OrderingUtil.cs rename to Shokofin/Utils/Ordering.cs diff --git a/Shokofin/Utils/TextUtil.cs b/Shokofin/Utils/Text.cs similarity index 100% rename from Shokofin/Utils/TextUtil.cs rename to Shokofin/Utils/Text.cs From 2db2b53f2551f4717a108057794fc37a9ee79028 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 01:55:50 +0100 Subject: [PATCH 0635/1103] chore: flatten text util file --- Shokofin/Utils/Text.cs | 669 ++++++++++++++++++++--------------------- 1 file changed, 334 insertions(+), 335 deletions(-) diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index ca030fb9..81e0626b 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -5,389 +5,388 @@ using System.Linq; using System.Text.RegularExpressions; -namespace Shokofin.Utils +namespace Shokofin.Utils; + +public static class Text { - public static class Text - { - private static HashSet<char> PunctuationMarks = new() { - // Common punctuation marks - '.', // period - ',', // comma - ';', // semicolon - ':', // colon - '!', // exclamation point - '?', // question mark - ')', // right parenthesis - ']', // right bracket - '}', // right brace - '"', // double quote - '\'', // single quote - ',', // Chinese comma - '、', // Chinese enumeration comma - '!', // Chinese exclamation point - '?', // Chinese question mark - '“', // Chinese double quote - '”', // Chinese double quote - '‘', // Chinese single quote - '’', // Chinese single quote - '】', // Chinese right bracket - '》', // Chinese right angle bracket - ')', // Chinese right parenthesis - '・', // Japanese middle dot - - // Less common punctuation marks - '‽', // interrobang - '❞', // double question mark - '❝', // double exclamation mark - '⁇', // question mark variation - '⁈', // exclamation mark variation - '❕', // white exclamation mark - '❔', // white question mark - '‽', // interrobang - '⁉', // exclamation mark - '‽', // interrobang - '※', // reference mark - '⟩', // right angle bracket - '❯', // right angle bracket - '❭', // right angle bracket - '〉', // right angle bracket - '⌉', // right angle bracket - '⌋', // right angle bracket - '⦄', // right angle bracket - '⦆', // right angle bracket - '⦈', // right angle bracket - '⦊', // right angle bracket - '⦌', // right angle bracket - '⦎', // right angle bracket - }; + private static HashSet<char> PunctuationMarks = new() { + // Common punctuation marks + '.', // period + ',', // comma + ';', // semicolon + ':', // colon + '!', // exclamation point + '?', // question mark + ')', // right parenthesis + ']', // right bracket + '}', // right brace + '"', // double quote + '\'', // single quote + ',', // Chinese comma + '、', // Chinese enumeration comma + '!', // Chinese exclamation point + '?', // Chinese question mark + '“', // Chinese double quote + '”', // Chinese double quote + '‘', // Chinese single quote + '’', // Chinese single quote + '】', // Chinese right bracket + '》', // Chinese right angle bracket + ')', // Chinese right parenthesis + '・', // Japanese middle dot + + // Less common punctuation marks + '‽', // interrobang + '❞', // double question mark + '❝', // double exclamation mark + '⁇', // question mark variation + '⁈', // exclamation mark variation + '❕', // white exclamation mark + '❔', // white question mark + '‽', // interrobang + '⁉', // exclamation mark + '‽', // interrobang + '※', // reference mark + '⟩', // right angle bracket + '❯', // right angle bracket + '❭', // right angle bracket + '〉', // right angle bracket + '⌉', // right angle bracket + '⌋', // right angle bracket + '⦄', // right angle bracket + '⦆', // right angle bracket + '⦈', // right angle bracket + '⦊', // right angle bracket + '⦌', // right angle bracket + '⦎', // right angle bracket + }; + + private static HashSet<string> IgnoredSubTitles = new() { + "Complete Movie", + "OVA", + }; + + /// <summary> + /// Where to get text the text from. + /// </summary> + public enum TextSourceType { + /// <summary> + /// Use the default source for the current series grouping. + /// </summary> + Default = 1, - private static HashSet<string> IgnoredSubTitles = new() { - "Complete Movie", - "OVA", - }; + /// <summary> + /// Only use AniDb, or null if no data is available. + /// </summary> + OnlyAniDb = 2, /// <summary> - /// Where to get text the text from. + /// Prefer the AniDb data, but use the other provider if there is no + /// AniDb data available. /// </summary> - public enum TextSourceType { - /// <summary> - /// Use the default source for the current series grouping. - /// </summary> - Default = 1, - - /// <summary> - /// Only use AniDb, or null if no data is available. - /// </summary> - OnlyAniDb = 2, - - /// <summary> - /// Prefer the AniDb data, but use the other provider if there is no - /// AniDb data available. - /// </summary> - PreferAniDb = 3, - - /// <summary> - /// Prefer the other provider (e.g. TvDB/TMDB) - /// </summary> - PreferOther = 4, - - /// <summary> - /// Only use the other provider, or null if no data is available. - /// </summary> - OnlyOther = 5, - } + PreferAniDb = 3, /// <summary> - /// Determines the language to construct the title in. + /// Prefer the other provider (e.g. TvDB/TMDB) /// </summary> - public enum DisplayLanguageType { - /// <summary> - /// Let Shoko decide what to display. - /// </summary> - Default = 1, - - /// <summary> - /// Prefer to use the selected metadata language for the library if - /// available, but fallback to the default view if it's not - /// available. - /// </summary> - MetadataPreferred = 2, - - /// <summary> - /// Use the origin language for the series. - /// </summary> - Origin = 3, - - /// <summary> - /// Don't display a title. - /// </summary> - Ignore = 4, - - /// <summary> - /// Use the main title for the series. - /// </summary> - Main = 5, - } + PreferOther = 4, /// <summary> - /// Determines the type of title to construct. + /// Only use the other provider, or null if no data is available. /// </summary> - public enum DisplayTitleType { - /// <summary> - /// Only construct the main title. - /// </summary> - MainTitle = 1, - - /// <summary> - /// Only construct the sub title. - /// </summary> - SubTitle = 2, - - /// <summary> - /// Construct a combined main and sub title. - /// </summary> - FullTitle = 3, - } + OnlyOther = 5, + } - public static string GetDescription(ShowInfo show) - => GetDescription(show.DefaultSeason); - - public static string GetDescription(SeasonInfo season) - => GetDescription(season.AniDB.Description, season.TvDB?.Description); - - public static string GetDescription(EpisodeInfo episode) - => GetDescription(episode.AniDB.Description, episode.TvDB?.Description); - - public static string GetDescription(IEnumerable<EpisodeInfo> episodeList) - => JoinText(episodeList.Select(episode => GetDescription(episode))); - - private static string GetDescription(string aniDbDescription, string otherDescription) - { - string overview; - switch (Plugin.Instance.Configuration.DescriptionSource) { - default: - case TextSourceType.PreferAniDb: - overview = SanitizeTextSummary(aniDbDescription); - if (string.IsNullOrEmpty(overview)) - goto case TextSourceType.OnlyOther; - break; - case TextSourceType.PreferOther: - overview = otherDescription ?? ""; - if (string.IsNullOrEmpty(overview)) - goto case TextSourceType.OnlyAniDb; - break; - case TextSourceType.OnlyAniDb: - overview = SanitizeTextSummary(aniDbDescription); - break; - case TextSourceType.OnlyOther: - overview = otherDescription ?? ""; - break; - } - return overview; - } + /// <summary> + /// Determines the language to construct the title in. + /// </summary> + public enum DisplayLanguageType { + /// <summary> + /// Let Shoko decide what to display. + /// </summary> + Default = 1, /// <summary> - /// Based on ShokoMetadata's summary sanitizer which in turn is based on HAMA's summary sanitizer. + /// Prefer to use the selected metadata language for the library if + /// available, but fallback to the default view if it's not + /// available. /// </summary> - /// <param name="summary">The raw AniDB summary</param> - /// <returns>The sanitized AniDB summary</returns> - public static string SanitizeTextSummary(string summary) - { - if (string.IsNullOrWhiteSpace(summary)) - return ""; + MetadataPreferred = 2, - var config = Plugin.Instance.Configuration; + /// <summary> + /// Use the origin language for the series. + /// </summary> + Origin = 3, - if (config.SynopsisCleanLinks) - summary = Regex.Replace(summary, @"https?:\/\/\w+.\w+(?:\/?\w+)? \[([^\]]+)\]", match => match.Groups[1].Value); + /// <summary> + /// Don't display a title. + /// </summary> + Ignore = 4, - if (config.SynopsisCleanMiscLines) - summary = Regex.Replace(summary, @"^(\*|--|~) .*", "", RegexOptions.Multiline); + /// <summary> + /// Use the main title for the series. + /// </summary> + Main = 5, + } - if (config.SynopsisRemoveSummary) - summary = Regex.Replace(summary, @"\n(Source|Note|Summary):.*", "", RegexOptions.Singleline); + /// <summary> + /// Determines the type of title to construct. + /// </summary> + public enum DisplayTitleType { + /// <summary> + /// Only construct the main title. + /// </summary> + MainTitle = 1, - if (config.SynopsisCleanMultiEmptyLines) - summary = Regex.Replace(summary, @"\n{2,}", "\n", RegexOptions.Singleline); + /// <summary> + /// Only construct the sub title. + /// </summary> + SubTitle = 2, - return summary.Trim(); - } + /// <summary> + /// Construct a combined main and sub title. + /// </summary> + FullTitle = 3, + } - public static ( string, string ) GetEpisodeTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) - => GetTitles(seriesTitles, episodeTitles, null, episodeTitle, DisplayTitleType.SubTitle, metadataLanguage); + public static string GetDescription(ShowInfo show) + => GetDescription(show.DefaultSeason); - public static ( string, string ) GetSeriesTitles(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) - => GetTitles(seriesTitles, null, seriesTitle, null, DisplayTitleType.MainTitle, metadataLanguage); + public static string GetDescription(SeasonInfo season) + => GetDescription(season.AniDB.Description, season.TvDB?.Description); - public static ( string, string ) GetMovieTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) - => GetTitles(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplayTitleType.FullTitle, metadataLanguage); + public static string GetDescription(EpisodeInfo episode) + => GetDescription(episode.AniDB.Description, episode.TvDB?.Description); - public static ( string, string ) GetTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayTitleType outputType, string metadataLanguage) - { - // Don't process anything if the series titles are not provided. - if (seriesTitles == null) - return (null, null); - return ( - GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage), - GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleAlternateType, outputType, metadataLanguage) - ); + public static string GetDescription(IEnumerable<EpisodeInfo> episodeList) + => JoinText(episodeList.Select(episode => GetDescription(episode))); + + private static string GetDescription(string aniDbDescription, string otherDescription) + { + string overview; + switch (Plugin.Instance.Configuration.DescriptionSource) { + default: + case TextSourceType.PreferAniDb: + overview = SanitizeTextSummary(aniDbDescription); + if (string.IsNullOrEmpty(overview)) + goto case TextSourceType.OnlyOther; + break; + case TextSourceType.PreferOther: + overview = otherDescription ?? ""; + if (string.IsNullOrEmpty(overview)) + goto case TextSourceType.OnlyAniDb; + break; + case TextSourceType.OnlyAniDb: + overview = SanitizeTextSummary(aniDbDescription); + break; + case TextSourceType.OnlyOther: + overview = otherDescription ?? ""; + break; } + return overview; + } - public static string JoinText(IEnumerable<string> textList) - { - var filteredList = textList - .Where(title => !string.IsNullOrWhiteSpace(title)) - .Select(title => title.Trim()) - // We distinct the list because some episode entries contain the **exact** same description. - .Distinct() - .ToList(); - - if (filteredList.Count == 0) - return ""; - - var index = 1; - var outputText = filteredList[0]; - while (index < filteredList.Count) { - var lastChar = outputText[^1]; - outputText += PunctuationMarks.Contains(lastChar) ? " " : ". "; - outputText += filteredList[index++]; - } + /// <summary> + /// Based on ShokoMetadata's summary sanitizer which in turn is based on HAMA's summary sanitizer. + /// </summary> + /// <param name="summary">The raw AniDB summary</param> + /// <returns>The sanitized AniDB summary</returns> + public static string SanitizeTextSummary(string summary) + { + if (string.IsNullOrWhiteSpace(summary)) + return ""; - if (filteredList.Count > 1) - outputText.TrimEnd(); + var config = Plugin.Instance.Configuration; - return outputText; + if (config.SynopsisCleanLinks) + summary = Regex.Replace(summary, @"https?:\/\/\w+.\w+(?:\/?\w+)? \[([^\]]+)\]", match => match.Groups[1].Value); + + if (config.SynopsisCleanMiscLines) + summary = Regex.Replace(summary, @"^(\*|--|~) .*", "", RegexOptions.Multiline); + + if (config.SynopsisRemoveSummary) + summary = Regex.Replace(summary, @"\n(Source|Note|Summary):.*", "", RegexOptions.Singleline); + + if (config.SynopsisCleanMultiEmptyLines) + summary = Regex.Replace(summary, @"\n{2,}", "\n", RegexOptions.Singleline); + + return summary.Trim(); + } + + public static ( string, string ) GetEpisodeTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) + => GetTitles(seriesTitles, episodeTitles, null, episodeTitle, DisplayTitleType.SubTitle, metadataLanguage); + + public static ( string, string ) GetSeriesTitles(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) + => GetTitles(seriesTitles, null, seriesTitle, null, DisplayTitleType.MainTitle, metadataLanguage); + + public static ( string, string ) GetMovieTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) + => GetTitles(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplayTitleType.FullTitle, metadataLanguage); + + public static ( string, string ) GetTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayTitleType outputType, string metadataLanguage) + { + // Don't process anything if the series titles are not provided. + if (seriesTitles == null) + return (null, null); + return ( + GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage), + GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleAlternateType, outputType, metadataLanguage) + ); + } + + public static string JoinText(IEnumerable<string> textList) + { + var filteredList = textList + .Where(title => !string.IsNullOrWhiteSpace(title)) + .Select(title => title.Trim()) + // We distinct the list because some episode entries contain the **exact** same description. + .Distinct() + .ToList(); + + if (filteredList.Count == 0) + return ""; + + var index = 1; + var outputText = filteredList[0]; + while (index < filteredList.Count) { + var lastChar = outputText[^1]; + outputText += PunctuationMarks.Contains(lastChar) ? " " : ". "; + outputText += filteredList[index++]; } - public static string GetEpisodeTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) - => GetTitle(seriesTitles, episodeTitles, null, episodeTitle, DisplayTitleType.SubTitle, metadataLanguage); + if (filteredList.Count > 1) + outputText.TrimEnd(); + + return outputText; + } + + public static string GetEpisodeTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) + => GetTitle(seriesTitles, episodeTitles, null, episodeTitle, DisplayTitleType.SubTitle, metadataLanguage); - public static string GetSeriesTitle(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) - => GetTitle(seriesTitles, null, seriesTitle, null, DisplayTitleType.MainTitle, metadataLanguage); + public static string GetSeriesTitle(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) + => GetTitle(seriesTitles, null, seriesTitle, null, DisplayTitleType.MainTitle, metadataLanguage); - public static string GetMovieTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) - => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplayTitleType.FullTitle, metadataLanguage); + public static string GetMovieTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) + => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplayTitleType.FullTitle, metadataLanguage); - public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayTitleType outputType, string metadataLanguage) - => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage); + public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayTitleType outputType, string metadataLanguage) + => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage); - public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayLanguageType languageType, DisplayTitleType outputType, string displayLanguage) - { - // Don't process anything if the series titles are not provided. - if (seriesTitles == null) + public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayLanguageType languageType, DisplayTitleType outputType, string displayLanguage) + { + // Don't process anything if the series titles are not provided. + if (seriesTitles == null) + return null; + var mainTitleLanguage = GetMainLanguage(seriesTitles); + var originLanguages = GuessOriginLanguage(mainTitleLanguage); + switch (languageType) { + // 'Ignore' will always return null, and all other values will also return null. + default: + case DisplayLanguageType.Ignore: return null; - var mainTitleLanguage = GetMainLanguage(seriesTitles); - var originLanguages = GuessOriginLanguage(mainTitleLanguage); - switch (languageType) { - // 'Ignore' will always return null, and all other values will also return null. - default: - case DisplayLanguageType.Ignore: - return null; - // Let Shoko decide the title. - case DisplayLanguageType.Default: - return ConstructTitle(() => seriesTitle, () => episodeTitle, outputType); - // Display in metadata-preferred language, or fallback to default. - case DisplayLanguageType.MetadataPreferred: { - var allowAny = Plugin.Instance.Configuration.TitleAllowAny; - var getSeriesTitle = () => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, displayLanguage) ?? (allowAny ? GetTitleByLanguages(seriesTitles, displayLanguage) : null) ?? seriesTitle; - var getEpisodeTitle = () => GetTitleByLanguages(episodeTitles, displayLanguage) ?? episodeTitle; - var title = ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); - if (string.IsNullOrEmpty(title)) - goto case DisplayLanguageType.Default; - return title; - } - // Display in origin language. - case DisplayLanguageType.Origin: { - var allowAny = Plugin.Instance.Configuration.TitleAllowAny; - var getSeriesTitle = () => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, originLanguages) ?? (allowAny ? GetTitleByLanguages(seriesTitles, originLanguages) : null) ?? seriesTitle; - var getEpisodeTitle = () => GetTitleByLanguages(episodeTitles, originLanguages) ?? episodeTitle; - return ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); - } - // Display the main title. - case DisplayLanguageType.Main: { - var getSeriesTitle = () => GetTitleByType(seriesTitles, TitleType.Main) ?? seriesTitle; - var getEpisodeTitle = () => GetTitleByLanguages(episodeTitles, "en", mainTitleLanguage) ?? episodeTitle; - return ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); - } + // Let Shoko decide the title. + case DisplayLanguageType.Default: + return ConstructTitle(() => seriesTitle, () => episodeTitle, outputType); + // Display in metadata-preferred language, or fallback to default. + case DisplayLanguageType.MetadataPreferred: { + var allowAny = Plugin.Instance.Configuration.TitleAllowAny; + var getSeriesTitle = () => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, displayLanguage) ?? (allowAny ? GetTitleByLanguages(seriesTitles, displayLanguage) : null) ?? seriesTitle; + var getEpisodeTitle = () => GetTitleByLanguages(episodeTitles, displayLanguage) ?? episodeTitle; + var title = ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); + if (string.IsNullOrEmpty(title)) + goto case DisplayLanguageType.Default; + return title; } - } - - private static string ConstructTitle(Func<string> getSeriesTitle, Func<string> getEpisodeTitle, DisplayTitleType outputType) - { - switch (outputType) { - // Return series title. - case DisplayTitleType.MainTitle: - return getSeriesTitle()?.Trim(); - // Return episode title. - case DisplayTitleType.SubTitle: - return getEpisodeTitle()?.Trim(); - // Return combined series and episode title. - case DisplayTitleType.FullTitle: { - var mainTitle = getSeriesTitle()?.Trim(); - var subTitle = getEpisodeTitle()?.Trim(); - // Include sub-title if it does not strictly equals any ignored sub titles. - if (!string.IsNullOrWhiteSpace(subTitle) && !IgnoredSubTitles.Contains(mainTitle)) - return $"{mainTitle}: {subTitle}"; - return mainTitle; - } - default: - return null; + // Display in origin language. + case DisplayLanguageType.Origin: { + var allowAny = Plugin.Instance.Configuration.TitleAllowAny; + var getSeriesTitle = () => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, originLanguages) ?? (allowAny ? GetTitleByLanguages(seriesTitles, originLanguages) : null) ?? seriesTitle; + var getEpisodeTitle = () => GetTitleByLanguages(episodeTitles, originLanguages) ?? episodeTitle; + return ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); } - } - - public static string GetTitleByType(IEnumerable<Title> titles, TitleType type) - { - if (titles != null) { - string title = titles.FirstOrDefault(s => s.Type == type)?.Value; - if (title != null) - return title; + // Display the main title. + case DisplayLanguageType.Main: { + var getSeriesTitle = () => GetTitleByType(seriesTitles, TitleType.Main) ?? seriesTitle; + var getEpisodeTitle = () => GetTitleByLanguages(episodeTitles, "en", mainTitleLanguage) ?? episodeTitle; + return ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); } - return null; } + } - public static string GetTitleByTypeAndLanguage(IEnumerable<Title> titles, TitleType type, params string[] langs) - { - if (titles != null) foreach (string lang in langs) { - string title = titles.FirstOrDefault(s => s.LanguageCode == lang && s.Type == type)?.Value; - if (title != null) - return title; + private static string ConstructTitle(Func<string> getSeriesTitle, Func<string> getEpisodeTitle, DisplayTitleType outputType) + { + switch (outputType) { + // Return series title. + case DisplayTitleType.MainTitle: + return getSeriesTitle()?.Trim(); + // Return episode title. + case DisplayTitleType.SubTitle: + return getEpisodeTitle()?.Trim(); + // Return combined series and episode title. + case DisplayTitleType.FullTitle: { + var mainTitle = getSeriesTitle()?.Trim(); + var subTitle = getEpisodeTitle()?.Trim(); + // Include sub-title if it does not strictly equals any ignored sub titles. + if (!string.IsNullOrWhiteSpace(subTitle) && !IgnoredSubTitles.Contains(mainTitle)) + return $"{mainTitle}: {subTitle}"; + return mainTitle; } - return null; + default: + return null; } + } - public static string GetTitleByLanguages(IEnumerable<Title> titles, params string[] langs) - { - if (titles != null) foreach (string lang in langs) { - string title = titles.FirstOrDefault(s => lang.Equals(s.LanguageCode, System.StringComparison.OrdinalIgnoreCase))?.Value; - if (title != null) - return title; - } - return null; + public static string GetTitleByType(IEnumerable<Title> titles, TitleType type) + { + if (titles != null) { + string title = titles.FirstOrDefault(s => s.Type == type)?.Value; + if (title != null) + return title; } + return null; + } - /// <summary> - /// Get the main title language from the series list. - /// </summary> - /// <param name="titles">Series title list.</param> - /// <returns></returns> - private static string GetMainLanguage(IEnumerable<Title> titles) { - return titles.FirstOrDefault(t => t?.Type == TitleType.Main)?.LanguageCode ?? titles.FirstOrDefault()?.LanguageCode ?? "x-other"; + public static string GetTitleByTypeAndLanguage(IEnumerable<Title> titles, TitleType type, params string[] langs) + { + if (titles != null) foreach (string lang in langs) { + string title = titles.FirstOrDefault(s => s.LanguageCode == lang && s.Type == type)?.Value; + if (title != null) + return title; } + return null; + } - /// <summary> - /// Guess the origin language based on the main title. - /// </summary> - /// <param name="titles">Series title list.</param> - /// <returns></returns> - private static string[] GuessOriginLanguage(string langCode) - { - // Guess the origin language based on the main title language. - return 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 }, - }; + public static string GetTitleByLanguages(IEnumerable<Title> titles, params string[] langs) + { + if (titles != null) foreach (string lang in langs) { + string title = titles.FirstOrDefault(s => lang.Equals(s.LanguageCode, System.StringComparison.OrdinalIgnoreCase))?.Value; + if (title != null) + return title; } + return null; + } + + /// <summary> + /// Get the main title language from the series list. + /// </summary> + /// <param name="titles">Series title list.</param> + /// <returns></returns> + private static string GetMainLanguage(IEnumerable<Title> titles) { + return titles.FirstOrDefault(t => t?.Type == TitleType.Main)?.LanguageCode ?? titles.FirstOrDefault()?.LanguageCode ?? "x-other"; + } + + /// <summary> + /// Guess the origin language based on the main title. + /// </summary> + /// <param name="titles">Series title list.</param> + /// <returns></returns> + private static string[] GuessOriginLanguage(string langCode) + { + // Guess the origin language based on the main title language. + return 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 }, + }; } } From 09e7342cd1b53f7d72c870123e82f164b0964774 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 02:44:29 +0100 Subject: [PATCH 0636/1103] chore: flatten namespaces and enable nullable on some files --- Shokofin/API/Info/ShowInfo.cs | 8 +- Shokofin/API/Models/Episode.cs | 2 +- Shokofin/Configuration/UserConfiguration.cs | 126 +- Shokofin/IdLookup.cs | 433 ++++--- Shokofin/MergeVersions/MergeVersionManager.cs | 29 +- Shokofin/PluginServiceRegistrator.cs | 29 +- Shokofin/Providers/BoxSetProvider.cs | 201 ++-- Shokofin/Providers/EpisodeProvider.cs | 389 +++---- Shokofin/Providers/ExtraMetadataProvider.cs | 1013 +++++++++-------- Shokofin/Providers/ImageProvider.cs | 216 ++-- Shokofin/Providers/MovieProvider.cs | 128 +-- Shokofin/Providers/SeasonProvider.cs | 274 +++-- Shokofin/Providers/SeriesProvider.cs | 172 +-- Shokofin/Sync/SyncDirection.cs | 45 +- Shokofin/Sync/SyncExtensions.cs | 1 + Shokofin/Sync/UserDataSyncManager.cs | 963 ++++++++-------- 16 files changed, 2003 insertions(+), 2026 deletions(-) diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index ce33fa9a..bd6c03f6 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -65,7 +65,13 @@ public class ShowInfo /// <summary> /// Overall content rating of the show. /// </summary> - public string? ContentRating => + public string? OfficialRating => + DefaultSeason.AniDB.Restricted ? "XXX" : null; + + /// <summary> + /// Custom rating of the show. + /// </summary> + public string? CustomRating => DefaultSeason.AniDB.Restricted ? "XXX" : null; /// <summary> diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index a923d3fe..96884fbb 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -66,7 +66,7 @@ public class AniDB public string Description { get; set; } = ""; - public Rating? Rating { get; set; } = new(); + public Rating Rating { get; set; } = new(); } public class TvDB diff --git a/Shokofin/Configuration/UserConfiguration.cs b/Shokofin/Configuration/UserConfiguration.cs index d122fe4d..f805f840 100644 --- a/Shokofin/Configuration/UserConfiguration.cs +++ b/Shokofin/Configuration/UserConfiguration.cs @@ -1,80 +1,80 @@ using System; using System.ComponentModel.DataAnnotations; -namespace Shokofin.Configuration +#nullable enable +namespace Shokofin.Configuration; + +/// <summary> +/// Per user configuration. +/// </summary> +public class UserConfiguration { /// <summary> - /// Per user configuration. + /// The Jellyfin user id this configuration is for. + /// </summary> + public Guid UserId { get; set; } = Guid.Empty; + + /// <summary> + /// Enables watch-state synchronization for the user. /// </summary> - public class UserConfiguration - { - /// <summary> - /// The Jellyfin user id this configuration is for. - /// </summary> - public Guid UserId { get; set; } = Guid.Empty; + public bool EnableSynchronization { get; set; } - /// <summary> - /// Enables watch-state synchronization for the user. - /// </summary> - public bool EnableSynchronization { get; set; } + /// <summary> + /// Enable the stop event for syncing after video playback. + /// </summary> + public bool SyncUserDataAfterPlayback { get; set; } - /// <summary> - /// Enable the stop event for syncing after video playback. - /// </summary> - public bool SyncUserDataAfterPlayback { get; set; } - - /// <summary> - /// Enable the play/pause/resume(/stop) events for syncing under/during - /// video playback. - /// </summary> - public bool SyncUserDataUnderPlayback { get; set; } + /// <summary> + /// Enable the play/pause/resume(/stop) events for syncing under/during + /// video playback. + /// </summary> + public bool SyncUserDataUnderPlayback { get; set; } - /// <summary> - /// Enable the scrobble event for live syncing under/during video - /// playback. - /// </summary> - public bool SyncUserDataUnderPlaybackLive { get; set; } + /// <summary> + /// Enable the scrobble event for live syncing under/during video + /// playback. + /// </summary> + public bool SyncUserDataUnderPlaybackLive { get; set; } - /// <summary> - /// Number of playback events to skip before starting to send the events - /// to Shoko. This is to prevent accidentially updating user watch data - /// when a user miss clicked on a video. - /// </summary> - [Range(0, 200)] - public byte SyncUserDataInitialSkipEventCount { get; set; } = 0; + /// <summary> + /// Number of playback events to skip before starting to send the events + /// to Shoko. This is to prevent accidentially updating user watch data + /// when a user miss clicked on a video. + /// </summary> + [Range(0, 200)] + public byte SyncUserDataInitialSkipEventCount { get; set; } = 0; - /// <summary> - /// Number of ticks to skip (1 tick is 10 seconds) before scrobbling to - /// shoko. - /// </summary> - [Range(1, 250)] - public byte SyncUserDataUnderPlaybackAtEveryXTicks { get; set; } = 6; + /// <summary> + /// Number of ticks to skip (1 tick is 10 seconds) before scrobbling to + /// shoko. + /// </summary> + [Range(1, 250)] + public byte SyncUserDataUnderPlaybackAtEveryXTicks { get; set; } = 6; - /// <summary> - /// Imminently scrobble if the playtime changes above this threshold - /// given in ticks (ticks in a time-span). - /// </summary> - /// <value></value> - public long SyncUserDataUnderPlaybackLiveThreshold { get; set; } = 125000000; // 12.5s + /// <summary> + /// Imminently scrobble if the playtime changes above this threshold + /// given in ticks (ticks in a time-span). + /// </summary> + /// <value></value> + public long SyncUserDataUnderPlaybackLiveThreshold { get; set; } = 125000000; // 12.5s - /// <summary> - /// Enable syncing user data when an item have been added/updated. - /// </summary> - public bool SyncUserDataOnImport { get; set; } + /// <summary> + /// Enable syncing user data when an item have been added/updated. + /// </summary> + public bool SyncUserDataOnImport { get; set; } - /// <summary> - /// Enabling user data sync. for restricted videos (H). - /// </summary> - public bool SyncRestrictedVideos { get; set; } + /// <summary> + /// Enabling user data sync. for restricted videos (H). + /// </summary> + public bool SyncRestrictedVideos { get; set; } - /// <summary> - /// The username of the linked user in Shoko. - /// </summary> - public string Username { get; set; } = string.Empty; + /// <summary> + /// The username of the linked user in Shoko. + /// </summary> + public string Username { get; set; } = string.Empty; - /// <summary> - /// The API Token for authentication/authorization with Shoko Server. - /// </summary> - public string Token { get; set; } = string.Empty; - } + /// <summary> + /// The API Token for authentication/authorization with Shoko Server. + /// </summary> + public string Token { get; set; } = string.Empty; } diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index af86c5cd..c367f0ee 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -6,296 +6,293 @@ using MediaBrowser.Controller.Library; using Shokofin.API; using Shokofin.Providers; -using Shokofin.Utils; -namespace Shokofin +namespace Shokofin; +public interface IIdLookup { - public interface IIdLookup - { - #region Base Item + #region Base Item - /// <summary> - /// Check if the plugin is enabled for <see cref="MediaBrowser.Controller.Entities.BaseItem" >the item</see>. - /// </summary> - /// <param name="item">The <see cref="MediaBrowser.Controller.Entities.BaseItem" /> to check.</param> - /// <returns>True if the plugin is enabled for the <see cref="MediaBrowser.Controller.Entities.BaseItem" /></returns> - bool IsEnabledForItem(BaseItem item); + /// <summary> + /// Check if the plugin is enabled for <see cref="MediaBrowser.Controller.Entities.BaseItem" >the item</see>. + /// </summary> + /// <param name="item">The <see cref="MediaBrowser.Controller.Entities.BaseItem" /> to check.</param> + /// <returns>True if the plugin is enabled for the <see cref="MediaBrowser.Controller.Entities.BaseItem" /></returns> + bool IsEnabledForItem(BaseItem item); - /// <summary> - /// Check if the plugin is enabled for <see cref="MediaBrowser.Controller.Entities.BaseItem" >the item</see>. - /// </summary> - /// <param name="item">The <see cref="MediaBrowser.Controller.Entities.BaseItem" /> 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="MediaBrowser.Controller.Entities.BaseItem" /></returns> - bool IsEnabledForItem(BaseItem item, out bool isSoleProvider); + /// <summary> + /// Check if the plugin is enabled for <see cref="MediaBrowser.Controller.Entities.BaseItem" >the item</see>. + /// </summary> + /// <param name="item">The <see cref="MediaBrowser.Controller.Entities.BaseItem" /> 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="MediaBrowser.Controller.Entities.BaseItem" /></returns> + bool IsEnabledForItem(BaseItem item, out bool isSoleProvider); - #endregion - #region Series Id + #endregion + #region Series Id - bool TryGetSeriesIdFor(string path, out string seriesId); + bool TryGetSeriesIdFor(string path, out string seriesId); - bool TryGetSeriesIdFromEpisodeId(string episodeId, out string seriesId); + bool TryGetSeriesIdFromEpisodeId(string episodeId, out string seriesId); - /// <summary> - /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Series" />. - /// </summary> - /// <param name="series">The <see cref="MediaBrowser.Controller.Entities.TV.Series" /> to check for.</param> - /// <param name="seriesId">The variable to put the id in.</param> - /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Series" />.</returns> - bool TryGetSeriesIdFor(Series series, out string seriesId); + /// <summary> + /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Series" />. + /// </summary> + /// <param name="series">The <see cref="MediaBrowser.Controller.Entities.TV.Series" /> to check for.</param> + /// <param name="seriesId">The variable to put the id in.</param> + /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Series" />.</returns> + bool TryGetSeriesIdFor(Series series, out string seriesId); - /// <summary> - /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. - /// </summary> - /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> - /// <param name="seriesId">The variable to put the id in.</param> - /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> - bool TryGetSeriesIdFor(Season season, out string seriesId); + /// <summary> + /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. + /// </summary> + /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> + /// <param name="seriesId">The variable to put the id in.</param> + /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> + bool TryGetSeriesIdFor(Season season, out string seriesId); - /// <summary> - /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. - /// </summary> - /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> - /// <param name="seriesId">The variable to put the id in.</param> - /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> - bool TryGetSeriesIdFor(BoxSet boxSet, out string seriesId); + /// <summary> + /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. + /// </summary> + /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> + /// <param name="seriesId">The variable to put the id in.</param> + /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> + bool TryGetSeriesIdFor(BoxSet boxSet, out string seriesId); - /// <summary> - /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. - /// </summary> - /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> - /// <param name="seriesId">The variable to put the id in.</param> - /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> - bool TryGetSeriesIdFor(Movie movie, out string seriesId); + /// <summary> + /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. + /// </summary> + /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> + /// <param name="seriesId">The variable to put the id in.</param> + /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> + bool TryGetSeriesIdFor(Movie movie, out string seriesId); - #endregion - #region Series Path + #endregion + #region Series Path - bool TryGetPathForSeriesId(string seriesId, out string path); + bool TryGetPathForSeriesId(string seriesId, out string path); - #endregion - #region Episode Id + #endregion + #region Episode Id - bool TryGetEpisodeIdFor(string path, out string episodeId); + bool TryGetEpisodeIdFor(string path, out string episodeId); - bool TryGetEpisodeIdFor(BaseItem item, out string episodeId); + bool TryGetEpisodeIdFor(BaseItem item, out string episodeId); - bool TryGetEpisodeIdsFor(string path, out List<string> episodeIds); + bool TryGetEpisodeIdsFor(string path, out List<string> episodeIds); - bool TryGetEpisodeIdsFor(BaseItem item, out List<string> episodeIds); + bool TryGetEpisodeIdsFor(BaseItem item, out List<string> episodeIds); - #endregion - #region Episode Path + #endregion + #region Episode Path - bool TryGetPathForEpisodeId(string episodeId, out string path); + bool TryGetPathForEpisodeId(string episodeId, out string path); - #endregion - #region File Id + #endregion + #region File Id - bool TryGetFileIdFor(BaseItem item, out string fileId); + bool TryGetFileIdFor(BaseItem item, out string fileId); - #endregion - } + #endregion +} - public class IdLookup : IIdLookup - { - private readonly ShokoAPIManager ApiManager; +public class IdLookup : IIdLookup +{ + private readonly ShokoAPIManager ApiManager; - private readonly ILibraryManager LibraryManager; + private readonly ILibraryManager LibraryManager; - public IdLookup(ShokoAPIManager apiManager, ILibraryManager libraryManager) - { - ApiManager = apiManager; - LibraryManager = libraryManager; - } + public IdLookup(ShokoAPIManager apiManager, ILibraryManager libraryManager) + { + ApiManager = apiManager; + LibraryManager = libraryManager; + } - #region Base Item + #region Base Item - private readonly HashSet<string> AllowedTypes = new() { nameof(Series), nameof(Season), nameof(Episode), nameof(Movie) }; + private readonly HashSet<string> AllowedTypes = new() { nameof(Series), nameof(Season), nameof(Episode), nameof(Movie) }; - public bool IsEnabledForItem(BaseItem item) => - IsEnabledForItem(item, out var _); + public bool IsEnabledForItem(BaseItem item) => + IsEnabledForItem(item, out var _); - public bool IsEnabledForItem(BaseItem item, out bool isSoleProvider) - { - var reItem = item switch { - Series s => s, - Season s => s.Series, - Episode e => e.Series, - _ => item, - }; - if (reItem == null) { - isSoleProvider = false; - return false; - } + public bool IsEnabledForItem(BaseItem item, out bool isSoleProvider) + { + var reItem = item switch { + Series s => s, + Season s => s.Series, + Episode e => e.Series, + _ => item, + }; + if (reItem == null) { + isSoleProvider = false; + return false; + } - var libraryOptions = LibraryManager.GetLibraryOptions(reItem); - if (libraryOptions == null) { - isSoleProvider = false; - return false; - } + var libraryOptions = LibraryManager.GetLibraryOptions(reItem); + if (libraryOptions == null) { + isSoleProvider = false; + return false; + } - var isEnabled = false; - isSoleProvider = true; - foreach (var options in libraryOptions.TypeOptions) { - if (!AllowedTypes.Contains(options.Type)) - continue; - var isEnabledForType = options.MetadataFetchers.Contains(Plugin.MetadataProviderName); - if (isEnabledForType) { - if (!isEnabled) - isEnabled = true; - if (options.MetadataFetchers.Length > 1 && isSoleProvider) - isSoleProvider = false; - } + var isEnabled = false; + isSoleProvider = true; + foreach (var options in libraryOptions.TypeOptions) { + if (!AllowedTypes.Contains(options.Type)) + continue; + var isEnabledForType = options.MetadataFetchers.Contains(Plugin.MetadataProviderName); + if (isEnabledForType) { + if (!isEnabled) + isEnabled = true; + if (options.MetadataFetchers.Length > 1 && isSoleProvider) + isSoleProvider = false; } - return isEnabled; } + return isEnabled; + } - #endregion - #region Series Id + #endregion + #region Series Id - public bool TryGetSeriesIdFor(string path, out string seriesId) - { - return ApiManager.TryGetSeriesIdForPath(path, out seriesId); - } + public bool TryGetSeriesIdFor(string path, out string seriesId) + { + return ApiManager.TryGetSeriesIdForPath(path, out seriesId); + } - public bool TryGetSeriesIdFromEpisodeId(string episodeId, out string seriesId) - { - return ApiManager.TryGetSeriesIdForEpisodeId(episodeId, out seriesId); + public bool TryGetSeriesIdFromEpisodeId(string episodeId, out string seriesId) + { + return ApiManager.TryGetSeriesIdForEpisodeId(episodeId, out seriesId); + } + + public bool TryGetSeriesIdFor(Series series, out string seriesId) + { + if (series.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { + return true; } - public bool TryGetSeriesIdFor(Series series, out string seriesId) - { - if (series.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { - return true; + if (TryGetSeriesIdFor(series.Path, out seriesId)) { + // Set the "Shoko Group" and "Shoko Series" provider ids for the series, since it haven't been set again. It doesn't matter if it's not saved to the database, since we only need it while running the following code. + if (ApiManager.TryGetDefaultSeriesIdForSeriesId(seriesId, out var defaultSeriesId)) { + SeriesProvider.AddProviderIds(series, defaultSeriesId); } - - if (TryGetSeriesIdFor(series.Path, out seriesId)) { - // Set the "Shoko Group" and "Shoko Series" provider ids for the series, since it haven't been set again. It doesn't matter if it's not saved to the database, since we only need it while running the following code. - if (ApiManager.TryGetDefaultSeriesIdForSeriesId(seriesId, out var defaultSeriesId)) { - SeriesProvider.AddProviderIds(series, defaultSeriesId); - } - // Same as above, but only set the "Shoko Series" id. - else { - SeriesProvider.AddProviderIds(series, seriesId); - } - // Make sure the presentation unique is not cached, so we won't reuse the cache key. - series.PresentationUniqueKey = null; - return true; + // Same as above, but only set the "Shoko Series" id. + else { + SeriesProvider.AddProviderIds(series, seriesId); } - - return false; + // Make sure the presentation unique is not cached, so we won't reuse the cache key. + series.PresentationUniqueKey = null; + return true; } - public bool TryGetSeriesIdFor(Season season, out string seriesId) - { - if (season.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { - return true; - } + return false; + } - return TryGetSeriesIdFor(season.Path, out seriesId); + public bool TryGetSeriesIdFor(Season season, out string seriesId) + { + if (season.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { + return true; } - public bool TryGetSeriesIdFor(Movie movie, out string seriesId) - { - if (movie.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { - return true; - } - - if (TryGetEpisodeIdFor(movie.Path, out var episodeId) && TryGetSeriesIdFromEpisodeId(episodeId, out seriesId)) { - return true; - } + return TryGetSeriesIdFor(season.Path, out seriesId); + } - return false; + public bool TryGetSeriesIdFor(Movie movie, out string seriesId) + { + if (movie.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { + return true; } - public bool TryGetSeriesIdFor(BoxSet boxSet, out string seriesId) - { - if (boxSet.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { - return true; - } + if (TryGetEpisodeIdFor(movie.Path, out var episodeId) && TryGetSeriesIdFromEpisodeId(episodeId, out seriesId)) { + return true; + } - if (TryGetSeriesIdFor(boxSet.Path, out seriesId)) { - if (ApiManager.TryGetDefaultSeriesIdForSeriesId(seriesId, out var defaultSeriesId)) { - seriesId = defaultSeriesId; - } - return true; - } + return false; + } - return false; + public bool TryGetSeriesIdFor(BoxSet boxSet, out string seriesId) + { + if (boxSet.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { + return true; } - #endregion - #region Series Path - - public bool TryGetPathForSeriesId(string seriesId, out string path) - { - return ApiManager.TryGetSeriesPathForId(seriesId, out path); + if (TryGetSeriesIdFor(boxSet.Path, out seriesId)) { + if (ApiManager.TryGetDefaultSeriesIdForSeriesId(seriesId, out var defaultSeriesId)) { + seriesId = defaultSeriesId; + } + return true; } - #endregion - #region Episode Id + return false; + } - public bool TryGetEpisodeIdFor(string path, out string episodeId) - { - return ApiManager.TryGetEpisodeIdForPath(path, out episodeId); - } + #endregion + #region Series Path - public bool TryGetEpisodeIdFor(BaseItem item, out string episodeId) - { - // This will account for virtual episodes and existing episodes - if (item.ProviderIds.TryGetValue("Shoko Episode", out episodeId) && !string.IsNullOrEmpty(episodeId)) { - return true; - } + public bool TryGetPathForSeriesId(string seriesId, out string path) + { + return ApiManager.TryGetSeriesPathForId(seriesId, out path); + } - // This will account for new episodes that haven't received their first metadata update yet. - if (TryGetEpisodeIdFor(item.Path, out episodeId)) { - return true; - } + #endregion + #region Episode Id - return false; + public bool TryGetEpisodeIdFor(string path, out string episodeId) + { + return ApiManager.TryGetEpisodeIdForPath(path, out episodeId); + } + + public bool TryGetEpisodeIdFor(BaseItem item, out string episodeId) + { + // This will account for virtual episodes and existing episodes + if (item.ProviderIds.TryGetValue("Shoko Episode", out episodeId) && !string.IsNullOrEmpty(episodeId)) { + return true; } - public bool TryGetEpisodeIdsFor(string path, out List<string> episodeIds) - { - return ApiManager.TryGetEpisodeIdsForPath(path, out episodeIds); + // This will account for new episodes that haven't received their first metadata update yet. + if (TryGetEpisodeIdFor(item.Path, out episodeId)) { + return true; } - public bool TryGetEpisodeIdsFor(BaseItem item, out List<string> episodeIds) - { - // This will account for virtual episodes and existing episodes - if (item.ProviderIds.TryGetValue("Shoko File", out var fileId) && ApiManager.TryGetEpisodeIdsForFileId(fileId, out episodeIds)) { - return true; - } + return false; + } - // This will account for new episodes that haven't received their first metadata update yet. - if (TryGetEpisodeIdsFor(item.Path, out episodeIds)) { - return true; - } + public bool TryGetEpisodeIdsFor(string path, out List<string> episodeIds) + { + return ApiManager.TryGetEpisodeIdsForPath(path, out episodeIds); + } - return false; + public bool TryGetEpisodeIdsFor(BaseItem item, out List<string> episodeIds) + { + // This will account for virtual episodes and existing episodes + if (item.ProviderIds.TryGetValue("Shoko File", out var fileId) && ApiManager.TryGetEpisodeIdsForFileId(fileId, out episodeIds)) { + return true; } - #endregion - #region Episode Path - - public bool TryGetPathForEpisodeId(string episodeId, out string path) - { - return ApiManager.TryGetEpisodePathForId(episodeId, out path); + // This will account for new episodes that haven't received their first metadata update yet. + if (TryGetEpisodeIdsFor(item.Path, out episodeIds)) { + return true; } - #endregion - #region File Id + return false; + } - public bool TryGetFileIdFor(BaseItem episode, out string fileId) - { - if (episode.ProviderIds.TryGetValue("Shoko File", out fileId)) - return true; + #endregion + #region Episode Path - return ApiManager.TryGetFileIdForPath(episode.Path, out fileId); - } + public bool TryGetPathForEpisodeId(string episodeId, out string path) + { + return ApiManager.TryGetEpisodePathForId(episodeId, out path); + } - #endregion + #endregion + #region File Id + + public bool TryGetFileIdFor(BaseItem episode, out string fileId) + { + if (episode.ProviderIds.TryGetValue("Shoko File", out fileId)) + return true; + + return ApiManager.TryGetFileIdForPath(episode.Path, out fileId); } + + #endregion } \ No newline at end of file diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index 3eb69056..0191907a 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -7,12 +7,12 @@ using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; -using Microsoft.Extensions.Logging; using Jellyfin.Data.Enums; using System.Globalization; using MediaBrowser.Model.Entities; using MediaBrowser.Common.Progress; +#nullable enable namespace Shokofin.MergeVersions; /// <summary> @@ -35,22 +35,16 @@ public class MergeVersionsManager /// </summary> private readonly IIdLookup Lookup; - /// <summary> - /// Logger. - /// </summary> - private readonly ILogger<MergeVersionsManager> Logger; - /// <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> /// <param name="logger">Logger.</param> - public MergeVersionsManager(ILibraryManager libraryManager, IIdLookup lookup, ILogger<MergeVersionsManager> logger) + public MergeVersionsManager(ILibraryManager libraryManager, IIdLookup lookup) { LibraryManager = libraryManager; Lookup = lookup; - Logger = logger; } #region Shared @@ -146,7 +140,7 @@ private List<Movie> GetMoviesFromLibrary() /// <param name="movies">Movies to merge.</param> /// <returns>An async task that will silently complete when the merging is /// complete.</returns> - public async Task MergeMovies(IEnumerable<Movie> movies) + public static async Task MergeMovies(IEnumerable<Movie> movies) => await MergeVideos(movies.Cast<Video>().OrderBy(e => e.Id).ToList()); /// <summary> @@ -279,7 +273,7 @@ private List<Episode> GetEpisodesFromLibrary() /// <param name="episodes">Episodes to merge.</param> /// <returns>An async task that will silently complete when the merging is /// complete.</returns> - public async Task MergeEpisodes(IEnumerable<Episode> episodes) + public static async Task MergeEpisodes(IEnumerable<Episode> episodes) => await MergeVideos(episodes.Cast<Video>().OrderBy(e => e.Id).ToList()); /// <summary> @@ -380,7 +374,7 @@ private async Task SplitAndMergeAllEpisodes(IProgress<double> progress, Cancella foreach (var episodeGroup in duplicationGroups) { // Handle cancelation and update progress. cancellationToken.ThrowIfCancellationRequested(); - var percent = (currentCount++ / totalCount) * 100d; + var percent = currentCount++ / totalCount * 100d; progress?.Report(percent); // Link the episodes together as alternate sources. @@ -396,15 +390,13 @@ private async Task SplitAndMergeAllEpisodes(IProgress<double> progress, Cancella /// /// Modified from; /// https://github.com/jellyfin/jellyfin/blob/9c97c533eff94d25463fb649c9572234da4af1ea/Jellyfin.Api/Controllers/VideosController.cs#L192 - private async Task MergeVideos(List<Video> videos) + private static async Task MergeVideos(List<Video> videos) { if (videos.Count < 2) return; - var primaryVersion = videos.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId)); - if (primaryVersion == null) - { - primaryVersion = videos + var primaryVersion = videos.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId)) ?? + videos .OrderBy(i => { if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile) @@ -414,7 +406,6 @@ private async Task MergeVideos(List<Video> videos) }) .ThenByDescending(i => i.GetDefaultVideoStream()?.Width ?? 0) .First(); - } // Add any videos not already linked to the primary version to the list. var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions @@ -467,9 +458,9 @@ private async Task RemoveAlternateSources(Video video) return; // Make sure the primary video still exists before we proceed. - video = LibraryManager.GetItemById(video.PrimaryVersionId) as Video; - if (video == null) + if (LibraryManager.GetItemById(video.PrimaryVersionId) is not Video primaryVideo) return; + video = primaryVideo; } // Remove the link for every linked video. diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index 6141f01e..3d171064 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -1,22 +1,21 @@ - using MediaBrowser.Common.Plugins; using Microsoft.Extensions.DependencyInjection; -namespace Shokofin +#nullable enable +namespace Shokofin; + +/// <inheritdoc /> +public class PluginServiceRegistrator : IPluginServiceRegistrator { /// <inheritdoc /> - public class PluginServiceRegistrator : IPluginServiceRegistrator + public void RegisterServices(IServiceCollection serviceCollection) { - /// <inheritdoc /> - public void RegisterServices(IServiceCollection serviceCollection) - { - serviceCollection.AddSingleton<API.ShokoAPIClient>(); - serviceCollection.AddSingleton<API.ShokoAPIManager>(); - serviceCollection.AddSingleton<IIdLookup, IdLookup>(); - serviceCollection.AddSingleton<Sync.UserDataSyncManager>(); - serviceCollection.AddSingleton<MergeVersions.MergeVersionsManager>(); - serviceCollection.AddSingleton<Collections.CollectionManager>(); - serviceCollection.AddSingleton<Resolvers.ShokoResolveManager>(); - } + serviceCollection.AddSingleton<API.ShokoAPIClient>(); + serviceCollection.AddSingleton<API.ShokoAPIManager>(); + serviceCollection.AddSingleton<IIdLookup, IdLookup>(); + serviceCollection.AddSingleton<Sync.UserDataSyncManager>(); + serviceCollection.AddSingleton<MergeVersions.MergeVersionsManager>(); + serviceCollection.AddSingleton<Collections.CollectionManager>(); + serviceCollection.AddSingleton<Resolvers.ShokoResolveManager>(); } -} \ No newline at end of file +} diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index f187547c..5694c881 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -13,127 +13,126 @@ using Shokofin.Utils; #nullable enable -namespace Shokofin.Providers +namespace Shokofin.Providers; + +public class BoxSetProvider : IRemoteMetadataProvider<BoxSet, BoxSetInfo> { - public class BoxSetProvider : IRemoteMetadataProvider<BoxSet, BoxSetInfo> + public string Name => Plugin.MetadataProviderName; + + private readonly IHttpClientFactory HttpClientFactory; + + private readonly ILogger<BoxSetProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + public BoxSetProvider(IHttpClientFactory httpClientFactory, ILogger<BoxSetProvider> logger, ShokoAPIManager apiManager) { - public string Name => Plugin.MetadataProviderName; + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiManager = apiManager; + } - private readonly IHttpClientFactory HttpClientFactory; + public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) + { + try { + return Plugin.Instance.Configuration.CollectionGrouping switch + { + Ordering.CollectionCreationType.ShokoGroup => await GetShokoGroupedMetadata(info), + _ => await GetDefaultMetadata(info), + }; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<BoxSet>(); + } + } - private readonly ILogger<BoxSetProvider> Logger; + public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info) + { + var result = new MetadataResult<BoxSet>(); - private readonly ShokoAPIManager ApiManager; + // First try to re-use any existing series id. + if (!info.ProviderIds.TryGetValue("Shoko Series", out var seriesId)) + return result; - public BoxSetProvider(IHttpClientFactory httpClientFactory, ILogger<BoxSetProvider> logger, ShokoAPIManager apiManager) - { - HttpClientFactory = httpClientFactory; - Logger = logger; - ApiManager = apiManager; + var season = await ApiManager.GetSeasonInfoForSeries(seriesId); + if (season == null) { + Logger.LogWarning("Unable to find movie box-set info for name {Name} and path {Path}", info.Name, info.Path); + return result; } - public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) - { - try { - return Plugin.Instance.Configuration.CollectionGrouping switch - { - Ordering.CollectionCreationType.ShokoGroup => await GetShokoGroupedMetadata(info), - _ => await GetDefaultMetadata(info), - }; - } - catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - return new MetadataResult<BoxSet>(); - } + if (season.EpisodeList.Count <= 1) { + Logger.LogWarning("Series did not contain multiple movies! Skipping path {Path} (Series={SeriesId})", info.Path, season.Id); + return result; } - public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info) - { - var result = new MetadataResult<BoxSet>(); - - // First try to re-use any existing series id. - if (!info.ProviderIds.TryGetValue("Shoko Series", out var seriesId)) - return result; - - var season = await ApiManager.GetSeasonInfoForSeries(seriesId); - if (season == null) { - Logger.LogWarning("Unable to find movie box-set info for name {Name} and path {Path}", info.Name, info.Path); - return result; - } - - if (season.EpisodeList.Count <= 1) { - Logger.LogWarning("Series did not contain multiple movies! Skipping path {Path} (Series={SeriesId})", info.Path, season.Id); - return result; - } - - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(season.AniDB.Titles, season.AniDB.Title, info.MetadataLanguage); - - result.Item = new BoxSet { - Name = displayTitle, - OriginalTitle = alternateTitle, - Overview = Text.GetDescription(season), - PremiereDate = season.AniDB.AirDate, - EndDate = season.AniDB.EndDate, - ProductionYear = season.AniDB.AirDate?.Year, - Tags = season.Tags.ToArray(), - CommunityRating = season.AniDB.Rating.ToFloat(10), - }; - result.Item.SetProviderId("Shoko Series", season.Id); - if (Plugin.Instance.Configuration.AddAniDBId) - result.Item.SetProviderId("AniDB", season.AniDB.Id.ToString()); + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(season.AniDB.Titles, season.AniDB.Title, info.MetadataLanguage); + + result.Item = new BoxSet { + Name = displayTitle, + OriginalTitle = alternateTitle, + Overview = Text.GetDescription(season), + PremiereDate = season.AniDB.AirDate, + EndDate = season.AniDB.EndDate, + ProductionYear = season.AniDB.AirDate?.Year, + Tags = season.Tags.ToArray(), + CommunityRating = season.AniDB.Rating.ToFloat(10), + }; + result.Item.SetProviderId("Shoko Series", season.Id); + if (Plugin.Instance.Configuration.AddAniDBId) + result.Item.SetProviderId("AniDB", season.AniDB.Id.ToString()); + + result.HasMetadata = true; + + return result; + } - result.HasMetadata = true; + private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo info) + { + // Filter out all manually created collections. We don't help those. + var result = new MetadataResult<BoxSet>(); + if (!info.ProviderIds.TryGetValue("Shoko Group", out var groupId)) + return result; + var collection = await ApiManager.GetCollectionInfoForGroup(groupId); + if (collection == null) { + Logger.LogWarning("Unable to find collection info for name {Name} and path {Path}", info.Name, info.Path); return result; } - private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo info) - { - // Filter out all manually created collections. We don't help those. - var result = new MetadataResult<BoxSet>(); - if (!info.ProviderIds.TryGetValue("Shoko Group", out var groupId)) - return result; - - var collection = await ApiManager.GetCollectionInfoForGroup(groupId); - if (collection == null) { - Logger.LogWarning("Unable to find collection info for name {Name} and path {Path}", info.Name, info.Path); - return result; - } - - result.Item = new BoxSet { - Name = collection.Name, - Overview = collection.Shoko.Description, - }; - result.Item.SetProviderId("Shoko Group", collection.Id); - result.HasMetadata = true; + result.Item = new BoxSet { + Name = collection.Name, + Overview = collection.Shoko.Description, + }; + result.Item.SetProviderId("Shoko Group", collection.Id); + result.HasMetadata = true; - return result; + return result; + } + + private static bool TryGetBoxSetName(BoxSetInfo info, out string boxSetName) + { + if (string.IsNullOrWhiteSpace(info.Name)) { + boxSetName = string.Empty; + return false; } - private static bool TryGetBoxSetName(BoxSetInfo info, out string boxSetName) - { - if (string.IsNullOrWhiteSpace(info.Name)) { - boxSetName = string.Empty; - return false; - } - - var name = info.Name.Trim(); - if (name.EndsWith("[boxset]")) - name = name[..^8].TrimEnd(); - if (string.IsNullOrWhiteSpace(name)) { - boxSetName = string.Empty; - return false; - } - - boxSetName = name; - return true; + var name = info.Name.Trim(); + if (name.EndsWith("[boxset]")) + name = name[..^8].TrimEnd(); + if (string.IsNullOrWhiteSpace(name)) { + boxSetName = string.Empty; + return false; } - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) - => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + boxSetName = name; + return true; + } + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); - } + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 12c991ae..be60585a 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -16,236 +16,237 @@ using SeriesType = Shokofin.API.Models.SeriesType; using EpisodeType = Shokofin.API.Models.EpisodeType; -namespace Shokofin.Providers +#nullable enable +namespace Shokofin.Providers; + +public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo> { - public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo> - { - public string Name => Plugin.MetadataProviderName; + public string Name => Plugin.MetadataProviderName; - private readonly IHttpClientFactory HttpClientFactory; + private readonly IHttpClientFactory HttpClientFactory; - private readonly ILogger<EpisodeProvider> Logger; + private readonly ILogger<EpisodeProvider> Logger; - private readonly ShokoAPIManager ApiManager; + private readonly ShokoAPIManager ApiManager; - public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProvider> logger, ShokoAPIManager apiManager) - { - HttpClientFactory = httpClientFactory; - Logger = logger; - ApiManager = apiManager; - } + public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProvider> logger, ShokoAPIManager apiManager) + { + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiManager = apiManager; + } - public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) - { - try { - var result = new MetadataResult<Episode>(); - var config = Plugin.Instance.Configuration; - - // Fetch the episode, series and group info (and file info, but that's not really used (yet)) - Info.FileInfo fileInfo = null; - Info.EpisodeInfo episodeInfo = null; - Info.SeasonInfo seasonInfo = null; - Info.ShowInfo showInfo = null; - if (info.IsMissingEpisode || string.IsNullOrEmpty(info.Path)) { - // We're unable to fetch the latest metadata for the virtual episode. - if (!info.ProviderIds.TryGetValue("Shoko Episode", out var episodeId)) - return result; - - episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); - if (episodeInfo == null) - return result; - - seasonInfo = await ApiManager.GetSeasonInfoForEpisode(episodeId); - if (seasonInfo == null) - return result; - - showInfo = await ApiManager.GetShowInfoForSeries(seasonInfo.Id); - if (showInfo == null || showInfo.SeasonList.Count == 0) - return result; - } - else { - (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(info.Path); - episodeInfo = fileInfo?.EpisodeList.FirstOrDefault(); - } + public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) + { + try { + var result = new MetadataResult<Episode>(); + var config = Plugin.Instance.Configuration; - // if the episode info is null then the series info and conditionally the group info is also null. - if (episodeInfo == null) { - Logger.LogWarning("Unable to find episode info for path {Path}", info.Path); + // Fetch the episode, series and group info (and file info, but that's not really used (yet)) + Info.FileInfo? fileInfo = null; + Info.EpisodeInfo? episodeInfo = null; + Info.SeasonInfo? seasonInfo = null; + Info.ShowInfo? showInfo = null; + if (info.IsMissingEpisode || string.IsNullOrEmpty(info.Path)) { + // We're unable to fetch the latest metadata for the virtual episode. + if (!info.ProviderIds.TryGetValue("Shoko Episode", out var episodeId)) return result; - } - result.Item = CreateMetadata(showInfo, seasonInfo, episodeInfo, fileInfo, info.MetadataLanguage); - Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},Group={GroupId})", result.Item.Name, fileInfo?.Id, episodeInfo.Id, seasonInfo.Id, showInfo?.GroupId); + episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); + if (episodeInfo == null) + return result; - result.HasMetadata = true; + seasonInfo = await ApiManager.GetSeasonInfoForEpisode(episodeId); + if (seasonInfo == null) + return result; - return result; + showInfo = await ApiManager.GetShowInfoForSeries(seasonInfo.Id); + if (showInfo == null || showInfo.SeasonList.Count == 0) + return result; } - catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - return new MetadataResult<Episode>(); + else { + (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(info.Path); + episodeInfo = fileInfo?.EpisodeList.FirstOrDefault(); } + + // if the episode info is null then the series info and conditionally the group info is also null. + if (fileInfo == null || episodeInfo == null || seasonInfo == null || showInfo == null) { + Logger.LogWarning("Unable to find episode info for path {Path}", info.Path); + return result; + } + + result.Item = CreateMetadata(showInfo, seasonInfo, episodeInfo, fileInfo, info.MetadataLanguage); + Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},Group={GroupId})", result.Item.Name, fileInfo?.Id, episodeInfo.Id, seasonInfo.Id, showInfo?.GroupId); + + result.HasMetadata = true; + + return result; } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<Episode>(); + } + } - public static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Season season, System.Guid episodeId) - => CreateMetadata(group, series, episode, null, season.GetPreferredMetadataLanguage(), season, episodeId); + 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); - 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) + => CreateMetadata(group, series, episode, file, metadataLanguage, null, Guid.Empty); - private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Info.FileInfo file, string metadataLanguage, Season season, System.Guid episodeId) - { - var config = Plugin.Instance.Configuration; - string displayTitle, alternateTitle, description; - if (config.TitleAddForMultipleEpisodes && file != null && file.EpisodeList.Count > 1) { - var displayTitles = new List<string>(file.EpisodeList.Count); - var alternateTitles = new List<string>(file.EpisodeList.Count); - foreach (var episodeInfo in file.EpisodeList) - { - string defaultEpisodeTitle = episodeInfo.Shoko.Name; - if ( - // Movies - (series.Type == SeriesType.Movie && (episodeInfo.AniDB.Type == EpisodeType.Normal || episodeInfo.AniDB.Type == EpisodeType.Special)) || - // OVAs - (series.AniDB.Type == SeriesType.OVA && episodeInfo.AniDB.Type == EpisodeType.Normal && episodeInfo.AniDB.EpisodeNumber == 1 && episodeInfo.Shoko.Name == "OVA") - ) { - string defaultSeriesTitle = series.Shoko.Name; - var ( dTitle, aTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episodeInfo.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); - displayTitles.Add(dTitle); - alternateTitles.Add(aTitle); - } - else { - var ( dTitle, aTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episodeInfo.AniDB.Titles, defaultEpisodeTitle, metadataLanguage); - displayTitles.Add(dTitle); - alternateTitles.Add(aTitle); - } - } - displayTitle = Text.JoinText(displayTitles); - alternateTitle = Text.JoinText(alternateTitles); - description = Text.GetDescription(file.EpisodeList); - } - else { - string defaultEpisodeTitle = episode.Shoko.Name; + private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Info.FileInfo? file, string metadataLanguage, Season? season, Guid episodeId) + { + var config = Plugin.Instance.Configuration; + string displayTitle, alternateTitle, description; + if (config.TitleAddForMultipleEpisodes && file != null && file.EpisodeList.Count > 1) { + var displayTitles = new List<string>(file.EpisodeList.Count); + var alternateTitles = new List<string>(file.EpisodeList.Count); + foreach (var episodeInfo in file.EpisodeList) + { + string defaultEpisodeTitle = episodeInfo.Shoko.Name; if ( // Movies - (series.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) || + (series.Type == SeriesType.Movie && (episodeInfo.AniDB.Type == EpisodeType.Normal || episodeInfo.AniDB.Type == EpisodeType.Special)) || // OVAs - (series.AniDB.Type == SeriesType.OVA && episode.AniDB.Type == EpisodeType.Normal && episode.AniDB.EpisodeNumber == 1 && episode.Shoko.Name == "OVA") + (series.AniDB.Type == SeriesType.OVA && episodeInfo.AniDB.Type == EpisodeType.Normal && episodeInfo.AniDB.EpisodeNumber == 1 && episodeInfo.Shoko.Name == "OVA") ) { string defaultSeriesTitle = series.Shoko.Name; - ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); + var ( dTitle, aTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episodeInfo.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); + displayTitles.Add(dTitle); + alternateTitles.Add(aTitle); } else { - ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultEpisodeTitle, metadataLanguage); - } - description = Text.GetDescription(episode); - } - - if (config.MarkSpecialsWhenGrouped) switch (episode.AniDB.Type) { - case EpisodeType.Unknown: - case EpisodeType.Other: - case EpisodeType.Normal: - break; - case EpisodeType.Special: { - // We're guaranteed to find the index, because otherwise it would've thrown when getting the episode number. - var index = series.SpecialsList.FindIndex(ep => ep == episode); - displayTitle = $"S{index + 1} {displayTitle}"; - alternateTitle = $"S{index + 1} {alternateTitle}"; - break; + var ( dTitle, aTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episodeInfo.AniDB.Titles, defaultEpisodeTitle, metadataLanguage); + displayTitles.Add(dTitle); + alternateTitles.Add(aTitle); } - case EpisodeType.ThemeSong: - case EpisodeType.EndingSong: - case EpisodeType.OpeningSong: - displayTitle = $"C{episode.AniDB.EpisodeNumber} {displayTitle}"; - alternateTitle = $"C{episode.AniDB.EpisodeNumber} {alternateTitle}"; - break; - case EpisodeType.Trailer: - displayTitle = $"T{episode.AniDB.EpisodeNumber} {displayTitle}"; - alternateTitle = $"T{episode.AniDB.EpisodeNumber} {alternateTitle}"; - break; - case EpisodeType.Parody: - displayTitle = $"P{episode.AniDB.EpisodeNumber} {displayTitle}"; - alternateTitle = $"P{episode.AniDB.EpisodeNumber} {alternateTitle}"; - break; - default: - displayTitle = $"U{episode.AniDB.EpisodeNumber} {displayTitle}"; - alternateTitle = $"U{episode.AniDB.EpisodeNumber} {alternateTitle}"; - break; } - - var episodeNumber = Ordering.GetEpisodeNumber(group, series, episode); - var seasonNumber = Ordering.GetSeasonNumber(group, series, episode); - var (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber, isSpecial) = Ordering.GetSpecialPlacement(group, series, episode); - - Episode result; - if (season != null) { - result = new Episode { - Name = displayTitle, - OriginalTitle = alternateTitle, - IndexNumber = episodeNumber, - ParentIndexNumber = isSpecial ? 0 : seasonNumber, - AirsAfterSeasonNumber = airsAfterSeasonNumber, - AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, - AirsBeforeSeasonNumber = airsBeforeSeasonNumber, - Id = episodeId, - IsVirtualItem = true, - SeasonId = season.Id, - SeriesId = season.Series.Id, - Overview = description, - CommunityRating = episode.AniDB.Rating.ToFloat(10), - PremiereDate = episode.AniDB.AirDate, - SeriesName = season.Series.Name, - SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, - SeasonName = season.Name, - OfficialRating = group.ContentRating, - DateLastSaved = DateTime.UtcNow, - RunTimeTicks = episode.AniDB.Duration.Ticks, - }; - result.PresentationUniqueKey = result.GetPresentationUniqueKey(); + displayTitle = Text.JoinText(displayTitles); + alternateTitle = Text.JoinText(alternateTitles); + description = Text.GetDescription(file.EpisodeList); + } + else { + string defaultEpisodeTitle = episode.Shoko.Name; + if ( + // Movies + (series.Type == SeriesType.Movie && (episode.AniDB.Type == EpisodeType.Normal || episode.AniDB.Type == EpisodeType.Special)) || + // OVAs + (series.AniDB.Type == SeriesType.OVA && episode.AniDB.Type == EpisodeType.Normal && episode.AniDB.EpisodeNumber == 1 && episode.Shoko.Name == "OVA") + ) { + string defaultSeriesTitle = series.Shoko.Name; + ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); } else { - result = new Episode { - Name = displayTitle, - OriginalTitle = alternateTitle, - IndexNumber = episodeNumber, - ParentIndexNumber = isSpecial ? 0 : seasonNumber, - AirsAfterSeasonNumber = airsAfterSeasonNumber, - AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, - AirsBeforeSeasonNumber = airsBeforeSeasonNumber, - PremiereDate = episode.AniDB.AirDate, - Overview = description, - OfficialRating = group.ContentRating, - CommunityRating = episode.AniDB.Rating.ToFloat(10), - }; + ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultEpisodeTitle, metadataLanguage); } + description = Text.GetDescription(episode); + } - if (file != null) { - var episodeNumberEnd = episodeNumber + file.EpisodeList.Count - 1; - if (episode.AniDB.EpisodeNumber != episodeNumberEnd) - result.IndexNumberEnd = episodeNumberEnd; + if (config.MarkSpecialsWhenGrouped) switch (episode.AniDB.Type) { + case EpisodeType.Unknown: + case EpisodeType.Other: + case EpisodeType.Normal: + break; + case EpisodeType.Special: { + // We're guaranteed to find the index, because otherwise it would've thrown when getting the episode number. + var index = series.SpecialsList.FindIndex(ep => ep == episode); + displayTitle = $"S{index + 1} {displayTitle}"; + alternateTitle = $"S{index + 1} {alternateTitle}"; + break; } + case EpisodeType.ThemeSong: + case EpisodeType.EndingSong: + case EpisodeType.OpeningSong: + displayTitle = $"C{episode.AniDB.EpisodeNumber} {displayTitle}"; + alternateTitle = $"C{episode.AniDB.EpisodeNumber} {alternateTitle}"; + break; + case EpisodeType.Trailer: + displayTitle = $"T{episode.AniDB.EpisodeNumber} {displayTitle}"; + alternateTitle = $"T{episode.AniDB.EpisodeNumber} {alternateTitle}"; + break; + case EpisodeType.Parody: + displayTitle = $"P{episode.AniDB.EpisodeNumber} {displayTitle}"; + alternateTitle = $"P{episode.AniDB.EpisodeNumber} {alternateTitle}"; + break; + default: + displayTitle = $"U{episode.AniDB.EpisodeNumber} {displayTitle}"; + alternateTitle = $"U{episode.AniDB.EpisodeNumber} {alternateTitle}"; + break; + } - AddProviderIds(result, episodeId: episode.Id, fileId: file?.Id, anidbId: episode.AniDB.Id.ToString()); - - return result; + var episodeNumber = Ordering.GetEpisodeNumber(group, series, episode); + var seasonNumber = Ordering.GetSeasonNumber(group, series, episode); + var (airsBeforeEpisodeNumber, airsBeforeSeasonNumber, airsAfterSeasonNumber, isSpecial) = Ordering.GetSpecialPlacement(group, series, episode); + + Episode result; + if (season != null) { + result = new Episode { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = episodeNumber, + ParentIndexNumber = isSpecial ? 0 : seasonNumber, + AirsAfterSeasonNumber = airsAfterSeasonNumber, + AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, + AirsBeforeSeasonNumber = airsBeforeSeasonNumber, + Id = episodeId, + IsVirtualItem = true, + SeasonId = season.Id, + SeriesId = season.Series.Id, + Overview = description, + CommunityRating = episode.AniDB.Rating.Value > 0 ? episode.AniDB.Rating.ToFloat(10) : 0, + PremiereDate = episode.AniDB.AirDate, + SeriesName = season.Series.Name, + SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, + SeasonName = season.Name, + OfficialRating = group.OfficialRating, + DateLastSaved = DateTime.UtcNow, + RunTimeTicks = episode.AniDB.Duration.Ticks, + }; + result.PresentationUniqueKey = result.GetPresentationUniqueKey(); + } + else { + result = new Episode { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = episodeNumber, + ParentIndexNumber = isSpecial ? 0 : seasonNumber, + AirsAfterSeasonNumber = airsAfterSeasonNumber, + AirsBeforeEpisodeNumber = airsBeforeEpisodeNumber, + AirsBeforeSeasonNumber = airsBeforeSeasonNumber, + PremiereDate = episode.AniDB.AirDate, + Overview = description, + OfficialRating = group.OfficialRating, + CustomRating = group.CustomRating, + CommunityRating = episode.AniDB.Rating.Value > 0 ? episode.AniDB.Rating.ToFloat(10) : 0, + }; } - private static void AddProviderIds(IHasProviderIds item, string episodeId, string fileId = null, string anidbId = null, string tmdbId = null) - { - var config = Plugin.Instance.Configuration; - item.SetProviderId("Shoko Episode", episodeId); - if (!string.IsNullOrEmpty(fileId)) - item.SetProviderId("Shoko File", fileId); - if (config.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") - item.SetProviderId("AniDB", anidbId); - if (config.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") - item.SetProviderId(MetadataProvider.Tmdb, tmdbId); + if (file != null) { + var episodeNumberEnd = episodeNumber + file.EpisodeList.Count - 1; + if (episode.AniDB.EpisodeNumber != episodeNumberEnd) + result.IndexNumberEnd = episodeNumberEnd; } - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) - => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + AddProviderIds(result, episodeId: episode.Id, fileId: file?.Id, anidbId: episode.AniDB.Id.ToString()); - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); + return result; } + + private static void AddProviderIds(IHasProviderIds item, string episodeId, string? fileId = null, string? anidbId = null, string? tmdbId = null) + { + var config = Plugin.Instance.Configuration; + item.SetProviderId("Shoko Episode", episodeId); + if (!string.IsNullOrEmpty(fileId)) + item.SetProviderId("Shoko File", fileId); + if (config.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") + item.SetProviderId("AniDB", anidbId); + if (config.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") + item.SetProviderId(MetadataProvider.Tmdb, tmdbId); + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index 1aff10cc..d1792007 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -17,645 +17,652 @@ using Info = Shokofin.API.Info; -namespace Shokofin.Providers -{ - public class ExtraMetadataProvider : IServerEntryPoint - { - private readonly ShokoAPIManager ApiManager; +#nullable enable +namespace Shokofin.Providers; - private readonly IIdLookup Lookup; +public class ExtraMetadataProvider : IServerEntryPoint +{ + private readonly ShokoAPIManager ApiManager; - private readonly ILibraryManager LibraryManager; + private readonly IIdLookup Lookup; - private readonly ILocalizationManager LocalizationManager; + private readonly ILibraryManager LibraryManager; - private readonly ILogger<ExtraMetadataProvider> Logger; + private readonly ILocalizationManager LocalizationManager; - public ExtraMetadataProvider(ShokoAPIManager apiManager, IIdLookup lookUp, ILibraryManager libraryManager, ILocalizationManager localizationManager, ILogger<ExtraMetadataProvider> logger) - { - ApiManager = apiManager; - Lookup = lookUp; - LibraryManager = libraryManager; - LocalizationManager = localizationManager; - Logger = logger; - } + private readonly ILogger<ExtraMetadataProvider> Logger; - public Task RunAsync() - { - LibraryManager.ItemAdded += OnLibraryManagerItemAdded; - LibraryManager.ItemUpdated += OnLibraryManagerItemUpdated; - LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; - - return Task.CompletedTask; - } + public ExtraMetadataProvider(ShokoAPIManager apiManager, IIdLookup lookUp, ILibraryManager libraryManager, ILocalizationManager localizationManager, ILogger<ExtraMetadataProvider> logger) + { + ApiManager = apiManager; + Lookup = lookUp; + LibraryManager = libraryManager; + LocalizationManager = localizationManager; + Logger = logger; + } - public void Dispose() - { - GC.SuppressFinalize(this); - LibraryManager.ItemAdded -= OnLibraryManagerItemAdded; - LibraryManager.ItemUpdated -= OnLibraryManagerItemUpdated; - LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; - } + public Task RunAsync() + { + LibraryManager.ItemAdded += OnLibraryManagerItemAdded; + LibraryManager.ItemUpdated += OnLibraryManagerItemUpdated; + LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; - #region Locking + return Task.CompletedTask; + } - private readonly ConcurrentDictionary<string, HashSet<string>> LockedIdDictionary = new(); + public void Dispose() + { + GC.SuppressFinalize(this); + LibraryManager.ItemAdded -= OnLibraryManagerItemAdded; + LibraryManager.ItemUpdated -= OnLibraryManagerItemUpdated; + LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; + } - public bool TryLockActionForIdOFType(string type, string id, string action) - { - var key = $"{type}:{id}"; - if (!LockedIdDictionary.TryGetValue(key, out var hashSet)) { - LockedIdDictionary.TryAdd(key, new HashSet<string>()); - if (!LockedIdDictionary.TryGetValue(key, out hashSet)) - throw new Exception("Unable to set hash set"); - } - return hashSet.Add(action); - } + #region Locking - public bool TryUnlockActionForIdOFType(string type, string id, string action) - { - var key = $"{type}:{id}"; - if (LockedIdDictionary.TryGetValue(key, out var hashSet)) - return hashSet.Remove(action); - return false; - } + private readonly ConcurrentDictionary<string, HashSet<string>> LockedIdDictionary = new(); - public bool IsActionForIdOfTypeLocked(string type, string id, string action) - { - var key = $"{type}:{id}"; - if (LockedIdDictionary.TryGetValue(key, out var hashSet)) - return hashSet.Contains(action); - return false; + public bool TryLockActionForIdOFType(string type, string id, string action) + { + var key = $"{type}:{id}"; + if (!LockedIdDictionary.TryGetValue(key, out var hashSet)) { + LockedIdDictionary.TryAdd(key, new HashSet<string>()); + if (!LockedIdDictionary.TryGetValue(key, out hashSet)) + throw new Exception("Unable to set hash set"); } + return hashSet.Add(action); + } - #endregion + public bool TryUnlockActionForIdOFType(string type, string id, string action) + { + var key = $"{type}:{id}"; + if (LockedIdDictionary.TryGetValue(key, out var hashSet)) + return hashSet.Remove(action); + return false; + } - private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs e) - { - if (e == null || e.Item == null || e.Parent == null || e.UpdateReason.HasFlag(ItemUpdateType.None)) - return; + public bool IsActionForIdOfTypeLocked(string type, string id, string action) + { + var key = $"{type}:{id}"; + if (LockedIdDictionary.TryGetValue(key, out var hashSet)) + return hashSet.Contains(action); + return false; + } - switch (e.Item) { - case Series series: { - // Abort if we're unable to get the shoko series id - if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) - return; + #endregion - if (!TryLockActionForIdOFType("series", seriesId, "update")) - return; + private void OnLibraryManagerItemAdded(object? sender, ItemChangeEventArgs e) + { + if (e == null || e.Item == null || e.Parent == null || e.UpdateReason.HasFlag(ItemUpdateType.None)) + return; - try { - UpdateSeries(series, seriesId); - } - finally { - TryUnlockActionForIdOFType("series", seriesId, "update"); - } + switch (e.Item) { + case Series series: { + // Abort if we're unable to get the shoko series id + if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + return; + if (!TryLockActionForIdOFType("series", seriesId, "update")) return; - } - case Season season: { - // We're not interested in the dummy season. - if (!season.IndexNumber.HasValue) - return; - if (e.Parent is not Series series) - return; + try { + UpdateSeries(series, seriesId); + } + finally { + TryUnlockActionForIdOFType("series", seriesId, "update"); + } - // Abort if we're unable to get the shoko series id - if (!Lookup.TryGetSeriesIdFor(season.Series, out var seriesId)) - return; + return; + } + case Season season: { + // We're not interested in the dummy season. + if (!season.IndexNumber.HasValue) + return; - if (IsActionForIdOfTypeLocked("series", seriesId, "update")) - return; + if (e.Parent is not Series series) + return; - var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; - if (!TryLockActionForIdOFType("season", seasonId, "update")) - return; + // Abort if we're unable to get the shoko series id + if (!Lookup.TryGetSeriesIdFor(season.Series, out var seriesId)) + return; - try { - UpdateSeason(season, series, seriesId); - } - finally { - TryUnlockActionForIdOFType("season", seasonId, "update"); - } + if (IsActionForIdOfTypeLocked("series", seriesId, "update")) + return; + var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; + if (!TryLockActionForIdOFType("season", seasonId, "update")) return; + + try { + UpdateSeason(season, series, seriesId); + } + finally { + TryUnlockActionForIdOFType("season", seasonId, "update"); } - case Episode episode: { - // Abort if we're unable to get the shoko episode id - if (!(Lookup.TryGetEpisodeIdFor(episode, out var episodeId) && Lookup.TryGetSeriesIdFromEpisodeId(episodeId, out var seriesId))) - return; - if (IsActionForIdOfTypeLocked("series", seriesId, "update")) - return; + return; + } + case Episode episode: { + // Abort if we're unable to get the shoko episode id + if (!(Lookup.TryGetEpisodeIdFor(episode, out var episodeId) && Lookup.TryGetSeriesIdFromEpisodeId(episodeId, out var seriesId))) + return; - if (episode.ParentIndexNumber.HasValue) { - var seasonId = $"{seriesId}:{episode.ParentIndexNumber.Value}"; - if (IsActionForIdOfTypeLocked("season", seasonId, "update")) - return; - } + if (IsActionForIdOfTypeLocked("series", seriesId, "update")) + return; - if (!TryLockActionForIdOFType("episode", episodeId, "update")) + if (episode.ParentIndexNumber.HasValue) { + var seasonId = $"{seriesId}:{episode.ParentIndexNumber.Value}"; + if (IsActionForIdOfTypeLocked("season", seasonId, "update")) return; + } - try { - RemoveDuplicateEpisodes(episode, episodeId); - } - finally { - TryUnlockActionForIdOFType("episode", episodeId, "update"); - } - + if (!TryLockActionForIdOFType("episode", episodeId, "update")) return; + + try { + RemoveDuplicateEpisodes(episode, episodeId); + } + finally { + TryUnlockActionForIdOFType("episode", episodeId, "update"); } - } - } - private void OnLibraryManagerItemUpdated(object sender, ItemChangeEventArgs e) - { - if (e == null || e.Item == null || e.Parent == null || e.UpdateReason.HasFlag(ItemUpdateType.None)) return; + } + } + } - switch (e.Item) { - case Series series: { - // Abort if we're unable to get the shoko episode id - if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) - return; + private void OnLibraryManagerItemUpdated(object? sender, ItemChangeEventArgs e) + { + if (e == null || e.Item == null || e.Parent == null || e.UpdateReason.HasFlag(ItemUpdateType.None)) + return; - if (!TryLockActionForIdOFType("series", seriesId, "update")) - return; + switch (e.Item) { + case Series series: { + // Abort if we're unable to get the shoko episode id + if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + return; - try { - UpdateSeries(series, seriesId); + if (!TryLockActionForIdOFType("series", seriesId, "update")) + return; - RemoveDuplicateSeasons(series, seriesId); - } - finally { - TryUnlockActionForIdOFType("series", seriesId, "update"); - } + try { + UpdateSeries(series, seriesId); - return; + RemoveDuplicateSeasons(series, seriesId); + } + finally { + TryUnlockActionForIdOFType("series", seriesId, "update"); } - case Season season: { - // We're not interested in the dummy season. - if (!season.IndexNumber.HasValue) - return; - // Abort if we're unable to get the shoko series id - if (!Lookup.TryGetSeriesIdFor(season.Series, out var seriesId)) - return; + return; + } + case Season season: { + // We're not interested in the dummy season. + if (!season.IndexNumber.HasValue) + return; - if (IsActionForIdOfTypeLocked("series", seriesId, "update")) - return; + // Abort if we're unable to get the shoko series id + if (!Lookup.TryGetSeriesIdFor(season.Series, out var seriesId)) + return; - var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; - if (!TryLockActionForIdOFType("season", seasonId, "update")) - return; + if (IsActionForIdOfTypeLocked("series", seriesId, "update")) + return; - try { - var series = season.Series; - UpdateSeason(season, series, seriesId); + var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; + if (!TryLockActionForIdOFType("season", seasonId, "update")) + return; - RemoveDuplicateSeasons(season, series, season.IndexNumber.Value, seriesId); - } - finally { - TryUnlockActionForIdOFType("season", seasonId, "update"); - } + try { + var series = season.Series; + UpdateSeason(season, series, seriesId); - return; + RemoveDuplicateSeasons(season, series, season.IndexNumber.Value, seriesId); + } + finally { + TryUnlockActionForIdOFType("season", seasonId, "update"); } - case Episode episode: { - // Abort if we're unable to get the shoko episode id - if (!(Lookup.TryGetEpisodeIdFor(episode, out var episodeId) && Lookup.TryGetSeriesIdFromEpisodeId(episodeId, out var seriesId))) - return; - if (IsActionForIdOfTypeLocked("series", seriesId, "update")) - return; + return; + } + case Episode episode: { + // Abort if we're unable to get the shoko episode id + if (!(Lookup.TryGetEpisodeIdFor(episode, out var episodeId) && Lookup.TryGetSeriesIdFromEpisodeId(episodeId, out var seriesId))) + return; - if (episode.ParentIndexNumber.HasValue) { - var seasonId = $"{seriesId}:{episode.ParentIndexNumber.Value}"; - if (IsActionForIdOfTypeLocked("season", seasonId, "update")) - return; - } + if (IsActionForIdOfTypeLocked("series", seriesId, "update")) + return; - if (!TryLockActionForIdOFType("episode", episodeId, "update")) + if (episode.ParentIndexNumber.HasValue) { + var seasonId = $"{seriesId}:{episode.ParentIndexNumber.Value}"; + if (IsActionForIdOfTypeLocked("season", seasonId, "update")) return; + } - try { - RemoveDuplicateEpisodes(episode, episodeId); - } - finally { - TryUnlockActionForIdOFType("episode", episodeId, "update"); - } - + if (!TryLockActionForIdOFType("episode", episodeId, "update")) return; - } - } - } - private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs e) - { - if (e == null || e.Item == null || e.Parent == null) - return; + try { + RemoveDuplicateEpisodes(episode, episodeId); + } + finally { + TryUnlockActionForIdOFType("episode", episodeId, "update"); + } - if (e.Item.IsVirtualItem) return; + } + } + } - switch (e.Item) { - // Clean up after removing a series. - case Series series: { - if (!Lookup.TryGetSeriesIdFor(series, out var _)) - return; + private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) + { + if (e == null || e.Item == null || e.Parent == null) + return; - foreach (var season in series.Children.OfType<Season>()) - OnLibraryManagerItemRemoved(this, new ItemChangeEventArgs { Item = season, Parent = series, UpdateReason = ItemUpdateType.None }); + if (e.Item.IsVirtualItem) + return; + switch (e.Item) { + // Clean up after removing a series. + case Series series: { + if (!Lookup.TryGetSeriesIdFor(series, out var _)) return; - } - // Create a new virtual season if the real one was deleted and clean up extras if the season was deleted. - case Season season: { - // Abort if we're unable to get the shoko episode id - if (!(Lookup.TryGetSeriesIdFor(season.Series, out var seriesId) && (e.Parent is Series series))) - return; - if (season.IndexNumber.HasValue) - UpdateSeason(season, series, seriesId, true); + foreach (var season in series.Children.OfType<Season>()) + OnLibraryManagerItemRemoved(this, new ItemChangeEventArgs { Item = season, Parent = series, UpdateReason = ItemUpdateType.None }); + return; + } + // Create a new virtual season if the real one was deleted and clean up extras if the season was deleted. + case Season season: { + // Abort if we're unable to get the shoko episode id + if (!(Lookup.TryGetSeriesIdFor(season.Series, out var seriesId) && (e.Parent is Series series))) return; - } - // Similarly, create a new virtual episode if the real one was deleted. - case Episode episode: { - if (!Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) - return; - - RemoveDuplicateEpisodes(episode, episodeId); - UpdateEpisode(episode, episodeId); + if (season.IndexNumber.HasValue) + UpdateSeason(season, series, seriesId, true); - return; - } - } - } - - private void UpdateSeries(Series series, string seriesId) - { - // Provide metadata for a series using Shoko's Group feature - var showInfo = ApiManager.GetShowInfoForSeries(seriesId) - .GetAwaiter() - .GetResult(); - if (showInfo == null || showInfo.SeasonList.Count == 0) { - Logger.LogWarning("Unable to find show info for series. (Series={SeriesID})", seriesId); return; } + // Similarly, create a new virtual episode if the real one was deleted. + case Episode episode: { + if (!Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) + return; - // Get the existing seasons and episode ids - var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); + RemoveDuplicateEpisodes(episode, episodeId); - // Add missing seasons - foreach (var (seasonNumber, season) in CreateMissingSeasons(showInfo, series, seasons)) - seasons.TryAdd(seasonNumber, season); + UpdateEpisode(episode, episodeId); - // Handle specials when grouped. - if (seasons.TryGetValue(0, out var zeroSeason)) { - foreach (var seasonInfo in showInfo.SeasonList) { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) - episodeIds.Add(episodeId); + return; + } + } + } - foreach (var episodeInfo in seasonInfo.SpecialsList) { - if (episodeIds.Contains(episodeInfo.Id)) - continue; + private void UpdateSeries(Series series, string seriesId) + { + // Provide metadata for a series using Shoko's Group feature + var showInfo = ApiManager.GetShowInfoForSeries(seriesId) + .GetAwaiter() + .GetResult(); + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for series. (Series={SeriesID})", seriesId); + return; + } - AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, zeroSeason); - } - } - } + // Get the existing seasons and episode ids + var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); - // Add missing episodes - foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; + // Add missing seasons + foreach (var (seasonNumber, season) in CreateMissingSeasons(showInfo, series, seasons)) + seasons.TryAdd(seasonNumber, season); + // Handle specials when grouped. + if (seasons.TryGetValue(0, out var zeroSeason)) { + foreach (var seasonInfo in showInfo.SeasonList) { foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) episodeIds.Add(episodeId); - foreach (var episodeInfo in seasonInfo.EpisodeList) { + foreach (var episodeInfo in seasonInfo.SpecialsList) { if (episodeIds.Contains(episodeInfo.Id)) continue; - AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); + AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, zeroSeason); } } } - private void UpdateSeason(Season season, Series series, string seriesId, bool deleted = false) - { - var seasonNumber = season.IndexNumber!.Value; - var showInfo = ApiManager.GetShowInfoForSeries(seriesId) - .GetAwaiter() - .GetResult(); - if (showInfo == null || showInfo.SeasonList.Count == 0) { - Logger.LogWarning("Unable to find show info for season. (Series={SeriesId})", seriesId); - return; - } - - // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - var existingEpisodes = new HashSet<string>(); - foreach (var episode in season.Children.OfType<Episode>()) { - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - foreach (var episodeId in episodeIds) - existingEpisodes.Add(episodeId); - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) - existingEpisodes.Add(episodeId); - } - - // Special handling of specials (pun intended). - if (seasonNumber == 0) { - if (deleted) - season = AddVirtualSeason(0, series); + // Add missing episodes + foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; - foreach (var sI in showInfo.SeasonList) { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(sI.Id)) - existingEpisodes.Add(episodeId); + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) + episodeIds.Add(episodeId); - foreach (var episodeInfo in sI.SpecialsList) { - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; + foreach (var episodeInfo in seasonInfo.EpisodeList) { + if (episodeIds.Contains(episodeInfo.Id)) + continue; - AddVirtualEpisode(showInfo, sI, episodeInfo, season); - } - } + AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); } - // Every other "season". - else { - var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); - if (seasonInfo == null) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); - return; - } + } + } - var offset = seasonNumber - showInfo.SeasonNumberBaseDictionary[seasonInfo.Id]; - if (deleted) - season = AddVirtualSeason(seasonInfo, offset, seasonNumber, series); + private void UpdateSeason(Season season, Series series, string seriesId, bool deleted = false) + { + var seasonNumber = season.IndexNumber!.Value; + var showInfo = ApiManager.GetShowInfoForSeries(seriesId) + .GetAwaiter() + .GetResult(); + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for season. (Series={SeriesId})", seriesId); + return; + } - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) + // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. + var existingEpisodes = new HashSet<string>(); + foreach (var episode in season.Children.OfType<Episode>()) { + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + foreach (var episodeId in episodeIds) existingEpisodes.Add(episodeId); + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) + existingEpisodes.Add(episodeId); + } - foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList).Concat(seasonInfo.OthersList)) { - var episodeParentIndex = episodeInfo.IsSpecial ? 0 : Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); - if (episodeParentIndex != seasonNumber) - continue; + // Special handling of specials (pun intended). + if (seasonNumber == 0) { + if (deleted && AddVirtualSeason(0, series) is Season virtualSeason) + season = virtualSeason; + foreach (var sI in showInfo.SeasonList) { + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(sI.Id)) + existingEpisodes.Add(episodeId); + + foreach (var episodeInfo in sI.SpecialsList) { if (existingEpisodes.Contains(episodeInfo.Id)) continue; - AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); + AddVirtualEpisode(showInfo, sI, episodeInfo, season); } } } - - private void UpdateEpisode(Episode episode, string episodeId) - { - var showInfo = ApiManager.GetShowInfoForEpisode(episodeId) - .GetAwaiter() - .GetResult(); - if (showInfo == null || showInfo.SeasonList.Count == 0) { - Logger.LogWarning("Unable to find show info for episode. (Episode={EpisodeId})", episode); + // Every other "season". + else { + var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); + if (seasonInfo == null) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); return; } - var seasonInfo = ApiManager.GetSeasonInfoForEpisode(episodeId) - .GetAwaiter() - .GetResult(); - var episodeInfo = seasonInfo.EpisodeList.FirstOrDefault(e => e.Id == episodeId); - var episodeIds = ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id); - if (!episodeIds.Contains(episodeId)) - AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, episode.Season); - } - private (Dictionary<int, Season>, HashSet<string>) GetExistingSeasonsAndEpisodeIds(Series series) - { - var seasons = new Dictionary<int, Season>(); - var episodes = new HashSet<string>(); - foreach (var item in series.GetRecursiveChildren()) switch (item) { - case Season season: - if (season.IndexNumber.HasValue) - seasons.TryAdd(season.IndexNumber.Value, season); - // Add all known episode ids for the season. - if (Lookup.TryGetSeriesIdFor(season, out var seriesId)) - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesId)) - episodes.Add(episodeId); - break; - case Episode episode: - // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - foreach (var episodeId in episodeIds) - episodes.Add(episodeId); - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) - episodes.Add(episodeId); - break; - } - return (seasons, episodes); - } + var offset = seasonNumber - showInfo.SeasonNumberBaseDictionary[seasonInfo.Id]; + if (deleted && AddVirtualSeason(seasonInfo, offset, seasonNumber, series) is Season virtualSeason) + season = virtualSeason; - #region Seasons + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) + existingEpisodes.Add(episodeId); - private IEnumerable<(int, Season)> CreateMissingSeasons(Info.ShowInfo showInfo, Series series, Dictionary<int, Season> seasons) - { - bool hasSpecials = false; - foreach (var pair in showInfo.SeasonOrderDictionary) { - if (seasons.ContainsKey(pair.Key)) + foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList).Concat(seasonInfo.OthersList)) { + var episodeParentIndex = episodeInfo.IsSpecial ? 0 : Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); + if (episodeParentIndex != seasonNumber) continue; - if (pair.Value.SpecialsList.Count > 0) - hasSpecials = true; - var offset = pair.Key - showInfo.SeasonNumberBaseDictionary[pair.Value.Id]; - var season = AddVirtualSeason(pair.Value, offset, pair.Key, series); - if (season == null) + + if (existingEpisodes.Contains(episodeInfo.Id)) continue; - yield return (pair.Key, season); - } - if (hasSpecials && !seasons.ContainsKey(0)) { - var season = AddVirtualSeason(0, series); - if (season != null) - yield return (0, season); - } - } - private bool SeasonExists(string seriesPresentationUniqueKey, string seriesName, int seasonNumber) - { - var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, - IndexNumber = seasonNumber, - SeriesPresentationUniqueKey = seriesPresentationUniqueKey, - DtoOptions = new DtoOptions(true), - }, true); - - if (searchList.Count > 0) { - Logger.LogDebug("Season {SeasonName} for Series {SeriesName} was created in another concurrent thread, skipping.", searchList[0].Name, seriesName); - return true; + AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); } - - return false; } + } - private Season AddVirtualSeason(int seasonNumber, Series series) - { - if (SeasonExists(series.GetPresentationUniqueKey(), series.Name, seasonNumber)) - return null; - - string seasonName; - if (seasonNumber == 0) - seasonName = LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName; - else - seasonName = string.Format( - LocalizationManager.GetLocalizedString("NameSeasonNumber"), - seasonNumber.ToString(CultureInfo.InvariantCulture)); - - var season = new Season { - Name = seasonName, - IndexNumber = seasonNumber, - SortName = seasonName, - ForcedSortName = seasonName, - Id = LibraryManager.GetNewItemId( - series.Id + "Season " + seasonNumber.ToString(CultureInfo.InvariantCulture), - typeof(Season)), - IsVirtualItem = true, - SeriesId = series.Id, - SeriesName = series.Name, - SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), - DateModified = DateTime.UtcNow, - DateLastSaved = DateTime.UtcNow, - }; - - Logger.LogInformation("Adding virtual Season {SeasonNumber:00} to Series {SeriesName}.", seasonNumber, series.Name); - - series.AddChild(season); - - return season; + private void UpdateEpisode(Episode episode, string episodeId) + { + var showInfo = ApiManager.GetShowInfoForEpisode(episodeId) + .GetAwaiter() + .GetResult(); + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for episode. (Episode={EpisodeId})", episode); + return; } + var seasonInfo = ApiManager.GetSeasonInfoForEpisode(episodeId) + .GetAwaiter() + .GetResult(); + if (seasonInfo == null) { + Logger.LogWarning("Unable to find season info for episode. (Episode={EpisodeId})", episode); + return; + } + var episodeInfo = seasonInfo.EpisodeList.FirstOrDefault(e => e.Id == episodeId); + if (episodeInfo == null) { + Logger.LogWarning("Unable to find episode info for episode. (Episode={EpisodeId})", episode); + return; + } + var episodeIds = ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id); + if (!episodeIds.Contains(episodeId)) + AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, episode.Season); + } - private Season AddVirtualSeason(Info.SeasonInfo seasonInfo, int offset, int seasonNumber, Series series) - { - if (SeasonExists(series.GetPresentationUniqueKey(), series.Name, seasonNumber)) - return null; - - var seasonId = LibraryManager.GetNewItemId(series.Id + "Season " + seasonNumber.ToString(CultureInfo.InvariantCulture), typeof(Season)); - var season = SeasonProvider.CreateMetadata(seasonInfo, seasonNumber, offset, series, seasonId); - - Logger.LogInformation("Adding virtual Season {SeasonNumber:00} to Series {SeriesName}. (Series={SeriesId})", seasonNumber, series.Name, seasonInfo.Id); + private (Dictionary<int, Season>, HashSet<string>) GetExistingSeasonsAndEpisodeIds(Series series) + { + var seasons = new Dictionary<int, Season>(); + var episodes = new HashSet<string>(); + foreach (var item in series.GetRecursiveChildren()) switch (item) { + case Season season: + if (season.IndexNumber.HasValue) + seasons.TryAdd(season.IndexNumber.Value, season); + // Add all known episode ids for the season. + if (Lookup.TryGetSeriesIdFor(season, out var seriesId)) + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesId)) + episodes.Add(episodeId); + break; + case Episode episode: + // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + foreach (var episodeId in episodeIds) + episodes.Add(episodeId); + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) + episodes.Add(episodeId); + break; + } + return (seasons, episodes); + } - series.AddChild(season); + #region Seasons - return season; + private IEnumerable<(int, Season)> CreateMissingSeasons(Info.ShowInfo showInfo, Series series, Dictionary<int, Season> seasons) + { + bool hasSpecials = false; + foreach (var pair in showInfo.SeasonOrderDictionary) { + if (seasons.ContainsKey(pair.Key)) + continue; + if (pair.Value.SpecialsList.Count > 0) + hasSpecials = true; + var offset = pair.Key - showInfo.SeasonNumberBaseDictionary[pair.Value.Id]; + var season = AddVirtualSeason(pair.Value, offset, pair.Key, series); + if (season == null) + continue; + yield return (pair.Key, season); } + if (hasSpecials && !seasons.ContainsKey(0)) { + var season = AddVirtualSeason(0, series); + if (season != null) + yield return (0, season); + } + } - public void RemoveDuplicateSeasons(Series series, string seriesId) - { - var seasonNumbers = new HashSet<int>(); - var seasons = series - .GetSeasons(null, new DtoOptions(true)) - .OfType<Season>() - .OrderBy(s => s.IsVirtualItem); - foreach (var season in seasons) { - if (!season.IndexNumber.HasValue) - continue; + private bool SeasonExists(string seriesPresentationUniqueKey, string seriesName, int seasonNumber) + { + var searchList = LibraryManager.GetItemList(new InternalItemsQuery { + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, + IndexNumber = seasonNumber, + SeriesPresentationUniqueKey = seriesPresentationUniqueKey, + DtoOptions = new DtoOptions(true), + }, true); + + if (searchList.Count > 0) { + Logger.LogDebug("Season {SeasonName} for Series {SeriesName} was created in another concurrent thread, skipping.", searchList[0].Name, seriesName); + return true; + } - var seasonNumber = season.IndexNumber.Value; - if (!seasonNumbers.Add(seasonNumber)) - continue; + return false; + } - RemoveDuplicateSeasons(season, series, seasonNumber, seriesId); - } - } + private Season? AddVirtualSeason(int seasonNumber, Series series) + { + if (SeasonExists(series.GetPresentationUniqueKey(), series.Name, seasonNumber)) + return null; + + string seasonName; + if (seasonNumber == 0) + seasonName = LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName; + else + seasonName = string.Format( + LocalizationManager.GetLocalizedString("NameSeasonNumber"), + seasonNumber.ToString(CultureInfo.InvariantCulture)); + + var season = new Season { + Name = seasonName, + IndexNumber = seasonNumber, + SortName = seasonName, + ForcedSortName = seasonName, + Id = LibraryManager.GetNewItemId( + series.Id + "Season " + seasonNumber.ToString(CultureInfo.InvariantCulture), + typeof(Season)), + IsVirtualItem = true, + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), + DateModified = DateTime.UtcNow, + DateLastSaved = DateTime.UtcNow, + }; + + Logger.LogInformation("Adding virtual Season {SeasonNumber:00} to Series {SeriesName}.", seasonNumber, series.Name); + + series.AddChild(season); + + return season; + } - public void RemoveDuplicateSeasons(Season season, Series series, int seasonNumber, string seriesId) - { - var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, - ExcludeItemIds = new [] { season.Id }, - IndexNumber = seasonNumber, - DtoOptions = new DtoOptions(true), - }, true).Where(item => !item.IndexNumber.HasValue).ToList(); + private Season? AddVirtualSeason(Info.SeasonInfo seasonInfo, int offset, int seasonNumber, Series series) + { + if (SeasonExists(series.GetPresentationUniqueKey(), series.Name, seasonNumber)) + return null; - if (searchList.Count == 0) - return; + var seasonId = LibraryManager.GetNewItemId(series.Id + "Season " + seasonNumber.ToString(CultureInfo.InvariantCulture), typeof(Season)); + var season = SeasonProvider.CreateMetadata(seasonInfo, seasonNumber, offset, series, seasonId); - Logger.LogWarning("Removing {Count:00} duplicate seasons from Series {SeriesName} (Series={SeriesId})", searchList.Count, series.Name, seriesId); - var deleteOptions = new DeleteOptions { - DeleteFileLocation = false, - }; - foreach (var item in searchList) - LibraryManager.DeleteItem(item, deleteOptions); + Logger.LogInformation("Adding virtual Season {SeasonNumber:00} to Series {SeriesName}. (Series={SeriesId})", seasonNumber, series.Name, seasonInfo.Id); - var episodeNumbers = new HashSet<int?>(); - // Ordering by `IsVirtualItem` will put physical episodes first. - foreach (var episode in season.GetEpisodes(null, new DtoOptions(true)).OfType<Episode>().OrderBy(e => e.IsVirtualItem)) { - // Abort if we're unable to get the shoko episode id - if (!Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) - continue; + series.AddChild(season); - // Only iterate over the same index number once. - if (!episodeNumbers.Add(episode.IndexNumber)) - continue; + return season; + } - RemoveDuplicateEpisodes(episode, episodeId); - } + public void RemoveDuplicateSeasons(Series series, string seriesId) + { + var seasonNumbers = new HashSet<int>(); + var seasons = series + .GetSeasons(null, new DtoOptions(true)) + .OfType<Season>() + .OrderBy(s => s.IsVirtualItem); + foreach (var season in seasons) { + if (!season.IndexNumber.HasValue) + continue; + + var seasonNumber = season.IndexNumber.Value; + if (!seasonNumbers.Add(seasonNumber)) + continue; + + RemoveDuplicateSeasons(season, series, seasonNumber, seriesId); } + } - #endregion - #region Episodes + public void RemoveDuplicateSeasons(Season season, Series series, int seasonNumber, string seriesId) + { + var searchList = LibraryManager.GetItemList(new InternalItemsQuery { + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, + ExcludeItemIds = new [] { season.Id }, + IndexNumber = seasonNumber, + DtoOptions = new DtoOptions(true), + }, true).Where(item => !item.IndexNumber.HasValue).ToList(); + + if (searchList.Count == 0) + return; + + Logger.LogWarning("Removing {Count:00} duplicate seasons from Series {SeriesName} (Series={SeriesId})", searchList.Count, series.Name, seriesId); + var deleteOptions = new DeleteOptions { + DeleteFileLocation = false, + }; + foreach (var item in searchList) + LibraryManager.DeleteItem(item, deleteOptions); + + var episodeNumbers = new HashSet<int?>(); + // Ordering by `IsVirtualItem` will put physical episodes first. + foreach (var episode in season.GetEpisodes(null, new DtoOptions(true)).OfType<Episode>().OrderBy(e => e.IsVirtualItem)) { + // Abort if we're unable to get the shoko episode id + if (!Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) + continue; + + // Only iterate over the same index number once. + if (!episodeNumbers.Add(episode.IndexNumber)) + continue; + + RemoveDuplicateEpisodes(episode, episodeId); + } + } - private bool EpisodeExists(string episodeId, string seriesId, string groupId) - { - var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Episode }, - HasAnyProviderId = new Dictionary<string, string> { ["Shoko Episode"] = episodeId }, - DtoOptions = new DtoOptions(true), - }, true); + #endregion + #region Episodes - if (searchList.Count > 0) { - Logger.LogDebug("A virtual or physical episode entry already exists for Episode {EpisodeName}. Ignoring. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", searchList[0].Name, episodeId, seriesId, groupId); - return true; - } - return false; + private bool EpisodeExists(string episodeId, string seriesId, string? groupId) + { + var searchList = LibraryManager.GetItemList(new InternalItemsQuery { + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Episode }, + HasAnyProviderId = new Dictionary<string, string> { ["Shoko Episode"] = episodeId }, + DtoOptions = new DtoOptions(true), + }, true); + + if (searchList.Count > 0) { + Logger.LogDebug("A virtual or physical episode entry already exists for Episode {EpisodeName}. Ignoring. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", searchList[0].Name, episodeId, seriesId, groupId); + return true; } + return false; + } - private void AddVirtualEpisode(Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, Season season) - { - var groupId = showInfo?.GroupId ?? null; - if (EpisodeExists(episodeInfo.Id, seasonInfo.Id, groupId)) - return; - - var episodeId = LibraryManager.GetNewItemId(season.Series.Id + "Season " + seasonInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); - var episode = EpisodeProvider.CreateMetadata(showInfo, seasonInfo, episodeInfo, season, episodeId); + private void AddVirtualEpisode(Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, Season season) + { + if (EpisodeExists(episodeInfo.Id, seasonInfo.Id, showInfo.GroupId)) + return; - Logger.LogInformation("Adding virtual Episode {EpisodeNumber:000} in Season {SeasonNumber:00} for Series {SeriesName}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episode.IndexNumber, season.Name, showInfo?.Name ?? seasonInfo.Shoko.Name, episodeInfo.Id, seasonInfo.Id, groupId); + var episodeId = LibraryManager.GetNewItemId(season.Series.Id + " Season " + seasonInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); + var episode = EpisodeProvider.CreateMetadata(showInfo, seasonInfo, episodeInfo, season, episodeId); - season.AddChild(episode); - } + Logger.LogInformation("Adding virtual Episode {EpisodeNumber:000} in Season {SeasonNumber:00} for Series {SeriesName}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episode.IndexNumber, season.Name, showInfo.Name, episodeInfo.Id, seasonInfo.Id, showInfo.GroupId); - private void RemoveDuplicateEpisodes(Episode episode, string episodeId) - { - var query = new InternalItemsQuery { - IsVirtualItem = true, - ExcludeItemIds = new [] { episode.Id }, - HasAnyProviderId = new Dictionary<string, string> { ["Shoko Episode"] = episodeId }, - IncludeItemTypes = new [] {Jellyfin.Data.Enums.BaseItemKind.Episode }, - GroupByPresentationUniqueKey = false, - DtoOptions = new DtoOptions(true), - }; - - var existingVirtualItems = LibraryManager.GetItemList(query); - - var deleteOptions = new DeleteOptions { - DeleteFileLocation = false, - }; - - // Remove the virtual season/episode that matches the newly updated item - foreach (var item in existingVirtualItems) - LibraryManager.DeleteItem(item, deleteOptions); - - if (existingVirtualItems.Count > 0) - Logger.LogInformation("Removed {Count:00} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", existingVirtualItems.Count, episode.Name, episodeId); - } + season.AddChild(episode); + } - #endregion + private void RemoveDuplicateEpisodes(Episode episode, string episodeId) + { + var query = new InternalItemsQuery { + IsVirtualItem = true, + ExcludeItemIds = new [] { episode.Id }, + HasAnyProviderId = new Dictionary<string, string> { ["Shoko Episode"] = episodeId }, + IncludeItemTypes = new [] {Jellyfin.Data.Enums.BaseItemKind.Episode }, + GroupByPresentationUniqueKey = false, + DtoOptions = new DtoOptions(true), + }; + + var existingVirtualItems = LibraryManager.GetItemList(query); + + var deleteOptions = new DeleteOptions { + DeleteFileLocation = false, + }; + + // Remove the virtual season/episode that matches the newly updated item + foreach (var item in existingVirtualItems) + LibraryManager.DeleteItem(item, deleteOptions); + + if (existingVirtualItems.Count > 0) + Logger.LogInformation("Removed {Count:00} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", existingVirtualItems.Count, episode.Name, episodeId); } + + #endregion } diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 2c109d55..53c79b54 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -13,139 +13,139 @@ using Shokofin.API; using System.Linq; -namespace Shokofin.Providers +#nullable enable +namespace Shokofin.Providers; + +public class ImageProvider : IRemoteImageProvider { - public class ImageProvider : IRemoteImageProvider - { - public string Name => Plugin.MetadataProviderName; + public string Name => Plugin.MetadataProviderName; - private readonly IHttpClientFactory HttpClientFactory; + private readonly IHttpClientFactory HttpClientFactory; - private readonly ILogger<ImageProvider> Logger; + private readonly ILogger<ImageProvider> Logger; - private readonly ShokoAPIClient ApiClient; + private readonly ShokoAPIClient ApiClient; - private readonly ShokoAPIManager ApiManager; + private readonly ShokoAPIManager ApiManager; - private readonly IIdLookup Lookup; + private readonly IIdLookup Lookup; - public ImageProvider(IHttpClientFactory httpClientFactory, ILogger<ImageProvider> logger, ShokoAPIClient apiClient, ShokoAPIManager apiManager, IIdLookup lookup) - { - HttpClientFactory = httpClientFactory; - Logger = logger; - ApiClient = apiClient; - ApiManager = apiManager; - Lookup = lookup; - } + public ImageProvider(IHttpClientFactory httpClientFactory, ILogger<ImageProvider> logger, ShokoAPIClient apiClient, ShokoAPIManager apiManager, IIdLookup lookup) + { + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiClient = apiClient; + ApiManager = apiManager; + Lookup = lookup; + } - public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) - { - var list = new List<RemoteImageInfo>(); - try { - switch (item) { - case Episode episode: { - if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { - var episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); - if (episodeInfo != null) { - AddImagesForEpisode(ref list, episodeInfo); - } - Logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId})", list.Count, episode.Name, episodeId); + public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) + { + var list = new List<RemoteImageInfo>(); + try { + switch (item) { + case Episode episode: { + if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { + var episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); + if (episodeInfo != null) { + AddImagesForEpisode(ref list, episodeInfo); } - break; + Logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId})", list.Count, episode.Name, episodeId); } - case Series series: { - if (Lookup.TryGetSeriesIdFor(series, out var seriesId)) { - var seriesImages = await ApiClient.GetSeriesImages(seriesId); - if (seriesImages != null) { - AddImagesForSeries(ref list, seriesImages); - } - // Also attach any images linked to the "seasons" (AKA series within the group). - list = list - .Concat( - series.GetSeasons(null, new(true)) - .Cast<Season>() - .SelectMany(season => GetImages(season, cancellationToken).Result) - ) - .DistinctBy(image => image.Url) - .ToList(); - Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId})", list.Count, series.Name, seriesId); + break; + } + case Series series: { + if (Lookup.TryGetSeriesIdFor(series, out var seriesId)) { + var seriesImages = await ApiClient.GetSeriesImages(seriesId); + if (seriesImages != null) { + AddImagesForSeries(ref list, seriesImages); } - break; + // Also attach any images linked to the "seasons" (AKA series within the group). + list = list + .Concat( + series.GetSeasons(null, new(true)) + .Cast<Season>() + .SelectMany(season => GetImages(season, cancellationToken).Result) + ) + .DistinctBy(image => image.Url) + .ToList(); + Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId})", list.Count, series.Name, seriesId); } - case Season season: { - if (Lookup.TryGetSeriesIdFor(season, out var seriesId)) { - var seriesImages = await ApiClient.GetSeriesImages(seriesId); - if (seriesImages != null) { - AddImagesForSeries(ref list, seriesImages); - } - Logger.LogInformation("Getting {Count} images for season {SeasonNumber} in {SeriesName} (Series={SeriesId})", list.Count, season.IndexNumber, season.SeriesName, seriesId); + break; + } + case Season season: { + if (Lookup.TryGetSeriesIdFor(season, out var seriesId)) { + var seriesImages = await ApiClient.GetSeriesImages(seriesId); + if (seriesImages != null) { + AddImagesForSeries(ref list, seriesImages); } - break; + Logger.LogInformation("Getting {Count} images for season {SeasonNumber} in {SeriesName} (Series={SeriesId})", list.Count, season.IndexNumber, season.SeriesName, seriesId); } - case Movie movie: { - if (Lookup.TryGetSeriesIdFor(movie, out var seriesId)) { - var seriesImages = await ApiClient.GetSeriesImages(seriesId); - if (seriesImages != null) { - AddImagesForSeries(ref list, seriesImages); - } - Logger.LogInformation("Getting {Count} images for movie {MovieName} (Series={SeriesId})", list.Count, movie.Name, seriesId); + break; + } + case Movie movie: { + if (Lookup.TryGetSeriesIdFor(movie, out var seriesId)) { + var seriesImages = await ApiClient.GetSeriesImages(seriesId); + if (seriesImages != null) { + AddImagesForSeries(ref list, seriesImages); } - break; + Logger.LogInformation("Getting {Count} images for movie {MovieName} (Series={SeriesId})", list.Count, movie.Name, seriesId); } - case BoxSet boxSet: { - if (Lookup.TryGetSeriesIdFor(boxSet, out var seriesId)) { - var seriesImages = await ApiClient.GetSeriesImages(seriesId); - if (seriesImages != null) { - AddImagesForSeries(ref list, seriesImages); - } - Logger.LogInformation("Getting {Count} images for box-set {BoxSetName} (Series={SeriesId})", list.Count, boxSet.Name, seriesId); + break; + } + case BoxSet boxSet: { + if (Lookup.TryGetSeriesIdFor(boxSet, out var seriesId)) { + var seriesImages = await ApiClient.GetSeriesImages(seriesId); + if (seriesImages != null) { + AddImagesForSeries(ref list, seriesImages); } - break; + Logger.LogInformation("Getting {Count} images for box-set {BoxSetName} (Series={SeriesId})", list.Count, boxSet.Name, seriesId); } + break; } - return list; - } - catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - return list; } + return list; } - - private static void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Info.EpisodeInfo episodeInfo) - { - AddImage(ref list, ImageType.Primary, episodeInfo?.TvDB?.Thumbnail); + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return list; } + } - private static void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Images images) - { - 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)) - AddImage(ref list, ImageType.Backdrop, image); - foreach (var image in images.Banners.OrderByDescending(image => image.IsDefault)) - AddImage(ref list, ImageType.Banner, image); - } + private static void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Info.EpisodeInfo episodeInfo) + { + AddImage(ref list, ImageType.Primary, episodeInfo?.TvDB?.Thumbnail); + } - private static void AddImage(ref List<RemoteImageInfo> list, ImageType imageType, API.Models.Image image) - { - if (image == null || !image.IsAvailable) - return; - list.Add(new RemoteImageInfo { - ProviderName = Plugin.MetadataProviderName, - Type = imageType, - Width = image.Width, - Height = image.Height, - Url = image.ToURLString(), - }); - } + private static void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Images images) + { + 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)) + AddImage(ref list, ImageType.Backdrop, image); + foreach (var image in images.Banners.OrderByDescending(image => image.IsDefault)) + AddImage(ref list, ImageType.Banner, image); + } - public IEnumerable<ImageType> GetSupportedImages(BaseItem item) - => new[] { ImageType.Primary, ImageType.Backdrop, ImageType.Banner }; + private static void AddImage(ref List<RemoteImageInfo> list, ImageType imageType, API.Models.Image? image) + { + if (image == null || !image.IsAvailable) + return; + list.Add(new RemoteImageInfo { + ProviderName = Plugin.MetadataProviderName, + Type = imageType, + Width = image.Width, + Height = image.Height, + Url = image.ToURLString(), + }); + } - public bool Supports(BaseItem item) - => item is Series or Season or Episode or Movie or BoxSet; + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + => new[] { ImageType.Primary, ImageType.Backdrop, ImageType.Banner }; - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); - } + public bool Supports(BaseItem item) + => item is Series or Season or Episode or Movie or BoxSet; + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 9c287e85..09844b53 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -12,80 +12,80 @@ using Shokofin.API; using Shokofin.Utils; -namespace Shokofin.Providers +#nullable enable +namespace Shokofin.Providers; + +public class MovieProvider : IRemoteMetadataProvider<Movie, MovieInfo> { - public class MovieProvider : IRemoteMetadataProvider<Movie, MovieInfo> - { - public string Name => Plugin.MetadataProviderName; + public string Name => Plugin.MetadataProviderName; - private readonly IHttpClientFactory HttpClientFactory; + private readonly IHttpClientFactory HttpClientFactory; - private readonly ILogger<MovieProvider> Logger; + private readonly ILogger<MovieProvider> Logger; - private readonly ShokoAPIManager ApiManager; + private readonly ShokoAPIManager ApiManager; - public MovieProvider(IHttpClientFactory httpClientFactory, ILogger<MovieProvider> logger, ShokoAPIManager apiManager) - { - Logger = logger; - HttpClientFactory = httpClientFactory; - ApiManager = apiManager; - } + public MovieProvider(IHttpClientFactory httpClientFactory, ILogger<MovieProvider> logger, ShokoAPIManager apiManager) + { + Logger = logger; + HttpClientFactory = httpClientFactory; + ApiManager = apiManager; + } + + public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken) + { + try { + var result = new MetadataResult<Movie>(); - public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken) - { - try { - var result = new MetadataResult<Movie>(); - - var (file, season, _) = await ApiManager.GetFileInfoByPath(info.Path); - var episode = file?.EpisodeList.FirstOrDefault(); - - // if file is null then series and episode is also null. - if (file == null) { - Logger.LogWarning("Unable to find movie info for path {Path}", info.Path); - return result; - } - - var ( displayTitle, alternateTitle ) = Text.GetMovieTitles(season.AniDB.Titles, episode.AniDB.Titles, season.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); - Logger.LogInformation("Found movie {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", displayTitle, file.Id, episode.Id, season.Id); - - 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), - ProductionYear = episode.AniDB.AirDate?.Year, - Tags = season.Tags.ToArray(), - Genres = season.Genres.ToArray(), - Studios = season.Studios.ToArray(), - CommunityRating = rating, - }; - result.Item.SetProviderId("Shoko File", file.Id); - result.Item.SetProviderId("Shoko Episode", episode.Id); - result.Item.SetProviderId("Shoko Series", season.Id); - - result.HasMetadata = true; - - result.ResetPeople(); - foreach (var person in season.Staff) - result.AddPerson(person); + var (file, season, _) = await ApiManager.GetFileInfoByPath(info.Path); + var episode = file?.EpisodeList.FirstOrDefault(); + // if file is null then series and episode is also null. + if (file == null || episode == null || season == null) { + Logger.LogWarning("Unable to find movie info for path {Path}", info.Path); return result; } - catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - return new MetadataResult<Movie>(); - } + + var ( displayTitle, alternateTitle ) = Text.GetMovieTitles(season.AniDB.Titles, episode.AniDB.Titles, season.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); + Logger.LogInformation("Found movie {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", displayTitle, file.Id, episode.Id, season.Id); + + 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), + ProductionYear = episode.AniDB.AirDate?.Year, + Tags = season.Tags.ToArray(), + Genres = season.Genres.ToArray(), + Studios = season.Studios.ToArray(), + CommunityRating = rating, + }; + result.Item.SetProviderId("Shoko File", file.Id); + result.Item.SetProviderId("Shoko Episode", episode.Id); + result.Item.SetProviderId("Shoko Series", season.Id); + + result.HasMetadata = true; + + result.ResetPeople(); + foreach (var person in season.Staff) + result.AddPerson(person); + + return result; } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<Movie>(); + } + } - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) - => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); - } + 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 05e43355..27d762b1 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -13,163 +13,153 @@ using Info = Shokofin.API.Info; -namespace Shokofin.Providers +#nullable enable +namespace Shokofin.Providers; + +public class SeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo> { - public class SeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo> - { - public string Name => Plugin.MetadataProviderName; - private readonly IHttpClientFactory HttpClientFactory; - private readonly ILogger<SeasonProvider> Logger; + public string Name => Plugin.MetadataProviderName; + private readonly IHttpClientFactory HttpClientFactory; + private readonly ILogger<SeasonProvider> Logger; - private readonly ShokoAPIManager ApiManager; + private readonly ShokoAPIManager ApiManager; - public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvider> logger, ShokoAPIManager apiManager) - { - HttpClientFactory = httpClientFactory; - Logger = logger; - ApiManager = apiManager; - } + public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvider> logger, ShokoAPIManager apiManager) + { + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiManager = apiManager; + } - public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) - { - try { - var result = new MetadataResult<Season>(); - if (!info.IndexNumber.HasValue || info.IndexNumber.HasValue && info.IndexNumber.Value == 0) - return result; - - if (!info.SeriesProviderIds.TryGetValue("Shoko Series", out var seriesId) || !info.IndexNumber.HasValue) { - Logger.LogDebug("Unable refresh Season {SeasonNumber} {SeasonName}", info.IndexNumber, info.Name); - return result; - } - - var seasonNumber = info.IndexNumber.Value; - var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); - if (showInfo == null) { - Logger.LogWarning("Unable to find show info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); - return result; - } - - var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); - if (seasonInfo == null || !showInfo.SeasonNumberBaseDictionary.TryGetValue(seasonInfo.Id, out var baseSeasonNumber)) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Series={SeriesId},Group={GroupId})", seasonNumber, seriesId, showInfo.GroupId); - return result; - } - - Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, showInfo.Name, seriesId, showInfo.GroupId); - - var offset = Math.Abs(seasonNumber - baseSeasonNumber); - - result.Item = CreateMetadata(seasonInfo, seasonNumber, offset, info.MetadataLanguage); - result.HasMetadata = true; - result.ResetPeople(); - foreach (var person in seasonInfo.Staff) - result.AddPerson(person); + public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) + { + try { + var result = new MetadataResult<Season>(); + if (!info.IndexNumber.HasValue || info.IndexNumber.HasValue && info.IndexNumber.Value == 0) + return result; + if (!info.SeriesProviderIds.TryGetValue("Shoko Series", out var seriesId) || !info.IndexNumber.HasValue) { + Logger.LogDebug("Unable refresh Season {SeasonNumber} {SeasonName}", info.IndexNumber, info.Name); return result; } - catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - return new MetadataResult<Season>(); + + var seasonNumber = info.IndexNumber.Value; + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo == null) { + Logger.LogWarning("Unable to find show info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); + return result; } - } - 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, Series series, System.Guid seasonId) - => CreateMetadata(seasonInfo, seasonNumber, offset, series.GetPreferredMetadataLanguage(), series, seasonId); - - public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage, Series series, System.Guid seasonId) - { - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(seasonInfo.AniDB.Titles, seasonInfo.Shoko.Name, metadataLanguage); - var sortTitle = $"S{seasonNumber} - {seasonInfo.Shoko.Name}"; - - if (offset > 0) { - string type = string.Empty; - switch (offset) { - default: - break; - case 1: - if (seasonInfo.AlternateEpisodesList.Count > 0) - type = "Alternate Stories"; - else - type = "Other Episodes"; - break; - case 2: - type = "Other Episodes"; - break; - } - if (!string.IsNullOrEmpty(type)) { - displayTitle += $" ({type})"; - alternateTitle += $" ({type})"; - } + var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); + if (seasonInfo == null || !showInfo.SeasonNumberBaseDictionary.TryGetValue(seasonInfo.Id, out var baseSeasonNumber)) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Series={SeriesId},Group={GroupId})", seasonNumber, seriesId, showInfo.GroupId); + return result; } - Season season; - if (series != null) { - season = new Season { - Name = displayTitle, - OriginalTitle = alternateTitle, - IndexNumber = seasonNumber, - SortName = sortTitle, - ForcedSortName = sortTitle, - Id = seasonId, - IsVirtualItem = true, - Overview = Text.GetDescription(seasonInfo), - PremiereDate = seasonInfo.AniDB.AirDate, - EndDate = seasonInfo.AniDB.EndDate, - ProductionYear = seasonInfo.AniDB.AirDate?.Year, - Tags = seasonInfo.Tags.ToArray(), - Genres = seasonInfo.Genres.ToArray(), - Studios = seasonInfo.Studios.ToArray(), - CommunityRating = seasonInfo.AniDB.Rating?.ToFloat(10), - SeriesId = series.Id, - SeriesName = series.Name, - SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), - DateModified = DateTime.UtcNow, - DateLastSaved = DateTime.UtcNow, - }; + Logger.LogInformation("Found info for Season {SeasonNumber} in Series {SeriesName} (Series={SeriesId},Group={GroupId})", seasonNumber, showInfo.Name, seriesId, showInfo.GroupId); + + var offset = Math.Abs(seasonNumber - baseSeasonNumber); + + result.Item = CreateMetadata(seasonInfo, seasonNumber, offset, info.MetadataLanguage); + result.HasMetadata = true; + result.ResetPeople(); + foreach (var person in seasonInfo.Staff) + result.AddPerson(person); + + return result; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<Season>(); + } + } + + 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, Series series, Guid seasonId) + => CreateMetadata(seasonInfo, seasonNumber, offset, series.GetPreferredMetadataLanguage(), series, seasonId); + + public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage, Series? series, Guid seasonId) + { + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(seasonInfo.AniDB.Titles, seasonInfo.Shoko.Name, metadataLanguage); + var sortTitle = $"S{seasonNumber} - {seasonInfo.Shoko.Name}"; + + if (offset > 0) { + string type = string.Empty; + switch (offset) { + default: + break; + case 1: + if (seasonInfo.AlternateEpisodesList.Count > 0) + type = "Alternate Stories"; + else + type = "Other Episodes"; + break; + case 2: + type = "Other Episodes"; + break; } - else { - season = new Season { - Name = displayTitle, - OriginalTitle = alternateTitle, - IndexNumber = seasonNumber, - SortName = sortTitle, - ForcedSortName = sortTitle, - Overview = Text.GetDescription(seasonInfo), - PremiereDate = seasonInfo.AniDB.AirDate, - EndDate = seasonInfo.AniDB.EndDate, - ProductionYear = seasonInfo.AniDB.AirDate?.Year, - Tags = seasonInfo.Tags.ToArray(), - Genres = seasonInfo.Genres.ToArray(), - Studios = seasonInfo.Studios.ToArray(), - CommunityRating = seasonInfo.AniDB.Rating?.ToFloat(10), - }; + if (!string.IsNullOrEmpty(type)) { + displayTitle += $" ({type})"; + alternateTitle += $" ({type})"; } - season.ProviderIds.Add("Shoko Series", seasonInfo.Id); - season.ProviderIds.Add("Shoko Season Offset", offset.ToString()); - if (Plugin.Instance.Configuration.AddAniDBId) - season.ProviderIds.Add("AniDB", seasonInfo.AniDB.Id.ToString()); - - return season; } - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchInfo, CancellationToken cancellationToken) - => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); - - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); - - private static string GetSeasonName(int seasonNumber, string seasonName) - => seasonNumber switch - { - 127 => "Misc.", - 126 => "Credits", - 125 => "Trailers", - 124 => "Others", - 123 => "Unknown", - _ => seasonName, + Season season; + if (series != null) { + season = new Season { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = seasonNumber, + SortName = sortTitle, + ForcedSortName = sortTitle, + Id = seasonId, + IsVirtualItem = true, + Overview = Text.GetDescription(seasonInfo), + PremiereDate = seasonInfo.AniDB.AirDate, + EndDate = seasonInfo.AniDB.EndDate, + ProductionYear = seasonInfo.AniDB.AirDate?.Year, + Tags = seasonInfo.Tags.ToArray(), + Genres = seasonInfo.Genres.ToArray(), + Studios = seasonInfo.Studios.ToArray(), + CommunityRating = seasonInfo.AniDB.Rating?.ToFloat(10), + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), + DateModified = DateTime.UtcNow, + DateLastSaved = DateTime.UtcNow, }; + } + else { + season = new Season { + Name = displayTitle, + OriginalTitle = alternateTitle, + IndexNumber = seasonNumber, + SortName = sortTitle, + ForcedSortName = sortTitle, + Overview = Text.GetDescription(seasonInfo), + PremiereDate = seasonInfo.AniDB.AirDate, + EndDate = seasonInfo.AniDB.EndDate, + ProductionYear = seasonInfo.AniDB.AirDate?.Year, + Tags = seasonInfo.Tags.ToArray(), + Genres = seasonInfo.Genres.ToArray(), + Studios = seasonInfo.Studios.ToArray(), + CommunityRating = seasonInfo.AniDB.Rating?.ToFloat(10), + }; + } + season.ProviderIds.Add("Shoko Series", seasonInfo.Id); + season.ProviderIds.Add("Shoko Season Offset", offset.ToString()); + if (Plugin.Instance.Configuration.AddAniDBId) + season.ProviderIds.Add("AniDB", seasonInfo.AniDB.Id.ToString()); + + return season; } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchInfo, CancellationToken cancellationToken) + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<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 fc864a2f..bccce524 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -14,107 +14,107 @@ using Shokofin.API; using Shokofin.Utils; -namespace Shokofin.Providers +#nullable enable +namespace Shokofin.Providers; + +public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> { - public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> - { - public string Name => Plugin.MetadataProviderName; + public string Name => Plugin.MetadataProviderName; - private readonly IHttpClientFactory HttpClientFactory; + private readonly IHttpClientFactory HttpClientFactory; - private readonly ILogger<SeriesProvider> Logger; + private readonly ILogger<SeriesProvider> Logger; - private readonly ShokoAPIManager ApiManager; + private readonly ShokoAPIManager ApiManager; - private readonly IFileSystem FileSystem; + private readonly IFileSystem FileSystem; - public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager, IFileSystem fileSystem) - { - Logger = logger; - HttpClientFactory = httpClientFactory; - ApiManager = apiManager; - FileSystem = fileSystem; - } + public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager, IFileSystem fileSystem) + { + Logger = logger; + HttpClientFactory = httpClientFactory; + ApiManager = apiManager; + FileSystem = fileSystem; + } - public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) - { - try { - var result = new MetadataResult<Series>(); - var show = await ApiManager.GetShowInfoByPath(info.Path); - if (show == null) { - try { - // Look for the "season" directories to probe for the group information - var entries = FileSystem.GetDirectories(info.Path, false); - foreach (var entry in entries) { - show = await ApiManager.GetShowInfoByPath(entry.FullName); - if (show != null) - break; - } - if (show == null) { - Logger.LogWarning("Unable to find show info for path {Path}", info.Path); - return result; - } + public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) + { + try { + var result = new MetadataResult<Series>(); + var show = await ApiManager.GetShowInfoByPath(info.Path); + if (show == null) { + try { + // Look for the "season" directories to probe for the group information + var entries = FileSystem.GetDirectories(info.Path, false); + foreach (var entry in entries) { + show = await ApiManager.GetShowInfoByPath(entry.FullName); + if (show != null) + break; } - catch (DirectoryNotFoundException) { + if (show == null) { + Logger.LogWarning("Unable to find show info for path {Path}", info.Path); return result; } } - - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(show.DefaultSeason.AniDB.Titles, show.Name, info.MetadataLanguage); - var premiereDate = show.PremiereDate; - var endDate = show.EndDate; - result.Item = new Series { - Name = displayTitle, - OriginalTitle = alternateTitle, - Overview = Text.GetDescription(show), - PremiereDate = premiereDate, - ProductionYear = premiereDate?.Year, - EndDate = endDate, - Status = !endDate.HasValue || endDate.Value > DateTime.UtcNow ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = show.Tags, - Genres = show.Genres, - Studios = show.Studios, - OfficialRating = show.ContentRating, - CustomRating = show.ContentRating, - CommunityRating = show.CommunityRating, - }; - result.HasMetadata = true; - result.ResetPeople(); - foreach (var person in show.Staff) - result.AddPerson(person); - - AddProviderIds(result.Item, show.Id, show.GroupId, show.DefaultSeason.AniDB.Id.ToString()); - - Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, show.Id, show.GroupId); - - return result; - } - catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - return new MetadataResult<Series>(); + catch (DirectoryNotFoundException) { + return result; + } } - } - public static void AddProviderIds(IHasProviderIds item, string seriesId, string groupId = null, string anidbId = null, string tmdbId = null) - { - // NOTE: These next line will remain here till _someone_ fix the series merging for providers other then TvDB and ImDB in Jellyfin. - // NOTE: #2 Will fix this once JF 10.9 is out, as it contains a change that will help in this situation. - item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{seriesId}"); - - var config = Plugin.Instance.Configuration; - item.SetProviderId("Shoko Series", seriesId); - if (!string.IsNullOrEmpty(groupId)) - item.SetProviderId("Shoko Group", groupId); - if (config.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") - item.SetProviderId("AniDB", anidbId); - if (config.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") - item.SetProviderId(MetadataProvider.Tmdb, tmdbId); + var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(show.DefaultSeason.AniDB.Titles, show.Name, info.MetadataLanguage); + var premiereDate = show.PremiereDate; + var endDate = show.EndDate; + result.Item = new Series { + Name = displayTitle, + OriginalTitle = alternateTitle, + Overview = Text.GetDescription(show), + PremiereDate = premiereDate, + ProductionYear = premiereDate?.Year, + EndDate = endDate, + Status = !endDate.HasValue || endDate.Value > DateTime.UtcNow ? SeriesStatus.Continuing : SeriesStatus.Ended, + Tags = show.Tags, + Genres = show.Genres, + Studios = show.Studios, + OfficialRating = show.OfficialRating, + CustomRating = show.CustomRating, + CommunityRating = show.CommunityRating, + }; + result.HasMetadata = true; + result.ResetPeople(); + foreach (var person in show.Staff) + result.AddPerson(person); + + AddProviderIds(result.Item, show.Id, show.GroupId, show.DefaultSeason.AniDB.Id.ToString()); + + Logger.LogInformation("Found series {SeriesName} (Series={SeriesId},Group={GroupId})", displayTitle, show.Id, show.GroupId); + + return result; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<Series>(); } + } - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo info, CancellationToken cancellationToken) - => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + public static void AddProviderIds(IHasProviderIds item, string seriesId, string? groupId = null, string? anidbId = null, string? tmdbId = null) + { + // NOTE: These next line will remain here till _someone_ fix the series merging for providers other then TvDB and ImDB in Jellyfin. + // NOTE: #2 Will fix this once JF 10.9 is out, as it contains a change that will help in this situation. + item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{seriesId}"); - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); + var config = Plugin.Instance.Configuration; + item.SetProviderId("Shoko Series", seriesId); + if (!string.IsNullOrEmpty(groupId)) + item.SetProviderId("Shoko Group", groupId); + if (config.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") + item.SetProviderId("AniDB", anidbId); + if (config.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") + item.SetProviderId(MetadataProvider.Tmdb, tmdbId); } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo info, CancellationToken cancellationToken) + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } diff --git a/Shokofin/Sync/SyncDirection.cs b/Shokofin/Sync/SyncDirection.cs index bb7222e5..25fde95e 100644 --- a/Shokofin/Sync/SyncDirection.cs +++ b/Shokofin/Sync/SyncDirection.cs @@ -1,26 +1,25 @@ - using System; -namespace Shokofin.Sync -{ +#nullable enable +namespace Shokofin.Sync; + +/// <summary> +/// Determines if we should push or pull the data. +/// /// </summary> +[Flags] +public enum SyncDirection { + /// <summary> + /// Import data from Shoko. + /// </summary> + Import = 1, + /// <summary> + /// Export data to Shoko. + /// </summary> + Export = 2, /// <summary> - /// Determines if we should push or pull the data. - /// /// </summary> - [Flags] - public enum SyncDirection { - /// <summary> - /// Import data from Shoko. - /// </summary> - Import = 1, - /// <summary> - /// Export data to Shoko. - /// </summary> - Export = 2, - /// <summary> - /// Sync data with Shoko and only keep the latest data. - /// <br/> - /// This will conditionally import or export the data as needed. - /// </summary> - Sync = 3, - } -} \ No newline at end of file + /// Sync data with Shoko and only keep the latest data. + /// <br/> + /// This will conditionally import or export the data as needed. + /// </summary> + Sync = 3, +} diff --git a/Shokofin/Sync/SyncExtensions.cs b/Shokofin/Sync/SyncExtensions.cs index 936ea01d..215e2e50 100644 --- a/Shokofin/Sync/SyncExtensions.cs +++ b/Shokofin/Sync/SyncExtensions.cs @@ -3,6 +3,7 @@ using MediaBrowser.Controller.Entities; using Shokofin.API.Models; +#nullable enable namespace Shokofin.Sync; public static class SyncExtensions diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index e58aba6a..62dbed9d 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -15,603 +15,590 @@ using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Configuration; + using UserStats = Shokofin.API.Models.File.UserStats; -namespace Shokofin.Sync +#nullable enable +namespace Shokofin.Sync; + +public class UserDataSyncManager { - public class UserDataSyncManager - { - private readonly IUserDataManager UserDataManager; + private readonly IUserDataManager UserDataManager; - private readonly IUserManager UserManager; + private readonly IUserManager UserManager; - private readonly ILibraryManager LibraryManager; + private readonly ILibraryManager LibraryManager; - private readonly ISessionManager SessionManager; + private readonly ISessionManager SessionManager; - private readonly ILogger<UserDataSyncManager> Logger; + private readonly ILogger<UserDataSyncManager> Logger; - private readonly ShokoAPIClient APIClient; + private readonly ShokoAPIClient APIClient; - private readonly IIdLookup Lookup; + private readonly IIdLookup Lookup; - public UserDataSyncManager(IUserDataManager userDataManager, IUserManager userManager, ILibraryManager libraryManager, ISessionManager sessionManager, ILogger<UserDataSyncManager> logger, ShokoAPIClient apiClient, IIdLookup lookup) - { - UserDataManager = userDataManager; - UserManager = userManager; - LibraryManager = libraryManager; - SessionManager = sessionManager; - Logger = logger; - APIClient = apiClient; - Lookup = lookup; - - SessionManager.SessionStarted += OnSessionStarted; - SessionManager.SessionEnded += OnSessionEnded; - UserDataManager.UserDataSaved += OnUserDataSaved; - LibraryManager.ItemAdded += OnItemAddedOrUpdated; - LibraryManager.ItemUpdated += OnItemAddedOrUpdated; - } + public UserDataSyncManager(IUserDataManager userDataManager, IUserManager userManager, ILibraryManager libraryManager, ISessionManager sessionManager, ILogger<UserDataSyncManager> logger, ShokoAPIClient apiClient, IIdLookup lookup) + { + UserDataManager = userDataManager; + UserManager = userManager; + LibraryManager = libraryManager; + SessionManager = sessionManager; + Logger = logger; + APIClient = apiClient; + Lookup = lookup; + + SessionManager.SessionStarted += OnSessionStarted; + SessionManager.SessionEnded += OnSessionEnded; + UserDataManager.UserDataSaved += OnUserDataSaved; + LibraryManager.ItemAdded += OnItemAddedOrUpdated; + LibraryManager.ItemUpdated += OnItemAddedOrUpdated; + } - public void Dispose() - { - SessionManager.SessionStarted -= OnSessionStarted; - SessionManager.SessionEnded -= OnSessionEnded; - UserDataManager.UserDataSaved -= OnUserDataSaved; - LibraryManager.ItemAdded -= OnItemAddedOrUpdated; - LibraryManager.ItemUpdated -= OnItemAddedOrUpdated; - } + public void Dispose() + { + SessionManager.SessionStarted -= OnSessionStarted; + SessionManager.SessionEnded -= OnSessionEnded; + UserDataManager.UserDataSaved -= OnUserDataSaved; + LibraryManager.ItemAdded -= OnItemAddedOrUpdated; + LibraryManager.ItemUpdated -= OnItemAddedOrUpdated; + } - private bool TryGetUserConfiguration(Guid userId, out UserConfiguration config) - { - config = Plugin.Instance.Configuration.UserList.FirstOrDefault(c => c.UserId == userId && c.EnableSynchronization); - return config != null; - } + private static bool TryGetUserConfiguration(Guid userId, out UserConfiguration? config) + { + config = Plugin.Instance.Configuration.UserList.FirstOrDefault(c => c.UserId == userId && c.EnableSynchronization); + return config != null; + } - #region Export/Scrobble + #region Export/Scrobble - internal class SessionMetadata { - private readonly ILogger Logger; - - public Guid ItemId; - public string FileId; - public SessionInfo Session; - public long Ticks; - public byte ScrobbleTicks; - public bool SentPaused; - public int SkipCount; + internal class SessionMetadata { + private readonly ILogger Logger; - public SessionMetadata(ILogger logger) - { - Logger = logger; - } + public Guid ItemId; + public string? FileId; + public SessionInfo Session; + public long Ticks; + public byte ScrobbleTicks; + public bool SentPaused; + public int SkipCount; - public bool ShouldSendEvent(bool isPauseOrResumeEvent = false) - { - if (SkipCount == 0) - return true; + public SessionMetadata(ILogger logger, SessionInfo sessionInfo) + { + Logger = logger; + ItemId = Guid.Empty; + FileId = null; + Session = sessionInfo; + Ticks = 0; + ScrobbleTicks = 0; + SentPaused = false; + SkipCount = 0; + } + + public bool ShouldSendEvent(bool isPauseOrResumeEvent = false) + { + if (SkipCount == 0) + return true; - if (!isPauseOrResumeEvent && SkipCount > 0) - SkipCount--; + if (!isPauseOrResumeEvent && SkipCount > 0) + SkipCount--; - Logger.LogDebug("Scrobble event was skipped. (File={FileId})", FileId); - return SkipCount == 0; - } + Logger.LogDebug("Scrobble event was skipped. (File={FileId})", FileId); + return SkipCount == 0; } + } - private readonly ConcurrentDictionary<Guid, SessionMetadata> ActiveSessions = new ConcurrentDictionary<Guid, SessionMetadata>(); + private readonly ConcurrentDictionary<Guid, SessionMetadata> ActiveSessions = new(); - public void OnSessionStarted(object sender, SessionEventArgs e) - { - if (TryGetUserConfiguration(e.SessionInfo.UserId, out var userConfig) && (userConfig.SyncUserDataUnderPlayback || userConfig.SyncUserDataAfterPlayback)) { - var sessionMetadata = new SessionMetadata(Logger) { - ItemId = Guid.Empty, - Session = e.SessionInfo, - FileId = null, - SentPaused = false, - Ticks = 0, - }; - ActiveSessions.TryAdd(e.SessionInfo.UserId, sessionMetadata); - } - foreach (var user in e.SessionInfo.AdditionalUsers) { - if (TryGetUserConfiguration(e.SessionInfo.UserId, out userConfig) && (userConfig.SyncUserDataUnderPlayback || userConfig.SyncUserDataAfterPlayback)) { - var sessionMetadata = new SessionMetadata(Logger) { - ItemId = Guid.Empty, - Session = e.SessionInfo, - FileId = null, - SentPaused = false, - Ticks = 0, - }; - ActiveSessions.TryAdd(user.UserId, sessionMetadata); - } + public void OnSessionStarted(object? sender, SessionEventArgs e) + { + if (TryGetUserConfiguration(e.SessionInfo.UserId, out var userConfig) && (userConfig!.SyncUserDataUnderPlayback || userConfig.SyncUserDataAfterPlayback)) { + var sessionMetadata = new SessionMetadata(Logger, e.SessionInfo); + ActiveSessions.TryAdd(e.SessionInfo.UserId, sessionMetadata); + } + foreach (var user in e.SessionInfo.AdditionalUsers) { + if (TryGetUserConfiguration(e.SessionInfo.UserId, out userConfig) && (userConfig!.SyncUserDataUnderPlayback || userConfig.SyncUserDataAfterPlayback)) { + var sessionMetadata = new SessionMetadata(Logger, e.SessionInfo); + ActiveSessions.TryAdd(user.UserId, sessionMetadata); } } + } - public void OnSessionEnded(object sender, SessionEventArgs e) - { - ActiveSessions.TryRemove(e.SessionInfo.UserId, out var sessionMetadata); - foreach (var user in e.SessionInfo.AdditionalUsers) { - ActiveSessions.TryRemove(user.UserId, out sessionMetadata); - } + public void OnSessionEnded(object? sender, SessionEventArgs e) + { + ActiveSessions.TryRemove(e.SessionInfo.UserId, out _); + foreach (var user in e.SessionInfo.AdditionalUsers) { + ActiveSessions.TryRemove(user.UserId, out _); } + } - public async void OnUserDataSaved(object sender, UserDataSaveEventArgs e) - { - try { + public async void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) + { + try { - if (e == null || e.Item == null || Guid.Equals(e.UserId, Guid.Empty) || e.UserData == null) - return; + if (e == null || e.Item == null || Guid.Equals(e.UserId, Guid.Empty) || e.UserData == null) + return; - if (e.SaveReason == UserDataSaveReason.UpdateUserRating) { - OnUserRatingSaved(sender, e); - return; - } + if (e.SaveReason == UserDataSaveReason.UpdateUserRating) { + OnUserRatingSaved(sender, e); + return; + } - if (!( - (e.Item is Movie || e.Item is Episode) && - TryGetUserConfiguration(e.UserId, out var userConfig) && - Lookup.TryGetFileIdFor(e.Item, out var fileId) && - Lookup.TryGetEpisodeIdFor(e.Item, out var episodeId) && - (userConfig.SyncRestrictedVideos || e.Item.CustomRating != "XXX") - )) - return; + if (!( + (e.Item is Movie || e.Item is Episode) && + TryGetUserConfiguration(e.UserId, out var userConfig) && + Lookup.TryGetFileIdFor(e.Item, out var fileId) && + Lookup.TryGetEpisodeIdFor(e.Item, out var episodeId) && + (userConfig!.SyncRestrictedVideos || e.Item.CustomRating != "XXX") + )) + return; - var itemId = e.Item.Id; - var userData = e.UserData; - var config = Plugin.Instance.Configuration; - bool? success = null; - switch (e.SaveReason) { - // case UserDataSaveReason.PlaybackStart: // The progress event is sent at the same time, so this event is not needed. - case UserDataSaveReason.PlaybackProgress: { - // If a session can't be found or created then throw an error. - if (!ActiveSessions.TryGetValue(e.UserId, out var sessionMetadata)) - return; + var itemId = e.Item.Id; + var userData = e.UserData; + var config = Plugin.Instance.Configuration; + bool? success = null; + switch (e.SaveReason) { + // case UserDataSaveReason.PlaybackStart: // The progress event is sent at the same time, so this event is not needed. + case UserDataSaveReason.PlaybackProgress: { + // If a session can't be found or created then throw an error. + if (!ActiveSessions.TryGetValue(e.UserId, out var sessionMetadata)) + return; - // The active video changed, so send a start event. - if (sessionMetadata.ItemId != itemId) { - sessionMetadata.ItemId = e.Item.Id; - sessionMetadata.FileId = fileId; - sessionMetadata.Ticks = userData.PlaybackPositionTicks; + // The active video changed, so send a start event. + if (sessionMetadata.ItemId != itemId) { + sessionMetadata.ItemId = e.Item.Id; + sessionMetadata.FileId = fileId; + sessionMetadata.Ticks = userData.PlaybackPositionTicks; + sessionMetadata.ScrobbleTicks = 0; + sessionMetadata.SentPaused = false; + sessionMetadata.SkipCount = userConfig.SyncUserDataInitialSkipEventCount; + + Logger.LogInformation("Playback has started. (File={FileId})", fileId); + if (sessionMetadata.ShouldSendEvent() && userConfig.SyncUserDataUnderPlayback) + success = await APIClient.ScrobbleFile(fileId, episodeId, "play", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); + } + else { + long ticks = sessionMetadata.Session.PlayState.PositionTicks ?? userData.PlaybackPositionTicks; + // We received an event, but the position didn't change, so the playback is most likely paused. + if (sessionMetadata.Session.PlayState?.IsPaused ?? false) { + if (sessionMetadata.SentPaused) + return; + + sessionMetadata.SentPaused = true; + + Logger.LogInformation("Playback was paused. (File={FileId})", fileId); + if (sessionMetadata.ShouldSendEvent(true) && userConfig.SyncUserDataUnderPlayback ) + success = await APIClient.ScrobbleFile(fileId, episodeId, "pause", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); + } + // The playback was resumed. + else if (sessionMetadata.SentPaused) { + sessionMetadata.Ticks = ticks; sessionMetadata.ScrobbleTicks = 0; sessionMetadata.SentPaused = false; - sessionMetadata.SkipCount = userConfig.SyncUserDataInitialSkipEventCount; - Logger.LogInformation("Playback has started. (File={FileId})", fileId); - if (sessionMetadata.ShouldSendEvent() && userConfig.SyncUserDataUnderPlayback) - success = await APIClient.ScrobbleFile(fileId, episodeId, "play", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); + Logger.LogInformation("Playback was resumed. (File={FileId})", fileId); + if (sessionMetadata.ShouldSendEvent(true) && userConfig.SyncUserDataUnderPlayback ) + success = await APIClient.ScrobbleFile(fileId, episodeId, "resume", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); } - else { - long ticks = sessionMetadata.Session.PlayState.PositionTicks ?? userData.PlaybackPositionTicks; - // We received an event, but the position didn't change, so the playback is most likely paused. - if (sessionMetadata.Session.PlayState?.IsPaused ?? false) { - if (sessionMetadata.SentPaused) - return; - - sessionMetadata.SentPaused = true; - - Logger.LogInformation("Playback was paused. (File={FileId})", fileId); - if (sessionMetadata.ShouldSendEvent(true) && userConfig.SyncUserDataUnderPlayback ) - success = await APIClient.ScrobbleFile(fileId, episodeId, "pause", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); - } - // The playback was resumed. - else if (sessionMetadata.SentPaused) { - sessionMetadata.Ticks = ticks; - sessionMetadata.ScrobbleTicks = 0; - sessionMetadata.SentPaused = false; - - Logger.LogInformation("Playback was resumed. (File={FileId})", fileId); - if (sessionMetadata.ShouldSendEvent(true) && userConfig.SyncUserDataUnderPlayback ) - success = await APIClient.ScrobbleFile(fileId, episodeId, "resume", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); - } - // Return early if we're not scrobbling. - else if (!userConfig.SyncUserDataUnderPlaybackLive) { - sessionMetadata.Ticks = ticks; + // Return early if we're not scrobbling. + else if (!userConfig.SyncUserDataUnderPlaybackLive) { + sessionMetadata.Ticks = ticks; + return; + } + // Live scrobbling. + else { + var deltaTicks = Math.Abs(ticks - sessionMetadata.Ticks); + sessionMetadata.Ticks = ticks; + if (deltaTicks == 0 || deltaTicks < userConfig.SyncUserDataUnderPlaybackLiveThreshold && + ++sessionMetadata.ScrobbleTicks < userConfig.SyncUserDataUnderPlaybackAtEveryXTicks) return; - } - // Live scrobbling. - else { - var deltaTicks = Math.Abs(ticks - sessionMetadata.Ticks); - sessionMetadata.Ticks = ticks; - if (deltaTicks == 0 || deltaTicks < userConfig.SyncUserDataUnderPlaybackLiveThreshold && - ++sessionMetadata.ScrobbleTicks < userConfig.SyncUserDataUnderPlaybackAtEveryXTicks) - return; - - Logger.LogInformation("Playback is running. (File={FileId})", fileId); - sessionMetadata.ScrobbleTicks = 0; - if (sessionMetadata.ShouldSendEvent() && userConfig.SyncUserDataUnderPlayback ) { - success = await APIClient.ScrobbleFile(fileId, episodeId, "scrobble", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); - } + + Logger.LogInformation("Playback is running. (File={FileId})", fileId); + sessionMetadata.ScrobbleTicks = 0; + if (sessionMetadata.ShouldSendEvent() && userConfig.SyncUserDataUnderPlayback ) { + success = await APIClient.ScrobbleFile(fileId, episodeId, "scrobble", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); } } - break; } - case UserDataSaveReason.PlaybackFinished: { - if (!(userConfig.SyncUserDataAfterPlayback || userConfig.SyncUserDataUnderPlayback)) - return; + break; + } + case UserDataSaveReason.PlaybackFinished: { + if (!(userConfig.SyncUserDataAfterPlayback || userConfig.SyncUserDataUnderPlayback)) + return; - var shouldSendEvent = true; - if (ActiveSessions.TryGetValue(e.UserId, out var sessionMetadata) && sessionMetadata.ItemId == e.Item.Id) { - shouldSendEvent = sessionMetadata.ShouldSendEvent(true); + var shouldSendEvent = true; + if (ActiveSessions.TryGetValue(e.UserId, out var sessionMetadata) && sessionMetadata.ItemId == e.Item.Id) { + shouldSendEvent = sessionMetadata.ShouldSendEvent(true); - sessionMetadata.ItemId = Guid.Empty; - sessionMetadata.FileId = null; - sessionMetadata.Ticks = 0; - sessionMetadata.ScrobbleTicks = 0; - sessionMetadata.SentPaused = false; - sessionMetadata.SkipCount = -1; - } - - Logger.LogInformation("Playback has ended. (File={FileId})", fileId); - if (shouldSendEvent) - if (!userData.Played && userData.PlaybackPositionTicks > 0) - success = await APIClient.ScrobbleFile(fileId, episodeId, "stop", userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); - else - success = await APIClient.ScrobbleFile(fileId, episodeId, "stop", userData.PlaybackPositionTicks, userData.Played, userConfig.Token).ConfigureAwait(false); - break; + sessionMetadata.ItemId = Guid.Empty; + sessionMetadata.FileId = null; + sessionMetadata.Ticks = 0; + sessionMetadata.ScrobbleTicks = 0; + sessionMetadata.SentPaused = false; + sessionMetadata.SkipCount = -1; } - case UserDataSaveReason.TogglePlayed: - Logger.LogInformation("Scrobbled when toggled. (File={FileId})", fileId); + + Logger.LogInformation("Playback has ended. (File={FileId})", fileId); + if (shouldSendEvent) if (!userData.Played && userData.PlaybackPositionTicks > 0) - success = await APIClient.ScrobbleFile(fileId, episodeId, "user-interaction", userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); + success = await APIClient.ScrobbleFile(fileId, episodeId, "stop", userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); else - success = await APIClient.ScrobbleFile(fileId, episodeId, "user-interaction", userData.PlaybackPositionTicks, userData.Played, userConfig.Token).ConfigureAwait(false); - break; - default: - success = null; - break; - } - if (success.HasValue) { - if (success.Value) { - Logger.LogInformation("Successfully synced watch state with Shoko. (File={FileId})", fileId); - } - else { - Logger.LogInformation("Failed to sync watch state with Shoko. (File={FileId})", fileId); - } + success = await APIClient.ScrobbleFile(fileId, episodeId, "stop", userData.PlaybackPositionTicks, userData.Played, userConfig.Token).ConfigureAwait(false); + break; } + case UserDataSaveReason.TogglePlayed: + Logger.LogInformation("Scrobbled when toggled. (File={FileId})", fileId); + if (!userData.Played && userData.PlaybackPositionTicks > 0) + success = await APIClient.ScrobbleFile(fileId, episodeId, "user-interaction", userData.PlaybackPositionTicks, userConfig.Token).ConfigureAwait(false); + else + success = await APIClient.ScrobbleFile(fileId, episodeId, "user-interaction", userData.PlaybackPositionTicks, userData.Played, userConfig.Token).ConfigureAwait(false); + break; + default: + success = null; + break; } - catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized) { - if (TryGetUserConfiguration(e.UserId, out var userConfig)) - Logger.LogError(ex, "{Message} (Username={Username},Id={UserId})", ex.Message, UserManager.GetUserById(userConfig.UserId)?.Username, userConfig.UserId); - return; - } - catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {ErrorMessage}", ex.Message); - return; + if (success.HasValue) { + if (success.Value) { + Logger.LogInformation("Successfully synced watch state with Shoko. (File={FileId})", fileId); + } + else { + Logger.LogInformation("Failed to sync watch state with Shoko. (File={FileId})", fileId); + } } } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized) { + if (TryGetUserConfiguration(e.UserId, out var userConfig)) + Logger.LogError(ex, "{Message} (Username={Username},Id={UserId})", ex.Message, UserManager.GetUserById(userConfig!.UserId)?.Username, userConfig.UserId); + return; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {ErrorMessage}", ex.Message); + return; + } + } - // Updates to favorite state and/or user data. - private void OnUserRatingSaved(object sender, UserDataSaveEventArgs e) - { - if (!TryGetUserConfiguration(e.UserId, out var userConfig)) - return; - - var userData = e.UserData; - var config = Plugin.Instance.Configuration; - switch (e.Item) { - case Episode: - case Movie: { - var video = e.Item as Video; - if (!Lookup.TryGetEpisodeIdFor(video, out var episodeId)) - return; + // Updates to favorite state and/or user data. + private void OnUserRatingSaved(object? sender, UserDataSaveEventArgs e) + { + if (!TryGetUserConfiguration(e.UserId, out var userConfig)) + return; + + var userData = e.UserData; + switch (e.Item) { + case Episode: + case Movie: { + if (e.Item is not Video video || !Lookup.TryGetEpisodeIdFor(video, out var episodeId)) + return; - SyncVideo(video, userConfig, userData, SyncDirection.Export, episodeId).ConfigureAwait(false); - break; - } - case Season season: { - if (!Lookup.TryGetSeriesIdFor(season, out var seriesId)) - return; + SyncVideo(video, userConfig!, userData, SyncDirection.Export, episodeId).ConfigureAwait(false); + break; + } + case Season season: { + if (!Lookup.TryGetSeriesIdFor(season, out var seriesId)) + return; - SyncSeason(season, userConfig, userData, SyncDirection.Export, seriesId).ConfigureAwait(false); - break; - } - case Series series: { - if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) - return; + SyncSeason(season, userConfig!, userData, SyncDirection.Export, seriesId).ConfigureAwait(false); + break; + } + case Series series: { + if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + return; - SyncSeries(series, userConfig, userData, SyncDirection.Export, seriesId).ConfigureAwait(false); - break; - } + SyncSeries(series, userConfig!, userData, SyncDirection.Export, seriesId).ConfigureAwait(false); + break; } } + } - #endregion - #region Import/Sync + #endregion + #region Import/Sync - public async Task ScanAndSync(SyncDirection direction, IProgress<double> progress, CancellationToken cancellationToken) - { - var enabledUsers = Plugin.Instance.Configuration.UserList.Where(c => c.EnableSynchronization).ToList(); - if (enabledUsers.Count == 0) { - progress.Report(100); - return; - } + public async Task ScanAndSync(SyncDirection direction, IProgress<double> progress, CancellationToken cancellationToken) + { + var enabledUsers = Plugin.Instance.Configuration.UserList.Where(c => c.EnableSynchronization).ToList(); + if (enabledUsers.Count == 0) { + progress.Report(100); + return; + } - var videos = LibraryManager.GetItemList(new InternalItemsQuery { - MediaTypes = new[] { MediaType.Video }, - IsFolder = false, - Recursive = true, - DtoOptions = new DtoOptions(false) { - EnableImages = false - }, - SourceTypes = new SourceType[] { SourceType.Library }, - IsVirtualItem = false, - }) - .OfType<Video>() - .ToList(); - - var numComplete = 0; - var numTotal = videos.Count * enabledUsers.Count; - foreach (var video in videos) - { - cancellationToken.ThrowIfCancellationRequested(); + var videos = LibraryManager.GetItemList(new InternalItemsQuery { + MediaTypes = new[] { MediaType.Video }, + IsFolder = false, + Recursive = true, + DtoOptions = new DtoOptions(false) { + EnableImages = false + }, + SourceTypes = new SourceType[] { SourceType.Library }, + IsVirtualItem = false, + }) + .OfType<Video>() + .ToList(); + + var numComplete = 0; + var numTotal = videos.Count * enabledUsers.Count; + foreach (var video in videos) + { + cancellationToken.ThrowIfCancellationRequested(); - if (!(Lookup.TryGetFileIdFor(video, out var fileId) && Lookup.TryGetEpisodeIdFor(video, out var episodeId))) - continue; + if (!(Lookup.TryGetFileIdFor(video, out var fileId) && Lookup.TryGetEpisodeIdFor(video, out var episodeId))) + continue; - foreach (var userConfig in enabledUsers) { - await SyncVideo(video, userConfig, direction, fileId, episodeId).ConfigureAwait(false); + foreach (var userConfig in enabledUsers) { + await SyncVideo(video, userConfig, direction, fileId, episodeId).ConfigureAwait(false); - numComplete++; - double percent = numComplete; - percent /= numTotal; + numComplete++; + double percent = numComplete; + percent /= numTotal; - progress.Report(percent * 100); - } + progress.Report(percent * 100); } - progress.Report(100); } + progress.Report(100); + } - public void OnItemAddedOrUpdated(object sender, ItemChangeEventArgs e) - { - if (e == null || e.Item == null || e.Parent == null || !(e.UpdateReason.HasFlag(ItemUpdateType.MetadataImport) || e.UpdateReason.HasFlag(ItemUpdateType.MetadataDownload))) - return; + public void OnItemAddedOrUpdated(object? sender, ItemChangeEventArgs e) + { + if (e == null || e.Item == null || e.Parent == null || !(e.UpdateReason.HasFlag(ItemUpdateType.MetadataImport) || e.UpdateReason.HasFlag(ItemUpdateType.MetadataDownload))) + return; - switch (e.Item) { - case Video video: { - if (!(Lookup.TryGetFileIdFor(video, out var fileId) && Lookup.TryGetEpisodeIdFor(video, out var episodeId))) - return; + switch (e.Item) { + case Video video: { + if (!(Lookup.TryGetFileIdFor(video, out var fileId) && Lookup.TryGetEpisodeIdFor(video, out var episodeId))) + return; - foreach (var userConfig in Plugin.Instance.Configuration.UserList) { - if (!userConfig.EnableSynchronization) - continue; + foreach (var userConfig in Plugin.Instance.Configuration.UserList) { + if (!userConfig.EnableSynchronization) + continue; - if (!userConfig.SyncUserDataOnImport) - continue; + if (!userConfig.SyncUserDataOnImport) + continue; - SyncVideo(video, userConfig, SyncDirection.Import, fileId, episodeId).ConfigureAwait(false); - } - break; + SyncVideo(video, userConfig, SyncDirection.Import, fileId, episodeId).ConfigureAwait(false); } - case Season season: { - if (!Lookup.TryGetSeriesIdFor(season, out var seriesId)) - return; + break; + } + case Season season: { + if (!Lookup.TryGetSeriesIdFor(season, out var seriesId)) + return; - foreach (var userConfig in Plugin.Instance.Configuration.UserList) { - if (!userConfig.EnableSynchronization) - continue; + foreach (var userConfig in Plugin.Instance.Configuration.UserList) { + if (!userConfig.EnableSynchronization) + continue; - if (!userConfig.SyncUserDataOnImport) - continue; + if (!userConfig.SyncUserDataOnImport) + continue; - SyncSeason(season, userConfig, null, SyncDirection.Import, seriesId).ConfigureAwait(false); - } - break; + SyncSeason(season, userConfig, null, SyncDirection.Import, seriesId).ConfigureAwait(false); } - case Series series: { - if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) - return; + break; + } + case Series series: { + if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + return; - foreach (var userConfig in Plugin.Instance.Configuration.UserList) { - if (!userConfig.EnableSynchronization) - continue; + foreach (var userConfig in Plugin.Instance.Configuration.UserList) { + if (!userConfig.EnableSynchronization) + continue; - if (!userConfig.SyncUserDataOnImport) - continue; + if (!userConfig.SyncUserDataOnImport) + continue; - SyncSeries(series, userConfig, null, SyncDirection.Import, seriesId).ConfigureAwait(false); - } - break; + SyncSeries(series, userConfig, null, SyncDirection.Import, seriesId).ConfigureAwait(false); } + break; } - } - #endregion + } - private Task SyncSeries(Series series, UserConfiguration userConfig, UserItemData userData, SyncDirection direction, string seriesId) - { - // Try to load the user-data if it was not provided - if (userData == null) - userData = UserDataManager.GetUserData(userConfig.UserId, series); - // Create some new user-data if none exists. - if (userData == null) - userData = new UserItemData { - UserId = userConfig.UserId, - Key = series.GetUserDataKeys()[0], - }; - - Logger.LogDebug("TODO; {SyncDirection} user data for Series {SeriesName}. (Series={SeriesId})", direction.ToString(), series.Name, seriesId); + #endregion - return Task.CompletedTask; - } + private Task SyncSeries(Series series, UserConfiguration userConfig, UserItemData? userData, SyncDirection direction, string seriesId) + { + // Try to load the user-data if it was not provided + userData ??= UserDataManager.GetUserData(userConfig.UserId, series); + // Create some new user-data if none exists. + userData ??= new UserItemData { + UserId = userConfig.UserId, + Key = series.GetUserDataKeys()[0], + }; - private Task SyncSeason(Season season, UserConfiguration userConfig, UserItemData userData, SyncDirection direction, string seriesId) - { - // Try to load the user-data if it was not provided - if (userData == null) - userData = UserDataManager.GetUserData(userConfig.UserId, season); - // Create some new user-data if none exists. - if (userData == null) - userData = new UserItemData { - UserId = userConfig.UserId, - Key = season.GetUserDataKeys()[0], - }; - - Logger.LogDebug("TODO; {SyncDirection} user data for Season {SeasonNumber} in Series {SeriesName}. (Series={SeriesId})", direction.ToString(), season.IndexNumber, season.SeriesName, seriesId); + Logger.LogDebug("TODO; {SyncDirection} user data for Series {SeriesName}. (Series={SeriesId})", direction.ToString(), series.Name, seriesId); - return Task.CompletedTask; - } + return Task.CompletedTask; + } - private Task SyncVideo(Video video, UserConfiguration userConfig, UserItemData userData, SyncDirection direction, string episodeId) - { - if (!userConfig.SyncRestrictedVideos && video.CustomRating == "XXX") { - Logger.LogTrace("Skipped {SyncDirection} user data for video {VideoName}. (Episode={EpisodeId})", direction.ToString(), video.Name, episodeId); - return Task.CompletedTask; - } - // Try to load the user-data if it was not provided - if (userData == null) - userData = UserDataManager.GetUserData(userConfig.UserId, video); - // Create some new user-data if none exists. - if (userData == null) - userData = new UserItemData { - UserId = userConfig.UserId, - Key = video.GetUserDataKeys()[0], - LastPlayedDate = null, - }; - - // var remoteUserData = await APIClient.GetFileUserData(fileId, userConfig.Token); - // if (remoteUserData == null) - // return; - - Logger.LogDebug("TODO; {SyncDirection} user data for video {VideoName}. (Episode={EpisodeId})", direction.ToString(), video.Name, episodeId); + private Task SyncSeason(Season season, UserConfiguration userConfig, UserItemData? userData, SyncDirection direction, string seriesId) + { + // Try to load the user-data if it was not provided + userData ??= UserDataManager.GetUserData(userConfig.UserId, season); + // Create some new user-data if none exists. + userData ??= new UserItemData { + UserId = userConfig.UserId, + Key = season.GetUserDataKeys()[0], + }; + + Logger.LogDebug("TODO; {SyncDirection} user data for Season {SeasonNumber} in Series {SeriesName}. (Series={SeriesId})", direction.ToString(), season.IndexNumber, season.SeriesName, seriesId); + + return Task.CompletedTask; + } + private Task SyncVideo(Video video, UserConfiguration userConfig, UserItemData? userData, SyncDirection direction, string episodeId) + { + if (!userConfig.SyncRestrictedVideos && video.CustomRating == "XXX") { + Logger.LogTrace("Skipped {SyncDirection} user data for video {VideoName}. (Episode={EpisodeId})", direction.ToString(), video.Name, episodeId); return Task.CompletedTask; } + // Try to load the user-data if it was not provided + userData ??= UserDataManager.GetUserData(userConfig.UserId, video); + // Create some new user-data if none exists. + userData ??= new UserItemData { + UserId = userConfig.UserId, + Key = video.GetUserDataKeys()[0], + LastPlayedDate = null, + }; + + // var remoteUserData = await APIClient.GetFileUserData(fileId, userConfig.Token); + // if (remoteUserData == null) + // return; + + Logger.LogDebug("TODO; {SyncDirection} user data for video {VideoName}. (Episode={EpisodeId})", direction.ToString(), video.Name, episodeId); + + return Task.CompletedTask; + } - private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDirection direction, string fileId, string episodeId) - { - try { - if (!userConfig.SyncRestrictedVideos && video.CustomRating == "XXX") { - Logger.LogTrace("Skipped {SyncDirection} user data for video {VideoName}. (File={FileId},Episode={EpisodeId})", direction.ToString(), video.Name, fileId, episodeId); - return; - } - var localUserStats = UserDataManager.GetUserData(userConfig.UserId, video); - var remoteUserStats = await APIClient.GetFileUserStats(fileId, userConfig.Token); - bool isInSync = UserDataEqualsFileUserStats(localUserStats, remoteUserStats); - Logger.LogInformation("{SyncDirection} user data for video {VideoName}. (User={UserId},File={FileId},Episode={EpisodeId},Local={HaveLocal},Remote={HaveRemote},InSync={IsInSync})", direction.ToString(), video.Name, userConfig.UserId, fileId, episodeId, localUserStats != null, remoteUserStats != null, isInSync); - if (isInSync) - return; + private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDirection direction, string fileId, string episodeId) + { + try { + if (!userConfig.SyncRestrictedVideos && video.CustomRating == "XXX") { + Logger.LogTrace("Skipped {SyncDirection} user data for video {VideoName}. (File={FileId},Episode={EpisodeId})", direction.ToString(), video.Name, fileId, episodeId); + return; + } + var localUserStats = UserDataManager.GetUserData(userConfig.UserId, video); + var remoteUserStats = await APIClient.GetFileUserStats(fileId, userConfig.Token); + bool isInSync = UserDataEqualsFileUserStats(localUserStats, remoteUserStats); + Logger.LogInformation("{SyncDirection} user data for video {VideoName}. (User={UserId},File={FileId},Episode={EpisodeId},Local={HaveLocal},Remote={HaveRemote},InSync={IsInSync})", direction.ToString(), video.Name, userConfig.UserId, fileId, episodeId, localUserStats != null, remoteUserStats != null, isInSync); + if (isInSync) + return; - switch (direction) - { - case SyncDirection.Export: - // Abort since there are no local stats to export. - if (localUserStats == null) - break; - // Export the local stats if there is no remote stats or if the local stats are newer. - if (remoteUserStats == null) - { - remoteUserStats = localUserStats.ToFileUserStats(); - // Don't sync if the local state is considered empty and there is no remote state. - if (remoteUserStats.IsEmpty) - break; - remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, episodeId); - } - else if (localUserStats.LastPlayedDate.HasValue && localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) { - remoteUserStats = localUserStats.ToFileUserStats(); - remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, episodeId); - } + switch (direction) + { + case SyncDirection.Export: + // Abort since there are no local stats to export. + if (localUserStats == null) break; - case SyncDirection.Import: - // Abort since there are no remote stats to import. - if (remoteUserStats == null) + // Export the local stats if there is no remote stats or if the local stats are newer. + if (remoteUserStats == null) + { + remoteUserStats = localUserStats.ToFileUserStats(); + // Don't sync if the local state is considered empty and there is no remote state. + if (remoteUserStats.IsEmpty) break; - // Create a new local stats entry if there is no local entry. - if (localUserStats == null) - { - UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats = remoteUserStats.ToUserData(video, userConfig.UserId), UserDataSaveReason.Import, CancellationToken.None); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); - } - // Else merge the remote stats into the local stats entry. - else if ((!localUserStats.LastPlayedDate.HasValue || remoteUserStats.LastUpdatedAt > localUserStats.LastPlayedDate.Value)) - { - UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); - } + remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, episodeId); + } + else if (localUserStats.LastPlayedDate.HasValue && localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) { + remoteUserStats = localUserStats.ToFileUserStats(); + remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, episodeId); + } + break; + case SyncDirection.Import: + // Abort since there are no remote stats to import. + if (remoteUserStats == null) + break; + // Create a new local stats entry if there is no local entry. + if (localUserStats == null) + { + UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats = remoteUserStats.ToUserData(video, userConfig.UserId), UserDataSaveReason.Import, CancellationToken.None); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); + } + // Else merge the remote stats into the local stats entry. + else if (!localUserStats.LastPlayedDate.HasValue || remoteUserStats.LastUpdatedAt > localUserStats.LastPlayedDate.Value) + { + UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); + } + break; + default: + case SyncDirection.Sync: { + // Export if there is local stats but no remote stats. + if (localUserStats == null && remoteUserStats != null) + goto case SyncDirection.Import; + + // Try to import of there is no local stats ubt there are remote stats. + else if (remoteUserStats == null && localUserStats != null) + goto case SyncDirection.Export; + + // Abort if there are no local or remote stats. + else if (remoteUserStats == null && localUserStats == null) break; - default: - case SyncDirection.Sync: { - // Export if there is local stats but no remote stats. - if (localUserStats == null && remoteUserStats != null) - goto case SyncDirection.Import; - - // Try to import of there is no local stats ubt there are remote stats. - else if (remoteUserStats == null && localUserStats != null) - goto case SyncDirection.Export; - - // Abort if there are no local or remote stats. - else if (remoteUserStats == null && localUserStats == null) - break; - - // Try to import if we're unable to read the last played timestamp. - if (!localUserStats.LastPlayedDate.HasValue) - goto case SyncDirection.Import; - // Abort if the stats are in sync. - if (isInSync || localUserStats.LastPlayedDate.Value == remoteUserStats.LastUpdatedAt) - break; + // Try to import if we're unable to read the last played timestamp. + if (!localUserStats!.LastPlayedDate.HasValue) + goto case SyncDirection.Import; - // Export if the local state is fresher then the remote state. - if (localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) - { - remoteUserStats = localUserStats.ToFileUserStats(); - remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, episodeId); - } - // Else import if the remote state is fresher then the local state. - else if (localUserStats.LastPlayedDate.Value < remoteUserStats.LastUpdatedAt) - { - UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); - Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); - } + // Abort if the stats are in sync. + if (isInSync || localUserStats.LastPlayedDate.Value == remoteUserStats!.LastUpdatedAt) break; + + // Export if the local state is fresher then the remote state. + if (localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) + { + remoteUserStats = localUserStats.ToFileUserStats(); + remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, episodeId); + } + // Else import if the remote state is fresher then the local state. + else if (localUserStats.LastPlayedDate.Value < remoteUserStats.LastUpdatedAt) + { + UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); + Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); } + break; } } - catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized) { - Logger.LogError(ex, "{Message} (Username={Username},Id={UserId})", ex.Message, UserManager.GetUserById(userConfig.UserId)?.Username, userConfig.UserId); - throw; - } } + catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized) { + Logger.LogError(ex, "{Message} (Username={Username},Id={UserId})", ex.Message, UserManager.GetUserById(userConfig.UserId)?.Username, userConfig.UserId); + throw; + } + } - /// <summary> - /// Checks if the local user data and the remote user stats are in sync. - /// </summary> - /// <param name="localUserData">The local user data</param> - /// <param name="remoteUserStats">The remote user stats.</param> - /// <returns>True if they are not in sync.</returns> - private static bool UserDataEqualsFileUserStats(UserItemData localUserData, UserStats remoteUserStats) - { - if (remoteUserStats == null && localUserData == null) - return true; + /// <summary> + /// Checks if the local user data and the remote user stats are in sync. + /// </summary> + /// <param name="localUserData">The local user data</param> + /// <param name="remoteUserStats">The remote user stats.</param> + /// <returns>True if they are not in sync.</returns> + private static bool UserDataEqualsFileUserStats(UserItemData? localUserData, UserStats? remoteUserStats) + { + if (remoteUserStats == null && localUserData == null) + return true; - if (localUserData == null) - return false; + if (localUserData == null) + return false; - var localUserStats = localUserData.ToFileUserStats(); - if (remoteUserStats == null) - return localUserStats.IsEmpty; + var localUserStats = localUserData.ToFileUserStats(); + if (remoteUserStats == null) + return localUserStats.IsEmpty; - if (localUserStats.IsEmpty && remoteUserStats.IsEmpty) - return true; + if (localUserStats.IsEmpty && remoteUserStats.IsEmpty) + return true; - var resumePosition = localUserStats.ResumePosition; - if (localUserStats.ResumePosition != remoteUserStats.ResumePosition) - return false; + if (localUserStats.ResumePosition != remoteUserStats.ResumePosition) + return false; - if (localUserStats.WatchedCount != remoteUserStats.WatchedCount) - return false; + if (localUserStats.WatchedCount != remoteUserStats.WatchedCount) + return false; - var played = remoteUserStats.LastWatchedAt.HasValue; - if (localUserData.Played != played) - return false; + var played = remoteUserStats.LastWatchedAt.HasValue; + if (localUserData.Played != played) + return false; - if (localUserStats.LastUpdatedAt != remoteUserStats.LastUpdatedAt) - return false; + if (localUserStats.LastUpdatedAt != remoteUserStats.LastUpdatedAt) + return false; - return true; - } + return true; } } From 8477b3dd89d254781922ba699c8b8af3d7937bf2 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 29 Mar 2024 02:26:04 +0000 Subject: [PATCH 0637/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index e22846ea..4f1f33b3 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.51", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.51/shoko_3.0.1.51.zip", + "checksum": "db4d30b9f5b8b122e194cede768ea5e0", + "timestamp": "2024-03-29T02:26:03Z" + }, { "version": "3.0.1.50", "changelog": "NA\n", From d9df7a8dde2d81436b824bd24d25cf844576bd1c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 04:10:54 +0100 Subject: [PATCH 0638/1103] fix: fix subtitle links --- Shokofin/Resolvers/ShokoResolveManager.cs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 83d9ff6f..3fe7083a 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -227,6 +227,7 @@ await Task.WhenAll(files return; var sourcePrefix = Path.GetFileNameWithoutExtension(sourceLocation); + var sourcePrefixLength = sourceLocation.Length - Path.GetExtension(sourceLocation).Length; var subtitleLinks = FindSubtitlesForPath(sourceLocation); foreach (var symbolicLink in symbolicLinks) { if (File.Exists(symbolicLink)) { @@ -242,12 +243,13 @@ await Task.WhenAll(files allPathsForVFS.Add((sourceLocation, symbolicLink)); File.CreateSymbolicLink(symbolicLink, sourceLocation); - if (subtitleLinks.Count > 0) - { - var destinationPrefix = Path.GetFileNameWithoutExtension(symbolicLink); - foreach (var (source, dest) in subtitleLinks.Select(path => (path, destinationPrefix + path[sourcePrefix.Length..]))) { - allPathsForVFS.Add((source, dest)); - File.CreateSymbolicLink(dest, source); + if (subtitleLinks.Count > 0) { + var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); + foreach (var subtitleSource in subtitleLinks) { + var extName = subtitleSource[sourcePrefixLength..]; + var subtitleLink = Path.Combine(symbolicDirectory, symbolicName + extName); + allPathsForVFS.Add((subtitleSource, subtitleLink)); + File.CreateSymbolicLink(subtitleLink, subtitleSource); } } } @@ -338,8 +340,7 @@ await Task.WhenAll(files }; if (isMovieSeason && collectionType != CollectionType.TvShows) { - if (!string.IsNullOrEmpty(extrasFolder)) - { + if (!string.IsNullOrEmpty(extrasFolder)) { foreach (var episodeInfo in season.EpisodeList) folders.Add(Path.Combine(vfsPath, $"{showName} [shoko-series-{show.Id}] [shoko-episode-{episodeInfo.Id}]", extrasFolder)); } @@ -350,8 +351,7 @@ await Task.WhenAll(files } else { var seasonName = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}"; - if (!string.IsNullOrEmpty(extrasFolder)) - { + if (!string.IsNullOrEmpty(extrasFolder)) { folders.Add(Path.Combine(vfsPath, $"{showName} [shoko-series-{show.Id}]", extrasFolder)); folders.Add(Path.Combine(vfsPath, $"{showName} [shoko-series-{show.Id}]", seasonName, extrasFolder)); } From 6611a79d4bfce5ce898fed2a1642deec869b85fe Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 05:19:16 +0100 Subject: [PATCH 0639/1103] misc: add discord notifications --- .github/workflows/release-daily.yml | 87 +++++++++++++++++++++++++---- build_plugin.py | 8 --- 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index c40a482d..e393a520 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -5,22 +5,59 @@ on: branches: [ master ] jobs: - build: + current_info: runs-on: ubuntu-latest + + name: Current Information + + outputs: + version: ${{ steps.release_info.outputs.version }} + date: ${{ steps.commit_date_iso8601.outputs.date }} + sha: ${{ github.sha }} + sha_short: ${{ steps.commit_info.outputs.sha }} + + steps: + - name: Checkout master + uses: actions/checkout@master + with: + ref: "${{ github.sha }}" + submodules: recursive + fetch-depth: 0 # This is set to download the full git history for the repo + + - name: Get Current Version + id: release_info + uses: revam/gh-action-get-tag-and-version@v1 + with: + branch: true + increment: build + + - name: Get Commit Date (as ISO8601) + id: commit_date_iso8601 + shell: bash + run: | + echo "date=$(git --no-pager show -s --format=%aI ${{ github.sha }})" >> "$GITHUB_OUTPUT" + + - id: commit_info + name: Shorten Commit Hash + uses: actions/github-script@v6 + with: + script: | + const sha = context.sha.substring(0, 7); + core.setOutput("sha", sha); + + build_plugin: + runs-on: ubuntu-latest + + needs: + - current_info + name: Build & Release (Unstable) steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@master with: ref: ${{ github.ref }} - fetch-depth: 0 - - - name: Get previous release version - id: previoustag - uses: "WyriHaximus/github-action-get-previous-tag@v1" - with: - fallback: 1.0.0 - name: Setup .Net uses: actions/setup-dotnet@v1 @@ -39,13 +76,14 @@ jobs: run: python -m pip install jprm - name: Run JPRM - run: echo "NEW_VERSION=$(python build_plugin.py --version=${{ steps.previoustag.outputs.tag }} --prerelease=True)" >> $GITHUB_ENV + run: python build_plugin.py --version=${{ needs.current_info.outputs.version }} --prerelease=True - name: Create Pre-Release uses: softprops/action-gh-release@v1 with: files: ./artifacts/shoko_*.zip - tag_name: ${{ env.NEW_VERSION }} + name: "Shokofin Unstable ${{ needs.current_info.outputs.version }}" + tag_name: ${{ needs.current_info.outputs.version }} prerelease: true fail_on_unmatched_files: true generate_release_notes: true @@ -59,3 +97,30 @@ jobs: commit_message: "misc: update unstable manifest" file_pattern: manifest-unstable.json skip_fetch: true + + discord-notify: + runs-on: ubuntu-latest + + name: Send notifications about the new daily build + + needs: + - current_info + - build_plugin + + steps: + - name: Checkout master + uses: actions/checkout@master + with: + ref: "${{ github.sha }}" + submodules: recursive + + - name: Notify Discord Users + uses: sarisia/actions-status-discord@v1 + with: + webhook: ${{ secrets.DISCORD_WEBHOOK }} + nodetail: true + title: New Unstable Shokofin Build! + description: | + **Version**: `${{ needs.current_info.outputs.version }}` + + Update your plugin using the [unstable manifest](https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest-unstable.json) or through downloading the release from [GitHub Releases](https://github.com/ShokoAnime/Shokofin/releases/tag/${{ needs.current_info.outputs.version }})! \ No newline at end of file diff --git a/build_plugin.py b/build_plugin.py index ed11a0c3..fde3ae8f 100644 --- a/build_plugin.py +++ b/build_plugin.py @@ -15,12 +15,6 @@ if prerelease: jellyfin_repo_file="./manifest-unstable.json" - version_list = version.split('.') - if len(version_list) == 3: - version_list.append('1') - else: - version_list[3] = str(int(version_list[3]) + 1) - version = '.'.join(version_list) else: jellyfin_repo_file="./manifest.json" @@ -30,6 +24,4 @@ os.system('jprm repo add --url=%s %s %s' % (jellyfin_repo_url, jellyfin_repo_file, zipfile)) -os.system('sed -i "s/shoko\//%s\//" %s' % (version, jellyfin_repo_file)) - print(version) From 6eda440b4a2b16c526c6cfa494686d58421e41aa Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 05:22:25 +0100 Subject: [PATCH 0640/1103] misc: fix get current version --- .github/workflows/release-daily.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index e393a520..7d336b7f 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -21,7 +21,6 @@ jobs: uses: actions/checkout@master with: ref: "${{ github.sha }}" - submodules: recursive fetch-depth: 0 # This is set to download the full git history for the repo - name: Get Current Version @@ -30,6 +29,8 @@ jobs: with: branch: true increment: build + prefix: v + prefixRegex: "[vV]?" - name: Get Commit Date (as ISO8601) id: commit_date_iso8601 From 1add14040ceedf137d6c6080ec8631442a8e5db8 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 29 Mar 2024 04:23:06 +0000 Subject: [PATCH 0641/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 4f1f33b3..5b13c88c 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.52", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.52.zip", + "checksum": "ac99fcb665a951dd7763e39bb2854811", + "timestamp": "2024-03-29T04:23:05Z" + }, { "version": "3.0.1.51", "changelog": "NA\n", From 2f6dba7259320129ff0b0bfd9b3e8b3b10be58b1 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 05:29:34 +0100 Subject: [PATCH 0642/1103] misc: show attempts made to match the media folder --- Shokofin/Resolvers/ShokoResolveManager.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 3fe7083a..852d7060 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -128,8 +128,10 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) .Take(100) .ToList(); int importFolderId = 0; + var attempts = 0; string importFolderSubPath = string.Empty; foreach (var path in allPaths) { + attempts++; var partialPath = path[mediaFolder.Path.Length..]; var partialFolderPath = path[folderPath.Length..]; var files = ApiClient.GetFileByPath(partialPath) @@ -159,7 +161,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) return null; } - Logger.LogInformation("Found a match for folder at {Path} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path})", folderPath, importFolderId, importFolderSubPath, mediaFolder.Path); + Logger.LogInformation("Found a match for folder at {Path} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path},Attempts={Attempts})", folderPath, importFolderId, importFolderSubPath, mediaFolder.Path, attempts); vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); var allFiles = await GetImportFolderFiles(importFolderId, importFolderSubPath, folderPath); From 04e085280a8b57f707d23a7c6cbe9995f19a75da Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 05:29:51 +0100 Subject: [PATCH 0643/1103] fix: don't add year if it's already part of the show name --- Shokofin/Resolvers/ShokoResolveManager.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 852d7060..90969ea1 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -322,7 +322,11 @@ await Task.WhenAll(files if (string.IsNullOrEmpty(showName)) showName = $"Shoko Series {show.Id}"; else if (show.DefaultSeason.AniDB.AirDate.HasValue) - showName += $" ({show.DefaultSeason.AniDB.AirDate.Value.Year})"; + { + var yearText = $" ({show.DefaultSeason.AniDB.AirDate.Value.Year})"; + if (!showName.EndsWith(yearText)) + showName += yearText; + } var folders = new List<string>(); var episodeName = (episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode == "en")?.Value ?? $"Episode {episode.AniDB.Type} {episodeNumber}").ReplaceInvalidPathCharacters(); From 033db52b2cf5ee067019838e2b41b9259d02b9b1 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 29 Mar 2024 04:30:56 +0000 Subject: [PATCH 0644/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 5b13c88c..d80ee0c5 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.53", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.53.zip", + "checksum": "0f7ec3b2c809977b7b7dc9e59ef43437", + "timestamp": "2024-03-29T04:30:54Z" + }, { "version": "3.0.1.52", "changelog": "NA\n", From 7a513eb814fb13b404ef08ba220a38c38d39a75c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 05:31:58 +0100 Subject: [PATCH 0645/1103] misc: update read-me file Update the version compatibility matrix. [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c9a8ba77..bc6fd543 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ compatible with what. | `1.x.x` | `10.7` | `4.1.0-4.1.2` | | `2.x.x` | `10.8` | `4.1.2` | | `3.x.x` | `10.8` | `4.2.0` | -| `unstable` | `10.8` | `dev` | +| `unstable` | `10.8` | `4.2.2` | | `N/A` | `10.9` | `N/A` | ### Official Repository From a93dc2e4d85288e7313c3b6886fc7c579dcbef0a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 05:52:08 +0100 Subject: [PATCH 0646/1103] misc: add changelog to discord --- .github/workflows/release-daily.yml | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 7d336b7f..41ec1e47 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -15,6 +15,7 @@ jobs: date: ${{ steps.commit_date_iso8601.outputs.date }} sha: ${{ github.sha }} sha_short: ${{ steps.commit_info.outputs.sha }} + changelog: ${{ steps.generate_changelog.outputs.CHANGELOG }} steps: - name: Checkout master @@ -23,6 +24,14 @@ jobs: ref: "${{ github.sha }}" fetch-depth: 0 # This is set to download the full git history for the repo + - name: Get Previous Version + id: previous_release_info + uses: revam/gh-action-get-tag-and-version@v1 + with: + branch: true + prefix: v + prefixRegex: "[vV]?" + - name: Get Current Version id: release_info uses: revam/gh-action-get-tag-and-version@v1 @@ -46,6 +55,18 @@ jobs: const sha = context.sha.substring(0, 7); core.setOutput("sha", sha); + - name: Generate Changelog + id: generate_changelog + env: + PREVIOUS_COMMIT: ${{ steps.previous_release_info.outputs.commit }} + NEXT_COMMIT: ${{ github.sha }} + run: | + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + echo "CHANGELOG<<$EOF" >> "$GITHUB_OUTPUT" + git log $PREVIOUS_COMMIT..$NEXT_COMMIT --pretty=format:"%s" >> "$GITHUB_OUTPUT" + echo "" >> "$GITHUB_OUTPUT" + echo "$EOF" >> "$GITHUB_OUTPUT" + build_plugin: runs-on: ubuntu-latest @@ -113,7 +134,6 @@ jobs: uses: actions/checkout@master with: ref: "${{ github.sha }}" - submodules: recursive - name: Notify Discord Users uses: sarisia/actions-status-discord@v1 @@ -122,6 +142,9 @@ jobs: nodetail: true title: New Unstable Shokofin Build! description: | - **Version**: `${{ needs.current_info.outputs.version }}` + **Version**: `${{ needs.current_info.outputs.version }}` (`${{ needs.current_info.outputs.sha_short }}`) + + Update your plugin using the [unstable manifest](https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest-unstable.json) or through downloading the release from [GitHub Releases](https://github.com/ShokoAnime/Shokofin/releases/tag/${{ needs.current_info.outputs.version }})! - Update your plugin using the [unstable manifest](https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest-unstable.json) or through downloading the release from [GitHub Releases](https://github.com/ShokoAnime/Shokofin/releases/tag/${{ needs.current_info.outputs.version }})! \ No newline at end of file + **Changes since last build**: + ${{ needs.current_info.outputs.changelog }} \ No newline at end of file From 8a9a9533a301a8d7c26b0248d16c26830e919dd8 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 29 Mar 2024 05:12:39 +0000 Subject: [PATCH 0647/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index d80ee0c5..3855c0ad 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.54", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.54.zip", + "checksum": "c67234ab9f6fe1d6598d1cc30d9901ee", + "timestamp": "2024-03-29T05:12:38Z" + }, { "version": "3.0.1.53", "changelog": "NA\n", From f181902a12f2efe70611e63877b8a0de090ff37b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 06:17:49 +0100 Subject: [PATCH 0648/1103] misc: add the full message bodies to discord previously you couldn't have seen this message. --- .github/workflows/release-daily.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 41ec1e47..61187eb2 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -21,7 +21,7 @@ jobs: - name: Checkout master uses: actions/checkout@master with: - ref: "${{ github.sha }}" + ref: "${{ github.ref }}" fetch-depth: 0 # This is set to download the full git history for the repo - name: Get Previous Version @@ -63,7 +63,7 @@ jobs: run: | EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) echo "CHANGELOG<<$EOF" >> "$GITHUB_OUTPUT" - git log $PREVIOUS_COMMIT..$NEXT_COMMIT --pretty=format:"%s" >> "$GITHUB_OUTPUT" + git log $PREVIOUS_COMMIT..$NEXT_COMMIT --pretty=format:"%B%n" >> "$GITHUB_OUTPUT" echo "" >> "$GITHUB_OUTPUT" echo "$EOF" >> "$GITHUB_OUTPUT" @@ -130,11 +130,6 @@ jobs: - build_plugin steps: - - name: Checkout master - uses: actions/checkout@master - with: - ref: "${{ github.sha }}" - - name: Notify Discord Users uses: sarisia/actions-status-discord@v1 with: From d956ae2f0168da7a0ea8497ba20fb9df6c2f8396 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 29 Mar 2024 05:18:44 +0000 Subject: [PATCH 0649/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 3855c0ad..2db71db9 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.55", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.55.zip", + "checksum": "82036927eba09ef9b1cd153ff4903bd3", + "timestamp": "2024-03-29T05:18:42Z" + }, { "version": "3.0.1.54", "changelog": "NA\n", From 522ca1cba609a1f8109373eb3109c303bb29ca1e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 06:30:42 +0100 Subject: [PATCH 0650/1103] misc: hide auto-generated commits + more - hide the auto-generated commits. - added a dash at the start of each commit message. - better spacing between commits in the auto-generated changelog. --- .github/workflows/release-daily.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 61187eb2..a81eb04e 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -63,7 +63,7 @@ jobs: run: | EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) echo "CHANGELOG<<$EOF" >> "$GITHUB_OUTPUT" - git log $PREVIOUS_COMMIT..$NEXT_COMMIT --pretty=format:"%B%n" >> "$GITHUB_OUTPUT" + git log $PREVIOUS_COMMIT..$NEXT_COMMIT --pretty=format:"- %BENDOFCOMMIT" | grep -v "misc: update unstable manifest" | awk 'BEGIN{RS="ENDOFCOMMIT";ORS=""}{print $0}' | head -c -2 >> "$GITHUB_OUTPUT" echo "" >> "$GITHUB_OUTPUT" echo "$EOF" >> "$GITHUB_OUTPUT" From 8a40c3d24907ffcdbe24b26025e9ee65974c5849 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 29 Mar 2024 05:31:43 +0000 Subject: [PATCH 0651/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index 2db71db9..efc85a7c 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.56", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.56.zip", + "checksum": "3b1322198a5614672a565cdafaaa92b4", + "timestamp": "2024-03-29T05:31:41Z" + }, { "version": "3.0.1.55", "changelog": "NA\n", From b133e56497df84c79facc33542c4e325bfbc1ff7 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 06:32:50 +0100 Subject: [PATCH 0652/1103] misc: remove auto-added dash from changelog [skip ci] --- .github/workflows/release-daily.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index a81eb04e..406528a0 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -63,7 +63,7 @@ jobs: run: | EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) echo "CHANGELOG<<$EOF" >> "$GITHUB_OUTPUT" - git log $PREVIOUS_COMMIT..$NEXT_COMMIT --pretty=format:"- %BENDOFCOMMIT" | grep -v "misc: update unstable manifest" | awk 'BEGIN{RS="ENDOFCOMMIT";ORS=""}{print $0}' | head -c -2 >> "$GITHUB_OUTPUT" + git log $PREVIOUS_COMMIT..$NEXT_COMMIT --pretty=format:"%BENDOFCOMMIT" | grep -v "misc: update unstable manifest" | awk 'BEGIN{RS="ENDOFCOMMIT";ORS=""}{print $0}' | head -c -2 >> "$GITHUB_OUTPUT" echo "" >> "$GITHUB_OUTPUT" echo "$EOF" >> "$GITHUB_OUTPUT" From 846faa20668efa2ce0e794303ac134cc8708e092 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 06:37:22 +0100 Subject: [PATCH 0653/1103] fix: clear all caches - clear all the caches when a library scan is complete, not just the manager's cache. (we have three now btw.) --- Shokofin/Tasks/PostScanTask.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs index 07a79fa1..af809700 100644 --- a/Shokofin/Tasks/PostScanTask.cs +++ b/Shokofin/Tasks/PostScanTask.cs @@ -1,11 +1,12 @@ +using System; +using System.Threading; +using System.Threading.Tasks; using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Library; using Shokofin.API; using Shokofin.Collections; using Shokofin.MergeVersions; -using System; -using System.Threading; -using System.Threading.Tasks; +using Shokofin.Resolvers; #nullable enable namespace Shokofin.Tasks; @@ -14,13 +15,19 @@ public class PostScanTask : ILibraryPostScanTask { private readonly ShokoAPIManager ApiManager; + private readonly ShokoAPIClient ApiClient; + + private readonly ShokoResolveManager ResolveManager; + private readonly MergeVersionsManager VersionsManager; private readonly CollectionManager CollectionManager; - public PostScanTask(ShokoAPIManager apiManager, MergeVersionsManager versionsManager, CollectionManager collectionManager) + public PostScanTask(ShokoAPIManager apiManager, ShokoAPIClient apiClient, ShokoResolveManager resolveManager, MergeVersionsManager versionsManager, CollectionManager collectionManager) { ApiManager = apiManager; + ApiClient = apiClient; + ResolveManager = resolveManager; VersionsManager = versionsManager; CollectionManager = collectionManager; } @@ -49,6 +56,8 @@ public async Task Run(IProgress<double> progress, CancellationToken token) } // Clear the cache now, since we don't need it anymore. + ApiClient.Clear(); ApiManager.Clear(); + ResolveManager.Clear(); } } From 8db56a875e6a90d1ec5bc35786a5aae85b226e2f Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 29 Mar 2024 05:38:14 +0000 Subject: [PATCH 0654/1103] misc: update unstable manifest --- manifest-unstable.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/manifest-unstable.json b/manifest-unstable.json index efc85a7c..dcd309cd 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.57", + "changelog": "NA\n", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.57.zip", + "checksum": "24034540bd83bbd1aa80694bbafcb2c4", + "timestamp": "2024-03-29T05:38:13Z" + }, { "version": "3.0.1.56", "changelog": "NA\n", From 30002edf81767d053559c9203e6cee16d67314a2 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 07:00:44 +0100 Subject: [PATCH 0655/1103] misc: add changelog to GH pre-releases [skip ci] --- .github/workflows/release-daily.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 406528a0..a0c58d7b 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -106,6 +106,11 @@ jobs: files: ./artifacts/shoko_*.zip name: "Shokofin Unstable ${{ needs.current_info.outputs.version }}" tag_name: ${{ needs.current_info.outputs.version }} + body: | + Update your plugin using the [unstable manifest](https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest-unstable.json) or by downloading the release from [GitHub Releases](https://github.com/ShokoAnime/Shokofin/releases/tag/${{ needs.current_info.outputs.version }}) and installing it manually! + + **Changes since last build**: + ${{ needs.current_info.outputs.changelog }} prerelease: true fail_on_unmatched_files: true generate_release_notes: true @@ -139,7 +144,7 @@ jobs: description: | **Version**: `${{ needs.current_info.outputs.version }}` (`${{ needs.current_info.outputs.sha_short }}`) - Update your plugin using the [unstable manifest](https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest-unstable.json) or through downloading the release from [GitHub Releases](https://github.com/ShokoAnime/Shokofin/releases/tag/${{ needs.current_info.outputs.version }})! + Update your plugin using the [unstable manifest](https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest-unstable.json) or by downloading the release from [GitHub Releases](https://github.com/ShokoAnime/Shokofin/releases/tag/${{ needs.current_info.outputs.version }}) and installing it manually! **Changes since last build**: ${{ needs.current_info.outputs.changelog }} \ No newline at end of file From 02b1a6f5a18f0215ab2ed29fc4dd62fbc9e67f03 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 07:38:26 +0100 Subject: [PATCH 0656/1103] misc: add changelog to manifest releases [skip ci] --- .github/workflows/release-daily.yml | 4 +++- build_plugin.py | 27 ++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index a0c58d7b..d48122b3 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -63,7 +63,7 @@ jobs: run: | EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) echo "CHANGELOG<<$EOF" >> "$GITHUB_OUTPUT" - git log $PREVIOUS_COMMIT..$NEXT_COMMIT --pretty=format:"%BENDOFCOMMIT" | grep -v "misc: update unstable manifest" | awk 'BEGIN{RS="ENDOFCOMMIT";ORS=""}{print $0}' | head -c -2 >> "$GITHUB_OUTPUT" + git log $PREVIOUS_COMMIT..$NEXT_COMMIT --pretty=format:"%B" | grep -v "misc: update unstable manifest" | head -c -2 >> "$GITHUB_OUTPUT" echo "" >> "$GITHUB_OUTPUT" echo "$EOF" >> "$GITHUB_OUTPUT" @@ -98,6 +98,8 @@ jobs: run: python -m pip install jprm - name: Run JPRM + env: + CHANGELOG: ${{ needs.current_info.outputs.changelog }} run: python build_plugin.py --version=${{ needs.current_info.outputs.version }} --prerelease=True - name: Create Pre-Release diff --git a/build_plugin.py b/build_plugin.py index fde3ae8f..f044224c 100644 --- a/build_plugin.py +++ b/build_plugin.py @@ -1,5 +1,6 @@ import os -import sys +import json +import yaml import argparse parser = argparse.ArgumentParser() @@ -20,8 +21,32 @@ jellyfin_repo_url="https://github.com/ShokoAnime/Shokofin/releases/download" +# Add changelog to the build yaml before we generate the release. +build_file = './build.yaml' + +with open(build_file, 'r') as file: + data = yaml.safe_load(file) + +if "changelog" in data: + data["changelog"] = os.environ["CHANGELOG"].strip() + +with open(build_file, 'w') as file: + yaml.dump(data, file, sort_keys=False) + zipfile=os.popen('jprm --verbosity=debug plugin build "." --output="%s" --version="%s" --dotnet-framework="net6.0"' % (artifact_dir, version)).read().strip() os.system('jprm repo add --url=%s %s %s' % (jellyfin_repo_url, jellyfin_repo_file, zipfile)) +# Compact the unstable manifest after building, so it only contains the last 5 versions. +if prerelease: + with open(jellyfin_repo_file, 'r') as file: + data = json.load(file) + + for item in data: + if 'versions' in item and len(item['versions']) > 5: + item['versions'] = item['versions'][:5] + + with open(jellyfin_repo_file, 'w') as file: + json.dump(data, file, indent=4) + print(version) From 1e956888d272eda870bc1b2b075d67dd17a7451a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 09:20:42 +0100 Subject: [PATCH 0657/1103] fix: fix vfs link creation - fix the faulty behaviour of alternatingly skipping and creating symlinks because of an accidental early return. --- Shokofin/Resolvers/ShokoResolveManager.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 90969ea1..73aea7f6 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -232,26 +232,27 @@ await Task.WhenAll(files var sourcePrefixLength = sourceLocation.Length - Path.GetExtension(sourceLocation).Length; var subtitleLinks = FindSubtitlesForPath(sourceLocation); foreach (var symbolicLink in symbolicLinks) { - if (File.Exists(symbolicLink)) { - skipped++; - allPathsForVFS.Add((sourceLocation, symbolicLink)); - return; - } - var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; if (!Directory.Exists(symbolicDirectory)) Directory.CreateDirectory(symbolicDirectory); allPathsForVFS.Add((sourceLocation, symbolicLink)); - File.CreateSymbolicLink(symbolicLink, sourceLocation); + if (!File.Exists(symbolicLink)) + File.CreateSymbolicLink(symbolicLink, sourceLocation); + else + skipped++; if (subtitleLinks.Count > 0) { var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); foreach (var subtitleSource in subtitleLinks) { var extName = subtitleSource[sourcePrefixLength..]; var subtitleLink = Path.Combine(symbolicDirectory, symbolicName + extName); + allPathsForVFS.Add((subtitleSource, subtitleLink)); - File.CreateSymbolicLink(subtitleLink, subtitleSource); + if (!File.Exists(subtitleLink)) + File.CreateSymbolicLink(subtitleLink, subtitleSource); + else + skipped++; } } } From aaa5483a0b16cea2db16834dcc1874b0afa52978 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 12:05:17 +0100 Subject: [PATCH 0658/1103] misc: change default cache expire - change the cache expire time from 1h30m to 2h30m to better accomodate a typical initial scan based on my testing. --- Shokofin/API/ShokoAPIClient.cs | 2 +- Shokofin/API/ShokoAPIManager.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index f1892309..1ba69eef 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -44,7 +44,7 @@ public class ShokoAPIClient : IDisposable private static readonly TimeSpan ExpirationScanFrequency = new(0, 25, 0); - private static readonly TimeSpan DefaultTimeSpan = new(1, 30, 0); + private static readonly TimeSpan DefaultTimeSpan = new(2, 30, 0); public ShokoAPIClient(ILogger<ShokoAPIClient> logger) { diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 61894adb..b9fc7d6a 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -61,7 +61,7 @@ public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient private static readonly TimeSpan ExpirationScanFrequency = new(0, 25, 0); - private static readonly TimeSpan DefaultTimeSpan = new(1, 30, 0); + private static readonly TimeSpan DefaultTimeSpan = new(2, 30, 0); #region Ignore rule From ceeb8fb28153cc6f7b24b5bb40cfc3cf41a572e5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 12:06:46 +0100 Subject: [PATCH 0659/1103] misc: better tracking of subtitle files - Add better tracking of the link generation for subtitle files. --- Shokofin/Resolvers/ShokoResolveManager.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 73aea7f6..6778371e 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -210,6 +210,8 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IReadOnlyList<(stri var start = DateTime.UtcNow; var skipped = 0; + var subtitles = 0; + var skippedSubtitles = 0; var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); var allPathsForVFS = new ConcurrentBag<(string sourceLocation, string symbolicLink)>(); @@ -248,11 +250,12 @@ await Task.WhenAll(files var extName = subtitleSource[sourcePrefixLength..]; var subtitleLink = Path.Combine(symbolicDirectory, symbolicName + extName); + subtitles++; allPathsForVFS.Add((subtitleSource, subtitleLink)); if (!File.Exists(subtitleLink)) File.CreateSymbolicLink(subtitleLink, subtitleSource); else - skipped++; + skippedSubtitles++; } } } @@ -263,6 +266,7 @@ await Task.WhenAll(files }) .ToList()); + var removedSubtitles = 0; var toBeRemoved = FileSystem.GetFilePaths(ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder), true) .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) .Except(allPathsForVFS.Select(tuple => tuple.symbolicLink).ToHashSet()) @@ -272,18 +276,23 @@ await Task.WhenAll(files File.Delete(symbolicLink); - foreach (var subtitleLink in subtitleLinks) + foreach (var subtitleLink in subtitleLinks) { + removedSubtitles++; File.Delete(symbolicLink); + } CleanupDirectoryStructure(symbolicLink); } var timeSpent = DateTime.UtcNow - start; Logger.LogInformation( - "Created {CreatedCount}, skipped {SkippedCount}, and removed {RemovedCount} symbolic links for media folder at {Path} in {TimeSpan}", - allPathsForVFS.Count - skipped, + "Created {CreatedMedia} ({CreatedSubtitles}), skipped {SkippedMedia} ({SkippedSubtitles}), and removed {RemovedMedia} ({RemovedSubtitles}) symbolic links for media folder at {Path} in {TimeSpan}", + allPathsForVFS.Count - skipped - subtitles, + subtitles - skippedSubtitles, skipped, + skippedSubtitles, toBeRemoved.Count, + removedSubtitles, mediaFolder.Path, timeSpent ); From ea4f70642adf496efc2190676b50fc7f8938f49e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 12:19:04 +0100 Subject: [PATCH 0660/1103] refactor: overdose on `.ConfigureAwait(false)` - Use `.ConfigureAwait(false)` where-ever we can. It sped up the discovery phase by 30 seconds over multiple runs, so I think it should be okay to commit this now. --- Shokofin/API/ShokoAPIClient.cs | 56 +++++------ Shokofin/API/ShokoAPIManager.cs | 114 +++++++++++----------- Shokofin/Resolvers/ShokoResolveManager.cs | 41 ++++---- 3 files changed, 105 insertions(+), 106 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 1ba69eef..af9f9779 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -84,20 +84,20 @@ private Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? $"apiKey={apiKey ?? "default"},method={method},url={url},object", (_) => Logger.LogTrace("Reusing object for {Method} {URL}", method, url), async (cachedEntry) => { - var response = await Get(url, method, apiKey); + var response = await Get(url, method, apiKey).ConfigureAwait(false); if (response.StatusCode != HttpStatusCode.OK) throw ApiException.FromResponse(response); - var responseStream = await response.Content.ReadAsStreamAsync(); + var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); responseStream.Seek(0, System.IO.SeekOrigin.Begin); - var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream) ?? + var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream).ConfigureAwait(false) ?? throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); cachedEntry.SlidingExpiration = DefaultTimeSpan; return value; } ); - private async Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null) - => await _cache.GetOrCreateAsync( + private Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null) + => _cache.GetOrCreateAsync( $"apiKey={apiKey ?? "default"},method={method},url={url},httpRequest", (response) => Logger.LogTrace("Reusing response for {Method} {URL}", method, url), async (cachedEntry) => { @@ -111,7 +111,7 @@ private async Task<HttpResponseMessage> Get(string url, HttpMethod method, strin var version = Plugin.Instance.Configuration.HostVersion; if (version == null) { - version = await GetVersion() + version = await GetVersion().ConfigureAwait(false) ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); Plugin.Instance.Configuration.HostVersion = version; @@ -125,7 +125,7 @@ private async Task<HttpResponseMessage> Get(string url, HttpMethod method, strin using var requestMessage = new HttpRequestMessage(method, remoteUrl); requestMessage.Content = new StringContent(string.Empty); requestMessage.Headers.Add("apikey", apiKey); - var response = await _httpClient.SendAsync(requestMessage); + var response = await _httpClient.SendAsync(requestMessage).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.Unauthorized) throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); @@ -145,11 +145,11 @@ private Task<ReturnType> Post<Type, ReturnType>(string url, Type body, string? a private async Task<ReturnType> Post<Type, ReturnType>(string url, HttpMethod method, Type body, string? apiKey = null) { - var response = await Post(url, method, body, apiKey); + var response = await Post(url, method, body, apiKey).ConfigureAwait(false); if (response.StatusCode != HttpStatusCode.OK) throw ApiException.FromResponse(response); - var responseStream = await response.Content.ReadAsStreamAsync(); - var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream) ?? + var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream).ConfigureAwait(false) ?? throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); return value; } @@ -166,7 +166,7 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method var version = Plugin.Instance.Configuration.HostVersion; if (version == null) { - version = await GetVersion() + version = await GetVersion().ConfigureAwait(false) ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); Plugin.Instance.Configuration.HostVersion = version; @@ -184,9 +184,9 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method throw new HttpRequestException("Head requests cannot contain a body."); using var requestMessage = new HttpRequestMessage(method, remoteUrl); - requestMessage.Content = (new StringContent(JsonSerializer.Serialize<Type>(body), Encoding.UTF8, "application/json")); + requestMessage.Content = new StringContent(JsonSerializer.Serialize<Type>(body), Encoding.UTF8, "application/json"); requestMessage.Headers.Add("apikey", apiKey); - var response = await _httpClient.SendAsync(requestMessage); + var response = await _httpClient.SendAsync(requestMessage).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.Unauthorized) throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); @@ -206,7 +206,7 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method var version = Plugin.Instance.Configuration.HostVersion; if (version == null) { - version = await GetVersion() + version = await GetVersion().ConfigureAwait(false) ?? throw new HttpRequestException("Unable to connect to Shoko Server to read the version.", null, HttpStatusCode.BadGateway); Plugin.Instance.Configuration.HostVersion = version; @@ -220,11 +220,11 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method {"device", forUser ? "Shoko Jellyfin Plugin (Shokofin) - User Key" : "Shoko Jellyfin Plugin (Shokofin)"}, }); var apiBaseUrl = Plugin.Instance.Configuration.Host; - var response = await _httpClient.PostAsync($"{apiBaseUrl}/api/auth", new StringContent(postData, Encoding.UTF8, "application/json")); - if (response.StatusCode == HttpStatusCode.OK) - return await JsonSerializer.DeserializeAsync<ApiKey>(response.Content.ReadAsStreamAsync().Result); + var response = await _httpClient.PostAsync($"{apiBaseUrl}/api/auth", new StringContent(postData, Encoding.UTF8, "application/json")).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.OK) + return null; - return null; + return await JsonSerializer.DeserializeAsync<ApiKey>(response.Content.ReadAsStreamAsync().Result).ConfigureAwait(false); } public async Task<ComponentVersion?> GetVersion() @@ -261,20 +261,20 @@ 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"); + return await Get<List<File>>($"/api/v3/Series/{seriesId}/File?&includeXRefs=true&includeDataFrom=AniDB").ConfigureAwait(false); - var listResult = await Get<ListResult<File>>($"/api/v3/Series/{seriesId}/File?pageSize=0&include=XRefs&includeDataFrom=AniDB"); + var listResult = await Get<ListResult<File>>($"/api/v3/Series/{seriesId}/File?pageSize=0&include=XRefs&includeDataFrom=AniDB").ConfigureAwait(false); return listResult.List; } public async Task<IReadOnlyList<File>> GetFilesForImportFolder(int importFolderId, string subPath) { if (UseOlderImportFolderFileEndpoints) { - var listResult1 = await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?pageSize=0&includeXRefs=true"); + var listResult1 = await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?pageSize=0&includeXRefs=true").ConfigureAwait(false); return listResult1.List; } - var listResult2 = await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?folderPath={Uri.EscapeDataString(subPath)}&pageSize=0&include=XRefs"); + var listResult2 = await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?folderPath={Uri.EscapeDataString(subPath)}&pageSize=0&include=XRefs").ConfigureAwait(false); return listResult2.List; } @@ -282,7 +282,7 @@ public async Task<IReadOnlyList<File>> GetFilesForImportFolder(int importFolderI { try { - return await Get<File.UserStats>($"/api/v3/File/{fileId}/UserStats", apiKey); + return await Get<File.UserStats>($"/api/v3/File/{fileId}/UserStats", apiKey).ConfigureAwait(false); } catch (ApiException e) { @@ -303,21 +303,21 @@ public async Task<IReadOnlyList<File>> GetFilesForImportFolder(int importFolderI public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, bool watched, string apiKey) { - var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&watched={watched}", HttpMethod.Patch, apiKey); + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&watched={watched}", HttpMethod.Patch, apiKey).ConfigureAwait(false); return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); } public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long progress, string apiKey) { - var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress).TotalMilliseconds)}", HttpMethod.Patch, apiKey); + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress).TotalMilliseconds)}", HttpMethod.Patch, apiKey).ConfigureAwait(false); return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); } public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long? progress, bool watched, string apiKey) { if (!progress.HasValue) - return await ScrobbleFile(fileId, episodeId, eventName, watched, apiKey); - var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress.Value).TotalMilliseconds)}&watched={watched}", HttpMethod.Patch, apiKey); + return await ScrobbleFile(fileId, episodeId, eventName, watched, apiKey).ConfigureAwait(false); + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress.Value).TotalMilliseconds)}&watched={watched}", HttpMethod.Patch, apiKey).ConfigureAwait(false); return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); } @@ -372,7 +372,7 @@ public Task<List<Series>> GetSeriesPathEndsWith(string dirname) return null; // Return the first (and hopefully only) exact match on the full title. - var results = await Get<List<SeriesSearchResult>>($"/api/v3/Series/Search?query={Uri.EscapeDataString(name)}&limit=10&fuzzy=false"); + var results = await Get<List<SeriesSearchResult>>($"/api/v3/Series/Search?query={Uri.EscapeDataString(name)}&limit=10&fuzzy=false").ConfigureAwait(false); return results?.FirstOrDefault(series => series.ExactMatch && series.Index == 0 && series.LengthDifference == 0 && string.Equals(name, series.Match, StringComparison.Ordinal)); } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index b9fc7d6a..010e791f 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -178,7 +178,7 @@ public void Clear(bool restore = true) private async Task<string[]> GetTagsForSeries(string seriesId) { - return (await APIClient.GetSeriesTags(seriesId, GetTagFilter())) + return (await APIClient.GetSeriesTags(seriesId, GetTagFilter()).ConfigureAwait(false)) .Where(KeepTag) .Select(SelectTagName) .ToArray(); @@ -205,10 +205,10 @@ private static ulong GetTagFilter() public async Task<string[]> GetGenresForSeries(string seriesId) { // The following magic number is the filter value to allow only genres in the returned list. - var genreSet = (await APIClient.GetSeriesTags(seriesId, 2147483776)) + var genreSet = (await APIClient.GetSeriesTags(seriesId, 2147483776).ConfigureAwait(false)) .Select(SelectTagName) .ToHashSet(); - var sourceGenre = await GetSourceGenre(seriesId); + var sourceGenre = await GetSourceGenre(seriesId).ConfigureAwait(false); genreSet.Add(sourceGenre); return genreSet.ToArray(); } @@ -216,7 +216,7 @@ public async Task<string[]> GetGenresForSeries(string seriesId) private async Task<string> GetSourceGenre(string seriesId) { // The following magic number is the filter value to allow only the source type in the returned list. - return(await APIClient.GetSeriesTags(seriesId, 2147483652))?.FirstOrDefault()?.Name?.ToLowerInvariant() switch { + return(await APIClient.GetSeriesTags(seriesId, 2147483652).ConfigureAwait(false))?.FirstOrDefault()?.Name?.ToLowerInvariant() switch { "american derived" => "Adapted From Western Media", "cartoon" => "Adapted From Western Media", "comic book" => "Adapted From Western Media", @@ -274,7 +274,7 @@ private string SelectTagName(Tag tag) /// <returns>Unique path set for the series</returns> public async Task<HashSet<string>> GetPathSetForSeries(string seriesId) { - var (pathSet, _) = await GetPathSetAndLocalEpisodeIdsForSeries(seriesId); + var (pathSet, _) = await GetPathSetAndLocalEpisodeIdsForSeries(seriesId).ConfigureAwait(false); return pathSet; } @@ -286,6 +286,7 @@ public async Task<HashSet<string>> GetPathSetForSeries(string seriesId) public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) { var (_, episodeIds) = GetPathSetAndLocalEpisodeIdsForSeries(seriesId) + .ConfigureAwait(false) .GetAwaiter() .GetResult(); return episodeIds; @@ -300,7 +301,7 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) var pathSet = new HashSet<string>(); var episodeIds = new HashSet<string>(); - foreach (var file in await APIClient.GetFilesForSeries(seriesId)) { + foreach (var file in await APIClient.GetFilesForSeries(seriesId).ConfigureAwait(false)) { if (file.CrossReferences.Count == 1) foreach (var fileLocation in file.Locations) pathSet.Add((Path.GetDirectoryName(fileLocation.Path) ?? "") + Path.DirectorySeparatorChar); @@ -327,15 +328,15 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu // Use pointer for fast lookup. if (PathToFileIdAndSeriesIdDictionary.ContainsKey(path)) { var (fI, sI) = PathToFileIdAndSeriesIdDictionary[path]; - var fileInfo = await GetFileInfo(fI, sI); + var fileInfo = await GetFileInfo(fI, sI).ConfigureAwait(false); if (fileInfo == null) return (null, null, null); - var seasonInfo = await GetSeasonInfoForSeries(sI); + var seasonInfo = await GetSeasonInfoForSeries(sI).ConfigureAwait(false); if (seasonInfo == null) return (null, null, null); - var showInfo = await GetShowInfoForSeries(sI); + var showInfo = await GetShowInfoForSeries(sI).ConfigureAwait(false); if (showInfo == null) return (null, null, null); @@ -352,15 +353,15 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu var sI = seriesIdRaw.ToString(); var fI = fileIdRaw.ToString(); - var fileInfo = await GetFileInfo(fI, sI); + var fileInfo = await GetFileInfo(fI, sI).ConfigureAwait(false); if (fileInfo == null) return (null, null, null); - var seasonInfo = await GetSeasonInfoForSeries(sI); + var seasonInfo = await GetSeasonInfoForSeries(sI).ConfigureAwait(false); if (seasonInfo == null) return (null, null, null); - var showInfo = await GetShowInfoForSeries(sI); + var showInfo = await GetShowInfoForSeries(sI).ConfigureAwait(false); if (showInfo == null) return (null, null, null); @@ -370,7 +371,7 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu // Strip the path and search for a match. var partialPath = StripMediaFolder(path); - var result = await APIClient.GetFileByPath(partialPath); + var result = await APIClient.GetFileByPath(partialPath).ConfigureAwait(false); Logger.LogDebug("Looking for a match for {Path}", partialPath); // Check if we found a match. @@ -400,22 +401,22 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu var seriesId = seriesXRef.Series.Shoko.ToString(); // Check if the file is in the series folder. - var pathSet = await GetPathSetForSeries(seriesId); + var pathSet = await GetPathSetForSeries(seriesId).ConfigureAwait(false); if (!pathSet.Contains(selectedPath)) continue; // Find the season info. - var seasonInfo = await GetSeasonInfoForSeries(seriesId); + var seasonInfo = await GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); if (seasonInfo == null) return (null, null, null); // Find the show info. - var showInfo = await GetShowInfoForSeries(seriesId); + var showInfo = await GetShowInfoForSeries(seriesId).ConfigureAwait(false); if (showInfo == null || showInfo.SeasonList.Count == 0) return (null, null, null); // Find the file info for the series. - var fileInfo = await CreateFileInfo(file, fileId, seriesId); + var fileInfo = await CreateFileInfo(file, fileId, seriesId).ConfigureAwait(false); // Add pointers for faster lookup. foreach (var episodeInfo in fileInfo.EpisodeList) @@ -440,8 +441,8 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu if (DataCache.TryGetValue<FileInfo>(cacheKey, out var fileInfo)) return fileInfo; - var file = await APIClient.GetFile(fileId); - return await CreateFileInfo(file, fileId, seriesId); + var file = await APIClient.GetFile(fileId).ConfigureAwait(false); + return await CreateFileInfo(file, fileId, seriesId).ConfigureAwait(false); } private static readonly EpisodeType[] EpisodePickOrder = { EpisodeType.Special, EpisodeType.Normal, EpisodeType.Other }; @@ -462,7 +463,7 @@ private async Task<FileInfo> CreateFileInfo(File file, string fileId, string ser var episodeList = new List<EpisodeInfo>(); foreach (var episodeXRef in seriesXRef.Episodes) { var episodeId = episodeXRef.Shoko.ToString(); - var episodeInfo = await GetEpisodeInfo(episodeId) ?? + var episodeInfo = await GetEpisodeInfo(episodeId).ConfigureAwait(false) ?? throw new Exception($"Unable to find episode cross-reference for the specified series and episode for the file. (File={fileId},Episode={episodeId},Series={seriesId})"); if (episodeInfo.Shoko.IsHidden) { Logger.LogDebug("Skipped hidden episode linked to file. (File={FileId},Episode={EpisodeId},Series={SeriesId})", fileId, episodeId, seriesId); @@ -508,7 +509,7 @@ public bool TryGetFileIdForPath(string path, out string? fileId) if (DataCache.TryGetValue<EpisodeInfo>(key, out var episodeInfo)) return episodeInfo; - var episode = await APIClient.GetEpisode(episodeId); + var episode = await APIClient.GetEpisode(episodeId).ConfigureAwait(false); return CreateEpisodeInfo(episode, episodeId); } @@ -577,7 +578,7 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) public async Task<SeasonInfo?> GetSeasonInfoByPath(string path) { - var seriesId = await GetSeriesIdForPath(path); + var seriesId = await GetSeriesIdForPath(path).ConfigureAwait(false); if (string.IsNullOrEmpty(seriesId)) return null; @@ -587,8 +588,8 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) return seasonInfo; } - var series = await APIClient.GetSeries(seriesId); - return await CreateSeasonInfo(series, seriesId); + var series = await APIClient.GetSeries(seriesId).ConfigureAwait(false); + return await CreateSeasonInfo(series, seriesId).ConfigureAwait(false); } public async Task<SeasonInfo?> GetSeasonInfoForSeries(string seriesId) @@ -602,21 +603,21 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) return seasonInfo; } - var series = await APIClient.GetSeries(seriesId); - return await CreateSeasonInfo(series, seriesId); + var series = await APIClient.GetSeries(seriesId).ConfigureAwait(false); + return await CreateSeasonInfo(series, seriesId).ConfigureAwait(false); } public async Task<SeasonInfo?> GetSeasonInfoForEpisode(string episodeId) { if (!EpisodeIdToSeriesIdDictionary.TryGetValue(episodeId, out var seriesId)) { - var series = await APIClient.GetSeriesFromEpisode(episodeId); + var series = await APIClient.GetSeriesFromEpisode(episodeId).ConfigureAwait(false); if (series == null) return null; seriesId = series.IDs.Shoko.ToString(); - return await CreateSeasonInfo(series, seriesId); + return await CreateSeasonInfo(series, seriesId).ConfigureAwait(false); } - return await GetSeasonInfoForSeries(seriesId); + return await GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); } private Task<SeasonInfo> CreateSeasonInfo(Series series, string seriesId) @@ -626,15 +627,15 @@ private Task<SeasonInfo> CreateSeasonInfo(Series series, string seriesId) async (cachedEntry) => { Logger.LogTrace("Creating info object for season {SeriesName}. (Series={SeriesId})", series.Name, seriesId); - var episodes = (await APIClient.GetEpisodesFromSeries(seriesId) ?? new()).List + var episodes = (await APIClient.GetEpisodesFromSeries(seriesId).ConfigureAwait(false) ?? new()).List .Select(e => CreateEpisodeInfo(e, e.IDs.Shoko.ToString())) .Where(e => !e.Shoko.IsHidden) .OrderBy(e => e.AniDB.AirDate) .ToList(); - var cast = await APIClient.GetSeriesCast(seriesId); - var relations = await APIClient.GetSeriesRelations(seriesId); - var genres = await GetGenresForSeries(seriesId); - var tags = await GetTagsForSeries(seriesId); + var cast = await APIClient.GetSeriesCast(seriesId).ConfigureAwait(false); + var relations = await APIClient.GetSeriesRelations(seriesId).ConfigureAwait(false); + var genres = await GetGenresForSeries(seriesId).ConfigureAwait(false); + var tags = await GetTagsForSeries(seriesId).ConfigureAwait(false); var seasonInfo = new SeasonInfo(series, episodes, cast, relations, genres, tags); @@ -698,7 +699,7 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul var partialPath = StripMediaFolder(path); Logger.LogDebug("Looking for shoko series matching path {Path}", partialPath); - var result = await APIClient.GetSeriesPathEndsWith(partialPath); + var result = await APIClient.GetSeriesPathEndsWith(partialPath).ConfigureAwait(false); Logger.LogTrace("Found {Count} matches for path {Path}", result.Count, partialPath); // Return the first match where the series unique paths partially match @@ -706,7 +707,7 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul foreach (var series in result) { seriesId = series.IDs.Shoko.ToString(); - var pathSet = await GetPathSetForSeries(seriesId); + var pathSet = await GetPathSetForSeries(seriesId).ConfigureAwait(false); foreach (var uniquePath in pathSet) { // Remove the trailing slash before matching. @@ -731,12 +732,12 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul public async Task<ShowInfo?> GetShowInfoByPath(string path) { if (!PathToSeriesIdDictionary.TryGetValue(path, out var seriesId)) { - seriesId = await GetSeriesIdForPath(path); + seriesId = await GetSeriesIdForPath(path).ConfigureAwait(false); if (string.IsNullOrEmpty(seriesId)) return null; } - return await GetShowInfoForSeries(seriesId); + return await GetShowInfoForSeries(seriesId).ConfigureAwait(false); } public async Task<ShowInfo?> GetShowInfoForEpisode(string episodeId) @@ -745,15 +746,15 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul return null; if (EpisodeIdToSeriesIdDictionary.TryGetValue(episodeId, out var seriesId)) - return await GetShowInfoForSeries(seriesId); + return await GetShowInfoForSeries(seriesId).ConfigureAwait(false); - var series = await APIClient.GetSeriesFromEpisode(episodeId); + var series = await APIClient.GetSeriesFromEpisode(episodeId).ConfigureAwait(false); if (series == null) return null; seriesId = series.IDs.Shoko.ToString(); EpisodeIdToSeriesIdDictionary.TryAdd(episodeId, seriesId); - return await GetShowInfoForSeries(seriesId); + return await GetShowInfoForSeries(seriesId).ConfigureAwait(false); } public async Task<ShowInfo?> GetShowInfoForSeries(string seriesId) @@ -761,11 +762,11 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul if (string.IsNullOrEmpty(seriesId)) return null; - var group = await APIClient.GetGroupFromSeries(seriesId); + var group = await APIClient.GetGroupFromSeries(seriesId).ConfigureAwait(false); if (group == null) return null; - var seasonInfo = await GetSeasonInfoForSeries(seriesId); + var seasonInfo = await GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); if (seasonInfo == null) return null; @@ -778,7 +779,7 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul if (seasonInfo.Type == SeriesType.Movie && Plugin.Instance.Configuration.SeparateMovies) return GetOrCreateShowInfoForSeasonInfo(seasonInfo, group.Size > 0 ? group.IDs.ParentGroup.ToString() : null); - return await CreateShowInfoForGroup(group, group.IDs.Shoko.ToString()); + return await CreateShowInfoForGroup(group, group.IDs.Shoko.ToString()).ConfigureAwait(false); } private Task<ShowInfo?> CreateShowInfoForGroup(Group group, string groupId) @@ -788,11 +789,8 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul async (cachedEntry) => { Logger.LogTrace("Creating info object for show {GroupName}. (Group={GroupId})", group.Name, groupId); - var seasonList = (await APIClient.GetSeriesInGroup(groupId) - .ContinueWith(task => Task.WhenAll(task.Result.Select(s => CreateSeasonInfo(s, s.IDs.Shoko.ToString())))) - .Unwrap()) - .Where(s => s != null) - .ToList(); + var seriesInGroup = await APIClient.GetSeriesInGroup(groupId).ConfigureAwait(false); + var seasonList = (await Task.WhenAll(seriesInGroup.Select(s => CreateSeasonInfo(s, s.IDs.Shoko.ToString()))).ConfigureAwait(false)).ToList(); var length = seasonList.Count; if (Plugin.Instance.Configuration.SeparateMovies) { @@ -854,8 +852,8 @@ private ShowInfo GetOrCreateShowInfoForSeasonInfo(SeasonInfo seasonInfo, string? return collectionInfo; } - var group = await APIClient.GetGroup(groupId); - return await CreateCollectionInfo(group, groupId); + var group = await APIClient.GetGroup(groupId).ConfigureAwait(false); + return await CreateCollectionInfo(group, groupId).ConfigureAwait(false); } public async Task<CollectionInfo?> GetCollectionInfoForSeries(string seriesId) @@ -867,14 +865,14 @@ private ShowInfo GetOrCreateShowInfoForSeasonInfo(SeasonInfo seasonInfo, string? if (string.IsNullOrEmpty(groupId)) return null; - return await GetCollectionInfoForGroup(groupId); + return await GetCollectionInfoForGroup(groupId).ConfigureAwait(false); } - var group = await APIClient.GetGroupFromSeries(seriesId); + var group = await APIClient.GetGroupFromSeries(seriesId).ConfigureAwait(false); if (group == null) return null; - return await CreateCollectionInfo(group, group.IDs.Shoko.ToString()); + return await CreateCollectionInfo(group, group.IDs.Shoko.ToString()).ConfigureAwait(false); } private Task<CollectionInfo> CreateCollectionInfo(Group group, string groupId) @@ -888,8 +886,8 @@ private Task<CollectionInfo> CreateCollectionInfo(Group group, string groupId) var showGroupIds = new HashSet<string>(); var collectionIds = new HashSet<string>(); var showDict = new Dictionary<string, ShowInfo>(); - foreach (var series in await APIClient.GetSeriesInGroup(groupId, recursive: true)) { - var showInfo = await GetShowInfoForSeries(series.IDs.Shoko.ToString()); + foreach (var series in await APIClient.GetSeriesInGroup(groupId, recursive: true).ConfigureAwait(false)) { + var showInfo = await GetShowInfoForSeries(series.IDs.Shoko.ToString()).ConfigureAwait(false); if (showInfo == null) continue; @@ -907,10 +905,10 @@ private Task<CollectionInfo> CreateCollectionInfo(Group group, string groupId) var groupList = new List<CollectionInfo>(); if (group.Sizes.SubGroups > 0) { Logger.LogTrace("Fetching sub-collection info objects for collection {GroupName}. (Group={GroupId})", group.Name, groupId); - foreach (var subGroup in await APIClient.GetGroupsInGroup(groupId)) { + foreach (var subGroup in await APIClient.GetGroupsInGroup(groupId).ConfigureAwait(false)) { if (showGroupIds.Contains(subGroup.IDs.Shoko.ToString()) && !collectionIds.Contains(subGroup.IDs.Shoko.ToString())) continue; - var subCollectionInfo = await CreateCollectionInfo(subGroup, subGroup.IDs.Shoko.ToString()); + var subCollectionInfo = await CreateCollectionInfo(subGroup, subGroup.IDs.Shoko.ToString()).ConfigureAwait(false); groupList.Add(subCollectionInfo); } diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 6778371e..782ee50d 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -134,9 +134,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) attempts++; var partialPath = path[mediaFolder.Path.Length..]; var partialFolderPath = path[folderPath.Length..]; - var files = ApiClient.GetFileByPath(partialPath) - .GetAwaiter() - .GetResult(); + var files = await ApiClient.GetFileByPath(partialPath).ConfigureAwait(false); var file = files.FirstOrDefault(); if (file == null) continue; @@ -164,8 +162,8 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) Logger.LogInformation("Found a match for folder at {Path} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path},Attempts={Attempts})", folderPath, importFolderId, importFolderSubPath, mediaFolder.Path, attempts); vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); - var allFiles = await GetImportFolderFiles(importFolderId, importFolderSubPath, folderPath); - await GenerateSymbolicLinks(mediaFolder, allFiles); + var allFiles = await GetImportFolderFiles(importFolderId, importFolderSubPath, folderPath).ConfigureAwait(false); + await GenerateSymbolicLinks(mediaFolder, allFiles).ConfigureAwait(false); cachedEntry.AbsoluteExpirationRelativeToNow = DefaultTTL; return vfsPath; @@ -176,7 +174,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) { Logger.LogDebug("Looking up recognised files for media folder… (ImportFolder={FolderId},RelativePath={RelativePath})", importFolderId, importFolderSubPath); var start = DateTime.UtcNow; - var allFilesForImportFolder = (await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath)) + var allFilesForImportFolder = (await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath).ConfigureAwait(false)) .AsParallel() .SelectMany(file => { @@ -218,14 +216,14 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IReadOnlyList<(stri var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); await Task.WhenAll(files .Select(async (tuple) => { - await semaphore.WaitAsync(); + await semaphore.WaitAsync().ConfigureAwait(false); try { // Skip any source files we that we cannot find. if (!File.Exists(tuple.sourceLocation)) return; - var (sourceLocation, symbolicLinks) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId, tuple.episodeIds); + var (sourceLocation, symbolicLinks) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId, tuple.episodeIds).ConfigureAwait(false); // Skip any source files we weren't meant to have in the library. if (string.IsNullOrEmpty(sourceLocation)) return; @@ -264,7 +262,8 @@ await Task.WhenAll(files semaphore.Release(); } }) - .ToList()); + .ToList()) + .ConfigureAwait(false); var removedSubtitles = 0; var toBeRemoved = FileSystem.GetFilePaths(ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder), true) @@ -300,7 +299,7 @@ await Task.WhenAll(files private async Task<(string sourceLocation, string[] symbolicLinks)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId, string[] episodeIds) { - var season = await ApiManager.GetSeasonInfoForSeries(seriesId); + var season = await ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); if (season == null) return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); @@ -313,11 +312,11 @@ await Task.WhenAll(files if (shouldAbort) return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); - var show = await ApiManager.GetShowInfoForSeries(seriesId); + var show = await ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false); if (show == null) return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); - var file = await ApiManager.GetFileInfo(fileId, seriesId); + var file = await ApiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false); var episode = file?.EpisodeList.FirstOrDefault(); if (file == null || episode == null) return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); @@ -465,9 +464,9 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file var shouldIgnore = Plugin.Instance.Configuration.LibraryFilteringMode ?? Plugin.Instance.Configuration.VirtualFileSystem || isSoleProvider; var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); if (fileInfo.IsDirectory) - return await ScanDirectory(partialPath, fullPath, collectionType, shouldIgnore); + return await ScanDirectory(partialPath, fullPath, collectionType, shouldIgnore).ConfigureAwait(false); else - return await ScanFile(partialPath, fullPath, shouldIgnore); + return await ScanFile(partialPath, fullPath, shouldIgnore).ConfigureAwait(false); } catch (Exception ex) { if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) @@ -480,7 +479,7 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file private async Task<bool> ScanDirectory(string partialPath, string fullPath, string? collectionType, bool shouldIgnore) { - var season = await ApiManager.GetSeasonInfoByPath(fullPath); + var season = await ApiManager.GetSeasonInfoByPath(fullPath).ConfigureAwait(false); // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given folder path. if (season == null) { @@ -490,7 +489,7 @@ private async Task<bool> ScanDirectory(string partialPath, string fullPath, stri var entries = FileSystem.GetDirectories(fullPath, false).ToList(); Logger.LogDebug("Unable to find shoko series for {Path}, trying {DirCount} sub-directories.", partialPath, entries.Count); foreach (var entry in entries) { - season = await ApiManager.GetSeasonInfoByPath(entry.FullName); + season = await ApiManager.GetSeasonInfoByPath(entry.FullName).ConfigureAwait(false); if (season != null) { Logger.LogDebug("Found shoko series {SeriesName} for sub-directory of path {Path} (Series={SeriesId})", season.Shoko.Name, partialPath, season.Id); @@ -526,7 +525,7 @@ private async Task<bool> ScanDirectory(string partialPath, string fullPath, stri break; } - var show = await ApiManager.GetShowInfoForSeries(season.Id)!; + var show = await ApiManager.GetShowInfoForSeries(season.Id).ConfigureAwait(false)!; if (!string.IsNullOrEmpty(show!.GroupId)) Logger.LogInformation("Found shoko group {GroupName} (Series={SeriesId},Group={GroupId})", show.Name, season.Id, show.GroupId); else @@ -537,7 +536,7 @@ private async Task<bool> ScanDirectory(string partialPath, string fullPath, stri private async Task<bool> ScanFile(string partialPath, string fullPath, bool shouldIgnore) { - var (file, season, _) = await ApiManager.GetFileInfoByPath(fullPath); + var (file, season, _) = await ApiManager.GetFileInfoByPath(fullPath).ConfigureAwait(false); // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given file path. if (file == null || season == null) { @@ -591,7 +590,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou var searchPath = mediaFolder.Path != parent.Path ? Path.Combine(mediaFolder.Path, parent.Path[(mediaFolder.Path.Length + 1)..].Split(Path.DirectorySeparatorChar).Skip(1).Join(Path.DirectorySeparatorChar)) : mediaFolder.Path; - var vfsPath = await GenerateStructureForFolder(mediaFolder, searchPath); + var vfsPath = await GenerateStructureForFolder(mediaFolder, searchPath).ConfigureAwait(false); if (string.IsNullOrEmpty(vfsPath)) return null; @@ -637,7 +636,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou // Redirect children of a VFS managed media folder to the VFS. if (parent.ParentId == root.Id) { - var vfsPath = await GenerateStructureForFolder(parent, parent.Path); + var vfsPath = await GenerateStructureForFolder(parent, parent.Path).ConfigureAwait(false); if (string.IsNullOrEmpty(vfsPath)) return null; @@ -650,6 +649,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou return Array.Empty<BaseItem>(); var season = ApiManager.GetSeasonInfoForSeries(seriesId.ToString()) + .ConfigureAwait(false) .GetAwaiter() .GetResult(); if (season == null) @@ -665,6 +665,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou // This will hopefully just re-use the pre-cached entries from the cache, but it may // also get it from remote if the cache was emptied for whatever reason. var file = ApiManager.GetFileInfo(fileId.ToString(), seriesId.ToString()) + .ConfigureAwait(false) .GetAwaiter() .GetResult(); From be99e46eb66dcdd1350153b0fc49b0445246946d Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 29 Mar 2024 11:19:50 +0000 Subject: [PATCH 0661/1103] misc: update unstable manifest --- manifest-unstable.json | 1304 +--------------------------------------- 1 file changed, 8 insertions(+), 1296 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index dcd309cd..25cc402f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.58", + "changelog": "refactor: overdose on `.ConfigureAwait(false)`\n\n- Use `.ConfigureAwait(false)` where-ever we can. It sped up\n the discovery phase by 30 seconds over multiple runs, so I\n think it should be okay to commit this now.\n\nmisc: better tracking of subtitle files\n\n- Add better tracking of the link generation for subtitle files.\n\nmisc: change default cache expire\n\n- change the cache expire time from 1h30m to 2h30m to\n better accomodate a typical initial scan based on my testing.\n\nfix: fix vfs link creation\n\n- fix the faulty behaviour of alternatingly skipping and creating\n symlinks because of an accidental early return.\n\nmisc: add changelog to manifest releases\n\n[skip ci]\n\nmisc: add changelog to GH pre-releases\n\n[skip ci]", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.58.zip", + "checksum": "1c32f315eac5dd815346c49776c07c24", + "timestamp": "2024-03-29T11:19:48Z" + }, { "version": "3.0.1.57", "changelog": "NA\n", @@ -39,1302 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.54.zip", "checksum": "c67234ab9f6fe1d6598d1cc30d9901ee", "timestamp": "2024-03-29T05:12:38Z" - }, - { - "version": "3.0.1.53", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.53.zip", - "checksum": "0f7ec3b2c809977b7b7dc9e59ef43437", - "timestamp": "2024-03-29T04:30:54Z" - }, - { - "version": "3.0.1.52", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.52.zip", - "checksum": "ac99fcb665a951dd7763e39bb2854811", - "timestamp": "2024-03-29T04:23:05Z" - }, - { - "version": "3.0.1.51", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.51/shoko_3.0.1.51.zip", - "checksum": "db4d30b9f5b8b122e194cede768ea5e0", - "timestamp": "2024-03-29T02:26:03Z" - }, - { - "version": "3.0.1.50", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.50/shoko_3.0.1.50.zip", - "checksum": "f25dbf75dc6bed8eae79600b23ff488a", - "timestamp": "2024-03-29T00:30:13Z" - }, - { - "version": "3.0.1.49", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.49/shoko_3.0.1.49.zip", - "checksum": "6c7905622a86e2494718ec17e0bbe62c", - "timestamp": "2024-03-28T10:00:21Z" - }, - { - "version": "3.0.1.48", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.48/shoko_3.0.1.48.zip", - "checksum": "1d04cb3a2862ebf6fba879f4048e7ee4", - "timestamp": "2024-03-28T09:51:09Z" - }, - { - "version": "3.0.1.47", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.47/shoko_3.0.1.47.zip", - "checksum": "0337e83677298801b4f2a668b8f1b466", - "timestamp": "2024-03-28T05:02:18Z" - }, - { - "version": "3.0.1.46", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.46/shoko_3.0.1.46.zip", - "checksum": "d9767d7a426382e3476e377c6d198b1a", - "timestamp": "2024-03-28T04:52:36Z" - }, - { - "version": "3.0.1.45", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.45/shoko_3.0.1.45.zip", - "checksum": "56c3a0ad2b2e86991adbc22f2a96c735", - "timestamp": "2024-03-27T23:03:47Z" - }, - { - "version": "3.0.1.44", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.44/shoko_3.0.1.44.zip", - "checksum": "cf57ecdb558caa137773d60a8434dcc0", - "timestamp": "2024-03-27T02:57:55Z" - }, - { - "version": "3.0.1.43", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.43/shoko_3.0.1.43.zip", - "checksum": "7fde63175bf69e223aac31a3f6b5ecb7", - "timestamp": "2024-03-27T02:27:31Z" - }, - { - "version": "3.0.1.42", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.42/shoko_3.0.1.42.zip", - "checksum": "e14caade748eb88bb699f961465fc9bb", - "timestamp": "2024-03-26T23:24:05Z" - }, - { - "version": "3.0.1.41", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.41/shoko_3.0.1.41.zip", - "checksum": "d8ac8a5b5b53ad2cda4082c2e8d3012b", - "timestamp": "2024-03-26T22:31:04Z" - }, - { - "version": "3.0.1.40", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.40/shoko_3.0.1.40.zip", - "checksum": "0a9a104b5dbeace65a6cb7c8ed3ac11e", - "timestamp": "2024-03-26T22:25:02Z" - }, - { - "version": "3.0.1.39", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.39/shoko_3.0.1.39.zip", - "checksum": "7cf4b0799238b646121d3b136afe7ef4", - "timestamp": "2024-03-26T16:28:35Z" - }, - { - "version": "3.0.1.38", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.38/shoko_3.0.1.38.zip", - "checksum": "cd1c5d495c7f57894d5258999eb63786", - "timestamp": "2024-03-26T16:07:21Z" - }, - { - "version": "3.0.1.37", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.37/shoko_3.0.1.37.zip", - "checksum": "124cc5a700383aca5a3f6e563a09fdd4", - "timestamp": "2024-03-24T23:50:44Z" - }, - { - "version": "3.0.1.36", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.36/shoko_3.0.1.36.zip", - "checksum": "35b2bb0aec419d475d0bf4330dd948cd", - "timestamp": "2024-03-24T04:12:56Z" - }, - { - "version": "3.0.1.35", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.35/shoko_3.0.1.35.zip", - "checksum": "85e81a2b8249974a9695244aca39bc2a", - "timestamp": "2024-03-03T00:28:23Z" - }, - { - "version": "3.0.1.34", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.34/shoko_3.0.1.34.zip", - "checksum": "6ff9a10177d5e16019d64108162a4ee2", - "timestamp": "2024-01-05T23:43:06Z" - }, - { - "version": "3.0.1.33", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.33/shoko_3.0.1.33.zip", - "checksum": "63494f377f9f89eeae46d02f1fd8bd67", - "timestamp": "2023-12-30T03:13:04Z" - }, - { - "version": "3.0.1.32", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.32/shoko_3.0.1.32.zip", - "checksum": "fffee5d1fb54590bf91dd0266c65d8c0", - "timestamp": "2023-12-20T14:09:07Z" - }, - { - "version": "3.0.1.31", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.31/shoko_3.0.1.31.zip", - "checksum": "9a6538dd9ff1ddc432f3630ddda5bd7b", - "timestamp": "2023-12-20T11:40:38Z" - }, - { - "version": "3.0.1.30", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.30/shoko_3.0.1.30.zip", - "checksum": "f8be84bf21f98a3e18a02f52a0483079", - "timestamp": "2023-12-20T10:40:46Z" - }, - { - "version": "3.0.1.29", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.29/shoko_3.0.1.29.zip", - "checksum": "325d37ceab46ce21a311a8a2387d159c", - "timestamp": "2023-11-28T02:16:20Z" - }, - { - "version": "3.0.1.28", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.28/shoko_3.0.1.28.zip", - "checksum": "61b812ff1a2f3129956474faaf1afb2e", - "timestamp": "2023-11-27T23:42:51Z" - }, - { - "version": "3.0.1.27", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.27/shoko_3.0.1.27.zip", - "checksum": "4ae344ba55277d8aaf5d8aae59e2c6aa", - "timestamp": "2023-11-17T19:57:54Z" - }, - { - "version": "3.0.1.26", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.26/shoko_3.0.1.26.zip", - "checksum": "8151eb0e1981c7a6c7e588b45905b153", - "timestamp": "2023-10-15T15:10:10Z" - }, - { - "version": "3.0.1.25", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.25/shoko_3.0.1.25.zip", - "checksum": "0913584dfe30dc68dc5b45516a996ee4", - "timestamp": "2023-09-25T19:45:50Z" - }, - { - "version": "3.0.1.24", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.24/shoko_3.0.1.24.zip", - "checksum": "0ed70482e00836e8dc3e8fdae4415f86", - "timestamp": "2023-09-25T16:04:36Z" - }, - { - "version": "3.0.1.23", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.23/shoko_3.0.1.23.zip", - "checksum": "7ee21b959208055f94dd61d6afcd612f", - "timestamp": "2023-09-21T15:25:49Z" - }, - { - "version": "3.0.1.22", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.22/shoko_3.0.1.22.zip", - "checksum": "8d3e4a0083638ad4a25fc71ae245bf89", - "timestamp": "2023-09-04T04:56:33Z" - }, - { - "version": "3.0.1.21", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.21/shoko_3.0.1.21.zip", - "checksum": "07437dd75d9bce5ccda53f4d7e2dc06b", - "timestamp": "2023-09-02T01:22:30Z" - }, - { - "version": "3.0.1.20", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.20/shoko_3.0.1.20.zip", - "checksum": "df7578099f719fd9a68a4ddbfec003ed", - "timestamp": "2023-08-28T23:15:43Z" - }, - { - "version": "3.0.1.19", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.19/shoko_3.0.1.19.zip", - "checksum": "06dd265bbe20caa8fabb7ed360a6b133", - "timestamp": "2023-08-08T06:14:36Z" - }, - { - "version": "3.0.1.18", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.18/shoko_3.0.1.18.zip", - "checksum": "0671f93c4e6b3762d37d73f1c530dd02", - "timestamp": "2023-08-08T05:04:38Z" - }, - { - "version": "3.0.1.17", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.17/shoko_3.0.1.17.zip", - "checksum": "5e7814c4f073a8a3d8272586000c2234", - "timestamp": "2023-08-08T04:49:10Z" - }, - { - "version": "3.0.1.16", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.16/shoko_3.0.1.16.zip", - "checksum": "0521efa7adea78623bce9c1cb73a2c49", - "timestamp": "2023-07-19T20:52:14Z" - }, - { - "version": "3.0.1.15", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.15/shoko_3.0.1.15.zip", - "checksum": "c19b4f67c93d87d90f9b5096453109d8", - "timestamp": "2023-06-12T15:09:40Z" - }, - { - "version": "3.0.1.14", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.14/shoko_3.0.1.14.zip", - "checksum": "cf987ccfde4dea78c73e2a5d12c29afd", - "timestamp": "2023-06-11T20:49:49Z" - }, - { - "version": "3.0.1.13", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.13/shoko_3.0.1.13.zip", - "checksum": "5a5ecc8bfc87c86159f1a45965fe8d90", - "timestamp": "2023-06-08T13:57:39Z" - }, - { - "version": "3.0.1.12", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.12/shoko_3.0.1.12.zip", - "checksum": "33a024e2258d101cac3e9d1ddde34179", - "timestamp": "2023-05-28T16:08:29Z" - }, - { - "version": "3.0.1.11", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.11/shoko_3.0.1.11.zip", - "checksum": "df8547d70c67ea451a3395e18c2a84ff", - "timestamp": "2023-05-23T22:02:42Z" - }, - { - "version": "3.0.1.10", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.10/shoko_3.0.1.10.zip", - "checksum": "0d3b06b263e984fed5d36c688b510211", - "timestamp": "2023-05-23T21:33:20Z" - }, - { - "version": "3.0.1.9", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.9/shoko_3.0.1.9.zip", - "checksum": "f5cf1d91e7e46b5fa12c2dc34106636f", - "timestamp": "2023-05-23T20:48:00Z" - }, - { - "version": "3.0.1.8", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.8/shoko_3.0.1.8.zip", - "checksum": "e4c15d4344077b02a016e3b1a8902c4b", - "timestamp": "2023-05-23T20:25:21Z" - }, - { - "version": "3.0.1.7", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.7/shoko_3.0.1.7.zip", - "checksum": "57715abe755b72aafb85e48fcce00515", - "timestamp": "2023-05-23T17:52:29Z" - }, - { - "version": "3.0.1.6", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.6/shoko_3.0.1.6.zip", - "checksum": "c7dba2fa1cfccdead4619f5671bfc950", - "timestamp": "2023-05-21T14:28:52Z" - }, - { - "version": "3.0.1.5", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.5/shoko_3.0.1.5.zip", - "checksum": "6868577bf03967bedcbcf5fa253c39bb", - "timestamp": "2023-05-21T14:09:50Z" - }, - { - "version": "3.0.1.4", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.4/shoko_3.0.1.4.zip", - "checksum": "394e754e37a4dc2c474e194f0b2b8c04", - "timestamp": "2023-05-21T14:05:03Z" - }, - { - "version": "3.0.1.3", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.3/shoko_3.0.1.3.zip", - "checksum": "a9fd79589a41624311e2457011f2138e", - "timestamp": "2023-05-19T22:44:22Z" - }, - { - "version": "3.0.1.2", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.2/shoko_3.0.1.2.zip", - "checksum": "73df8ebd208b91ca2a1b679b40ba1081", - "timestamp": "2023-05-19T22:31:51Z" - }, - { - "version": "3.0.1.1", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.1/shoko_3.0.1.1.zip", - "checksum": "ae30c98bac4c12c8d7b0fca79b5cd41c", - "timestamp": "2023-04-23T19:58:11Z" - }, - { - "version": "3.0.0.2", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.0.2/shoko_3.0.0.2.zip", - "checksum": "fc683a63a29a00d72bf247707cbeb875", - "timestamp": "2023-04-19T00:50:57Z" - }, - { - "version": "3.0.0.1", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.0.1/shoko_3.0.0.1.zip", - "checksum": "da3e3819e158d1cdd078549711da30f9", - "timestamp": "2023-04-18T06:12:16Z" - }, - { - "version": "2.0.1.44", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.44/shoko_2.0.1.44.zip", - "checksum": "da49cf3b62c8d0259a83d47f3a6f5a84", - "timestamp": "2023-03-20T21:02:58Z" - }, - { - "version": "2.0.1.43", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.43/shoko_2.0.1.43.zip", - "checksum": "f55ecc226671430dc085ea7d3bea962e", - "timestamp": "2023-03-20T17:38:57Z" - }, - { - "version": "2.0.1.42", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.42/shoko_2.0.1.42.zip", - "checksum": "ef799de6ae1d52016c5a74315794cc30", - "timestamp": "2023-03-08T23:23:47Z" - }, - { - "version": "2.0.1.41", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.41/shoko_2.0.1.41.zip", - "checksum": "805c101b03b516be60266c17bf779067", - "timestamp": "2023-03-07T18:04:08Z" - }, - { - "version": "2.0.1.40", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.40/shoko_2.0.1.40.zip", - "checksum": "0ad9e8024c9ea46a9fec1b0abc978443", - "timestamp": "2023-03-04T20:57:12Z" - }, - { - "version": "2.0.1.39", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.39/shoko_2.0.1.39.zip", - "checksum": "c7e4f7087d45095a0c78e68e53dd36d6", - "timestamp": "2023-01-30T22:30:48Z" - }, - { - "version": "2.0.1.38", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.38/shoko_2.0.1.38.zip", - "checksum": "da2541287d39f79a43e46f5732bc3cb7", - "timestamp": "2023-01-28T04:02:53Z" - }, - { - "version": "2.0.1.37", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.37/shoko_2.0.1.37.zip", - "checksum": "843915aa68b889874a717290203bf15f", - "timestamp": "2023-01-26T07:37:07Z" - }, - { - "version": "2.0.1.36", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.36/shoko_2.0.1.36.zip", - "checksum": "5a46e15f566d0116f919a92aa1bb2326", - "timestamp": "2023-01-26T03:31:28Z" - }, - { - "version": "2.0.1.35", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.35/shoko_2.0.1.35.zip", - "checksum": "efe50ae0b63f424698c3e09d4d285f9d", - "timestamp": "2023-01-26T03:29:40Z" - }, - { - "version": "2.0.1.34", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.34/shoko_2.0.1.34.zip", - "checksum": "d37356ec1266c68c975330e5ee24967b", - "timestamp": "2023-01-26T03:16:11Z" - }, - { - "version": "2.0.1.33", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.33/shoko_2.0.1.33.zip", - "checksum": "adb467a4f805cbf43b909e186d7b3f75", - "timestamp": "2023-01-24T22:38:46Z" - }, - { - "version": "2.0.1.32", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.32/shoko_2.0.1.32.zip", - "checksum": "083dab201466e7942163c44f0500fa6f", - "timestamp": "2023-01-12T15:56:11Z" - }, - { - "version": "2.0.1.31", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.31/shoko_2.0.1.31.zip", - "checksum": "1dc0263731c7dc88d87842fdf91a4d90", - "timestamp": "2023-01-12T15:54:07Z" - }, - { - "version": "2.0.1.30", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.30/shoko_2.0.1.30.zip", - "checksum": "bcb1f9a440d1d04764ab9b89b47cccc0", - "timestamp": "2023-01-12T14:35:05Z" - }, - { - "version": "2.0.1.29", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.29/shoko_2.0.1.29.zip", - "checksum": "8dc7ae5ba853512dfb000f2d59031f7e", - "timestamp": "2023-01-10T19:33:26Z" - }, - { - "version": "2.0.1.28", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.28/shoko_2.0.1.28.zip", - "checksum": "4f4140a3ad1fba0f735eb13a5965df13", - "timestamp": "2023-01-10T01:36:30Z" - }, - { - "version": "2.0.1.27", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.27/shoko_2.0.1.27.zip", - "checksum": "dbd00c11e9fa31a2e60f00b049afa4f1", - "timestamp": "2023-01-09T23:48:34Z" - }, - { - "version": "2.0.1.26", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.26/shoko_2.0.1.26.zip", - "checksum": "29450ae8a17a6a6f5517727e03ef8542", - "timestamp": "2023-01-07T11:28:40Z" - }, - { - "version": "2.0.1.25", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.25/shoko_2.0.1.25.zip", - "checksum": "bfd927a165806263641a53ffe736774b", - "timestamp": "2022-12-26T17:51:25Z" - }, - { - "version": "2.0.1.24", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.24/shoko_2.0.1.24.zip", - "checksum": "8c31376a8aaa09fdb6169d15ea93affa", - "timestamp": "2022-12-20T17:10:15Z" - }, - { - "version": "2.0.1.23", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.23/shoko_2.0.1.23.zip", - "checksum": "8b9eb1496bd4963e48f5bd721e031210", - "timestamp": "2022-12-02T02:33:59Z" - }, - { - "version": "2.0.1.22", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.22/shoko_2.0.1.22.zip", - "checksum": "02e3bf819a4962128a18264a032975f6", - "timestamp": "2022-11-30T11:54:35Z" - }, - { - "version": "2.0.1.21", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.21/shoko_2.0.1.21.zip", - "checksum": "83f9bf24a84e9fba758f8635115f7520", - "timestamp": "2022-11-29T16:51:20Z" - }, - { - "version": "2.0.1.20", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.20/shoko_2.0.1.20.zip", - "checksum": "db1ccd70db78eddb1ff16a2757f82f34", - "timestamp": "2022-11-28T21:03:16Z" - }, - { - "version": "2.0.1.19", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.19/shoko_2.0.1.19.zip", - "checksum": "350b736361b2888dd667cf3c8301d44e", - "timestamp": "2022-11-28T21:01:27Z" - }, - { - "version": "2.0.1.18", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.18/shoko_2.0.1.18.zip", - "checksum": "a0714d937ad7fcf24a20465663e8c219", - "timestamp": "2022-11-28T14:09:03Z" - }, - { - "version": "2.0.1.17", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.17/shoko_2.0.1.17.zip", - "checksum": "b259a603bfccfe0e2c811e4607300c5d", - "timestamp": "2022-11-28T01:24:16Z" - }, - { - "version": "2.0.1.16", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.16/shoko_2.0.1.16.zip", - "checksum": "00bfca9599ce94812d9da884baf9922a", - "timestamp": "2022-11-28T00:23:07Z" - }, - { - "version": "2.0.1.15", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.15/shoko_2.0.1.15.zip", - "checksum": "a24552a019c4d999edf94af113351ea7", - "timestamp": "2022-11-27T23:34:00Z" - }, - { - "version": "2.0.1.14", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.14/shoko_2.0.1.14.zip", - "checksum": "b4f359ec464c46d05494d24304cb912d", - "timestamp": "2022-11-27T17:28:07Z" - }, - { - "version": "2.0.1.13", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.13/shoko_2.0.1.13.zip", - "checksum": "6cdba349a116a8763762cabd3d4d7209", - "timestamp": "2022-11-26T21:15:18Z" - }, - { - "version": "2.0.1.12", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.12/shoko_2.0.1.12.zip", - "checksum": "9d9f461f3c67ec520e9b86f15b24da13", - "timestamp": "2022-09-26T19:36:14Z" - }, - { - "version": "2.0.1.11", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.11/shoko_2.0.1.11.zip", - "checksum": "3e34a066c08e2f0176c873819421484f", - "timestamp": "2022-09-22T21:51:13Z" - }, - { - "version": "2.0.1.10", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.10/shoko_2.0.1.10.zip", - "checksum": "ecd0216e43e6f4d1e942b056e5c14b5c", - "timestamp": "2022-09-07T21:17:48Z" - }, - { - "version": "2.0.1.9", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.9/shoko_2.0.1.9.zip", - "checksum": "cb4e25ea07e6cc98376eeebc4bc1d3a1", - "timestamp": "2022-09-07T21:07:03Z" - }, - { - "version": "2.0.1.8", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.8/shoko_2.0.1.8.zip", - "checksum": "e5ddea633abe74a9b4ca577be51080a6", - "timestamp": "2022-09-07T20:51:54Z" - }, - { - "version": "2.0.1.7", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.7/shoko_2.0.1.7.zip", - "checksum": "b95218f9d0a37e0433e2035f2a579149", - "timestamp": "2022-08-19T20:26:11Z" - }, - { - "version": "2.0.1.6", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.6/shoko_2.0.1.6.zip", - "checksum": "17043404ca6c6b39d5a9a28dceb1dc62", - "timestamp": "2022-08-03T08:44:38Z" - }, - { - "version": "2.0.1.5", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.5/shoko_2.0.1.5.zip", - "checksum": "47dc550823f89e4f14e5adf1570b3170", - "timestamp": "2022-08-02T12:40:38Z" - }, - { - "version": "2.0.1.4", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.4/shoko_2.0.1.4.zip", - "checksum": "82a6baab533cfa4b3ebc1dcafc202d54", - "timestamp": "2022-08-02T11:46:58Z" - }, - { - "version": "2.0.1.3", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.3/shoko_2.0.1.3.zip", - "checksum": "2f6c82bd6bc6a63f1d94ebd59a4a9f0c", - "timestamp": "2022-07-29T18:17:26Z" - }, - { - "version": "2.0.1.2", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.2/shoko_2.0.1.2.zip", - "checksum": "02c5cc61b7555aa6678c31756ff0370a", - "timestamp": "2022-07-26T20:18:21Z" - }, - { - "version": "2.0.1.1", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1.1/shoko_2.0.1.1.zip", - "checksum": "a0af02cf4b4f4e5a8bf404dcd1c86be4", - "timestamp": "2022-07-26T20:16:07Z" - }, - { - "version": "2.0.0.2", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.0.2/shoko_2.0.0.2.zip", - "checksum": "de0edec29d6986a39a00e3ae27d8a2e8", - "timestamp": "2022-07-02T09:51:14Z" - }, - { - "version": "2.0.0.1", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.0.1/shoko_2.0.0.1.zip", - "checksum": "cfe8f16db569e0693b65cfa363635361", - "timestamp": "2022-07-02T09:45:51Z" - }, - { - "version": "1.7.3.8", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.8/shoko_1.7.3.8.zip", - "checksum": "426fa1895295d28a6a93ac38643316cb", - "timestamp": "2022-06-27T20:04:42Z" - }, - { - "version": "1.7.3.7", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.7/shoko_1.7.3.7.zip", - "checksum": "30acbdf13ee211cda1b4e952ba33527d", - "timestamp": "2022-06-27T19:50:56Z" - }, - { - "version": "1.7.3.6", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.6/shoko_1.7.3.6.zip", - "checksum": "bbda41e32829d521dc968ebd537118f4", - "timestamp": "2022-06-27T19:49:52Z" - }, - { - "version": "1.7.3.5", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.5/shoko_1.7.3.5.zip", - "checksum": "a3aeb8eb6a22cc5fa7e9b2a3c9e5c075", - "timestamp": "2022-06-27T19:42:06Z" - }, - { - "version": "1.7.3.4", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.4/shoko_1.7.3.4.zip", - "checksum": "b79a09736992db8f6d5c35c5e93ffbe7", - "timestamp": "2022-06-26T11:52:43Z" - }, - { - "version": "1.7.3.3", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.3/shoko_1.7.3.3.zip", - "checksum": "82d69ddf7b3de713f81235a55c7a65a4", - "timestamp": "2022-05-05T13:40:35Z" - }, - { - "version": "1.7.3.2", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3.2/shoko_1.7.3.2.zip", - "checksum": "3f6a5e8a0c2555c735af519c25a78763", - "timestamp": "2022-04-21T21:46:07Z" - }, - { - "version": "1.7.2.3", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.2.3/shoko_1.7.2.3.zip", - "checksum": "2cac755495cf473933d987fbaae83da1", - "timestamp": "2022-03-08T16:17:00Z" - }, - { - "version": "1.7.2.2", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.2.2/shoko_1.7.2.2.zip", - "checksum": "66db4d4add3cba25da612532b5ee5acc", - "timestamp": "2022-02-19T17:21:32Z" - }, - { - "version": "1.7.2.1", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.2.1/shoko_1.7.2.1.zip", - "checksum": "0be794cab597dab51bff1f33852bdfc3", - "timestamp": "2022-02-12T21:42:47Z" - }, - { - "version": "1.7.1.3", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.1.3/shoko_1.7.1.3.zip", - "checksum": "41daba9f409f259556f6c62e6b1f0090", - "timestamp": "2022-01-23T20:02:03Z" - }, - { - "version": "1.7.1.2", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.1.2/shoko_1.7.1.2.zip", - "checksum": "a1d5f252aecc989fb8963e4c98dab334", - "timestamp": "2022-01-23T19:11:51Z" - }, - { - "version": "1.7.0.1", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.0.1/shoko_1.7.0.1.zip", - "checksum": "b27f97c6b135f382a2c16d6f92e9535f", - "timestamp": "2022-01-12T19:07:52Z" - }, - { - "version": "1.6.3.1", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.6.3.1/shoko_1.6.3.1.zip", - "checksum": "ca4e838886f3417a1a6dcd9fa35e568d", - "timestamp": "2021-10-22T13:51:27Z" - }, - { - "version": "1.6.1.1", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.6.1.1/shoko_1.6.1.1.zip", - "checksum": "61d46b7d810fb731f4d773e3e42e79bf", - "timestamp": "2021-10-18T17:19:34Z" - }, - { - "version": "1.6.0.1", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.6.0.1/shoko_1.6.0.1.zip", - "checksum": "203348a22a644986c656d5c3b2f77ca8", - "timestamp": "2021-10-11T21:40:55Z" - }, - { - "version": "1.5.0.44", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.44/shokofin_1.5.0.44.zip", - "checksum": "14c338219348938e2219f8cf2d15fabe", - "timestamp": "2021-10-06T18:29:27Z" - }, - { - "version": "1.5.0.43", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.43/shokofin_1.5.0.43.zip", - "checksum": "9f06b18e4442e122ad6a08a5fde83fb4", - "timestamp": "2021-10-05T18:37:33Z" - }, - { - "version": "1.5.0.42", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.42/shokofin_1.5.0.42.zip", - "checksum": "2c2d63b22d8d7120ebdee7970f80aaf0", - "timestamp": "2021-10-03T20:49:23Z" - }, - { - "version": "1.5.0.41", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.41/shokofin_1.5.0.41.zip", - "checksum": "b8a8d9b7f3400ae5d32b693bba580607", - "timestamp": "2021-10-03T13:59:20Z" - }, - { - "version": "1.5.0.40", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.40/shokofin_1.5.0.40.zip", - "checksum": "d80c92c25a9e7484e8c5964ab9be1ee4", - "timestamp": "2021-10-01T15:06:53Z" - }, - { - "version": "1.5.0.39", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.39/shokofin_1.5.0.39.zip", - "checksum": "faf3a2aa045b16d67feccb06492d5ee1", - "timestamp": "2021-09-30T22:54:23Z" - }, - { - "version": "1.5.0.38", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.38/shokofin_1.5.0.38.zip", - "checksum": "1eb233f6014d379460dd056899d5b148", - "timestamp": "2021-09-30T20:39:38Z" - }, - { - "version": "1.5.0.37", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.37/shokofin_1.5.0.37.zip", - "checksum": "4fe510cdd9a1ed2bda424f87622a52ff", - "timestamp": "2021-09-29T23:43:06Z" - }, - { - "version": "1.5.0.36", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.36/shokofin_1.5.0.36.zip", - "checksum": "29d3a5712a35d54bb629648c71213de5", - "timestamp": "2021-09-28T23:49:35Z" - }, - { - "version": "1.5.0.35", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.35/shokofin_1.5.0.35.zip", - "checksum": "024ec0a310061396848d273133444f4c", - "timestamp": "2021-09-27T20:36:27Z" - }, - { - "version": "1.5.0.34", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.34/shokofin_1.5.0.34.zip", - "checksum": "b9674475edc9d81166df6e35ce839383", - "timestamp": "2021-09-26T20:35:07Z" - }, - { - "version": "1.5.0.33", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.33/shokofin_1.5.0.33.zip", - "checksum": "07fdee18b59133670cbfa37591293f75", - "timestamp": "2021-09-25T21:49:46Z" - }, - { - "version": "1.5.0.32", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.32/shokofin_1.5.0.32.zip", - "checksum": "b7c321018194e48d83b7d3301bc4a3ac", - "timestamp": "2021-09-24T22:57:51Z" - }, - { - "version": "1.5.0.31", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.31/shokofin_1.5.0.31.zip", - "checksum": "e2786dea21a2ed1579d9a3138fa153f7", - "timestamp": "2021-09-24T22:52:10Z" - }, - { - "version": "1.5.0.30", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.30/shokofin_1.5.0.30.zip", - "checksum": "7f9e5da97481f96037839f5da18dffc4", - "timestamp": "2021-09-23T23:21:44Z" - }, - { - "version": "1.5.0.29", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.29/shokofin_1.5.0.29.zip", - "checksum": "59e565cfa5b59feb8791f8fb726ba0a2", - "timestamp": "2021-09-23T23:19:15Z" - }, - { - "version": "1.5.0.28", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.28/shokofin_1.5.0.28.zip", - "checksum": "76f2086a5750fc3fe6b9e25b5996d572", - "timestamp": "2021-09-23T21:58:03Z" - }, - { - "version": "1.5.0.27", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.27/shokofin_1.5.0.27.zip", - "checksum": "ab548ef8ab5d7f4fad8318c19a689fc0", - "timestamp": "2021-09-21T23:54:00Z" - }, - { - "version": "1.5.0.26", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.26/shokofin_1.5.0.26.zip", - "checksum": "2f644d90b0e70056a5f77127fddb6468", - "timestamp": "2021-09-21T23:34:25Z" - }, - { - "version": "1.5.0.25", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.25/shokofin_1.5.0.25.zip", - "checksum": "2e7143bebb23853a17c1b23e61c74797", - "timestamp": "2021-09-21T23:27:49Z" - }, - { - "version": "1.5.0.24", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.24/shokofin_1.5.0.24.zip", - "checksum": "2c3ce7332b5821efef6680a94f220d6f", - "timestamp": "2021-09-19T20:58:40Z" - }, - { - "version": "1.5.0.23", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.23/shokofin_1.5.0.23.zip", - "checksum": "e4df3ec9d96d4d2eac68c7b7dfdd741b", - "timestamp": "2021-09-19T20:30:33Z" - }, - { - "version": "1.5.0.22", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.22/shokofin_1.5.0.22.zip", - "checksum": "1ca1227c0eb19d6c506e58c5a40d014b", - "timestamp": "2021-09-17T20:19:26Z" - }, - { - "version": "1.5.0.21", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.21/shokofin_1.5.0.21.zip", - "checksum": "e2a2f7351fce440436d53d32b56d5218", - "timestamp": "2021-09-17T01:58:55Z" - }, - { - "version": "1.5.0.20", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.20/shokofin_1.5.0.20.zip", - "checksum": "538fb912295a30167bf5407231b66fd8", - "timestamp": "2021-09-14T22:40:07Z" - }, - { - "version": "1.5.0.19", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.19/shokofin_1.5.0.19.zip", - "checksum": "512a57c552d36387c010051052f1f994", - "timestamp": "2021-09-14T21:33:24Z" - }, - { - "version": "1.5.0.18", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.18/shokofin_1.5.0.18.zip", - "checksum": "66130e1d8508a5316e0db88dc24000f5", - "timestamp": "2021-09-12T22:21:56Z" - }, - { - "version": "1.5.0.17", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.17/shokofin_1.5.0.17.zip", - "checksum": "15b37a792d79baf15ddad4890d0d896a", - "timestamp": "2021-09-12T18:39:36Z" - }, - { - "version": "1.5.0.16", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.16/shokofin_1.5.0.16.zip", - "checksum": "c44459c10a42cea673d1cce21fc83313", - "timestamp": "2021-09-12T18:26:34Z" - }, - { - "version": "1.5.0.15", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.15/shokofin_1.5.0.15.zip", - "checksum": "14325846ab086fbd39b6a9ec772a07ec", - "timestamp": "2021-09-11T18:08:57Z" - }, - { - "version": "1.5.0.14", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.14/shokofin_1.5.0.14.zip", - "checksum": "3cc15d702cafe7b32e1cfb349c591768", - "timestamp": "2021-09-10T22:33:21Z" - }, - { - "version": "1.5.0.13", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.13/shokofin_1.5.0.13.zip", - "checksum": "4c4360c6139dccf1bb42a324273f5cd4", - "timestamp": "2021-09-08T22:30:53Z" - }, - { - "version": "1.5.0.12", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.12/shokofin_1.5.0.12.zip", - "checksum": "c549d18c15a71ddd808494e079d2b8ab", - "timestamp": "2021-09-06T21:38:39Z" - }, - { - "version": "1.5.0.11", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.11/shokofin_1.5.0.11.zip", - "checksum": "50a625c223c2b875bdf20e4b213d9f8c", - "timestamp": "2021-09-06T21:28:40Z" - }, - { - "version": "1.5.0.10", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.10/shokofin_1.5.0.10.zip", - "checksum": "ff9d75f2beba5953a8b9bcf294e684c2", - "timestamp": "2021-09-05T22:06:46Z" - }, - { - "version": "1.5.0.9", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.9/shokofin_1.5.0.9.zip", - "checksum": "782e0312a2953b61e817ca020ecf58a8", - "timestamp": "2021-09-02T23:18:47Z" - }, - { - "version": "1.5.0.8", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.8/shokofin_1.5.0.8.zip", - "checksum": "ab88a37687332b28c7f5f817aeb40ffb", - "timestamp": "2021-09-01T18:31:55Z" - }, - { - "version": "1.5.0.7", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.7/shokofin_1.5.0.7.zip", - "checksum": "8e976e353733943b5b2fcc7ae9d1a4ea", - "timestamp": "2021-09-01T17:15:05Z" - }, - { - "version": "1.5.0.6", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.6/shokofin_1.5.0.6.zip", - "checksum": "bec863c6fe9d16ee44452021dc20cfdc", - "timestamp": "2021-08-31T17:46:04Z" - }, - { - "version": "1.5.0.5", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.5/shokofin_1.5.0.5.zip", - "checksum": "a6be64b2fbe544d54c9da6bb2fb89f02", - "timestamp": "2021-08-31T12:41:33Z" - }, - { - "version": "1.5.0.4", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.4/shokofin_1.5.0.4.zip", - "checksum": "91bad7c76687ef8ad60d40a5daad89d0", - "timestamp": "2021-08-30T21:28:12Z" - }, - { - "version": "1.5.0.3", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.3/shokofin_1.5.0.3.zip", - "checksum": "7589e19680e18a285d7c8b74e5a1f4e6", - "timestamp": "2021-08-30T16:38:22Z" - }, - { - "version": "1.5.0.2", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.2/shokofin_1.5.0.2.zip", - "checksum": "b133d4bf937653e2d0158e9f5bb4a06c", - "timestamp": "2021-08-30T15:56:03Z" - }, - { - "version": "1.5.0.1", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0.1/shokofin_1.5.0.1.zip", - "checksum": "daba4219cddf344890b8e5c91cc9266f", - "timestamp": "2021-08-30T15:45:52Z" - }, - { - "version": "1.4.7.3", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.7.3/shokofin_1.4.7.3.zip", - "checksum": "da198a7a6ef1b6c4cb4128fb705d9eed", - "timestamp": "2021-08-30T00:12:13Z" } ] } From 867a9f8b8921fea8463456d80d0e7254b67d348d Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Fri, 29 Mar 2024 13:07:56 +0000 Subject: [PATCH 0662/1103] fix: forces version specific release url in manifest --- build_plugin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build_plugin.py b/build_plugin.py index f044224c..e4f3a5f3 100644 --- a/build_plugin.py +++ b/build_plugin.py @@ -35,7 +35,9 @@ zipfile=os.popen('jprm --verbosity=debug plugin build "." --output="%s" --version="%s" --dotnet-framework="net6.0"' % (artifact_dir, version)).read().strip() -os.system('jprm repo add --url=%s %s %s' % (jellyfin_repo_url, jellyfin_repo_file, zipfile)) +jellyfin_plugin_release_url=f'{jellyfin_repo_url}/{version}/shoko_{version}.zip' + +os.system('jprm repo add --plugin-url=%s %s %s' % (jellyfin_plugin_release_url, jellyfin_repo_file, zipfile)) # Compact the unstable manifest after building, so it only contains the last 5 versions. if prerelease: From d4f5f766660f4b1028e1cd9542b3c8ea4f7adfe0 Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Fri, 29 Mar 2024 13:34:32 +0000 Subject: [PATCH 0663/1103] misc: manually amends broken links in unstable manifest --- manifest-unstable.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 25cc402f..4050d532 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -12,7 +12,7 @@ "version": "3.0.1.58", "changelog": "refactor: overdose on `.ConfigureAwait(false)`\n\n- Use `.ConfigureAwait(false)` where-ever we can. It sped up\n the discovery phase by 30 seconds over multiple runs, so I\n think it should be okay to commit this now.\n\nmisc: better tracking of subtitle files\n\n- Add better tracking of the link generation for subtitle files.\n\nmisc: change default cache expire\n\n- change the cache expire time from 1h30m to 2h30m to\n better accomodate a typical initial scan based on my testing.\n\nfix: fix vfs link creation\n\n- fix the faulty behaviour of alternatingly skipping and creating\n symlinks because of an accidental early return.\n\nmisc: add changelog to manifest releases\n\n[skip ci]\n\nmisc: add changelog to GH pre-releases\n\n[skip ci]", "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.58.zip", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.58/shoko_3.0.1.58.zip", "checksum": "1c32f315eac5dd815346c49776c07c24", "timestamp": "2024-03-29T11:19:48Z" }, @@ -20,7 +20,7 @@ "version": "3.0.1.57", "changelog": "NA\n", "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.57.zip", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.57/shoko_3.0.1.57.zip", "checksum": "24034540bd83bbd1aa80694bbafcb2c4", "timestamp": "2024-03-29T05:38:13Z" }, @@ -28,7 +28,7 @@ "version": "3.0.1.56", "changelog": "NA\n", "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.56.zip", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.56/shoko_3.0.1.56.zip", "checksum": "3b1322198a5614672a565cdafaaa92b4", "timestamp": "2024-03-29T05:31:41Z" }, @@ -36,7 +36,7 @@ "version": "3.0.1.55", "changelog": "NA\n", "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.55.zip", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.55/shoko_3.0.1.55.zip", "checksum": "82036927eba09ef9b1cd153ff4903bd3", "timestamp": "2024-03-29T05:18:42Z" }, @@ -44,7 +44,7 @@ "version": "3.0.1.54", "changelog": "NA\n", "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/shoko/shoko_3.0.1.54.zip", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.54/shoko_3.0.1.54.zip", "checksum": "c67234ab9f6fe1d6598d1cc30d9901ee", "timestamp": "2024-03-29T05:12:38Z" } From 81b37831de370bdec5486ae620c463f8bc25e6f5 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 29 Mar 2024 21:30:33 +0000 Subject: [PATCH 0664/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 4050d532..a7cfad0f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.59", + "changelog": "misc: manually amends broken links in unstable manifest\n\nfix: forces version specific release url in manifest", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.59/shoko_3.0.1.59.zip", + "checksum": "e5e2bfba77cf5414b774bdec246be68d", + "timestamp": "2024-03-29T21:30:32Z" + }, { "version": "3.0.1.58", "changelog": "refactor: overdose on `.ConfigureAwait(false)`\n\n- Use `.ConfigureAwait(false)` where-ever we can. It sped up\n the discovery phase by 30 seconds over multiple runs, so I\n think it should be okay to commit this now.\n\nmisc: better tracking of subtitle files\n\n- Add better tracking of the link generation for subtitle files.\n\nmisc: change default cache expire\n\n- change the cache expire time from 1h30m to 2h30m to\n better accomodate a typical initial scan based on my testing.\n\nfix: fix vfs link creation\n\n- fix the faulty behaviour of alternatingly skipping and creating\n symlinks because of an accidental early return.\n\nmisc: add changelog to manifest releases\n\n[skip ci]\n\nmisc: add changelog to GH pre-releases\n\n[skip ci]", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.55/shoko_3.0.1.55.zip", "checksum": "82036927eba09ef9b1cd153ff4903bd3", "timestamp": "2024-03-29T05:18:42Z" - }, - { - "version": "3.0.1.54", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.54/shoko_3.0.1.54.zip", - "checksum": "c67234ab9f6fe1d6598d1cc30d9901ee", - "timestamp": "2024-03-29T05:12:38Z" } ] } From 7d7aac3a7ad9b2b4d30c8232e015df368c2b12b3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 29 Mar 2024 23:53:06 +0100 Subject: [PATCH 0665/1103] fix: impl. actual attribute parsing - Implement actual attribute parsing on the file/folder names within the VFS, instead of the faulty and error prone guesswork I added in the initial PoC. It was bound to fail eventually. --- Shokofin/API/ShokoAPIManager.cs | 10 ++--- Shokofin/Resolvers/ShokoResolveManager.cs | 10 ++--- Shokofin/StringExtensions.cs | 52 +++++++++++++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 010e791f..e07d047b 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -345,10 +345,10 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu // Fast-path for VFS. if (path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { - var (seriesSegment, fileSegment) = Path.GetFileNameWithoutExtension(path).Split('[').TakeLast(2).Select(a => a.Split(']').First()).ToList(); - if (!int.TryParse(seriesSegment.Split('-').LastOrDefault(), out var seriesIdRaw)) + var fileName = Path.GetFileNameWithoutExtension(path); + if (!int.TryParse(fileName.GetAttributeValue("shoko-series"), out var seriesIdRaw)) return (null, null, null); - if (!int.TryParse(fileSegment.Split('-').LastOrDefault(), out var fileIdRaw)) + if (!int.TryParse(fileName.GetAttributeValue("shoko-file"), out var fileIdRaw)) return (null, null, null); var sI = seriesIdRaw.ToString(); @@ -686,8 +686,8 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul // Fast-path for VFS. if (path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { - var seriesSegment = Path.GetFileName(path).Split('[').Last().Split(']').First(); - if (!int.TryParse(seriesSegment.Split('-').LastOrDefault(), out var seriesIdRaw)) + var seriesSegment = Path.GetFileName(path).GetAttributeValue("shoko-series"); + if (!int.TryParse(seriesSegment, out var seriesIdRaw)) return null; seriesId = seriesIdRaw.ToString(); diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 782ee50d..81f9156d 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -595,8 +595,8 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou return null; if (parent.Id == mediaFolder.Id && fileInfo.IsDirectory) { - var seriesSegment = fileInfo.Name.Split('[').Last().Split(']').First(); - if (!int.TryParse(seriesSegment.Split('-').LastOrDefault(), out var seriesId)) + var seriesSegment = fileInfo.Name.GetAttributeValue("shoko-series"); + if (!int.TryParse(seriesSegment, out var seriesId)) return null; return new TvSeries() @@ -644,8 +644,8 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou var items = FileSystem.GetDirectories(vfsPath) .AsParallel() .SelectMany(dirInfo => { - var seriesSegment = dirInfo.Name.Split('[').Last().Split(']').First(); - if (!int.TryParse(seriesSegment.Split('-').LastOrDefault(), out var seriesId)) + var seriesSegment = dirInfo.Name.GetAttributeValue("shoko-series"); + if (!int.TryParse(seriesSegment, out var seriesId)) return Array.Empty<BaseItem>(); var season = ApiManager.GetSeasonInfoForSeries(seriesId.ToString()) @@ -659,7 +659,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou return FileSystem.GetFiles(dirInfo.FullName) .AsParallel() .Select(fileInfo => { - if (!int.TryParse(Path.GetFileNameWithoutExtension(fileInfo.Name).Split('[').LastOrDefault()?.Split(']').FirstOrDefault()?.Split('-').LastOrDefault(), out var fileId)) + if (!int.TryParse(fileInfo.Name.GetAttributeValue("shoko-file"), out var fileId)) return null; // This will hopefully just re-use the pre-cached entries from the cache, but it may diff --git a/Shokofin/StringExtensions.cs b/Shokofin/StringExtensions.cs index 6869c4b3..f9c5cf1f 100644 --- a/Shokofin/StringExtensions.cs +++ b/Shokofin/StringExtensions.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using MediaBrowser.Common.Providers; #nullable enable namespace Shokofin; @@ -65,4 +67,54 @@ public static string ReplaceInvalidPathCharacters(this string path) .Replace(@"?", "\uff1f") // ? (FULL WIDTH QUESTION MARK) .Replace(@".", "\u2024") // ․ (ONE DOT LEADER) .Trim(); + + /// <summary> + /// Gets the attribute value for <paramref name="attribute"/> in <paramref name="text"/>. + /// </summary> + /// <remarks> + /// Borrowed and adapted from the following URL, since the extension is not exposed to the plugins. + /// https://github.com/jellyfin/jellyfin/blob/25abe479ebe54a341baa72fd07e7d37cefe21a20/Emby.Server.Implementations/Library/PathExtensions.cs#L19-L62 + /// </remarks> + /// <param name="text">The string to extract the attribute value from.</param> + /// <param name="attribute">The attribibute name to extract.</param> + /// <returns>The extracted attribute value, or null.</returns> + /// <exception cref="ArgumentException"><paramref name="text" /> or <paramref name="attribute" /> is empty.</exception> + public static string? GetAttributeValue(this string text, string attribute) + { + if (text.Length == 0) + throw new ArgumentException("String can't be empty.", nameof(text)); + + if (attribute.Length == 0) + throw new ArgumentException("String can't be empty.", nameof(attribute)); + + // Must be at least 3 characters after the attribute =, ], any character. + var attributeIndex = text.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + var maxIndex = text.Length - attribute.Length - 3; + while (attributeIndex > -1 && attributeIndex < maxIndex) + { + var attributeEnd = attributeIndex + attribute.Length; + if ( + attributeIndex > 0 && + text[attributeIndex - 1] == '[' && + (text[attributeEnd] == '=' || text[attributeEnd] == '-') + ) { + // Must be at least 1 character before the closing bracket. + var closingIndex = text[attributeEnd..].IndexOf(']'); + if (closingIndex > 1) + return text[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString(); + } + + text = text[attributeEnd..]; + attributeIndex = text.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); + } + + // for IMDb we also accept pattern matching + if ( + attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) && + ProviderIdParsers.TryFindImdbId(text, out var imdbId) + ) + return imdbId.ToString(); + + return null; + } } \ No newline at end of file From 233bf4526e6cacd6615f64efa966718d325bf3f3 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 29 Mar 2024 23:00:52 +0000 Subject: [PATCH 0666/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index a7cfad0f..5af2e41a 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.60", + "changelog": "fix: impl. actual attribute parsing\n\n- Implement actual attribute parsing on the file/folder names within the\n VFS, instead of the faulty and error prone guesswork I\n added in the initial PoC. It was bound to fail eventually.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.60/shoko_3.0.1.60.zip", + "checksum": "762320590c2cb57b5dbc4c73f529bbb1", + "timestamp": "2024-03-29T23:00:50Z" + }, { "version": "3.0.1.59", "changelog": "misc: manually amends broken links in unstable manifest\n\nfix: forces version specific release url in manifest", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.56/shoko_3.0.1.56.zip", "checksum": "3b1322198a5614672a565cdafaaa92b4", "timestamp": "2024-03-29T05:31:41Z" - }, - { - "version": "3.0.1.55", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.55/shoko_3.0.1.55.zip", - "checksum": "82036927eba09ef9b1cd153ff4903bd3", - "timestamp": "2024-03-29T05:18:42Z" } ] } From a4c79130eb8bc16a82cd5aa6a881cc7b4627147b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 30 Mar 2024 00:38:00 +0100 Subject: [PATCH 0667/1103] misc: update logo - mascot accredited to @Queuecumbr - image accredited to @ElementalCrisis --- LogoWide.png | Bin 43157 -> 109118 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/LogoWide.png b/LogoWide.png index 1e961fb718c1310e2a71bf9d0530af534dd0316d..f91941e8283605932c96d9fa739ddc02f217bf81 100644 GIT binary patch literal 109118 zcmY&g1yoes+8(47L`p&F5D*ZMmIeW7P*NIcNog5UI;2Y)>27Hlk?!tph8U!ChW`xz zcfH?rEthQOoW0-u#`ESF{7z8{2a5y?1Onm6NJ}V#K$zYj5Zdee_kiD+Rj5A({y}k2 zmU@FS)AP~`SUmVFt?2*)VG|<%qJWZ8o`O&c)?_5aRKKL`Ex5R=m@Ii8(l5l+yvx`N zduZoPg;mMh{n$96xi$n9z2mE|5VKofEaUzA_wKPiHa@_~$;ok}=d-;YC;s7RoRX-s zB$9@k&2SFSLZfK5Z^|mtf^#!OJsnl<&_a@k{FC9tkB?eSh(q%KOVFQNiATeU-NXMZ zK{EFo={dOmS-s|Xfcozxeb2vNC5t9Q;U|3dpPk4;-2Z3kH~;VDUK$6=Y%m7SKRcf( zR)A82AO2m+uKdqZ>dF7S8ys@)UnK7ZHT;{%X%udZZFyt!e|8?`I{lyJi=UY03?%=# zk$DWpzuz?vHT|EZf9JoK`RVlkzOSlM(Z8uum7DvYWjDqDyc+^X``5Yo!Mf=GBJv%o zz{7uWJ>z8~&cBzB1#pKIx&CvbcR_H>f4v)-n1%N5Wth>wcaHo@i}9~>rH91+EkWt7 zAOExb3WBr!m3B}l2u}HzQ$azX)F*$1D<S4RNbz@NCE-1Y^LJJJ)_V~3uR;}zd-Gub z{p_WrcMa(8s$a^x=IMV|V%{|vf8RTj@UHpq3HHEe|2@4@(whkL?|YS0qM-qQH7SBt zH1ykFMM}Is98D#T`j0N4XubKX7vbMPEG&O-7bxi+kNTe-y-{gI`vU*7$FCoF85?f{ zF#dm!WvctbSQ%pf?D!cC&AcP>2g?6{HqCpl`uAVc*?WSb_2j=RTof%9^Z%ID-a`~E z-T!`ufua@m-xUZ&tNHJWD-2X(@mKV-lHQY`zXD>R5w*nlOLPrh26w=Jh!$nOKRhe$ z`w!6>575j@X#T^7cYOD%Yoh)`bUqQvEXCg|<l~{tDh_A-bD?}3l-bh%eujxM`{=*# zp`gs#{9U~W1z8XMz20L$c3yuu9YQ54fc2N?MEnf$zxv>9b$|F#;D2N%N`YpMDfu5x zQ*qy`7N<oLE#Qnu*N>y1<{eUX(}dz9ncah*A%=V~Df_6!N&t%+_|+Z=h2$BM1X1{2 z%j>P+=SVOapcY*dK~j=|;b$s`p9@?pLkrE^`0f;Y8eX}tcaOzUiPE|uAEP4AtV#(Q z0p5IvheE=ai4=6jyOarE3gm8l_!&`{$eWZu8qp-&Mm`q#7=^@?8c8MRNGf*-0rD|M zyo`@1$j82rbj%>|vVcRWi0*N{ypu=b_TVF*r^v^&fyZ!=k39;7?Ulbk?pA&eg=7+W z=jGSigJ1mac00z;_{fZW>?~yan;<@Nw@86|ToR&ybbk#-rhUXmeh9LE;~ij*R2y^h zg=aOiZvl#y?a<7Fkhb7mgO^H;FN=IE<^FKweWcEu(h7-V+{p-2$~!<6$+&#Ng=Zld z62P0U>F;r6BY7lBw>|jo0rE|6acWWVN5~f*uP;Mqx){=HBhmvCB5KPZO2S^mE)mtS zkHsxyuM$RYd3pGcR(!83D&A|RZLF^DtgLK6p{WRo=Blc^1f?rqSlS{~WY9jR$6Q`U zR{C}+yZyrN;`?~)`ue(rFSSCNeya1GVv(VtkQ^a-OT{3yJqT`gfKM)jK$N?h&CBIP zz2!uOr@TRCBKpmZjiXsY9mX8=49NsB-yS^3&3<llg}X}laHQfBE=9J#R0?19`S@WQ zG~tC%&b>QEpM`;NG)DkEkNm2eyR}osQ#7Anul>2)ZN#8<`S+mk;^3kl7#EizRS`NO zL7SbE7#$NP^DK21kJR$Z#VeLov02~zKn>R0db7Uf#;G`pcUrMXcy5&N4!DODf^=(I zLzUjaeBo5kuU<95zSyIeDzhwgk%JwQdkSb3#If-Pud;=LWb577SOjUf_dM4VIUxs& zjv{;UqYlA$e$l||{_wkpNKsWxOMz_&Ir!^7q7Iq$x;<ttbZ~lNtsCZK8Vb;*0$Y@8 z@#I_?Pb7E9laLa52d2S)mkDUe9tH{t7E+lExJz8Nu;TQJ7c4V0{Ou`n<m!oDX1F-0 zfNAy#(Toh0wTz8-7Rs}kHEhX+>uirXa?C0p&;bXF?gAuGAR$@q6R(ilmoH8v*A+v@ z6HQENj?Aj>pmuxdz%}OU6TZOSjK&p-uqC_fc1q?jR6a$%BRU4Wjg3^!&0%vej0aZb z-HGF#R#L38Vm)}~`L>G?jd}yt?8mu<&^+2N!>nNz3NyKrjuybi1|;ru#}hR=oN6Qs zEtS*)=gx;dnQ*IP7J5|4K}VMpAoV<jFX0Pfb%(8sStz_7$#>8!U<M>e?fH^Yv*WP# zh&o5B`pgX~2}o6T=$^xaw??ZSeGl>mQx?n5&cy}dC>U(-jG%-{R6GZ11m;BoE^cnJ z232nt)tI0s;2bmjWbxFIs6k`d7AJwTh&AT7flnWPl2wW!MPAMO3P0nA1+ay;(dauc z>~iGc5(yhvqi}m~%d&Nhj0I{DW5AUwDJb3ance;N>%b3{SPBN-J7t-m5fw*865aJ9 z?qoi5cbT9>|GY?dY^F~Rs!4@uWN3KPqrx5-QJQ%6b+pPj0tu5DifHEMNX$1_;)+UR z7SU*2%pQX475;FrQwI}GME<d{QCob$dcUXEMhdprXuo<tN2=$}M~GVAA08$E9NsV1 z0udohV#W!>n|1fHmOZ`dpcqAlu6X4f4K;dQYk;#1x{YUlw%Us5Bi@7Kt?qP5)dz&L zk2J--fU7ZwMo-%()=Pt1{#$#0ve2msjNTZ|r+dMxzO;@sFEI=C5h580vJmwSP)CX) zDfj4PZ)0Q3^03q0$zoSWCpN8v^}EN~Pew*Y7LHMwgT2+Q-Muy14f$2hoa}bQ)pcf? z^}_(J9J5PYr2dO)0+%KQe#+ouQW_GXmmgXxu8`^>;MaJPXC@#dP0KB4s5oGztP;cN zMe<lRdt)27nOOsAaok=RF>h_&5RZ;Vmo5b%E{CzW$q5vYj^iB`3Q3X_0HNmoy<-j# zKFb^ZqBSil$f)n@(sk`xv+=_3`w3c(<jTb#jh1%U>YlJZKUP)e6#N!0>~0Jf@HH~Z zicQ<lztf#d0HGO>f{atK_OQ7C4;319x~jb_JE3~CS3%m)Hf5{Dk*fhqaPd0dy<tgm zjb~zPLt)IEw@h<iGi~o2u*~(K>NKRei&MITK{$<X01ETNEE*je^kTL!cx<-cXV%3i z=AFDWR5^S`ULQgK^kG!v*-MI>06sY<uWLET?9V1<4f``S_@@z{NRq?ll@@s4BCP1- z#qFKf@Ad`Z2uL6|^|RP@=++3uuKomF-D0?%G>rc!_NuchIXXH_M#iU=>bpF@nc39M zSJum;;JRQMf+78z>s>a_ndZg*T{nfaE&Z}mvl)(PMLH~_H*Zdc0*P_<@=vJl+%AZN zO0-QFiK)HHW?*MKJMs%SO8QnIG$l1It%HC*hFW;>&!3O0{&F%2tPHx4L?VhbqSN80 zq|D#tmGoiSS>XCIVU5$k7fB4<dx${_TOC1{`J23x#2Uf!Pe#OEUeH_uxz!Fi0gSUc z=gaC*-@CvhThco~2nllQd4nhOb>it*9#_|@MNDl4tu%OO;#NAn`*Xp>Y;ZPyec8}& z91>mk-x{SUIh`Fb4I@r7QLp)+r+j5>o8{324+H!-&2T*~&I%x&?6;cMZ{8AH!W)Jh zCQj1QMhEEc#W1ey&0Np9*nGbZ9?kmy!vU~?BM&#u`h9g{?>kilfww6AG)tur+0raQ zpTr|1J)c^7U^dteccQu6`DtbQc`$geqoejJ6kWjO#N!Y>Y+)7dXXR--3c-zP^wbgT zF82LF`Ji7vf9!<r(qd}V4IIRk%!NjMxVk~8?`1c=M=O;rj?`Z~aukwqAHcUq#$t^* z)Y|Ez1)exQxc?3uDR`e=MN2|rJi?6<;r;^JtH#i}$`2!X`Yyvd`KCm>0sTp&;hQ)- z<uQuAi;q3suz2B$DFz2${u+(-Zrjjo$+rO|I#s4SZnC&YH3Z=iqL4iD1F$F32RLX* zrV2Hg>*&S%)!@c7wa(b+BzHLYWQ8`7SAdj|kVThzEq;qQ4cBUNx@=R96_M%rCqwH1 zPbtA2-Z{|`TEmuX10`&1hMNrUmI%qls~#88=#Ku}jcRl*L#(oxblnBkLH;0|K;$KZ zq^(ac!#+|zfi6yDN!V!<M_2wLF<i^UB@gGbe+i2aQTX&py*OiRx4B4&bi6doYk`0E z^@NtH&&X5wajUJkUhQgqGyTxeQf0k%E7eFILCkWj`~ECtx}so%OI79nE07fs{rDoS z#45YyWZrt7Sn}%B?(FnEIFc+;S$PeC5HK8zuUqp<Jht<h>22DrpUz*Q=m-rKNZWk6 zwsydxTX9w7QvAi{yfzo@9LMhL{Xr3(%1(1RMEG)hd5{}x5E-X|%JBg%h5_O#4=>zt zh``O=vRxd{GJe32N;0I5_pQ^fYRfR3EDX5T<(V}@OdPMq?!x@d?!>@SVUE(z!d|b= z4F17wJo(&<nA?6!?XHHrbA;i?uahjgN5Nol@HQGXVjQ}r0=YQ-#e<%Aha?|>5Jw7$ za35raPA@kwtSvoHg3anH?QN~}JfA!asDmg;z!2=4fACcb6>bA}b)kuG78Xy+jtlu| z8!MG*X$HNHPfLFu7rR|W4q$rdyNl&bg$l&gjq1y>OGWsfng3XRc$b*$;$?h&i1dJb z<1uM{YVIuqAz?PH{&5t~-E!O8^P5E$v|Bh>n0h;N=0njEzE~C?91rmoG)6MXe-636 zZf|l{U&Klk@mfin^K@p*JyTIYi(%YMp#34#gY+U=Z@e)ONNre`5Q1L%Cy-8f9vG!t z3^92<lyn}=q!6C2I9^e@j<H>|C$Qqr|1I>oPPB0fyV~l<rf-QucKb7OMi&^xt?}=b zh20W2@KA6ScD2ohdU-~jO7I{aG4UZh{t4xy_ZA>zF6b?Je^^u<xQi&!PQef=ktgMF z-4Fh;Dn@SZt1aL2sZzNH=Y`IkQLnFvQ$6NUNSWK`@~2sRcq+287Xb==B2Udvv3%8G zM_whRL9{Z@FE<AWRfN_496z|biYbjK>I_oFISLqraCI6MAU3+GMWLg<#^SF?jJ&Sl zll__nEBG~Kn`#w?6)WN%h3XX^jDc~VVK*t8+{K>Y5knJ!l9{x_iulCJ#etG=C4-yv zcDaNE7puplmb)@+lRuF8%69<m*Mk6Ed!$HrTHl4qiRA=aUp)Za(TrS+-P&MnsG--j z?kf^&9oelNlKCpB)^PF@y|m2!T)k0-g=);GxrW+C_Z7bhW<U4(>~{&g5@a$ox$EnJ zn7C{t<6=<@pOA8K1uo+XD4MsCvbaa-p+%KOUjLsL?{%PJm1f^U8zCQ!E;<K*NPJG? zxCCJs)#A~_EM#=8-2ME!{5cVu?&XYmk;Z*i<-)bq)5SQZ;MZ{y$_Gmjhz&gH(?K7E z8yP@SxuBW<dI?D5Gns|-5V-j`1LqoUp~`#yfP*rc>OMOJ?rG*}epI4e!?wA%rm`KT z=<nt%cPphbry)+n-sPz~{4<z^`6NjY@-9yitb|+fmLpV+ZN=|+YpeQ;%-;L{JAmtc zfI{+$7&wpYE6I+-?eG8!&JJ{8J{C~lPf<~|rUSuz;QljC-qA}zjgpH$KhjM4W?N!) z@lVP9#{c|*{<MNW!ku&O-<m1R?5AZ^QC1#Tx+aeJqJk*7<I@blr+6T8@+Mbl5<dT8 ztJNC5uZ#Woo%1)c2fuyEMf^(iVn`@#t_{I1=RT?Y80R}b9GpBXXy+2T2p?8dXqXPn zS6(n{6G0DKzb&T;ox+$rEZ6OzY$G1xZxrE9A2vPij!~i&MV&;7BlW$Y(Dfzy*^KjW zmMUWbPdOhnH^6@(Nq}v0o1Hz^&Yn%Hx;-DiWnw_s2(5-Q5oSAIZRM`7J^AP8XuozN zQsdIz5XVG_Q*U$xLiS*PLg(sz<y{aV7!Jbu5CN#wnb_f#p!r0EC)>ok{X|kIO^r3K zb~F8cE9!f7x!aC-uuD@Y1oEPY?}g>k`ye>{{L+@E((X8Ni1-P(Dl{x~y1U=jn_U=j zmMOVw>hWTqOX*H+1p!01dIR89iAs&I3n?#Mof&R|`{m;lYL|kQ6_*y>lRv*u-m8z* z*FCmcYLSKr&yY2K#19n7YKylO1-yLKt_F9a760$XP0JGfM2>}$&mXmkq7-g=Vvc90 zqFhyw_ALtJwN&?jxZOOq^3-*GkuYsz{A9&VGc%V;Q1Hv2GoN=AHtv&d;CT1l67Q)U zeyC&S%NMMH7PCIB<;~*9$9Y~=cBwzK!!J~e=zFDC`;G)GI2QEE4F<*!?ZfYY{xcwM z8h~Hk0WXr%t_MG;WVmvsCJoMa6YPGgsNgjl{C2q!WKazio{^gO^_5e)>8?t_8hqr8 z=Zfw`&9V=eqTJGs$(k^={>w)@!(v;jhHL!xcz*42#9P(*)LUtHhzneT1Gq$fy3c8S zXVJi}eDW62<Lq(JrZf&N1TnJ(L>xgsGlm?7;+}HOHh*BRAnH#RUl_9D77M!EuN{Ud zSn1^UI+Sr~*DQ7nDaHQCHkDOquC<!kVOxr}mO?V>^Jg@3QFq|5cbw?E$KD-t_ei+j zS-3bnP}C5)da*gpZl^v5z0m^~oU{j9Ac7?Q^K+sY^d4{JE=P%^AHL9=KXY1-`n<U} zKV`E28yO@om){@uRt19Oj5o3qTf9=9TGh7tX8QV)&b89NXR3&$BAf}@d$Q04oL-LB z>N`{N<;UObWTf4k<w;7G91L#{?lbe0@*AwE<1}>~-O9_zB$&z1RbI%s)&PnR0s-|q zjJtxx>AON<Ts@axWp7VUykOux_Me*?G0<K=T%+5IwzD`#ut&v8+i|UVY3JD2iK~5$ zNP|}?h>JE|%dOcu#WE$L$FNzo)O5~79Dr4)wZmx&CUcR1&V$H+a!@3)LKvH)3WmMf z_N_GPDyaCiNyS35I`93p(+`bw%I@rtQP};9Xj?$0Jo=1;)k1@#juI&`6md4??{?;R zc0^$E{8=_+S%a$-AKy+YZ6BE?{Tji`KuH8jZlYo#Xxo5pU8xpiCTfe_G9cD_<ZL0< zYc!?kSh4O~<V#!W#3s+XW${*T`MArB$Vn3zHFJ+h=R7AH-GVq`ANo~ScMFNIK3h=u zXJ?>riJ}Q4V4|K!Q(LgLJdE?bg7aZ{54ZId1biw_zQP3koJO+O{U}%}n7}YB`(c-G z7A@ig>bkFA;gMPaS^E6oez^zvjIld~5i`DU#0VKu`<7n=l0+6csBk51+$ky#Bb{v# zVYBCBVP(a)@;nE<YQ63^6>`E5tCr1a84U;JgTR4&mDV$-x8#Bcdf7qyyWO2nDQ~y@ zYH?vYENyysdjeHeZ$spsjOaG*p1VcAh1~eu%v?@N-X2-b>wD>X9_a3-B|}_8Y2a7& z7bY6%Kg2pO{4UGXrRBc?COHDpv1k4u9UHYy{q!L&F1Zd@UGqaXWG>=OOC^db3)Jzv zCDzM^P!kZ#`dyPT4o?yesr`IkBJkd%7FhyD=j#@@eF6@~P$3U%$}*re(wR@EY;pL5 zscnl%;q=D`aPu!{d+1mCq4kC-;?Z~ggK{^(L|g&g#!Kiwtv9czw6zi01G3ny+|l~O zfvNXy=Sg18mIekRUwr}NDR(mJl5R)>N=b0eLpUXGz---Mw?`hg3r8I_y{X((yaro( zG?*4?d*!yk#w<u?U?uK4EInJ=uw9hj+OryCval!%n4We(y(Z1f8mONxa=%ojl>f|D zT#>B(G2m7&)@S6)$6m9!c%`3q9Tkv;&xhx=cKC@ox3g`N#d?>}`vtsr1x<ms-WcQl zfL7N;)(b~m9A=wAN9|!RA}+@AYzDqZxnHZZPSp{;z#Fg8ZFSyvDlM(Vp=X<0BziZN zjKx>qXay}~d%HEHU$>EQ?w;n=nrAVQvM7uHFrfUWrPV>X(QRE$hstlGaB^&%Ppqe` zNckF+0rK4el>8UJ{p1!&8BZC+AZKwww}#xrG$_2K(b(=}gtqhIf}tg2tp3Rhyy1+G zD>e(K6cj|I7Pm4Q+ARf-%{iV?dOcr1?m#%{J1~EEm0YBd+sLd@eB~a?A`s7{Ti+HS z#M;tQ^D;AN{QBIiNnLD5_s+gp@iS23kccd~UOa^QruJg0q{yLSdr)7YW~v$!mw-+0 zn}`?d#%9<_I`}c$%Jpw-y$$BqmanK*KjCY)=<^G&L%Xz__=;DD-`n_OnZSKy>Z-Qq zCRCOxOvaL&w5PCj3p$31xFkdbxorNVOF7FmU?YQ<Y07(C8OXxWtH6{~*X>+=_d;Rj zIp=b1rn(<<-@m_Zep~U9S4?DdZ2{`PxV^f!55Mre6(Dq-=pA}HTWK3{I2LjOHS5EY zWzWf7)Q8w?<h9W)_#<mTK=XiP7}*6_e^Ya{cR=~La$lOK*8dHiYNR7~7ak#JiF&XO z>(eHSbp2BjMgD~>DYUG^Ngvmxfi#1@7F6Pc6R(Ylu7rt5%tp@-9Jxi=+JIm{-a^!( zsI!0%^G@j|%vd5j{8WL0I^nj>x&LHRw#Cjvy=u$8etF}{dbeLI+r`1{B*=h^;_382 zXWNSa8~#<59E&r375FY6q95v-su$;oeW%K#fGW!YsvPMrc*?haOou-A(`9tI;O8)t zE4J0>sb!V6VEQ$;IIr+XsDg|P<Qw_P?vS?ijLY6Zt&Tg~O0)HCjeD}n!BV=W{%izY zHZr`u7WKxcMs{Zs*9(TkC-=@hDjFV=ifI)>3e+_j&tU^r7xJ*FlVpUh!I)`;u*mTD z$XC~K90dhE>b6=xluv6&&p(})kc&H>bn+%IHrR<Z-1TLCJp#}Y*(CY=`DlC#zY%fv zQul2@Shl2FTxc$2EI5I6z@zysY^r*fvt=)c9jr9epH8y2mfNh_AFpsdphG!bo!FtI zGQc|<8|(OKb81dqKF;Qj3B&w=CVT|U;E`Ow6;KBgFu1mbNruQaN2R`3cv|f;>Yhb7 zn3_6O;S8Jl^5qN8)rxKHZ+}j&B*)X<lu5SPN@yew<U-YTvM{f3ZG-;4O9GoNeBuMt z`7T>l0Q_H)Q~<`ZxpDrGurR_GSAW>;i*)n*v~m1tGSsErhi`sgIvn$_k7SJDTq88f zu%4tkHk?p)rC*tO5+@^44foPrQk$EsG{x+I62u3N(qzZoF{DB`1BN32bXk_EfSqyX zPCP#zO@Uop3V4<AXLlhNZ;gWx8-IR(wLWSpJ~s=+t~vOANGn$N(*WjD#qgndt?zVh zGPQPoC(lf2b4gw-==%D?CoL>z?kiG<xk5lV(@y{$Ze(!shF!Mq`PeoUxH!Z(691%u z%;L{^a#F%>KaB*1yXsBW=|Z;X-3!l8`d>F@jWO5kz5Wb^xpQ+<elUu-eKs44-0u(C zJuU-Vz~1-1j{`Z+HK(teUr@tynW^yC)3&A;=gIL(+h&7tsKmDhLE$xUP)L-UVk7ZS zC@sze6|{GQ&($M7>rM?s9RQ^A0MwvdM0slK+cDa#Q@7w4ktW{<H%Sb!I*#My0c?>N z>$d0|W=O?8ajG6$m#K0~wn|%@8Ba0Q!W9zjGFX7TB@J-A(>MjY4!5pLm`&<Kj0s1Q zU;TyH4^FLQ<%Hlffg&~!<Gv)_`ngvK<|}B=)Wj(EHaB8xHGfY^dO7m{`%nY>Ab@?$ zQ-Mn@72|vjOOkFt-%C$zk^CqxpK`8M4|GgsoXP69zPUwZR$4P4wfclVCtEum$&_lK zX!{xsutS9yF46PkX7=)rStnjEqHVAP4e~CZK))T@jp?zn&N=&I#cj91HO$!9W}N%e zp0a&1QeO4BdO}FJCh>t;;O<I52l3Zepy*{Dm3`{EoJo1qtfTGckls^P&Z*RrO~|I7 zGS`x8m3)5nS?|YSjc4``*{6m^=Qz8bJMABXC-abodkSdkBfxM)#h;f9p>EoqmT4<B zoPK36ztIhuhC~FOSv;R}rgcs`dGTJkX8*Z~tl|}7AXlR-CEDBr2_`__l505w0F!a0 z*%)5aVqB|v)rQlkxXxqd6f|o4r8GLD#_pOGZ3bE=@K)Ydq_y|_hu8j*@ijS?V|~O* z(wsO*T+Sy!A^EHW+=+=n*Bd5c<{;XPL6g$-QP@d>(>(omS@zmN*doNJUeL{7UXSbq z?525}BHI%idvfi^9(Z0F^A9a704>`{T4Wlo$c{RWGA6C*w8%)Z!+9~s*(KBD85ws4 zCL5iOeFW!l7Cw(R8q&F*MSo@FHd$IOWw;fj`bWY*IUn>4*hW>d;c9+23*DdJsUq2p z$vQV_ryxlzGOrpFpOZvA5|9kr(hrEvM15U%RqA*9FEZEx)z(5<K#5c*(;Aroiu&Il z1|jPt85pAzTWN%wL_5<P72k92_5nFO*R2=KgUcQ5O<c!FqR%<Unz6Y)B_xb!5jdIh z)uz+B)0go85YF-gfHfg8FGQ!TM>jSKu(Llri}dYX%(=hR;Cd+qU!SoYPjdkLU|&^& zpnOlyl8Ytyw3qQ839A7vq#Niqcq>0I9AX={7&S*&q=ptf4i$gI)K><Sxk!IF@c1c4 ze=Ng_E3DOQQ8|FIw&_*kE2|K57m$#XQ~$#ipbre<18gudQQ|JLN`+kabg-pA4(;>1 z!8&E{>`*9Dd6U+B<zA5~8p?CDM@K4dwbx0ZKnItQ0x$*i4)q?Fw;-Uc-Qo>d>0K1f z8+&Upr|+X#_r4LZLC61Gb0!PZ4(Vl`>pSE0vkmE0vbT3AlrdYk-|1-wfP3p<7&xLB z1nArEuLm2^pF|s)<YOPac=6o+07hr)68AA{m5#}IhJIpB6^n!ZxxClU@BetwN<a+# zK<AW-Jb33gk~`47P>{K!w@}>kC!5w4{IOqSA5m!{pI$%-2J8I3Mppbo(wWZHrRG>S z7qi-8rj8d8cfQyM*ySZ4Oe%h0mkc_$n?i^&{P<@qC<@b)lT-Rk_Z5A+QkjZQ;Y2!@ z{Q-LIJu!d@d9ebuX0@gCr>sEK0m`_4j|*f8@E}M!1Vnx`Q<<S|Evc{G_@Z13L1b=l z5?k(8rL7wEF{{)1T1W(H`HUx8zjr~PN?Qh@Z*@n+4M0S(gf9ja7URw_sV{vT8P}V< zDKmsB>Zu|I34+!9i(?=`g;C!T?saeZ$nq0RAi`m2dwUcr;3`<HMEHyckl<a<9A(<? zA<i!%<?*fP9Hv_RL*Gz+;Sr5N*+f>Rr<s&V%F4Q^s7|Fe>TEQd66Lr&kQed=j3PL@ z&PNw0?72N{(?;hfFc^o`b6&A8UoL*Fj-(n-hamCISlk=qE}P$##?O$VY)aGcn6&WZ zrl$+A)uXI8ONdNbRYN3l#0YyiCh%J2Oc47wWCsKeSm<d6gn_-9UgRraKI2jLrjmfo zH9NYyJgd2jj$MH`AP5+w_(cN{yrO*z6uV_lOr9_RiG{OG*mOM$3&-k+Z`>xE9rK3| z**NW*7(pUV_F@MZ99CRkI);SR91!EeVUX;D65W=oWcH)IyIBKe0Q~TPF)Q!H?EE1Q z$R%s}dzF!dpV1%Sa(D!6y7#pqOo((spPrL#z&M6tlW%?w&S+*^dpCW*j3)Ba?>vK_ z4y%6(w6i}t$K9#Dt{$9F+X;L8QC3D6e%j^Mf*WUjr}>eOP)NKlx`#nW&%uCSMa;h; zZ+Ja374_Js^|S57Xm@j?JY!FU2u!WU9f~U)wU%Njn0Y8*v0)q(mN=48{gBk(y|P-g zsD{HT`@V|uYVlPLP5>7lFC9{nz*qx{G>|q>F=S3{ahRlALtx&i<UwTDQ<N_8FQsuB z6E^H_HFk*hMO0x|@}B;51gt5kOC{kCNyDZQ5pGe2lY%PPfNyzoa{{J&Jd=c+Zjg`w zM99Ly<^3}IwY$-{Qi3k(JwQvf_|Dk?9|RAx%sM+@@9Z+Uj8=-X$XUGlHPfXrI|E`C zL`3r>{^lvsJ>9rpaPD3T_I=B#u`48)y(rCiXok=nZfqG<F{Y9M$SwlRSvnj8XW1?j zbDO&8k*h`_6D$c!w#DJPvwA4TR?q87PH%%L_VN#>8Jq1)pwF}F&r&5RXjyg^B~;IN zur946znYeqOux_|K%7ZY^3|EEVnQctNn`+L@*U{biP8h+rbsZpg>vk&UUV5!XUX&y z?{}>=)>7pnkmsHdH#4g-@*R;+-LrozWErVXuFF&A-Oik_=@gaqK787<SEw%NJ9FXA zX$pe3E&h~Qn6mJMj3`3^73AFoklr2g5a%_=$gaZzy{?6a@RYFdmtj0Y^0S5Bd{j3e zu!oj4TwumtUof99XCC2V_~#dTYsr1aTB@<@SQk@1AXLpTFC+KbO-aoGSo20}_NH@# zkgzJPC+k5wfD%9~odKj0%ikq%hm9iU#~K?$8*^!tVGGTCTgmNmTUGGF#kQ(`%$R0o z^>|k%$r(w{f!`Ef3a%VE&y|X3yGR+}xz-Q!=R^a-Vxz&gi^Y>kf|fxbK(YXM#ESW1 zkcDlZx?XHT1Z86hTolmnzv(}avh^PXUmas`LUk5GaL6b=ltuMB1ou<VF>~Yy-%p>& zjDARp^SzN)e_)owDpo8w!Es*1EAY&=_*W-S77$1*FC+H`+L>g2+o$!nDJ6)XJFlUR zrXu2D=&G-tW}Y3?H(CTU@-gOFV%6;GT!h*1YfSB>v8`Bn2mR(L);pb$e#WyiyyffT zMB0Gy#L@YDyuM*OMfU_o5q~53!@{TM>DfZa1m~hM7|#-kgrAK8f7A=~`J9#JL2yV` z`c`mV1_#>8VC8Ukvxu?Vwgdf;vPrKOkM7ng&6du)09{BbRd!CPxi;-;EBR&Z8DO#% z#t~8#1V^?k{wEOD+)38_lgH{vxIxy%K;u9oZ<^U|w<<Zg>knMxr{_r(yD&_ByZ`%A z#D;yC+sblfOp86v{a9-zx)~==$%b_S-mPjcv4VxAr>_bm4#fC*c`s{X_vljHcdt?u zCvvoOWdV{wWJCZOgaB8#roCDr6;~&pyy`m45&G89_>$|YmR9jic$n<Lrn=Q|zBm7= z^=xn<_9>oUkS<U_*eM!E45=1O7!G0pMv;So_kLZeF}`I<9LiV>NEP&p9Z=2uN6$#g z8CEg2TlCAYi>Ic0`cZY&67>~&WD-Ou(jE6@VF@N=@cH^Y#j*r5CUb8l{h@gF#Fhf` z>}7;xX#9TDDuWrNKDrs6e@|phrL~h}I9CPGCj$l|0943OHa7qDC`da!-J`{($`<0n z!7Ebqeg|G|slvdp)RcB@l4jBycYBe%C-P<Z<BXW6e~ilLDYHZ4xawyRCFL!OfFe|= z`EkmMeob>WjvYH^axDI*$Cm(xfj-Lv%qav$`nVM4K|(uSa!JB?d~|!TegCSpp_`a% z6V?__lg0Es&4!e9RK72xPyD7Ko-T*Df0(qzpzo{O&omU;&%&66$)EpzF$sosV1${L zOw>;!Fd}|?`BChtA4wAeJ+Xhzu>e|dT(a|5f(Ec~ceV*4JF&#MC`P`^(LkdZ+okT~ z-ru_5Cl*tZ0a0W+zjKa{-I}!W-vz@+y~dK^GmeGfXZE;e8=kqb>(JAXV1bYvaEn0M z@@{GhmuGvJ0T+yX7CPS3CW~u`0=RonI1-OYyNA7N0LHns2-Lot8=NhX{Deze=Ecc( zxRWDTc2cI6cM-`BcENp?Tj>KnF7l6gKtC41>SVLf5Q$h@(N?S{W~I4|{~~X30iP+; zc<&q#sid)PJFh(R%-(2mmzi78%6YDkAc6*{rT=pxkl}l)2a#rUk<~%>Hdt1D>^a&y z=k>S*HY1E&^$O1ayu6)xcus@0M0Ul7epTqM-I*@*WVTjpGXj%MU>pAJH~Tn>zelb6 z={LLH!B_5UOEAe$W&z)QnseBPv-d%xD&Io^NbxoTYU{%k99$XptCL&QMG@e*q8F<^ zn*?-$Sieu9>79sAoxBox*sw*md=mk7XGsb~nxM_?;6|bdOvF;VC8_L%Km?hpb5{)` zovY_DR!>iGZ#er|!Zk4RU}9Dt*TZ!>;UkOtk@fGrV$6rVlY*aSG=^Q#*I5RADC&`y zmTRnX7aK=V0`<4^`W?U}NguPw|D@<E^P#rZT3D76+2=H|HfW~D`nLWjx>P@L>Suf< z=gAi{=gz2IejJK}<7qkmmC<_i=3OS<5Ol!!?}qI_H~_pbJ%{ZN(YmC$4Csrtqi@jO z$91-DtTnAL;Mj022`$qvu%iU4<v(xJ$$URU{&O=_<b3Ip6#7P3GYulcjJ{>3L%)KE zKT!7&dNad_!3MwO{J?b`t#~`Px!AB_Y1*7~Lgtzw{14=C0iB%uz&aq8``z3!)IzoD zW7u1yCP^va=(h4wMYfVJ6y0T)biB~xO5}U+tEWfy>#3d)ip8t#yk-Vg*5RfSpW}Rg zf=jhmBzY4o*}pm?4grvBT>GY2m2Q{Rg9ra?+IJ+Y6h*qj3Fyc-5Y8_}AQa`sV;6<I z%aUoEvFy-NFqPpg)w?uqV&v;d2^Er!WfE}>0OsR%3L*#b2!*O#>$@nSrwE@6o>VUP z^P)~V9l=H_i4`wO=yk^|XrHo;!0o2ipH>yjCUW`=7_^u2#xM{81XkJJ40mm>;j|IM zS|a(BTHPIFsjn#C;xLvHdP<R&vy`?CmscyuQ}$+xv6=5#TYB{A?1ENkDN&_-Az5|+ zl|ify;yO@RkQJM1*#A`k=*kJQDsRYL;kRbr?61#c0t^HJ3gQMt=dm<~-Y`B-n^tGt zb}a=(AjGn4t=={Y>~9h-7ppRaKNd!v`Z2G=Vb+!@6CpBcG~fS*ypfqJJXbniKP>nG zXHsmeh12oLrMrSbb8hdTL1W#RmzRLFZmqpaI#7QB1~0suff$;DutM0)_3F{Q=j=?M zb}{;fqsvAu?wOSfLBE9)P)LfS5Sd}|iX2y=j$vf917{IvQta^@B`@=eA9+?b9C%J3 z-NmMJY3bg>EYG_2=pp@?XI3t@1FPH)OKRYdhuLO8{p3Dy7;=v02=B5AlX&m2<Sa7? zyO(}tzMx3o<2f(@>FROVYJ|GO73=t1Ek+31nR;_P_hX<+aN2r0cG(8LK_OZ6{6<JX zz<RV)!68RmMCrEi!EC@>;KU)3r0jGmUuv3$GsY;O4c$Oh;@99v2FkObrx`+mO#%Cm zC`7~yLzWlG)%lnS4B>KaPd{y*Qqak2d>0&(DNAr<5`>-gqVE#PcOMxvrmgio@|<fz zACG#f)>mU2K`m@!ddxu+W{AV4v_$Pz`Wjk&o}fguGB;UvWAXh%(fb&HmHU95k==Vn z5*c1sD}uY^INJRAJw9=4AS3t2WRtC7I3?#XxVcek1~>OhJSziKm0;NUYwDE+PhKjQ z&DcS`OWt%tbq$2w0^e)XcsrvL>(MKBxIVyo=GWdYfVRsgoRFY8s=KN$5c}NKNf?34 zX*-gsyY@e4S^WC6!pLYkSIwu@VDl95b2siOhhiv1Su<G6X)KeXAKW}T7BOW#p0nBI z&_3VuCO5l%n$QcZk-xt4C+{ov+qJ`?PV)IGtpb#Wh(x_QBisV@ghSnspapGpcOKE= z2_%U8dJFh8WOXG-dVMaP7qL&FBM)Fm6w&GO?>S$E0!CB4gS`#iP56+I+$MMy<T4(@ zva`IHL+R*tb*Qo9QVfUQyWHwAmX*_;g<Woc4|;<xcQSpFCj?>mxG2ZJ=1Y?tANwVC z{+j^Vk7bx4P?R3DQvPks5mE`%Q-EqW^4|D>P~RgRk5_e_Xw}p$JlOfDh@J6pHi3|! zOpoK1u~qktkCs!c&2!^6<0L(Zbd3;fqrqAqZWy(W3ZP{;|A{^kWy@i<8#TL<;&6U9 zyfI#3FJ~N<<*a9ac&uUJg5QyLVYCtyZ&>mcIaN~w^mab$0CB=wAP5-|(Q`OE!?zQ2 zmDpRfx7{aTXq`7sLs%AHOxz=0a*tNrz<cSMK(lreRx^_Vi7MS>F4^_0S51j}35+E4 z&595|c@lp))}mjGoj>+2N=CIKHo7TyuIRkYmZ|bOV>sQNNkhfh(r)RC+%1oyEzPX! zBr?P@zy`oT3aGyp(;n>)5hf6sk7^<L?zq<bvy9=HVb*|Gh<UAu<rcjicMyEH)<3J# z>#JJXL=%FS*(8lY<!GPOp>gud$r#T<r`pq_Qkt0j-$rcEcIK_E^Ma!8$IT2c8HcOG zXtYCFkzNBXaqS{>S6N50oddvBxnRKW?m@C#9IBQdSag^9K#%g)2-(tkZ=a=g$i>08 zNt=@!9mT1&x6Hg69N_&{knCmh;V4XJmg{Xu6@3VC*|h5^TZnw7@SEw*xFWN#7_T*T z+izggcC^d$xb9V+xGW<Hfr%t!!VP?v1H;=3kn(Y`cDgt{Yu{2`9DovQD;4Rs{)(Kp ze{dL5OU;MGE=fNfXcVg5-U=AWDkLtx?!v9@e~o&@2_5nt9PBAuT&OiV%t{B>x#7&Y zi36<e{*EV-CHd*{oJF-<;v7J%`Z}OG&TX5Sc$&h%8X|Dn^*bsPo(h}}0!;!nreB2J z!=h>JB}34+2Q52$`^>N;WdJo?p;;qD<Si>|u*6X#hF+J!pCmQ}8i5xMw|<W-V#hRg zx%QMTN6GlMT#a`u#WX3Nxky(htEmm9B$%}ihA~6PXf9tUJ*(f(loNKlAp8B$r~Ic^ z6?1}8P~}skj(-RQf{+<t#-bjNR}}YqecM5Y77gnKTk!F2Y8*C=*X*Ye71@Z?b?uS{ ziDN3_kEHQsX>fH#{gSC_x3CAC>nUAOFE%9=`_XT?bd`d_JP9D{%Y`y6Gg$@AaoEfS z8usEQe#bDk*O$jEjK{g7GId{g97R1U)R8$4h#3edsicg}mp^4LAq1Uob)O+DUF<s2 zueUl=7(4x5AE&RrFE+l6Far{q?Bw3o4Q0JNA*ZavwWJzyRj0i?br!1_KkPhlOrHLu z<!Rp)0_(i@6%2?ET(U3~ON{=7eeF}pMBXQ2kJ!{&BWvp5RTXycGp&G;3=l{iurg5_ z^I=h<yHMZeVZN>spyGB;z1N3ZN{L?0gERHx-y@zYXdR_66iGhgCE@Mcx0$RF>P#Td z_!YR|U!q@Q3KP(zcrI#2{WPpkpc(JU6Z)-O>KJvjNn6&faAmm$Pgl=w=6|d@9tiV9 z7Xb;=kJx%qRNa>9eVa?)=r5C*D$_xH`6j7-sa~XKF&?N0Zk8PKd0`kqsm)bm%}VS@ zdS<dR=wY195EB{C(`+}AM@e~o?7@&aI4fyQBJ)$3az1?ws>$cF$Fo=T+t#nUKI>t8 zbqN7E?d*d!WRcqdAF$=`p$~B|8NeQo5~~Hv#jx$BVp~&ls<IyKbKinfLoItg9$X3E zMmy3{OZkkou<If&uSHoC94Yxq-gQy%3F*=ltbBCf*8o73-OYAaXCZr5t;&;RY{R$| z61)oj@iqoysW{$Rz_tAb(YNZtAN2DMK0o*Yss&65wS5ex$dCdOqrEkxPIJ=J>m<Dr z-2a$;snnxUWOIlxa%3eDglk^AjFS+qn$|ESUvmp}4zNz=h6)}FMyufTUx=@iRERw0 zkZ$oX(!;%6{C#+ILVnZs1n4gW8gQ{%Y0I=LIo|crj)4sBmm!z=Ybv0)u*A3vx11t& zQHU~0(2udzZRGk?9b@2}Gy(1N;^@mVe}KQ`bGbT{e&zf*YrYH!%YNTea1h86$GUZg zFSewHrhB@tPhzuP-YeGTgtZ@pcGZx#To#9VIWwEz?EC$(UMfiv28#NfFkHFYHlPi9 z6a>Gnl{A|B=l<U#fcvY{h`0R+0`7nNMf?E92utDs9-1Pp65+iCL$)mA$@s(?Tq7I5 zS@5w$SUfvA?EM8EDArt<&9;<PNF6EuP@_*$0sUra5b4whdpf?l+OY<2nN2y!;a6A8 zH7vkC-*SvUc)mT-XuGo@WIU%x(V5+z$RZc~|Bf43$pP^I62K4A12QL0k$e*=K=xAO zt&|5Z(=9hUz_>&9lnM@PDFbE$GpMIa@0ssoYsJaQhxEY<TU2;1rV1H`BoYY*)3L~- z;T|(>Q>l*3PpuXz$4DN_-!AdkhBJ$pH=VW>d{5yOi<ozw>F82QYhHg0pZh^~;6z}_ zjZ8XT0U;kqLNp+NoUmCqkM!qm1Mr2J?Y`b$nIQ5HWtt;;ZO>CN>!ll9ojebkP&IF# z3EZR=F}4Xt_*rXJm=0>scz(KQAhbab{ZMOV>aECYx+IiZRn;-10T;l^jg5WA64F;S z6{KLMi;5*(RTI5r-fEt**xpP0p~f5;LXZIt4G$BC<V^Miv|zoR;J>uJmhHBKU3j1m zHAD!49T1NlN*xc6*@R4^rGhz>Bm7b%U<)-j#|T$Fo);(HF8)l>@{w(F;J0fAF+ITi zd_dWC7fXJ*Q=@|Ag{?P5#mJR|pehV;&a5#+0aS`_uftwE2MWg6b4?t1=OV>--;YAh z6?%W~9xmrpo<|5;ou<PitFL~z%kY-zA&iS-40?}y-|ROKTP@7UoI9GkJe7rHmH%*A z(5_`&cbyr20-z@|z>!3GnP5(>xL#-xEl;|`>5%PZ;j^XPPdK+YxgH|=TetQeMY$>h z;#Mcc&QDCH&8K(PV694NV<jiMnlB6RKRPVBE4HIQ0GL9hgk%ckGZJU<o4NEr58XN= zwtZ7g0L62HX;moahu|DQaPdSKcVe5JRx8GX`0?my?$#eMFU@ZEGRu>S^aS@|y1cUR zS=WKygs>j2l0!(K6fx6>%xf2~54?KesxkVMdvh(Tp-r|{wIjnO5MTy@<&;(S!CbZb zK$DAvP9)Ot+5^cAs37vk52w+2g!_DR(9gWOLfWtXwz>{p*Dm$CKc<LXtGo~aB^yc7 zjyo4`=jc~f6Ng)I2I`Nzc*!wkg@z#Vxpd@3l3fz(C#MzHFh1ys`pcK!<;Rk5&Y}KB zXNzOd5=|-iD24F%1(_sT6$;LaIbS`K^e8ZKjxkxpVqQ(1<tR|n5M>2|O)6><u4=iE ztKunvh(#Br6O)d3DNpY9PMt4ak;t+ewxC=*3nu~_2t1~JXNZHD&vX3st#54#`82|3 zJtu%9X7&?8U(0cQFFekn-e6D3bzUbu<|PVvx=>`n*W{{6_;O*XQg$aM19Na@SEV4S zLustrjTac}o-H*rR89%9B=}1T9|u=8(BKfdm`iW&RU`1wycC*l<hE+Fr*0gI8slq> zB|A&A{Ym0lmKmj<BZ)Nmk^z2^ALdb&sR`thd;)Sp%DLZ{aM?V|R9K0p@iM~3k{`RC zj4cwzZ`uxzHslmPU)!CDjTX#`tL}0>Pj6^Ds84vl@J91N!jS)?QZ`r;(s4u*0@m9N zG&yjhw$HZS0|=v|BcXpOVo&#~HPYE@Hwn|N=6)`NNZ7kP=J6WscgNwC=nBTnE2A@j zjQZ;<_10h$OUd-7f)QkJ^W`$BnoavwLX_F)DTlY-Hdp%IrSE=#i)Q8=2bB?aD&wac zRXk-k@(rig3Nx`~Z^@yP8fk!HefqqZpb6wwcV7j>00DzvKOQKfnWNkE#70NPlT?_= zwU;UYb)<f&6C2pq(_~`Ua9?;)`h2m66t#PoOOfd}j;&sswid?umb|#R58Zf_0!6x) zI;?*_pOZ8kCpm=ziO7kmB!!ppMT3gXCAL;=(iPoOy>OWe3v}{;)74tkx@r)w*1H#x z75FE0SAF0q0O5Ds0eYpNZlIwS7n2E~+9e#FSj3A_T|JVWT6kqNG4|SJ;ht|3v#MPW zG1crSlcgHTkz>e!<N<E{y6v#+s4Io*Mc-mh40zpM3_3bgqhB}F(7`$A?RgY1*^+Mi zr>r2?YmoSw&cnmsk5}wGt^B1|GZ6txDM7&Ze9hE$5yHD~)1?73PaqS(f)ojW_UOUO z`xcXWA#p6CqfQSW5Gw{gvNYc8fJ0_B&zt;sM}=*4%QRr~&Cz}6VcKU!JZBEr>h3pm zEceHfh&*pX%eZ&+)^slwiKFz7_IHo3q)*+Z9Oj!cHp%I<bcA5mIt@H0x^`2~MS&82 z9^hp~WsJ@9!z56^?N?)^U&WM*A9lsH31FSqmTC!^r6gKD)O2jGGUgk3+nWe9h~E|w z)a#FVw4b~wbEyJ{ep0|PaW(EQo#Q9gC?ZznYc-yoh9F9`iL*a6%?+1f*B;RAM4L9D znqDRw^R$%2e>r`#y$r4?iNZmq3CLs{S-KXd13I!bWG}tR?8IFjrNPSUpVnVyhS~~4 z16-ePUPN^qICsO7A|Ldj?sC_HL-V{=#1n3I-ZxcGV28*?-z2#oOg1gDX|gM%CDlAr zHEHisE-q&B8F`&0`JPcd7YI=AdKIEi%!l8h2H!hb_77~9)rd4SiHdUk$#&Wb=hH1p z^ny<deN^O6bv!mE_|`dm_JlBI)gDrOJHGEZVUbk7K8CAcsr<T+Ru@y@0qm*aq7UE8 zLa>8&f~<l9k;jY~(}V@PL(zni(u2lS0_UzGN7n@RuQZBu{&il64=3uR|Ii@?08|Ez zvH1(>%&Qceepn+=-3ulP%<2`67ryw^6~YJ~(5Isn-Kx(YP^SI8>Fgyu)Q7>wzoVJ? zZK6@$Hi=s_kMww*Z5Saacr{ijvmmKIDWd-_9Vf5Yt(1}P)8_8&;uqo1FwcXdI7w1D zAL4ui>Ew~Zx{F;TCjBx(p3ZM<-mL_T1#sAz7^LPBu)uzee@QD|<xtj=!&cTZ5M5c^ zRg~r(qK =v{4_OmSp3XuYJ??6=YzH|8*P{k$C_vVcuo1y*>{vogV`D?)yp6}K<U z!r`u1du!8na0~_Ck~pO}f}=JGmL^QbYD?kDA#und04ikspb9jM_+*GPq-+H95V!Z? zUclI4g8TLCP0P;#2qH{4Cd7&S<$_=$n(s%kYpfo|U{^gp!!=jHCX0A`&t8S39k^>} z9PaYTo1Uug5I9|QZKR(Od1w})R<jE`t9LOxBH!V&@%-9poqED#Jsdp68t^E7UH`$I zvqYXh4h-bDN^7JyMkHyLs}TniVO9p+e7X){!~E{$ksdL11(_FZaOI%W6DH*^{(*D} zV1<K~Pp5CO1q2Dx@lrQhGj1ds*+g<3oT|W@r*fg;DPuVy+kIN(8L4PvH#HlTO&fR| zc51(v!z7<F*LswAW_~h>NT85d#CQ09i~Pb12su^pPK1Lin_kzOq6JZ01fjR_a+aRi zU}`nz%Apr<1NRAs4WLn1d-w~AY#-Q($KPzeug-QPG~wqoTTR>lv8lsnI;E>^6RkT@ z0+w*bvHMdSC?pqO3)WP)f9-iWi@w&O>u@qCpiZer>2h_ES*vB@TE0Y_3{NX#X$#cT zqTU>8lcR5<9$7^KAkbt3SyPFN(*Tnh_bx-vblh|@J0_&BF-e~5@zuhb2XS_tSo!wE zbId9}jE41LV@y_TGt%;;I2>X}ny1Ml&CKLICtD37e(rWgFk>Yw*(}|&={o2cx;Es~ zK*+4CjM+RIJhz)y3|HS}a$<JP7cJ}K+ajy*MHr`R^R1l4O#+*^PX8V+lWeUpvPy-l zm4aBP#l>|1iX2gMZ<kdrC83jnbp)(8$NSyiB0`JxsxB>K5*UTTJ7}D(KX4oQNh&>S zK8_#k;O=4zjnXG9OT#9&m}I1s42|7yS`T7zgTF3qo8gqIf{%Wuv}&l;B8+$O5Z?Kr zfG!f+iX8zaVlz4&qxGDxwzw(>^0LYcjfl*gV#+?E`j%`Zc}g#XA^TuwdH^x+x_cSy zT;@U2qaY#hmUU`qsN=3UmygQH0cL{d$-$=uRI`KQ(syd&E)dhC#inw}DNKyVu;w#A ziMAU#yvg$-hb`C>htiU<j6S(Fv)RfywwSBi!`*Nz2$$nbI?TVSo_Z>+J?+N&@S=u> zb^5l*x@Bmx`U(mJ2b$!S2UBg3D#W5i&wa*^wOgmIG7UJ$;P^EM;Ka-co{`kVR12R! z6=KMjWB|Ir?=3DCreh$kai!a8rIqfDg`7~VH|>i9MY(X7qRsq$-czmXetz-(wi#U} z&6C&~-L3?v(A!L)efoAjdF~jUlMTjyQS^HsJX5*Q16@16{SGHDP6aD5EjpF6gqkTS zuzhNcJhR^FB_h9<*0^}UhEqKEJ3ct`(Y_W!aw!k8yaJo>f*zEjTv~t7x##n$*#uw4 z1vwXZ*H_H=Xly?HW)ze<(A8Lwt462858n^!go@<rY{Z$W@B&|&&P1cWI!{kf+`HQT zY9rplI$o3KL#Vm6GLjzeF}jeMcLH{?LtJT!&<}FBHfLu&I?zM71|^1`x?j)T!_lub zNeK;YnXYH-a?9sbTp8|h#|oVH+RXBtsk*6JG%SffG+Q~Ps3|rG3LAuaMD1`VPo>s< z$SZVskCt~WFj<_)t2nA~$6aJE$sG9aKGY+tg?X)o@_Wl=ke)*OgA5*pk}8Wpc;V}1 zArl5=<trE3tjIIhn~EQO5skzg2ja)7$Kh`}G2uUX&V3OS&t5x}x%ivqeBbgyYc)<9 ztBGxmG~Fb;@@$~icj^<CU=J-H=$m47=gliKO(|AZT#taNB!5#H-M#ttp-AXhzJy2x zQhRt%kuNUtGJTY7j?Iabr{rBx>5Qig<FmZaCvkrsKVkrA1%(3xjG!}b44hDDt@JUR zB)4Bh#U@+S-C}qg!bDb`f243vZo!QQRil$cecefp{+QP_#VP#jhk=XRLIocZzE&<V zSmu&J1SV5;1>7fOrf$%!Nf~($w-jHJS+=4)LN&Wxx1Rg@>b$m0kMp1;rDe8oEguWM z7<J045nTI%qvgS|!wvlhi}l>T!L&ty!?H4IGcAsa`U9^`8)R{wG;1*27ik$|%!{ae zd^e7a3A9u}*J1$yeA%Ty+3fc7RMKs=&a4id;LZM)NlaSZ(&zSuV^&W@G>nNN3L?PX z74b4L@-z}g4H)m^KKA&6`2S<-E2E<RzOOAnq@`OxKtMpcTe`bJy1R1}q)Q}5Y5?gR zx<jOM=x!L0hM{BNKk)rMYdx<%Yt5@0=j^@DzUSV-M`3Mf^g-7JC><SAjmhjN4X0K( z&6u8oDVsL2NJZ#B`On-;RZBx%JC3Ic3|_vOtqQrxlaw6VQB^FR3M~^{OK$%W6ZJhH zb0K3#f`H=mgWf<7^JhG_J0LA(8`Xb};$#>I2H8ivCDWL|FM8=~rFxHC)@?L3eMbUc zfXHW-LOMd^+Z#3JN#PoMYhwX}bXG$D=DNE*x-QawA5g6<f&Zp1v#!Kd*u%IpdXvy_ z4ut0lP^CJfxx1Nt2adT44B-wp8cis-ZN_{K)+~f)flfAGmbU8bvr$WYr^CZrXa>}X zw`&r?H%H2)0kQ?%pNDsn7uM)=+S>EI5{_H3qlj=#tvt#<bV@w}I~n2<`Llh*lSgwu z*Dkhk_8`qyTM52|QRdg_tqN3>Wp4_XWa52F->Z$s@c)Fv|Gq4E_-CkEqc3fd;D6b3 zcTWH;{ehK?a!2WRNsirJTld`GYe(_9Uo^9=>CESt2<B-#GtTZ=(I3kl<&~dfNc^(B zu%G)m&3wl7J8Jcc385Yzta8(p#b#*c*iP3a2nYoDuV4)MY{z^|+nnP#+JWT}!SUo; zovB^NMaAU(xtEiPNhTIKZH_MR(K0j)zSx^@wH&4rko(h*6yUK!J}q?G3Gns&?B097 z7U$s-xC{-b59kFq!;2+R-Y)C4SuZqolQiJ4BPRaU3*`mk!e>1JyY&aLmpi9niVJ|f zdc)G)&n}wNIRaE8hH`)<j{6{xB*uI)W7-hnLSCzH(sh}tKBbJuIp-o)Ygc<+7mFu; z(*Uh=rVGtjoev}4nAU-s5#6!zSXN*ZWw56@rnMFY10O``oa=C`0K>LT+Kup_fUNnB zCQX(-*xquIbY0w-o@YllOHlxykauWKCvR!{jK`FEZ!H6RhJBeMMdGm-IzALde2$YP z&@m)Au|)+&R*E%yZ!8{8-_2I4?|}7vSjy&{3b$^HbcpL&!h9|nPWACWrGFbc)LuDS z*8TtsDJQ9w?5Yvk?(clptBk~aZJ8y_v0apS8Q|g$fz8p<mn4qX_Wp?70IDv_+|nMb zRGNOH2_QG4;Q*fVTp5+YXyJE&l8u+rB@KVNkDNBJYM}emdGI~!EvY_)VtlrY^BVD4 zMh!<?g4=O>ess>`5v|DOOe5@y3wkvlsJ2!AE1fytLu1PrF}{%NGv=daBPP~8ty=Sy zpM9G<tne^S8XaEWUfiu=JlQKZiF9y57VY%%Jy#o}4_&Sb1HC*ZKWhR6d0v+?RYK5{ zkB{n4=&&GhCWcoEnN~_>${|lf(XL&~jP2m<_Cy(zR*2%{Q0tFJ%ee&&8^Omd64(;$ zgNT)jBO=#mJ;E|w$un@W)HG-lw3mq*Dy<nOoGzR2cJ89W@W>;Fsr`IWs+V+WXh-=# zVmzLoiBApPy#GfW<W&WjwpiUxkm&kX@C*p7-@m;P*N#aiu{-f88s2%aF$1*n85XX~ ztj*r-%?L><cjt9nabKvm@|@6y=>#gZ^QU2zuplceRWqq|BN<<K+3fHK_qTd8-Ad-C zPi^Eh;#~i?n7&Vo$=sK<qL?~&zuyR}0(tKLhQgqJfLcC)qf^R?N1U*QN_Batez9or z#P*A2yYjtXT4OpQ{zXy7d?BWD&wgg+bRMOZ)G(NRLc=X0jFncpH~Pm|&<*&dmia8j z_g-fc?>zIFW_6(hxraf1fwGSPGw}4z<wpR@4L?mmx2`**pM8@`BdYStgMmQ0oN(C2 z^BAmPmJRKgjo+nu1^Kjgr6}eIMe)pu=M~~}MBO!dgg2ZE$*0>=vIE<$O6|HBtQ)79 z0h@r=!*L=z3mf%B5gNs7`HYc*T(rxrR<Ni7sNra(`a}7|U#_{gOkJosR*RBCr@KS9 z3}=^<YtR5#c90)lFqY&v3n==^;#nqB2Eiex!#x}HVBoKH1y2qs3p}>NC2Am86KIdy zRNT@Aa*8u1<~KUm1O9m_b)NRRmBy_<%(Lv56S!Z1`cYAPM^Pd8!`q80G?37mE+F}X zSCV7#xE9m;OAB<KqD7(+pE-&II%cjpWy$ph5jxB!S7>;o{Gxa)QQL})89Ce8dfy?m zeF6I;SuHePLpsby1c{fg{|+O<b@Z70r&y;`Pr8k^lC;8IEQBB0`-#Ks)k-W+%=V~- zyl{nEw>YviPVD>zRzSQsAA|y_L?COt#TH`$T9DV&RO%>4w|%|w1tkWAc!UZeZdaK@ z5ua7VFrpjg{K~V&uaNxP`C}D;BkWW(tqvNX&1X6V)sE1}?@wlYQ=lR^N7hPbTO4Uu z{7aX=w57{hq`^{MIyS1(25f5O<s-Z&czkLc)csFJjTaEyiyb}w>vQ_f%!qXKL+Exl z1=Gl$?qpVb5x2Uz#SMmKMba$IRyxQ+@rFO-5RAEqCn9~$)tOE>z3bGy8~dG?#ngN7 zEE*4Eg@6sAA^zy2K1Y1s1qTY)um@0>irltRl5=`C!}`>Xudtl5*I!)rsUz1ij-6RW z%4m1clFwF}##ir_>|2&{AF1pcBh{L3+cM<c4g~u1bk(AXF)YXqGB^3luBh3hQ;Yap z&utqOcfbES(%{18zppLGdiZcUOUG@t<5UcN>sc0qMJB0hzFFga8Y`4?rY;Ife)}bF z*P-~w>9|l&%#<-*ag99vYvfdQ@h2G7dip$wqr;QO%%3rj7@&Q#DGX}HZ#GBJ0yLmq z3{D_|d|=`&5kvKl>3|x%+2=N;X*f~*idnOvONeyCUkH^TlIIH#&zkIy0KcG+^W}$s z1z$SMa^wdad>H(rzuGVP!O2tFjvFbH<Sr6Ws90*gS#CRu1n`f<t*u_cIqV^zxv~p( zNW);0=0}~S#IMvmy-q4fL)&7H4(aiQ*yAvrMA6}%qvkgTIeLaA$DL|pTTaK(#<-XE zk*DV>F3%UX|JCXmAp&8+)0VZGn9^jfH52&;r;~mB6xuSg{F9iZ+96+Me3`jYe(5y< zrkB3jX<GIfhWTO}nQ#;9!=3}(XFX4-KvhoFdV@##5~WC^qHd2_HzN6L75IN1$96Ax z-8C&t_RVy7N&1UlY_r;i_C>uSe%fo~$GuK`&gPz%-+$Klr?1FmE5p|Y7Mo|W)K0v6 z9TJJwbXj#{ekLAsuKw*2NTqAG-14>uN}4Ri>qP8^F<qc5IT+yf-nXHABPCp4Hy6cg zd3`_+Sy*_H{z0`D)jV1zIQ16Gjc>V&<FaV++j_NLCOTmn6)%%rRb*B-$}w?;p}cYR z$~#KtI-N4I<OxumQf^{v0jm^k$9`6%)S#-DdRj^Dr<JsjKsJ=M;SET(?jQ=gJJWbD z8P+h~*hkzbq~V$=EZ{=5_u=(fj=!d$k8{%Yv)Jf4Xwz>}f5{v{bv;^+kG79WFb|p2 z$n&7zg5&iyc<>>1c&WF$F@%DnP(8CPY)D|{uN3O-2N^z`TQ^lYQ@bRKm#CM6gx4Lm za={(N6cyNVd|itfGuUpBt4ROgVaUKS<$TTW!&vmk6fgVsnCTi~=!T8f!U5&#C1XF1 zJ4xDXT++o|T;n0}5~LairxpMpfzrf#wUnNw;txE(w~rP#2e;+wD>jg8_ac_!h!Bf+ z6o=6_?OWECZDxOCOl8T8-+(9R04bNLss*`dyAKcT`1Q3QdOTQGtJm5zwBzm%=RaPr zj^Ia;F+^S2`@fqSvlx$i+nb@YS#}dsqEe!@<O>Oha&5?@EBApM;tcWGR1?Pq-#r1S zz&iwhK0C*j(0-GuoQcNEJZ}LrAst?+ml<z!sFXL*Fjn{J)Dla?8hjYoGr4B8Ouncj ze9t}|(IVv;ZY9)0zAMX{_+>H~Fw3nTuD*GDFM9vN4F9>+aw`dUas8;K=fsQ_JeLW+ zRGR{$LS>pqd1vqvi+CFTRaPVfX7v8md@a-s8qStI)pW^vg~cP~MQ{<7>T|^3fkKK- z^)y1XrxCVcOW0Mvk<CQ&2DdX!@D%>gmw$sxrq6{MTV4*@?zv6<moJXuz9|SG+n<cz z?yhSut+-N{t@v^f-(V$?-cHyd4%G!1bMWqgYfAlQBm#VYmQBrYxElk)WJy!J%0pua z$V3T=YIjuUGNr8HL&W!>he}_IW1`eI68n}j#MZsVnd16V0RflGlET`)AKU*N<9!yx z<C_bVAkpqCi+Wnj!u>~O^7UC?R;{{Z9>j@kC;y~WE<ORuqZ9CPeZP68VNIIkU%?Bg zl@&7PZTJJKH@<*oGBQ2z_Wm<3%N+AvqL?C8_1EjR+(z;Zl*JaG)Li<AT)Rsu@}7^h z`uagI?7wX9Gnr-~2F7^@+X}v;dW{jv!DI{S1gdoBYFZ0FlBy^c(a#S%E)Pj5$AxP} zZ&)&Foa{=$q*G-@Pm5VN`82{W;n}rvh!Osb9QNcpyr#69bhMeAnmX0-lP=E5Ny%qK z?j=t@aK4#j0Ri>>P_&NV!G+{WQ8((5H6;Z!fa_TMGKjBgDEW5Y`&Y4>Tr$}T(?<7G zeh+=Q3EIy&*Tlk&PG;N{hJMv#=ix1`U6=chY-@DTu4awhIw`GX`OCU3GOSdFU4Ylz z$_{dgqLlrr7614Q_@$8XR@H7D>A!f=@&jm($b>D9UWUQ=DS(@ct=>4W?)R3H`C<!^ z@F7zza860j@#Gpv@-K|gg*&3aP!Naj!0&#hSsFA2){0t!b2XjA)%w8ZR&b9aoAU_B z3q;C$``n10m6~)79$1x@jhCY(D6u;luAj=Q=EtkA4+~si*X37X{Oj|9y_?Oj&8bhi zts)5kSWYQtm)`P-OP^${tdqD;R$#8-K1)o7tJI4h{%^2yZypD$g2?=Dh`}af58+r6 zYP-M;R>Ux_4R&f_O|g~UxBN`P-v;lujl!G1UcshLf?F&{Y-yA#qt8l3!sgP3^a2KI zQUDJtBr}f6pe0(C4HD<R3P&0`^1KyZO14VM6W2_S%dT0fa?K!Qo{{0f{GwI-Uf&Yl zUI)^dhl>n1|8g!aHZL6)F-F{j_*mG=_}fdTh*J;3y*Bax!ZZ4D^Pe%BkK+F}MQF@N zEKwZR)z+lkagDr7V<I?O+%LJ1OG6z?&ZxD#t$q#Tn4lr&H@D0?F<!4Z#!AR9>#~bT z9>{EW?dsC`i-QYdBlIZI`B|n(b$hae4JqfuC*n>HJh1O#g+~Avl)|g7(w$#=SY$7j z3#a)vH+{aA?IEa6CciQoKB6g3|K8id;x9r@%*L3j=k=F?lhYM9^A+FeJt-ma)Bek$ zJT7U)HzK_RM!|sT$7J<t7I(~WIaRyzHeySe8L%^T1Q2}tLp}xZ>?(`=^}$*;ZyD<A z9Q##urrQH<Dua^QZHf+2ymB-2KmM=|9l$6Xv&!?mQAm|-c4c%O5KVovY<D<tHph7i zjs(c8Vhkk6hjC>WjaGvl^y^o2&(+Nv4Vx2zsDi8);erKLs=+>3whpA1%#e(ElCO_| zpXK_*Ngt^~r@y{=M9;gnsZvS5Wc+I+O<45gv%M|<^8L$TNEkP6q29P-%4K<2%pt9H z1njB<^deu<E~zCkSG1Yi+>YF`64ZV|IndEy4A>qUa%3;DRAZ%xOI7j!t%Z*vz%l+P zyByDm49;PhO|FP^q*-r{ELD2Lkej|D^zG>4l)sh)zTYgJ@6=a^QmY1`j7jB_>EzLT z{z&nnv_X%oG%JD5{MI2HgysXbr0h6$asPIElB0iRP$Xyzsmh;)Yb3T1Mtr+)2q|~P z638>oi6w$tiw;Dz10m#Mb<=iGMREzVIs|9>Tzs01VSG_dOlmR*CDnqr2ucVT|NiP{ zO&b`0Or;!v^XaGUo0v|gePPIlo7EWg{S>yl@w{NcnV4hD@{8_g+(O56%JQg0b83hI zVmuAd;)ymte?!+p*Ly4Kb$ByTU<T&|7(>7LQM6z#+Nrd6W3@GRA4*RcS!pgN73u;G zU*p#_L9{W;(ye52*%5Ee^dN(cuqch`^#yD<%<6{meCyPYUnjmL+dljRFC;GsG$LCd zum1AhBHkqS)b706atsp-pEGNtClX1ZzL1jI7^KIwi-pEFP?0H)sAof!?@}MmDO3s1 zi>oN@%I7oH0d215;h|2&B>#ox^T*H(f6c}zq4!gm&^f(*;!L7r!HEHCWpRb|@MF6> z_SExNVBg1?3GRW`Kc}ybkUUR>zG#3e9~X7HQ$JhS_(vJ}wS1|Mv`MwxRUbs=lyWS( zDqMOJskM0O*O~geoj=n$#ymIthigHZ6ss`TQzN=v-Ra~3#twQiAJ&8y2dKS`c(BOU zax?n@1T`UqwX1m=Lc@b*NJ)sAdyEOqsv6(uVmXGy9%lCoz`i|(tLW`xwe)O^Dl|V= zXv~cYd_~GHMTgKQe;wNjTBi!NymVH@Qrf=FGHZt4MQc3ZwB&yQezNVP!LZUq7h0fb zX0&}frw92L)ucJ=gs0g0S-`pRPvQATz=wgAcx)IhPSj-C#Ff<pJcz{4=i<xRe)h2N z?xdf;bx3+#ff_MsS&meltfD+!IWG*zdx|O4C%_m63r<#jF)**FuU^Xx4inc|!+W#R zan9u<4SFi}t@$2{(JZR)?0#$g8++)QcFv_TV#L!q3)##54>8?`GhCVr9{9DHk$)NR ztX|TS&yupbXAl2!9%O-cS<7-H!#{gZXSRW|Z;SO!-yel-H>V3$W>obRVtlj;7M#ax z6Lv&SP#b{5jsSliZh+gE(hw4AA1;t-;X_~%h$4ZzD{U;^)piBuvzZ`8!euY?<(|7J zmZdZK4KwLkTh`uE=g2=B&sq4~pNLmZV*kN{Xxh^{4~ro)C)OcY_HXchvE4;9d#wIN z!dI0ZtEHDk3eqi}j4Hd`W~v-A;{b5Oo7tL?NZ1An<zIkt?FD8r%U@jwM6DiVAfQID zvplhj3%P~{>U&@KI)gTL)SIpK7)_1xLKXW|Q`)E?dv0&<Q9W-j4ceSgZaB%=Ky4_h zD4*S62JP7|-d>Cw_+t@G=M}5uPohcx29ORt+z<>4T*4h<=qMc#5}3jC=U3gS)bCFb zV)rz1MG5rb1cH3X+i5@UYnf(OcGvk!K~{>xmWC~D3CV+k-+dECucGZGD=N$i?hWa_ z<K5{~=16e{xGqNI2}@%-ywl%XZ(jKaf1oq2wi9$&Np5(CH9$>6-<t*y=bdd**lTI? zzT4z3v84e-fagI@eg<uePBNXp+ubpEO;z+sQMmAc4PNU0S%fF9OkOLyt+OI%f1%Kj zSx(6C;8uIr$t5|RA{S4h@pU!Dyoy;p?wdy(`Re&p3*x_ItFfI=5!UxZ(mLwkL9-M` zl#rjQXdm{3fX3ro)!5)nZO)881pK(7SDVcniy8Tpbwyd_Hqbme+)P3%g!@S3Q*KSd z+bQzQ_?z3?flU(s#Vq7C>v(pyonl__l|K8+wzMpDrK7kMJI$+hW@cM`B(g+eqRNAU z_f47BN`E)7FO@j)TV@ssH%y`?Ps4JouyATVUni`$8fF-+FUOpSEt527s607CHP{Hl z@>!uwc((k+$iK!P{{euGwkAHJ_rKQH;))j(GGDhd=jQt{D*0;cOKuwcOf1~WDkuaj zK3h#4qE2%4Qiku^YsjCps&;x=Y^td`vppVoC)jt5d&3W^oY5VTT;pBI++!=b7;xV1 z=DL9?iu3t^Rw|?<0X7HZYGwL>I{2p^mr54$1Y<#3mQ@n(p)9(M(e|&@^n)u+MOn03 zj%`zZ_l1>WWOK@gVZL@}#}e89<tFK4ZmM+r5Cdk_K6l6GkiY$R3g*B(ndmJ=;gRS) z+XS>uv^=D6+Atf5<k`>#X>D5GVmXP|O?#@9`k*`qgwBU-P)pY-L1d`(4@kTxvyy)( zB(KDsUYB2K#^d(uF^*U*NqD-czBe!`Uva*mi9jUVuI}Wj&&h$Ne7%#wov%X|0xy$? z^i4l(j6lO9jB!{g?FE{Z9^&`sePX`g)=c~r>cL1$x+~^AQW;<%>m-R(OC^7Dx%7}8 zA>10NYxFI1EC_8bI;Q6C&O;~X+dfy%+Qt*<mm8HGR(yTQF7#4WzZkne%&UjRgHC^s z*h`{!^w&(@JWmeI2KSVUmGB+~n|;1YpqA9vVd*oECQr)oWxL9oJ?}w?0#*fcl8D;g zBD4V0C@PP?$)9D!=jL74-nnpqeAGORxUwzGzvbs-TP|P75q7cel&~E|<Oxq70}LL@ zjg1JJQi(}PkO+e>L&1HluIQC@P|uT;8>M(O6h)(22H9$o3m`~~;>vs4C3%$#7R&7v z!gV|kdk2Ar+Ihp<>*o?dlDYC6MRkg4AfW;qXF6270(dV4e?_SM!KdC3u6B~1y37{{ z=jgMN*{5l<u79rwM>gjW1^}@CTkQAFD$VN5#{N>tUciI+lKf3B7!uzJR*oj)vrwF> z(-SIL>%V~7X)Dkg9aimUt@*`F8q=q{j<{A-@C|Zr0QKGXX{0#vpjI4L@AGxjAe_fo z!d;IZ<NULy)z9$y6gqu>*60Sz<iGit)Mx%;TBQp-kd3SLH&n?R+{83BMbn;Fr*{PJ z-k`7VpKiJ$XD^-jVIz>5<39wjmRN^6iqW6jO`6P%Fr{yf`u9#0c#Ubh>QyW>n7G~W zOS&e2kefZe=v43?T?wcO1qf?fF11r|-^V~=4B$wZ;$x+y()FD^{$efu4TIw(tpLke z<@mcZ;PZ<YBgV{|#2%8pHIZMD@Q%jWlqd^wVFBWKg8v!1Pwu!VX{$PM%>`op$pRNl z=UZBP9D%OB#zTU3_cK^tdKw#p+3V-(s_aKb7@4C$@5aki6g9C-;==qnV)7!wI7tY} z=axl3u8H<bZP$cMq~@w?W@juB$b}$eA8;5NyW3Oo#YM|8aDd~`dld$Kt`&nHdG{MU zSv2*}Y9&A|B^L3TiYlU3oB|nwXa(Puui?8%^tcVgG-=7HI0j*!;AmL<n2Nr<SzrS| zfjiw1LQDh;sss$tq47^P`uS5Sc;UNe_D>E+h;V%Ke*JE!=9#`pnuO4Amfcj{@X5Gv z4XLKceFZ=oR)k#~5x(QxHR(DOKMIz}5r2_KM?U+Oa8=A$I;EdwVbIgS=_bZ4Uj3Hi zqfU_DtdEczMFbyxe~TZj|2VL_-I9ZQeq9{~Q4N26g@|jHnIN1lFE!A;c4mjp@%W<? z&=^SFklENAErQ+Rk@{7TnRYb(Vsz%W0nw9BwSe&yG0pY7q2(W+*XZPYAM6hYJv7>C zc6r}hBpxW1btS|OCir_xmx9qwTrWqfeF?aTE^=fM8t8k>o@C3SPk&&}B`L>#!H=WW z%alpt!<q)EOezIAe4nJ>Z@jHV3uDP|0mAc{+SVg3Z4&aJ{cQqo@pk^bh!4?y>IpA1 z=HIeX9=6m-Il;<qikI{$D=e1$#pHJ<Z8>KzzkcuU58pMtLo-poyeF6N!F|G*qZdyA z$=71R=57%ILgQn-Stt~~)-n)#T?gkMnX?(!NZgoVR55~8zl*fN3NE#rBwu|O&n}zo zZN`(c;k&<RxbU)nI#s)fjr!lO=#?SU`@~ZoA`IQS%(j#B%E0LNhbv^1o85Jms5JE8 zEuY_Cc7(cnq%1GQ=-s0gyKQ1H>E~{nheOc(`n3kl`Hh&4<YwQu(e@+An;$jM%}uvu zrQ#69^h{m6rwk28y*=-vnjqePW?S78l_vG*Cx~g6Bh2bcx#8gz?>4{T;^WFYaD7do z1HQ9PzSTP;dHi+vKeG}j)7Y$Dy1svog+fsirFuoSTNTh<?|*qW4*!ygQZg=XSIX+u zr<v)6d{L8Y<XdXtPf(L|1nbrXY?#(@$)83xj#w?TA&d}RF2<8?%21`Eht?<=F=kq5 z>@8~Rl(9A@)c+@AGWspUVcbq|xuFP)bwp7_qr|3U{0)QBwa=Oi@`m((r$bL+ElR6C zhX`w+Fw^BYraR)~2!RAh&8Q0(c03*qNz)mUyW1kELIKKrx95Dr7uzp{ODL^<Z#e`a z08Zgp<cMIQf&DHo;_D22vuTx!G+X~gQ}KGjB%{+Y^KR)tV0(7@W0{rH0=H82wZS#) zE*t6=mUQ*I(`i9L#GYQ<c#=2dr~<jG#DTsm6)+xb$m7)4;#J$F;2>;}Ou<<?q4M2x zxl8u#y<RiB_#f#|3&!}@G*4F7?W5K8Rj%k~&u9`$0Lv;Tpx&j^GPRMggHHYEYC+Ag zr;~t@prCJsd#AbivY+SEe5}6Xa=gDlfsS|271_2x>E<hwd!u2A2I-a-!6IO+B*fCK zI6F`B0H=dKC6RH&%UWoQYjCs0nlkfxJ~CYau)u!N<NMusbmUZ=^}F)YE#VsP2Eya> zG})fJI3?+I$Yfu=Ff_?zao^>vNC1L0q@{E7BR?q1xfA-Unc9#nzcReZN8tH8#-~V+ z&@3lvV$X+!BKIiaY+Kxfk~C<KREJS5|Gi+xa??>c9sJq+k*Rs8S^F=2fTVf{$4zQ% zJZecG=f{nU?r;WpIB-Exw=+;$uouzA#XKziq8m{(*6u6Z^XTN?4V^u^-Lkh!mNtfr z^6isl66(xDaaF4W5dkBGp?CS<kTaB(UXQ0JVt=gHGkl8EenD?^NbV=y!Y8@I1ChMH z$r#mCI`t&w8SSzPYwINPzf$E#n;sHjdxAr2<R>Av$S99&NQ^K?X#D?v6aeI;_>t>w zdcrn6#pDJ5j_gHp`_`H|FWMfJL*7zg6aHK7+%=o=K^GW*uM|(o`MXF^B(5Pydk7=t z8xEpEFc)bg<ZmJ^u`O?t#;|QQzPOaxn0bQssm1nj#DeHlLsZUZT;8m#Sq#+@al0A~ zgG*=YCBp_0i2zL3AI_XOYK|tYHJ?>>K}ev?m>a`wT26=Yww2Q1$E;odHhj`igYqSr z_>~y}%HzS!%SVuG!Ke`oEzd=q+FoK`6yznzf-a~>i(;~92fQun{++x5vbNVVnD5<O zQ{VvI6-y!mnkS>AbGFeDe$v$~O9EEco4;M%vQ-qz=xL)-4WgH<pHWvbA8^`juitA; zwU|1x19IOT$PxR+NJ6WwzOMQ0Zp<fE-<lEV0PmYR&xZ4d3-i;Xs1O%Y5bx3SPh)!N zXyibC{4}<x&lWg`RsQ1(3j6ax<e*S>q=0;XlTHZZVpql(<q72V(VxaIRKp!QZT`&g zcx4>jX}kJ{szj~WdGWmAy4my98$ELov+9&S;$&(8x6{Puh@u~8y`{q1bkd77R5D3~ z<E)YIbZjh!tY|Yn_d^!tq8FFdE4{WeH=-hrZIa1~l$BWCO%+*`o_wBw@ZU&FsjYvg zlJoVoD$Bt*hngH<P-rvVXv#L|h;d7vfA8d=;zTGM=qFjd67Zncu{SDryjLOCO0WWX zkcjq_yRsWOmIq>~5iqd6Cb*@{dK6{PIG?mli$e`_=(O|A&~a>?8JByqq2phX4x@}$ z%<=v1g1^DV5q|%w7+H+(FAdUuYjh7kGz6Ys7BUtmxNNI+;T3<Qr=joBcF@*+m{Q_< z<&SrLWeIJ8eY!Xu;5afy%bWyi)~+eZ+n)XA9jZtNp3PMtCK``&X<oYYqS%A9@S^IB z)QZLm{R8|wC=+NlYC}$=j-yADo9=GH<UKBl0}Q!E--8*mTpcj1foqv)dQlCbTV4wL zJS-8eSoi&fb2Wa)ny)&Zqz<FNV`?80j2ENpnLX!RK#+k|7cDvNSeO!?cZ-l~z#Y1n zynS!aFRyo*Q-69xj*I*rVi|7aA>o7kbedhw8Pu#Z##g^)-nJx1HKM4S*UzvlwArwo zxI*QB3+(}473c^w^(Jh4=}x&_IW*fO)Rh+Fqk37#rzG{Xl<LJ+d!}+amfesjfe5!I z83pL8&+7R-NUl=q!Jip%3k2Rq+;Vp%D6Lg^VwFy7uBv_$SNqqJ`28JL2gKk1x>nw; z#P~hC%fpYP-Y@*MSzvWlV$R1re^&We=YFkEPVO2dp)l#+^*rAVJlps!q##$6zj(jT zY=2w*`MT!TBPAPQ*?e$W96Ut<LI4}-oS8{j@70QQyb@)`%QIqAuZ0vtw3wUEK}~A2 zBxNaMVvW}|U#%uR$fsE&7C-+aEO>L+@!5@j@`T(d&PNfH*dDPfe5!KjK)IAR<b(jX z`n!FmWQ?)aT(^WwWWNBpIxRk9+T&MK2U9*3T<#O8eB#=CtczA>%P2!>3@7w(NNnLb zlhr7&><2o@W3GIieN=IO^vnMu=#E4bVY+fXG@vA?6&uE+z_-g)#N_!s_g8!rJ_Y*$ z6Eqp5O%wvm@T9g!L_E$6dRAkhT)$O5dck?Qgi`Xu2dRy=|JoD~(tCk*Y?WIKh})D+ zM1rYzR+AgPuxd?_@Yr4+ln$#+`>Epd>E3%%#+L~RSReQ9&5SP^UYD#@Y8+TB6ks+4 zTpFaTxZc2(H;{*|eZLg0GIu~C{V|)RyYN-RBF#lwDY@&?Mm00|G4?kPSfxa|p|m1c z7rUay(jEjK$3>KJ9!=`-C%M;x%ZMltHjm{(FI};OOSEhGOKhS2kdfCFgy}+!ECQw~ z>ZQz?e5i2}{&Jx>bsJj00ORVp0t7Ox?wmK59f^`H8;5ch{0cW?pCmo=+mC@_F6HuA z#FNDkPl<YJ)?R?QdMJ{#cc@O_a-2zr_(0An9d;XUCg#mayau?liHj41cWBY_w_~}? z$i!_nl)ivP-UxnG(^)nZ<>h!vtH1ZjO<=?9(+c85Bb|CW$4t&PXq4UYdBNF`9s6D} zS>QB!V7P7d7q<KO$VUrS)cz45Rt;5EiEHRGNi03U;9DPmbD;=XhAAUO19F5jTp6FD z-enP=Z{mFI6G$`Qt+nK_V-l=Kt;F}5Y)l7~DXrcyFI1>Eb6<Mnqe#QSpLl({NYyo% z#2RrdKV<wCLHVB{<O3C1k+QEj0YeN04|<hdvGxx0#~pOnFw9%!44<2?zmXzJ9%KUs z^@_o=S9Zh2g(uK}84g%#xP(Wf`lVGy;Hmwj!g&}|a*^U5NQkbdusv3MI@oS4p&hTO z*s!^6saG;`OxZ;?tiBt?Z**o27o{4G!u0>8l~>f3nYE%Y`_QgYY_(Ua|7fHv1OBEB z>kGqIlzo;0!qayftX==4f3LFi-ql%o5wpc;pJAl5Wq;rasO97Qd}Ve{+i*=j1J(+Y z6(Q+yiTnWc+#zP@!nddI(sq!)MbVjW8Z-VR7+CRDyYz$p<mC{FFLWL!D8#|86;P~9 zNba+2R%FyVrID7kH)(%%`n$DQxhbK57NxP4>8N>2({}b1=&i>Pzj9>D;<IMDuEExg z!r%VqydzGZUj}(NezWQdETlcMFs<mNKl9)u6Qv12n5Lu8o?wt=+#J2Eayx{0xliCC zRzq5Qe+m4!5J;%YEZaDqsx!I8L<mc~9&r5^C$C-D`YO6<<!2ndR*d?G>6imqV)u)E zi-q}n9*5UQuX7FgaD=R|6_K6?d-=5ILWx;}4fJs9HBu4EHsBvpC=!|rOz<K8dl(O< z?YOH$nB`lg>oAyf=w{jhsSSUnD_ZF^RA6f61CJdmvsd+nb8p^M699~1W`0<Dxzy&v zHm3)q4eKVw*7&fqx^<=ElwE?X4=IO3UstZsDj~%PySl#&AO~fCN&xIWxqknf;^DXg zVv6)3x=V$2GgJpl1ndlX;(?ZWU)ANaOG*vkC0^<e?t)^jAUGrg!HxrW$xD;xmct_W zMn6{qB9!o%Ix2t1x|p5dNPbrz94h5phjN=Grvb*J#3-HQ7TaAc8Y<R46~`P$RQ&tk z?HZ(qaI=Gigz5B2Z>L0=P5~3kf+<Q982}+~Ss(N*vEX?|p6%j=R)N3t<G;;}Qz@)l zePx*nobRM~LptYaCSlc1lg83wpj5oiXA)kzAANa2E_?o)3S5~I#~mVg`FALk<A{eO zo((^?+dVs~kpqeWFO3J$oz`0ZeKpAlEiF!>-+9(gCw#f;hG^hO;pv2K-!5=6Ugl>L zdMFOgyA4wZw$?K^?}c6?;Kb}mHn%vR(z-1DyL5Jrz=XzebQ8k_&lki2;a%yOXaRw} zUDI{uWVH$hZHnmD(jd;n>x~f+<h#k4f9FxWy7}4x_*3sNBVasdLFGk_k!a)PpzwRc zsZg)56afX{FO8S!Uj>C|`(lnib~M$JKJYr#!%*L{2`v&d*&L8%m)ry%j4#?oBHAZa zH_efF1G{n)E!nLf1M^wx(;15B8Ij)0I8+YwtX8eXwKUKVx{7ph%~gF0`P2F7=k%|g zii(N5Ym?l(j@~dIy*LSN+G_Qt*q{1V6`mAjT<f^Pu_Haf+nwAGKNM*ZH~8`k{+px~ zxq5GV<ssAedKTuhKhU<0Pc!iB-)4@$deCCuG{v6Cv3dfJo?cop(M|g>C;Wr*8Yy9! zEfHrgxz4`#;hiJCq(L$nSD`8}A489faC?b?56UoSJ7e)1%N!Eu%47ec|AYVKpVKBw zf)-YKpD1PiDrVuXgfAwj;yETkfY;Pd$w%}w$A|HG#9<&kJ4Us>PR=DZU7m(atGD&_ z-5P=<N3J^?#N4)qKUiS{+i9y;yo41)qb&j_#8ClYLwv5-s&H3GQA)8;0lut7U=sYy zXx6(JfOGzOLkONC61(szduF4co;X<pbEht?yW&fH0Gd6k!J>TK1?Apa?P*mMKr*{z z!R?_aIPP$?#+-w3z9qm3pYTI{Yn*EJ!P4+UBFINEmHR`Ukzk8|$ET{=Ix{T|hnbvo zC-&4*u*&zwnxT<?yr%LaZI<COy6T;5vg;I^Z{_GC6|^#8*AH&kV~8h6zJ@;Kp?9!1 z8>g;RDWV<c>uFFx=uyC*V!f0^h_%2E!JGs9PCq+tr$$+234e`YFVEEG-ooXu?^Bvq zis^qL`BWy*2hPG7)77Kq4^bM4gKQiR^SH7t{)GoEsR6G5(b-yC!FxFBL~&FUd@deI zS{eyb16}=Y@PkxrR!AjrIWWO+Jt~ft|9rYMXHR~;>NrcRY`hv|{2=_EVzz<Zv?lUR z6Tfe{iMF}#O=1SNdaQ{Hj$m$^N^@O6b6m`q1!WszFBZv>tK=`)exo5(R&Oe+Efwmm zBG4(wa2SH6ZqN~jIX+J^S?;X|Qw@=RjSkXitsVD`PX|R&IYojkv>RLm#AMB6^|s4S ziUQP@fA0-SirPqS2BfE!@2+w|!tK~!n)O@;NFIDL5Z({ewVVE}7K-S{r3{Td5LfS* zy8PZ|;h+V_<HO~@AElaC?W1kD0ZrC_Y0u=@TZ?gyEHz-;>}79nW7RI0p_@ma7dkF2 z*KORGN0{R6t*QU+R!jz{hnUy;AyUw9k&Hf)ONqOY=^!S&KpG@0cE(SOfiCB)$g;UZ zo@T&&{{-W{)lRDQ+=d|l9kmyNCJIHLCO4p^%2rbdAuq8M(CSXuuy=;FeCf4>-3ex+ zb<2<%gH|Z3t-nX2*=mAC%y+2jc$jn1SNBOKW=>wWmYUg18B3pjs29<1e5J7mBS)AJ z3^3OhWHLN0z#LWhG#s!%jmSVVfd~{)p4w8MR;L+Q+k(kMKZi2q&x^dt-@4n&3F~=K ztl&VOwJ$@|c2km}G3mzeC^1vYf7&1c(z`k*U4^=7iw?11z4N&Xb-lkUCEDEz?{)q) zZT>1$FHUNgj<pYr`$6*9Y)k&sGf+r<@x|H~ecQ?VyWtcYyNPC*f-T1id%zN8qca=# zy&6NOk}K{5DsdUze1$7U;k3oG&NPDl767y9E-+CtvH5Sejk0~@{?Ie4VwYvm+RT5a z*FRmij_?QfXQ#>5$W6w+ohZbK14c;v54K#YYQG7!dz9FyEPLVRHF0T!X%F^{waULu z2_G3Rd+|x$A2xQDFR4sCIMc7}8ZvD~57+D!{02NkAWZEABre|wVFeMM6PG@npJOb+ zp(EZ$q&?u%w?8o*VzDl>!NHGWZ1v#llO_{#h|ShT8cp7+ErTn6V$fuXrSU6X@>w?O zyp-D~%=h(89|SsOp1={_h^Fxt=Ot@S(<X<fCNCk|^`CFv1JsUz^h_67+@`30Gv$#K z$}NlY%%fFz<_W*3c?AB%oAWo?mdnTO=tov9OhS_VrMfCjrZX8e$W6ScwQuI~F}lvt z_=e}q&H^W3Z6$&+&gnO7AL};OOX^s>s_VQH&9V$pdqoM4V;dnADKIjf^+!kz3ZW1q z^iU`+Yz$vF<mQR^M8^7bkp4&isq}otI-erOfv93W1*FMta3dIw;q-ng+{PI9<C#$h z?|`iEeS3qbb}i5+)EM9FnY+=z>6fF}Gml>nmDW;5a;6nteyMLjI6UUQ(8Fcg<VQJZ zf05=O`Gy)wTqJf_76DwL<SYcmicjII*~plJZb4;T^&d9t_H&J{Pr*Ki37X{QQQMHY zO~0W*C2!q0!;uCzHW<%OEHh;N;qoQz<IWE+oEP6@#xfM4o+UlSR}0e9=XI6?8)xS; zy?yoS27kV}Nwc)E!C{fZ{)&XV(r8g2fh{Ykr9ivXQ8xOK{(F~)o1}O%hN$TDhI~1w zVBn{dKcR>p5FS#jzHTko)J_O=j=St(3B{JX8*Qq!SyLcvWp4UoqA&Ccb{!l6w8Esc z|HY~upa{~itnd{&VSbZ>Fj`ILT#80YaAxz?wV?|^FTwRl7RZL$6U1jx6Mm*}^1kWV z^ol0AC<r2NQeEe(aMx-q;#LRwrSk1>8CXv5lfrjaJ~4zmHHTC^HTu8%g{U#nC$=dA zL25?=R$5a7i7vO|n+iYGHw4AO|A^Sr6owgKSS)U9r=tk_<baOy$I&O|_Ln10<`OsT zAd}zdVg&lj^2G;`^W|IXRi}ln+57bKE6b}*;h{nP;bk+8qxRXvox>T$gm(Wch(}fJ z4L|>!2*CJQO~Vh05#m_wQPY`~<ixs?e0%ZJ^WgJYOXl^Y%c)R~b}fkoN07NlGbMNY zaeGo?rqC>e;R?)L$DEYxY6*EG@-uO9V60vbcvM890e{2j4X$>QQH66oad8s)li(M} zr;tFXjqbp|8R{Rn@5i@KCKkjApG58jPiRK&3O^-R)J#W!5e`qD?|%JvE8zwLwgaXC zYQtvC>n@w;34xsZJBPEiF6t4$=8uBrD%Hz-?UsZ)M$y+EN6SQ((EANymVp{<sJAH( z?4;{k0CI!o1k~pwGvz0^-r;?lHq|XdnnGHj+OikqvjVTXHAW^sh)UZcbiZIdQ93>W ze|0?CyHR=-i9DhB@APYcERz+m8GD4q%h2CorHXq_PxviXsWGwQZB5v#$9h^68uo|^ zUk$n~M@wJ))0^7E0zK3tdMssY*%576<Xqd*K_N=j51hrl`v$xgY8zc8sfgwRyH{%$ zYMQ)`&|fSO{c#L~yhF1#<At=RK2@e0Fjsg+pU5`R)kYWc%SB>@ty*wgZP(Reo2Dh? z7p*_iV*hO@Y=$O30727(GcVgp26(<E%jb<~Q?Rs)`iU1!q`StQX&gpfuGO-(IUe+g z49&9im|wu|T1RijlY$Y=Lj^~ijxzUr&jGgtaV^d^tQ~*FB&=F4lME|aKLslH%ger| z82+-{`qc0%O8eggwL%b4Suet5l~jl4rDgNW?Q}m~A-iOE(w@`$@X$5EWlc50^~M<w zywk~T6u)&ahh^TU82FYwlv`>H(TasB$U4DeKK_%AmVAzYa!a;lkwc@jh=#ZxA`M$0 zWDP&~)llrvgsSuBmNvuP+s*&`P4(@78+5y4*xVLbln_We!KPaqd*vx97&J&;kl?#B z3hUPLkF2p<s891M)X0}uQP!2aM;|ZGj0Ew7mrgr3OHMW8mgB$Q7(rSmtZ+@Mb1CY4 zo8o<L<-_L5Ut)#!Wd3UyJ+bfXs5AO7qQ|OF{~;9dW=-L6pf9WaIN>X_1G;i9yX6Q? z(x7~ThHKDtiC)H3S=4*VB?`h_F<tZTRt=IUuq80QBnb3&b1vDNZ!O-@-)KDDw9)H! z^2~fqv1qOCEEl2lwk**2jSkOI6+8PSkW=LGf|3n}W*nM9o<wIQVayk@7FF!+dsZ_) ztTH;Zs_ic8e#%Hvf(_n@a4?SsI}^0P#3D!SJ{E`qFMYQfuxGX!edb=#qTGL~?C^5r zR4ht9|0i_?wqFK6f#UxrrGqHThCc-yia771h<H~QYmpT6VR_j}R={<Cr~mbjTKn>2 zLu|QM*?!HZ((BAHqGtbBG!c;X(#u-GxL0U3sGOm_qiUWrhi0d5`pTKLsuq58%X!}+ zovR2QaM&@ML(w0&CN-l@X^(t^FlAARrSJRxUdHG<Uez>eP<{N$Qo*4<p6N?eQ>!0t z(F-0NGOvDUB<IfKf$-A^(tH#)d59KIu~r&Cece!KX5Ui|bj?d&X#NX9{1=$T$!lx} zT=%tGkosJX_V=fgB^941sryCYB{2f<=Z7y7jiyKtOqJ|&<k}S-_zI2q{YLltjP{%s zD`+oK|Api)u5`~0jkCqf4^c!UL;_(3h!$t5yIYPQ1#~FaiGIi#dK4YNLDD7BBTe>^ zdyZf68(La6b14?x4@QuFdarJz=C{DP@rSS@1sQJWKpuPAX}z|wki7q%xF_yRN`S_N z!D=pz{#6`7<w+#_!>=P&u9yJ%{XV*n@`(kkj%*GF7d?PFO037Tvs-_KI?rEAKX;d8 zEYD681L5XpcbO@P;*VYGDzZ=W!VW=5_|CINmrl=>k=14f9a(GhzYKMEHwSQO?N==7 zro3%m)jxQS+^k)+x(R~oGh6y=7<}~fKo7(BCtC*-iIV$)b6+l~z#H>510sNr#_hhy zb9xrvXTJM;hS?f8mbVXvnQC0TZpk$>XOx2C=kVv}(qXf<pKT~JpS|u>ZUlh>7fIIj z303bsf#;3dfl8KH`e^qaa=p!__BYCWtpePm5%vxU1ZpS39j?oSrDKx%ZO$TB)v}<) z?rN8G)@9D%y#W+p{nGevyZ$$=c%nyszR|3iiPfwfA9n&p+`v(SLLS8vkc`Q$he@_A z@sVQgI_|(;mjWermSWB{_;`(T&sm25?R~&nH}km5%13tp;9C8{AT6QnV0o!H<G}tT zAD(Z-SLZ*b!p7()y@Z=nDh?uCT};duFF(g#Y7tzA^6)LiO^;&+v#b((Ru4TFm{UFq zQ9kTwkKHI;lJa=CeTice3Fk0jPFltHiPL3bgwnxr`M)_&1Ux)9BgJ^1{-d)O?$Dvn z1F=$j>J<{T`2M8+-0jV*wVu}?Y&Uq7I(xuNx~T!Y)lr@7K6)K%0q%0!l5+dh)$cLR z2eWMt+lTi<xDX%^v-@LZ{>Sm^0*5^uj+C<;S=3vE^h*(~n_klX6ZZVo$wp6qE2T&) zvwsE&_wtzL!nwIoooLU&sj3_DxQ2>1zx>;`HjH-}v^&*Fo<!~tiaO>nMP`m*VR9CG zdT{621Mv*~-S=QD#F5hKXW`Z*i!qmgC_VIB)sB^HsuR8XpcO9l9?BN{epzok?ucQ1 z{zoSMI`I0*52mXa$Rc(GQ1CfwCM9o5rzEWn-?8qEogW*wQ<vllTE&fJ>kxafN{;M8 z9()(ZDmC@J%v|@V4FcOlB)@8Qt<4<JVd#&|ti=UN+Zf4-_x-9uxi5zDo?_+rQmWRg zlp}18cUY?Tf=hQJd#T3)AJnoks@t|j6!L4rR|w~4c!2h|l!{pC3IxX=7q*WMI3?fc z@ZIPJ3TK_oKzA5Ag#UtuoIzfFSv|Lj@Iq&9+x6_u@72=wQe}nndJ+#EN&co*{vX!v zZpt&l{Ouu@39c!zNPZwe#oJVdrw+BO@u!yCEWWeHNSrT8mtIX--E6!c`JUrZMix-w zkw3qo`#BznyGs4B!(HD~4=l%pI#F=#23o?pg`y2mv!}%Bk6Kus@?3p6k^WKPG`u?- z@e1uWU~l58cMP5XgYo!S857McrHHkS(Sj{o^~%KhCc>F(E$T5b7V&9_$SZPRR+F`5 z)TCESx!*nK#D6oVykt9GsJH_zSP{e(Ql72!Zs8_LXv;;EdCh-U5bo<XB+&M#;n#7` zi++w*R+rt4EG&N9Jh-}8V9BAoR9S}q1Qq6!pi-oX%_h!=bRGaTj*974vLh%bz?spY z2aY+t8CT2?6AGk8k~w~Jw4)JW+=zNZLbq$4YxegB55nUnO3kjJpqjDpr5*`WHMc_c zea(9mD`I!)9CZ=UU6}59a0LsIux8AqU*uxv{wdWfhe?rVzm@&DzJmn0Gs}>WVulHy z%q{Y6BU@OUXNZ6JW}yNzt@BV$CH5O=JlMOjc?F4FD$c#o!*60dL%_d(I&cC~(2phj zwkX;`K7`lD=quCI6>R-KCT4kJV#a5>$itt$dSYR-VXwhlgBMd16amvyV{T~8{rNHe zK8csTDRVY=z_Y`HQPV3<IxoOds36au5N8f=gj27yqgke8ES>Nj^OV_C#3HI6MMFPe zO0GH3_4>tMh>V7tuCvBURV-1`B@2bi&aT)x^x`FQ7#OC04<@b8MN5{&dovKIv?(L9 zpa45%?ijvIFOTqvU}bczez}B{xv(R6R3UcWbjF<YBQpbMbMB{i<3_Yd^fIB#F9jFf zI43Cu`iHwtmo3Mf%hXT%1r2BVR=>9&x^nUU%eSWnGSLtmJP8hk%x14@TLD+h65G7I z^lK*W(Qo$5ottduu=roJok+|s>DL8gx9ES@*L9FGjN&i<MSaS5O=u8g=JP(If2A2V z_pxx<;fKt^rzrdM@!J!Qi(ag{t3o$$rHdNz?G~c-_j@#LdcL8x{!nCKaGS<GYoh@_ zplI{La3Gnt$lZ+O@)UFaB`Rsl%6hYLJ@8&azSaGMWy)%ffZ-P9G`L43cuV6>(`8JU zvcM|;wL|`oq~J`b)k=IBe&ir<!k*LdM(h?0F!^MVr!cTVW;mO>@ZaVUJ_ROzv=u^l zeD%C@h*LGJyl7ycL5_TRDJbEDxH6sMzJ4^S^6%~CpF#dmI)CsMqHP5n(g`*Yp{_}I zYcJicWz&6S{n^DO65fVM$Z|ldvy0=dxUV{+8&@tp^iGgUy%s^#X<WFpBz`V1fJ?R` zC;p+KpypBVgVJuZzhcvDTpN=T-L3yhKd$nj`ZTev5V(ghFF(q8F)xYZ@-PZVZ`fpB zY2#*?L7#E0yGUeR+^~Ooy4VPCya>0yRTU#%yp4TlbSP-mE;f9FXdhu&`=eKteVq5- zZJ5tb0?Qg{>WQosOF&Bj)Vb{BT1fH;WAT$EvB^a?dbTgR#kiEx-_8o-@3eJ2p_Z7> z2~!YHow@Q*N09>#?}6^o3Ojq&3Kt0rYZJb>A2p=HFg_Cx8!SesWFnf@!d2&Jj*Ew~ zbbLn~e|i`Coi4W6uR~WncWO;~-v|jU{RYbv3?~cThqYB|q&U`2?Va+?f1{x31#{e1 z1c(`TxBtpaA=#9G$$ameJm|sb3$sLAJUK(CGNZ>+A9Tfxcg1~!81D3$SK0pbXU)f3 zkZhtRp!;EzRW@!4Qg7emi8LeH(?-sVOCk@u-kuI27&9f~m1Yn-Gt5z<WzpussB=Ds z=&ZwqH2?g1yQtzhTBkiB1HH(nB$r!*$;BgvC{DZiqN6S@g&Q17?JI!iFuD8bh)s4> zvnK_ta>+opag2~OvV|;Y?XQ1${g@``gxy<1AoWce^>1YKJ?%TD@c<NB7rTG-VuTHl z9d5nBRZ;OUhJ|R-I>r-8Xc%$(1y!LXPhf`41ZJs`V!8pvs?`0+j-Y;*LwDYR5%1aj z@J81lxz!<CvBdqmDBGFoVZ?br;{U@<q$k`wP~#7^{3T3y9NExYp@-TpUEd?^n^5MU z(SBa9&MoWVK4n~t*>PbdYjQPJ$hDNQvPf3AYTD}|C##rMa&24ODV^~l%3AHFta|Je z@MwiyB^o8!4&5?PLH9wk@95bW7nGMx$HVVl65;=hbu=V~N~o`$#GKadP5W-h^YM*$ z-~ZJ2D&)U~Ff@)7kWP=yxEQ*tf#v!;xxu9%_{>WDtEtkA;E(eF=J+bqY<Nb$2nDHQ zed+x6%aodqFjzx7(q=E5p}<vnxyzPB4HY49zCXypyg%Fgmy7?~!b0!IT&yFnu9Axy zLFi>sADQ_+wvr(|f1}+&|G3voEGMI(nrs{2xmW8pYsypdMl~5X^XSw-h+Aq7{ltbG zXgB<+e?$E01|IEb`^^R}OUeL7DT!_dgd!8_A~{*ZahA0F^4=aJ4p@V5utpO+e^&~^ zBWg4-nS}fm-r_T@>7c)fmkY*q6}q37GVZ6z+N_DZ8v=^ulKu&c(NQTo#ZnNS<0o46 zKm6-hRr}ZpbPwF|Ta|4bUJ15MYhU_~k+1ljl6qhxuM0ZZ>9032l)!-bbPXi$KSRzB zzh@Nvr8Q)gRLxLGYf4T<SO-pj9Xti<m`Sm37=$Qc!QQsSKT2yjozo8)i!%&7A0Ly4 zHxKcNk~aXJy8k2UtD@rUnq~<J1PcU*03o=$ySsbv;6Au(a3{DkxVsHba0~A4?(T5r zh3{YIk{cGA-Mg#0ySkon2sw;nQ}LP2RAi<$w5GwSxh23nc2$tIcpR6IoEY6NZqq#Z zg2<12jvAz*+y5RDiFU6FiYoQ1D&j4Qh+g0fO{Bb2>0BSe^If8~l4Wl_$@Y+lkCunB z?nh%Kg?rWI0AkW)*xd@5mnM9kmOQCmpDx3cnm0Q8M}J?zHFlZPvEmQ(fqKNiUnW)L z2WypjUKtlb-Gs?V<-#NP3I7+hkZ)02=CptHK{XE?7&M$-!^Ik~u0;|A23%k1PFIGD z%Z4pU(}{s|*!U*|4)O6yncD@=Q@VZlea0_=V4#XvLA5Yuvkz@K;zx>UZ30M*9;75O zofZ(wB6GbkJ>W|wACXxzQLwZpg%Ak<3Pz8!i_S(P;Oh=%s2|;D0pghSL&HkBO$jB~ z2!1c?eH5qO+wvP9LlQA>D`2oBv?d*2Y<Vv2UcTN9xH<6XW87;NPL_HGvfzv{{}2bj z9ou>niB&LLs6G^-u|8@441e^7dK2N_ymh@Yr?H^N2G@&Kq!87Ne3i%}q20#}4M%Ej zv8S>ljdggQWmpTQ^$rj>AE?Qgwurf5QZJx9@kak>DkdX(;th+px-lE?+koedX};84 zUPPjNB!(*{^{3U$&h1iQqzMnRs~}yJ&P<WM@QUDcwgx8b$JNBEbV%&d#w@i-*@tZa z>^Ws-8L^<PHtVv@OV~&o!Ng|UfGgJ||Ld$nTAQY8{r7#=t*4{#ANUueSeD$`CH4CP zEn0=XpzVf`T6~ZT^h(?TW0KCrf;SUqj2s{;>PlY`<&9$h?}V*b`J>t2f5$&>$=*2> z3+1&4S~I!MaNYJyj)f3w-f%w!$epTC?C&MSkLQqwT|ITy$4?`GBNKAIaO1zeQp$aS z^k`=IX%&`?*3w8L-%^`%r0uv`;Nqj|Av1sYphTakuDFGtpm(*(bu%50y8sFb#lTQb zqm*rPb?$RLAhTkV*&s?Uy|<@5=5a%PkGQzj)?X~nH8rDqXp_t<Bkdy2((1Nc)v9cs zd$&WW_gPB8zCEYt*Cx7Vosk-NLXrrK|Hj-zQ)2!G61BK*5lPcS4;CJtxC`|4hwF6^ zrV)BCPw9-J;D1%Cbb_rieMOHomi3R~^s<BR5Uij#=HiEsw(6$s*uE8dm_C@89S`po zI#lb4#B2)nqKA~bsLfro-BQw5w%2x1tO$jP`fpe2@N*o?cP$PjP=|Z*DPe>b0f$}M zg=mFDo`uqukaP4CX~LQLs9o%a?m&Y*t$7C$GfOJV{?rhaWZ&PUUsfAgjwKtUUso4A z=SA?qIrvbVFO<UNxq(I1J6W<EUN-n*LDj3F+Hu=%L2e)JeXwo!mP2dNz^&z$&2i#f zCCpI!WM|>kYD7=6i}1vboJC}6%hi*^5kSY)mzMDXtQc9zGlVAf4RY49y#4`%D*%PV zw9R7AH0LY1C|{n<t#cq?nNc_QKqo7l?=QJ9heN|(heQP>jPTiU6}F!35uR=0y(O&_ zcl1P>$@_z`WIrV9$wWKNr(O>mS%UT^z>2Ork(qhQw^Srx?EwFs@J_8oUjiMGce8y) z-Dsw$=f?IY+Om{JXKw_V06iiM<x|4J#`MMkIYBaKz4xU#AJ+GGmiJTETgOIlv<3R| zH?(A0=W4upkanNkgZIvn7<YUfyAf-x16GINq5@S3vx)1B>fn95z8`o`9HA~lH)rt| zDR>`k<9A)j1{s|8HnxjUafz6h+6tZ7_Ra3OzLj_Vbx6rXntJ5Tx4xe6(?2O0(n_0W zkygO#z5ZRDm<E=KR%&2VfAs#hGJTsLS-EG9ibt_nw7TEd&Ivr#!4MyD2%Gwp5qn2& zNqO(hGw;%)Y&9=4jb6H%$Aqt)2XsGayDN>fRUv(D3E@JKbadgjeZMK}w^+35ruQ^% zYXvoI#lD6`*%TD5(Fe@$dmk3uxW({SBgY@3!5DGwti-j+?tf^=I}!FqWsM`Fl|}W~ z$lk2g7Iox9$pKEFL|JOp>6eQ4@I28DU8y5}rHgw$(z(0Ri4(EY;hora4c1akaF{@l z(D=5E7uGzq&#E)@!w)c%t7Khan%t&0u2!9~6nb|o^)TqP$(H1G#_)Q0Su^P;3L<FJ zKFvU+ftDlCk@elwCw0hu!phnIeBAzhbnVx`#D!w4AnC=Ok3YWQe*=1rw{>P8R(&jf zCTSO`ekmCQY`Ijt$`r<|rDS%I?U~LC*E`*3wncs9{T<?HXGLK!c9;#QCud|mNKxSy zu;Xyuk43ZiVo(HGJA5OLW?*+!q;VCMJe!jfrYwK*>0=QOAB6opTOJ7%Rw8ieJssIR zhN7NpQfhK65YEbMG=u$IL!xUrFRr^AgkR9I+E;$3kT+1^1g1$7YECI_i<PXWeeCQb ztLab4NZNQjAfLnTp@9~55E<=Ma&_=!>BooZRg;NP9CnGNZdnR5Sn1IB$EERpWr*N* zmwK$MXK%NZ<CKgK4Kcl!=aor!euO<t6XgUbN~%Uc8Cf$r*C17m7GI`*hRWa0=M=}= zhEDbs8S_L`O<w;P&2T)!{H~(ckhcNl-rYN^g!^NKlp}ed_1V<{S*(NQ&>&XlY@o&s zDkzEBa=673+|?Q@RE-n)uFa)Zpkws`zPct4-h<@!L}>-YE=#Emq{XiG{OCzxktiSL z<f*GRG8*=6mVhbMX{f$DGNWV_z3AvVHTQt6)13<vWUVXh!F6ktXatS+xE<gvo^Z=Z zaFr;#<?JES2)j(C8XkSM50a(Ry<qykgo*l2JUhYr-sFAkPewl<KL$dD8S=J#<0!y4 zLQZrQyB@Y+*za47KeJYH70l^UB;`R1#TMfNsVTD8H%mG$s4_F=bgjyq0b3@++rL<_ zpk*a8f`wlnGW5SZ(6chvXxa$ooBVC5whMO&MwJQR#cMuUXqKgPXnF{cQf&WPpPEUc zxW87zHmlptuRGv0MOlNSYf^NtZq{M2IFX3A-qvDH)%GeAXS-{8l?@C&-DW=Y5nw^C zM*&;=B|Sbb2Vqi_%(?W0G97AFxgB7J(0ym3_|K&Vf}iXCZfPw)npUhWN{`%91%!eB zay8}bw02vmL46oMZkpt~U=AvGz9BhR>opb3{hc-!6wYJ;(M@!ZptfLW!=>i0EU)eY zQV*V+lXqO!wmJz$;;nY%y{n?R8$?=N!;3en?${llKPcoZDxMra+g~57tX_X@p_Zfl zf)K0KAZ&j)5tG-157xJ*r3>f|0-i);MUO*Y7`HoDZ3${Wdr8J=Ufj2-C<4g8uYcFB zi(mV_AIFHV6Oux8#}F{V?NK3deYJlW_s8kkC7f>HzRKvWL7N%yUh`oFeyDxUEhGJ6 z2Hf!O@)pq8$+5Uv1eW1KQge-AP{et-VV<i8I~mNTuo56tNJ()tbFuyl0PAF0+l~6H zldp)1pVCS*6==Zg(wZK!eGOnyPF((vgFk#A5=;jKndHRbQRy$=XTj}n%2pSTn$Sk< zfx&L#{Vi-1S6-u!B-|Sg2Ng#4f6_10h#oLrBQVy{`iR8Rxogs^^Ea>B5iD3X94Y#s zT>eD;2Au2d>f~$$Czhz~fNdEF|JJ~IT3PJUU)Fo>9mZw$4<mjXKFWMsHG$B0ZvtZI zCGoa@vvi9ec-cU?sG0EXrFS`6-oDkRK!F;>h97A^W-3$wwnYe@$8wUjdq}ExJFc5) zE5VaK7Yg!Z65Vgz#E-A|Ca@3BS;*IXIF4|2ch$4D4E7W6!C|w!9sUH}cNm}GV%T-i zaL$a*Dkkd9c7=0sZNVz}Iwv&ApA3+^;?*EJsvNv%_{Wp4R%g8aY8*3*gj3b9V8VCS z!v5FKygb_E7ZmrZjs4ID-wQ%+#b(o5czFniXFAG6IbOs$NdJE*SO4Da69KbH)(i#} zO(z4DGB6x0Zsz#+MCMV*iQ(d@J+0BbNPa4442kN@kA8g&KlD{<GEV3o3fsKc{+)rZ zi1FXRO2<jv0F^R$JC7Sa{i!jN<Ri3n=_||ejm-;M;|AD;HK}^2koGvLnOVe1bPQ`j z=?m(qZe5}5IX2h_et!aDV3RD)Dz2W9Wc4A6=E~8*@e0=%pEe~n!}e70N>DH7mt>*7 zIfwoTauIFCJxU)@s~Ol%_0DYT(+HapZ__uH#14COzAq^XkMLC0AOJeMt8Av6z3TLP zEWYhgLM-?mUzvatkRfz00zAf`j0Y}jgh06!mwDZM5O88Pn!G^g2^;eSED=@{8#US& z$ta4|P>-q*8_Ak;dI*j8^nuD5AkOAQWqRp9|KgiKZqKGYybYk(b?Q@y5>n{sdL_NY zD(8PVo*YVil2+<)Pg<S`Zmjjh<t2^K641;=3y`iqvt6M3{E|tRS7EYOyQeiycbbt0 z)>&}MDvcS@uEz4|rp=?{OR!%S_)PwLhMqnwY7V_d&c?;unA8GGQ*{T{6VpW<=kbq{ zT@0TO;!)LAg#Md}WUBnH%~HRA**|U}o$6%xv$8)F7H@C#J2jYUs^pvS>lX08ql%#g zmm}8PxEDn%D;(|@dp=J9rV;Y)e9#rms=)+O*#&Cl(w28;z9YKwKidOfpd*%a*0w|= z&T{JW%@(0*WCsSmH;JGg;5nRE4)tUr&c$Y1dKr3(E~|xme}aFY4yV>pav)|f<@D)M zfYJ|yYy{nUX1QMF0%m~(sJvN4#(a8Vww^jdmrVFNktuvWccS$=w#N9d?ruJ?+hUB! z{=jMUf~O_G010>r+_MPW8m1sXU92g7&zdD~RyAXA;H(*4pJL3@gmKU1H{5uA;K6LO z@Va-1*6)7LeT8|Esh4y-VV(}nxG(G)IYYi+UkmqQ2>nzY;ac5=kw~Y@)M~O3*7ub7 zlj9TRrUu1h$kw(*tCL|sd!5U*_73heQ$<gU<xNd9a{G0D(b=?#uH|a0WUt}f<MZbU z4)|oC=+7St`I`3A8l-s-H@0REWgzL%$~EtTRn9t(`L7#_GqPFSa&urwY|xY73+yWW zp~%y&3{&pY(w-WteX=FDK?(EWzg%To2{E?PP~hKp*8_;-cZnetJwj%gcIWV-qR~$y zeRhS}mqfhY(;DpC`wPNp3j6w%^U`|49qx8a-Wu@L;Y1}-(g-In4a@f~+mDBNq16iK znxSul^^4FE>vi$*|I~EdnDOW}I0{m)ll%`;F^^VIC6(64ZpwhShD=NOKFrh_c1Oe| z<~`~ECiiP?@nRL9+ivdHH0idZgtF$U&ia~>=wfE~N9~(<FxMp7(sf-uw&$+V>36lW zV9o%8#YvAbLjjF^LW4+X1&oV0k#F$k{&rD?E~N#0OsN0`t&5N{Dg#jlitY6wOTU|h z`BiQYyL50nxWXHI#=JW>7PG*)SIkur!&cKnpl>Wrg3nqXBW}mU=u?yhk!i?bOKelV zWu)uYe$PDLCed<t$tm-tk3e11zah#CJ6};HtL)Rss-c<JGG==H+e2UyvGx|3t1SD5 zXx%bc$53x&sCZByz;tkhrEtb~Q3KV^8>S2@<ho*Ov+nsUh)bunBIT3=34kBj$4W+~ zBTMi~HIH?%9b^)DWM}j)UGF%lEYsWt$KcxrztsPZhVXH3o#>gG>&8T-Ok8%O>ug__ zo?<$u*{XzKGC3~BM$5;<AO-wOM-Q;nNSd8P+85XlhrBbE^UXv_Cv9&H`0NtPayL`K zwcNIMYn_z8>tP0XnN`DUXXsax`d@R`hgB4VNArVtKPq3*31#*ME2&+bbga5dAm2+& zIkM9Nv7D>oyssEt0p7xJZc2oExE;B_a)O83?%=kLp$PZ$-HTy-JB%%)7qX<y%W-9! zh}Fh+cU-J*Z?)^6JfG)oa7?j*ll>p=bn_Mo*msWk=|9Hh1J~>HSaayh)I_=iPLvuv zb*Y)j?UZv7tCka$j`ORi4RUPYBD~T}+cf1Ip+V)WLI^yh8JN`dmp<TE7tXCJMxYSW z45!&OQA@~Jw$emM$sSn8fr^Q=(n4p`X?dvEp&I7hz`TG1udrS${T;8hH@i@Ljoz>P zUYnjwk<K9(^QXVzh{~==+WXo~b=s0AKIqZ*{^IQ1%Kow%ma>7aje2sIMV0XQVLS=q zf?)Gz^ZvU_|I)*%sazxV#lhG9Fh$ajePQ>j8$iG+U*G>8m(^SIE0eHF1}N4bs%kau z2VO{s6LXJWYrFIP_t#&d2-C#5N6c+Z`3xa*p+<}NdN2!JpgvAy2_qfT<OLchP4GOi z;<|3J@173XK(f?)FxVr_CpgWgZ=ys|+jGY#zTK!zx!uUSOm}t@#}+zRx0P0Vk4n-g zWUEN|wsqPPkvNWg^<z#+{T}#|`UbJ^eYeZofm1A0-dfUCa%k1S)rh3w!|qN-b*{i9 z(7seln&`nb5f!9ZWwEglrs?_lEO>E8@p}(P(9>xBr}u{Qn;eZ_M4$WFb$}$f4Y|t~ z>i-fZtHWH&fwq^iQrM=TC`;n*?)cO%bbes?cl)O+j1;)n5ui#dPU>~Ev#%j<8wSg% z=F2RX^n#vp;`!$NfsI5TYgs6!?c?QIM~#IOPHzi!LBB3j4%xO4G&bxlLPSr<BZusN zBa&Vkg-|JC6T3V-R)*AztdbnDr~0+4Ltejw>pQLF!~`_XfTzknFWq~OGn!GbG1<_l zBz(qPq@IA0`K$NM2_$uBS;WUXQtfQ4xn<Ak5}k@=x7K<>m{7^0T;GV$;2)3^d6|WU zFiy^(UwS??7<fa~EUUESC^$7tCK#u?kM16ANX{lJ_J5bBAAwA#0*hOCUFD(+#C)QY zsR7Xc^~)g6;NNZ@qL9IK(gfAsyGv&i8H4)re5pP+_e){%u%@kKjsVrhuA2BSsI(S( zHN`S7t~wn0e%e6k06T#&@w#jbChf0gW3DhU1M(kGVGR<mX}7jExV?6$feYTU*$$G^ z=a+5%6}k061CX~v<}e`qr*k)2x7ZfXAw$M^M1Qdti_n+j*U4CAH~CLxx&AR54iEb% zJB7rChncWUy0u~;mhzwK-BgIS1S>*a4LU;-RI;eN>A6$FqxP6N3Mz-Q?sm?x5vyFs zKbpjQpD!o}QRm^_>v*4UZyrt%@&;e&yl+I>pVVW*?qddfivPCvB?_h?$$_j0;~OMc zEsu)2V|3K}#n$yzzq8FfI1b1K5=vTu4Qad9K3oc+-4^k;W<Gi?O&o$K=4mVbHB^lZ zi_XbcJ^z}D8CgO>T%Tl_n&NBqDmBRrvyoIhlE?&d$Z6lx0K>Ft(=*F?zcU#FiYJ}n z%7qxH`!l-hn9qasAX1>OKB0MgU>rxR<y0(L9%8lLcoR^Qzno{lC7oYf^C(}xSuH@A zW4r}3|1f_luTs?-8Y-;c@!}8aJGIsQWod2&m7zv&k6EXd=t5<T$VW$wKVKqy;3iIb zV-8mqn_NecZ`}8!CsMB!CUb=5dopHlPOEDz6d2LxL-|_6SArWbt<W#k($(0^LtCin z9u54tioE{-gTrtbkkv=El6k$etmmYuD6<Pako`EWHjWp8D~ihaQ9BV_QH~tJmuq1J z7*Rl70`7z|xofFFO7m(Wv-q}J_=ObKRK=ukrYVn!6ax=m8v$QCec$G?5G*inRcoek z=9kPeZtE(?!ynY-36FbPTHTQTP`hQE1qvYSVskQ9Ia%*i>HJQ}TPpLw-YpY5*1y6< zW%tFYRv@Jcnz~k^eiwVntvv?aw9lGN-)Al75+?4&>)cgdk6K3K5UXscQX<#mgN2)C z5q@yk15VsoQzOhp?Z)IX@{Xl)%e_b=)SXYg4h}XIn&`0;j~@G+`%q3dEw>3?#>!>x z9AZ||;CS3{PgB1|wZEjTSmkFM%Sb{W*_J%)V0aL5=TW|a9EADT_pA8=zM52xKx_vp ztHcn%vzEk8bHAfsbUc;O?rqwLApwO*^#==VVt7Xq3)n;lQuXecU1TBcFhk@}@spWV z|6713sy(x|Ok3Bp+m46y@(vH;e8a>)M~>X-q$8<v&XMN6MGvw+MK<Q68G5fjyh;X% z(I(Vf$H2D(@E=8}KhJ2LnpT7HhS0uy5D4cxr%~CZ;VV-BL9I_pY;1dk{^WwN8g7(| zG$&YgR=!x@^t<qU)9XUWEB)^rU_*?B-baE3cOb)u*M`-p*;9OiQVxedMvoFh3My11 zF5iz?0u7NDhoi8}4GmUftgVS=W0$%A#N^c}Qn-ddqQ)JmvwhlqSehAjM+8NPtORQj zAOmqWCZZ1%jy#n1sk=+U8KTcJXOp6I#h>>vjoQY?rubP%0`}C(C!KmHKS3%(xe`+A z{utZ})3J5g$6a~YT1?g|Zt_^u+kHgXb!0r5WmCxL<^uk!kDRuc1)l92>izs<V6mlQ z#kyi*en3DK6cTwa>-GF$e)WB-3c>O|nB$-R>g>(Btk}#}gdR7&O$v-$H1@0C!7cc> zFx-??oRDhANt<$naQvh@v5!#A<my068>*ax!c=nnfG7Xrj(`daud_5|wH!7=jFYrO z3|y#NM%?XSO^r$Z#z`zj{DbfMAd((lGU!j~(TTu~ub3m0nX~>!lb9kK%f1nX*r1#g zBw9fN9&+CVK}yuEkV;UG?`+T09eC@E4nI5P8;a^7pz=EW43}bSD}eS?!X?wYx0w~2 z6MVw0yzfgcC;H3_DNM#L)AyH;w2{Jq{vsz?+uj<hJq&@j1}wxB+8Z~4P!Yo6Uj%q{ z%6EZYkafTRARk23TnYOZms@=<X>5LwUF^g%!Fhj<9V2qKYC+dW@w!i^yGo?g(F?zy zBJk~acz-OX&ttcYJx2k=k<8h-;VTb1VMj{1LM<#<@PY(aWCKHvBG$!0)i(`Y69O$| z*@m%lWQxPpTy>`Qn>!RVRw{HH1+A`9w+n)s=eFNzU;GBw;8xJ!C)l5Y6Gs6au5$ZF z;whUR;>`@@P}f+2P_aLSEemzMcNR+GsZeM|KkXVE+zf9<C)R0+-ozH5NIT4#JUz;d zDop$R$15QUk>9E~2+9DjdwaULAs_?6YvE}w2p>pTDnWJF&M+?XgoLCF<#(hRT{>2& zGZZo;7fnsLk@&~*B{Qp>#aan2BJzl=Yv>uJO4LrbJ>Z!)9fK#1t5NVJAjBW!%)TnY z8@kJl%Je!hGuqz<|3qxYHPJd%oNBhA)I8e`^BO+5#Yfa^^Z0O{;|cxp+mv(gYsg;n z%{XoU6FI7WE%$GFKPu_Xvy+`I_xvU0?IUr<bo{bay&offes~g|U+3=$8lXk7NY?_W z_xVMP7KdFHlhzbst_cPWz80G^_y0F2_epsd&}VXjv6#VPw3*uKqdycl{=&boiu|nn zhuimG&=P`U-4}P^PwDwAaI$%LNxrdkLWH=iuvOx?k!^^(QuTJ+kW%^lUQ(;`gY!A& zPQ7iDRq!sg$mw^hRYv5|XrAWC2m5-y&K6j{4`g)V!bqReO4*hO+SQHFA!Pw`cGdG$ znP;eYj?|HA42f-qBoSRjcR#b_<3`r``ZtGLMQ=|0zcD)&i!FOG*?fNjB4+k5T9Y0v z9|pFR?zf0PN0PJkN}pj}kR0L_o4?;#@jDEIkM+@%sl<xh{j&kH&Pf5x?T3Ci)nCo` z6n=Su#oy?-i1hL1t+lF4>)6VDR3gEKf=+!LF-7#=(<0Xk5#q8rxO8Mh`p37x@Wy4@ z<r&Z5X=JqRz_PzMQ40^;dX>{5U;Ke_B_9PlbfU77Iq)g)K$->X&on~OiJp#(XWc~* zjOZCFEfe)j4>DV4IY3^zg~Yq^;M?3k&QFuGV^fbT?AZP(YzN%E6yVP~-X0G%**8G> z_f=nd3K)=227Y&pLOe;l+LIwd#-uUY53MFcx*aM3|0u7L^X9b-QHc8n2R@-cbYCnF zFOxcYXP>b#kAg=(Z4I+^V<F@pV;JIbgVFi47kWcGn>?(@s6k*ii#UwfHS+0>CH-t4 zx=FNVMk_lfY-dvLDV&7{7jds&+mV)R$jx?c_ust@H_?cum+W$35sH#<!QOEf)~kea z6r=Yv2}@U?sRf2f%iNBTxm4GHpI8t6O0PU+v#{`AOhc~$w|nO2fr!uLUMm4lDAS|A z&1Z0wk`5MJBDpcS57i%NHa@y88JO>bX%#ne1^ZMP6MFpNG=R}{9-GrIBp)|$Ib7S} zbX|gZ`W-Kn-?d`=B4f~F`SwCznh?Z(R_@7MIK5K9z$Y-JW9p5W!F2jskBGC*6RCH5 zD745*k+}a*4zrY4@!_*T9OM%Y*F~wmG7&QG9H*zdRoc?ff?prrv7QuafY!w(^pe<9 zR9ps~)0Z#WZB)nhyKBaJ%m?$dxMdmK-iVX>#flTVCBD3jOg}PVEEL);ePi&xR$=jb zaEX-WI&nU*q+kC{Y(2rrV*i}K^mrK^6a~o6u1<C=)k*a#ERHv93%ek8AE+dJdvg#f zZ}yZB@=#YpzyX%JLX^A<dtQ;XAzawX3;qQ`%r4*buMXdnnzxejZ8aIu@9tL#XRb(; z^g5bSEtfgLFD=g2)_o!is9jdZ9eni}GafCVY5ib^XVS1{9shM~kEIeu?db6O?)gzo zk!KKhw@c#-^63N^cihxUX_Da<TRcn_lgF@&_g?-{Y%Ddo?B1<2X>MdI(=X9=>YsZZ z8!zYaC|!S3zIirq-@p||i1VrD;EfQELev@Q6VKIrQZcN%A(bj%&DQ@p*ruXpeJr9Z z^X6a+!Ef}x>-t~?5#h33`p27L>0*EAun<mF53~){nWU)P3F~+$*f!D<k2z(*Myiyx z))Rr3Wo$?}ci$>p*OUOfCFyMSzPCP{GH18y;mBiy?lzsjjoLt%vSj5kE>t;$@M{t) zQx=rfnUfs6{T_Gq)rKdeQUI>Z((#_NTFtGk%~fNu|LcIqR{ukS=Vj+{NP>L;A@a0= zN^U_uLy>xFd-%b(B8HxJggFE}QlVH0H3LS(?J#2?XRa?tQWDhE!Ap)<=u)DC&cSvA zxm4%J<l(Rx3*E_An!pfWZE@|;*c>&GZL4(4zBFr$xo>Cb84Ed)!8;(vzqN}XGI1}q zM`v_gG~+g+AJ4AJk8=|Y2N~RQJ;M~jzG7_cdPdlY*=XKE-X2;$HyKi<Att?C4D)mr zyfUrSov+;+lFE-SGG&R$Jy}XI+RtEgWxSGW`aj+q)!v$Ni_!iJvpcT0qaWY@n{4QH z!9Hv&K4I=#%R@`r^wn74LUQ!PXKnwIG$$n*%x?mkJ2#GJ6x#l3{i{_e7>M}BI;}sg zVsJo@y&w&(bc2o^E(^>^OE{wrI8d*%oUn>}_Tm=9Xlug6k#50%mPXZuJXP8%7p8GZ za{HaVVXR7yW4pAxZFk5!820s@To=Fjf<o|f)PAT9==yM+-+!_|x+*5f3m+tWObUZR zok;bP)HLkOea{(#h^`^&Tu4r~VK1c36`lO|NhNXG96Pp1KJt--$8EAdH0ozl`gq}@ zRuN&6_}7gI#L?&-r)Ecu8`a{5QU=&GqL3%wet<woQ2Hr+?K03lM(${(F-aPV)KB5; zxUMOXvzS8V{O)Da;8BidRZ>E1t)q9?n8kWpId)mxZ(80jkBH1Hq0GM{?UkPAWWaN4 zvZQl{3w`v<rylIleh=I)4?``R!YZGh`6S#&(c7AEa2R8o7|Lcs_$<ed>d4hU9V3Rl zLFVh56&(bGxx(1<Jx6+nu%cu)-FzK@-%9};5{l|&&BGG{^R!@|b}`c5xOF&Z>CrDg zOX$G~#u7W8B5&dOhg`xYCOobqo|EU@-S|*zul%L3PhED3vd1e~E@cnlW;%hbq~yy> zo4fp<MyZIlFapc|ETmyFFqL4nvOc5Z2c>4xb9u<%$B4-G^&#Q$!Spy#BE$L9W#r<8 z@U<bBU2$i6P2QI+GEe+s8UHm+tGy;oZeLCbZ-8|y#V;>TOLdz{t=%&mM#8lT-Y8<y zX-@DAT<AvT#X}!hG$#qLrWyd5wZ@8y*>jLRzJSp?{ZAYvcx?g9i83MI@pVcNhkw+T zyKp*bs&g^a4nKXqZW&so?truQgl&!!L*+!C^iWmsNrx>)q?Xx%W@eQ%BYVOWiq85N zhK0oALDw_iIekaq?kZH0aaGg`SM?kja)?r@tu^GLb6Q0!CY34pbxHie!*TXEu`@b? z;o&oy%BDt2?r_tu#lZ}%??KYYVw0{oA7?A4{|p!Z3u41Jkp+4`Gx)Ofa9Y;+ta#r} zsj;b=?AKsh=2}06!u>RVXx^Zp)G@BVmR(j2wU>y2x4W+WA~UO1(!r+Om|7Kf!ow+{ z?Fs+*aUo)yxfU5?H>m94qeKzo|0v))OX4S}-LLswK2CjWQO$Ox1hA#p-K;q#cT?}z z<K)w5-@wo$cPivMVcWkB5+e?)1<crY6Dod~3Cl?x)q)}OXzJPcMC_?doX)>g+zWPM z(lR;3rclVuC3kRT;}(;gsj-+<)b|pXa5^Ib9pI8F4Us=1eze64Jtd(rYeN<*z7PD_ zI}~&sF5yU1v2nBAT%zip-fACj6Hs2ZpqV-hSgdlqte3%=W-%<+k{cQoV;AC>U@$)P zY*=7MCAzQF&S}sDZGCA7S1vcNAxQpLhPC?af`=0M1h2Ck@r8we>q{d>X-jFoE>J7t zbk#&)p=|DKeyczk@jnM(2%rBkD`kNd51|+h){-<0ln4ig4|LsiME4W}Y~I@>%2ts> zV0U3sUH>CULj-syGa+M0)x(p@+7RbV+QiqVg%Oyr&?r;0y^-^Vf~n%ma480fAU1OD zf0hE_I+}6X6uGhIF&ORo&ZURC{R4R(kjyF95AM=3Jj&C=np}YILe=7NyE_~=-D*o} z#AtAnO1eG%Co5>ElBI%Ulu_S3n^`QDyZ?^yYYQFkqV~y6#v)Go6B1u4?_ng)+{zO= zULUt)Y&1wf!fQ+<{3D;Su#Pnom0P&I#cBUR<$+gS2AOv-<@EQ*5X?NLe7p}VR0kmV z^~LwC7RW?<BU%tDVE%EeafPjq8kYaa6xhmx>2>8#=c{N(u-YpF?igtY{sNOB)aAoh zmawz^m~@<36eNp>>y#zW2hn}(Dn>OlNo$c)_3e&%)vBhl_@va_<ZI}j5>K+1nogI$ zmc;AG4ELkuN8y5FkD3HEuH$aUf1WRYR@ZG#+-x19dAjFh<q5QnfJT#Lov56E+`?;1 zRyu8deJ{(EAq=r3n@YiSnbu$OcqB+zM4tAp!aR2Ve0(IQwBC34S@V0tCfN|es_p<z z6KT8C$(LT?Yba*12ZqymlZ@@MWtRL&%@7xehF`H5;r*<6?^sU$+vA?8bGkcVA7mgD z+*iTQ!g4}CqCSu&rSPN?^UOPF53^1YpUN>bTKUM{S%8_lyo78ldgpK%;O>}m{lmkp zF_jBr+5KmoRY`;ioY0q3yk|jxz?`HOQ`NG6HCc5`<wTsvA9Ltfj{-ioi<lM{-w^-A zdK#CfC-65hm)X5)Z~3cbtXK0}rIuXhjB4!QUmu+g)?3D!Ml)A>BhFdmTplCaY~dsN zh!{l#H!g&0<i6)-Ule9Jm5I_Sx-fMBqJGc20%4;VSFgiGk9+;)#K>QJu74g>oX?du zN}TLOoq9_IuWH%iK<{FB2r-s0z9ZYz|G@my0)z(24ksRAOpr>L!@a96;mm7*z9L-G z@M`%i#yP*psiJ<pEP<{`f=H4;L6*Oqw>QJV*|?0q9O%a7@6;H9ha0xe$*y?R2bJ&4 z={Q-ddS4^gYOjob>j5iHJ8Hli6rh@KjwxT0jkZ6)a^dTpU*&BCwrmYE!k11P5r;s= zw~am}vN<UO{s{h^**?UePVX{Cm3yseGXlzTnjAF1yi$BKKj^SvyTV&4P(RF%Pq8E? z?lcVv+Ut{};*rXh+zY?1Xmed@6Be;etplltrc~iBbH+=nxk&~2Y4je$03(C99I4i} z0-J90+;UIwf^LCz$s>w8A|xMoyC(O_Vh3g^-m58oymy{+^@q;8Uc){%PZLL?DBa>= z3)6^*f@IZ~g>A0Mact(FyBh&wkJW69Q?FI<?O3qZOhDf*3a@9@D17H`LWi*L*|s*6 zpjUjK6&tVdnyeGt>QWSJM%Ed<(&bk_*)E$A_}wwd^<7WcTlw*JH7gz^EXz;nwB3@` zD`IZzPmKeysppDnx`}g*Sb^#6HpVTDD2sJg>ec2?|Egp(Srg_>gb3Pv+LqE2@<Nf4 z8MUBm#yd9Wdqn!FG=H8>S!Mk?zMr1!LCQ!Y;PsbD16&qIIDGATZbNX$Yk?0+N)|CK zySZb%k3&Mmn`2zxoOSq==k;cN50MG;{x0<UCwD_oRk4sN`4wP?TwTm>5lJuvWAt{@ zC8-X7J`=tCSpC9zDXag)%Qj(Tis1x)299Jd)w$}F=jdU17M5aPd0VA*4)PA3<xEDF zw3_=|($M{vvd2_HQ>_v3EvJp+cv40-{eMAvomI;gV_PJc`4ALskMik3>WtZa8Xly> zlYX#Zj}|FPf@yX?e#!JEwRI(NiPW_+;=2H5@jN=3lw_IK+MRyjVN{KeU=PKXKIVCh zPGl*bw#i?_zQ@;=i?ZT%=SVsGx?@jttx^dh<CTlLkGa=5tI=_i9nSm^0=fFCh>W%y z^h3Mu&6Ns*|IPgs0;2oR=P`IyZI#K27Af?s`23}ceFff}XlaS8nPIoc+C`xz>FLwU zk&Hah3F<swS!iSA=els%^6SlA?=QN#b+=Gn@TyvhOn+x()n-1yF?P*LNR(PGS+84` zhr&W(f;2bxWh|RdXzu|8hG=cBl{|*|=DpSGo)LL|YrG$&iKV%4>@F<n2RKP{NV|`| z5LBXYkWsO*+9cMDx&?@tTbPQvTA%QhT0aL~2h+Kh%aZ3>N9Aa@2bBImED{v$^Z&GE zWHqa0Vm9Ee>Dkn1%@@hbTAh>(HA6U#2!l5eWYv+nb(|GvjU;azZ34F`R^!O1i0G=* zQWfTrZug!Ql;1}e{IISMmgdOjW5^x#?wv8&@*a!B1Pm5Vzsg1oXYW-pPdSL0c8Tse zFB8(H=*?B1ZMiLKFDC)LF{Ve&V}}&!9X~vp<^l@uqjZ&rBi$WwZVLvC)6g#_0X?Eg zRNR(rLktm3H1TP8gS5Ed()27800Td$XH4sr^QF_VFx$H7$FmbcBz}dUHE_vdsx+(G zWVt<K$>NSYAgByvpTS{mzPS_e)AAERCYo;B#H`zRLpRs-SDVznJ0@6LXe-9$lE@_- z`05zBlZ8#`hL_U)mAd9Al^f~{=U8!)4O>XH+w6QN6=P$~FA7i7q~s$=I#Mk_MaOq8 z^#A__5LUa_Nh8FbO5B0RtIQixGhAp)okkg#48v}N#)+X&F?*g0(bJ()<`37w>F#sh z6N}*m-6ME6AfrcH+U^k=CGeNL2h}8TS-tlqA~<EvmB|O(@T(-w>psLLr{?YI4-)?) zHmIJ(%!y1pBT@TUA(BA>vtr8pjcik0`7iN>BOr}^=FvJ&^J8B?cPgFjAA6r;WhSLC zGwnx*nK9wX1C5NK**ymG#C%H80w7I(6uUpWpH2&b`iKS5?tTxLf5Twn^ZY9xrpM_l zAaeF<9wx%Q75$-SRwGqlrbZ(BH}{p4?pRg&id(rR`@P>H5AiljfWY3`uepCZb}K_> z1{<Nc60zDL-gDl~H>}2auW|z734{LTncnnQtJ{Ax0fiRVL|@&gkTMimMh*j(cc$Is zHX|JZxMj~MDY6VNMbrD{kMlPhfOx2|M6*{r7wi{Elas8>_B=hxv%OqQa4_ty1QrTT z&hcIUxEoZ1I<^jBq}V%3(2vxE`nEc9Hju0h$Qu=T`9PVfT{DWn33;Wc`Hfex*Ap2H zR%1q$>2b{;f2e(5lCrYbCG`+`JExGc2H22iYHOEsCUa{_O)hm-f<rRo8eC2Rn-3#r z;5tP;51;o6u6+RAn~R*)t;a0uE+@$$79SbdjDFo|_1?p%eJ2Nh1itgrE!_o=5WD+% zNXUwIi^ktzS^WfU3m0^`;K-2IWV4Q;%RpFxPH2_1!~TLRxPS6U8?R2)>W%?2*+Vw6 z2R-Qw_0#x3i)PINigHzSS2{qqs3s-%zro{DHyVSy-CNXsQCuu1rj=u-3x#UOGxge+ z?|w2)BJ`vvTEXgfg&NLikh}$ati`%w^EI^J+Zi1WrYMmf8q4>YD%GOAmglBw7rvfB zai}*!HA0Dq;L7STfRcu&)<cAo#L3axVdmt3Ri}Kv#?2O_12JODyA1bgusErqMev9R zx5krckiL)ZU}RCw{cGsri-G5!@Oylky{(Vk8<;GX0NJu~!2%=G?oPwxt(i4#H^#xB z9DPX$2FyX1@7O=Dd*q<sAw%Q+>6reI1Mbe96lG9S;w;6{4k$l}dc_zg-_4wadVz$b z`;myN9vTQcpE2N8Nv+`-2v|)^NSAx(ddK%Zb%A2i!fh(n6@eNp=g1M|>?ga%tx;zR zOL%i0fLsBbI35*P@`p2GO4Ahc-V`3LLe(IFtwTc|ZamOph)4P*EbK?ISCO@D&+*U3 zv@a|0`swMH4MjPeU|q9ej5)iG0z*)0VtV4sk)wwBL)Io4k~XhUehO(APl-`q)t7_1 zNxfMGgKw2d(hgNuYMdorPrQcPrnOKvAsW|t6|{67ujHY7CXi)U0KY~mIuu?!4ry{2 z`E|etefu5J1JD7{D;73ggd}FuhDWc5P?Zor$9ow0zlYIR{tRA_`Gi%iBlUcKZPMP@ zjF~?U2C8wRX}eQSq_~u%FSLMyY(FPqhn8blYet;dxA^QYB2>;yfa6u*maL_`eHgu; z)5qX+stom`z>Y?!)nYz9#^E9H6H+rHe-aVB!v}bOkNY4AMY}^hVvq!}L_ZCJotpMl z&Db+(tOF{<0OOp^CmbYE7nf8zW`cT#UkU`fC)1B^&`n@U!N6QB;>Fm4vQmJgyOQ7P zetx(8@Ye@vl{dPAqOUTL#u#KeubR3n7;E*HG*yfby_%Ec$519${Rx`~AZe-~lu<_* zT9!a*NpG|3RCnOzDa&El1u6zRHv^W#l|W16V?wot2Y-ndRg8$F9iyPfSKYg}#a{^i z_jwY;hpi8_@rP!PU7~zmVwG6}UJJ)q)Ae4o2A~~IDJ6VYb-+!Kjqq*YqC3z1d7d4g zraA@v_Zfi6^p^uvuEa|Ynu%B4BDwkUmVYx0%0Jhy^NxdTE%!`nj&RqHu}KV%Q1auD z*fx-GSNJ^;2a^o2^K7p31R-U0#@v__MKAaYEDmQ353Lxo<nOKo%*_Yk+sDopizrwa zgD8d<N3B+BLBE>_QeFBEjyVK}CWGW<%*GEa6r0iQ+u9Hl&sVw!#%qkd`m2NatU5um zs^^(E7S;@N2f32JYON{AHg6gUQlj706A+Z|&jbcMseRpJEN&0kD3x!U=(mH6xhEf5 zIb=XL3S-O=u_CFPsKl+pj?qfCXg^1;LJc4hsqQUPYaWnJG=QT*^L9sV6FH~czYjw) zWi!pWdit}0X$b93IEVC=?DHsEr;Ng2urB&<i*W~P33r<s*HmM1No4uXZJwsbyRkNd zjPVW@md`~o&{4RCg+-%^ZYsRJhO{0J1kCr)F=o==b{A@aajG~=X;E3c2K~xTSJI^R z56-!d<!a<47fM!`hzy=qU5!H{;~_2B-d?+eULkx3sbuey^*`_Q;RpQv#qI=px0@m5 z*+qMx8=?RUe%_uL&WL)z-e<*c)K4(Pl}~I)^#P-zigUl^F|_X1CBP~eeWHYwPPwCU zr=#bH)@s#Hni|JrRx{c!_fOerzi@HLZuVKrPaWh4QH902Hv!s{nnqp2H6fE$O99eQ z1VDG@iHqsazKIx=p-6D)oF;_#oP!(jXBnh?&9*Nyf>A2P_;~FK5t?PDtNB40P5F$i z9Mz@>IjyjTJ8P9Eve)(EB(A!G{maSgd*?p#XOhSReJ2U%aJ(vozj(p5-}9;i?(xw! z`O6J!+>-Cr|K`>IcVH;j2X2cLwe;_sZLd4K6SPJNAibYkO)t2B4->BIMw|xr>EJJr z7%4|TKAB60e~D3y|7hC6xA-qk$@W|-ID&oD2_(BI`$=}oxDEaikL++><b*BxK!Z^( z!NX*3IoDC`6jz>oO#e0&fn`2#D+Vuv0z}R9IKMrI7ZyDH8{%`+qXYCgB9i{-s2DC^ z*ss>JCGUKzh?U%;Jc%|muO5*eHo9f?!*zSG{W@hINlr$>No3pe=Wpi43}>YEqh{vE zx4lHzo*62oI<duS)GkoT0*p?ddj@QuwPPxTwiHJL)8(yj_88&2fw8^E;u!NF$Ah4X zeeoS7==bIc_j~1}2<FdWGb{Qi!yicK$2Vg&M|C1a*@(7(a^X)GPWp!qu6|FOs~bg^ zp!hr*6pB=A9Cg}2r+yk7(cm)dn9mu#(&jbPJm_W~39dC$+b-lry_`s#`UC@VsDC!d zEjE-*#0nL&Us}y2cq_9mc{IDE2Uh3M;tsg39J()NQ%6gaz7A1Y;h~(mkr;Un$u78d zo1>>VD@BOoGTWJ2$!L3ca%^0Rkdh(B;42Hs47v4)K$lDqntJIut@UOZ4sw>Z_WWpE zrACybsiM70`i9IIR$SCy=Q6KeU%!!m6R+ad3>OLy$%-JzMg;qw-(%kMd$3Rj@2GBr zoTe;!pwLO!A;V&s!f=q!L|KzVQK1Ku-gLyHX+0WI{b6DnHCA8nfTSotitD~}y~t?n zb!n^NaCHky%~R*m58UUtVPluseeN_vD#M;F(WWH%we|-jb252>f|=*Q(G^J(@a%*9 z>57}slrv@^SBrPG$mBC|-WF<zu=V5$$uW1eVOZ#mOYTBOyI45u#Do%>P$GTOrgd(b z=cVscu&UEJXZ&OCC5Nl#fFq6bPy6(NnICL)-TvH>ubj+}<|u2G2ca!ETsHmQsVLYS znA0An?<p(yO|=d2HHh0M!7_xD2}ZevH1_gZrNR44ZFE_`Uf0JM8Ezv9xRiU+O^O)B zX-Lx^K278(9DWD)5(U4q1tgGyQzHEb&<~qu2=!4X3qF_1B4FAi0|DQRiL`77Tfj$- z($?W$MHFltBx?`#O!&aXs=k0~4rL?IRzs7($IgPwi#F(Ji$_h`-o?>>0MZ!63(x1P zW8u#IM!*OqmQ)Wd0zS`Qd8L=25w7rg%T=!ngX;svgHyBk4fhWq`o9K0l2f)WRkh-t zp60jt&Y8w>c*PGUoc>lOuNbNZ7!a<Pmnn^p;J-&R!h1CPj-B!}9HpfI@Hz|Nr9$h= zkArM@KQYsr^L8;vJlIS2;!g)u;QJI;)lCK(8p%=obNP-eZPRn;v8dhjC0=L4?Y44u zThrZ$r`h!0wcd$_qrilhx7{FWqjnsy59|2+FgWt1EPIr$>tjKYWrJ+lV+1`Cp+}Rr z{Kjc{<|V9bo0RH2jqYF7ocd=$TZQxWyG_!tV{*K_-d0?`wg3#x5ylryLDf}%&#ljP zKON)M*GE1M=<Cg6hMraG+D7MP2@%<ZeTruUYKP2k|5VCB+Gi{2GyP2BO&tFciF__q z|3ka`f93E0XdE)Q@<;wi!xi~6x7!<_Hew&J)LC|DaTK|4$3)tM^LkDv(gP~p3oMSC z4E#KpadDyeFM9Grcc5nN6Qt%Mf05FbNwv(k+H`%8nCxVDwj5ojEpu=ZOHYmthyko5 zY*B;#N@~=SjT=Mb5MSp4{+}e+dE;*TbIT^p!q7(f8Im?)n>9Ufic3n6pux~(fZZv3 z5e@?H@=8x;M)MmLcv!G;9>(`;@Y^EY-GE!<_9mad)xWqx33l(sAaEY{(22|N#MB6u zvma_#zUF>Q`>VxtKANn&lN#&9^ld*s|GmWZ|6eHo_Bs_9@KxpJqLT=LVTj&)_{{=} ziONn#`H*m#bDesfnaeD?SY6FktS|dr@uMlnIaR%pk(|E4bjh+|f)JsMf@Tb5G+=GP zi<?(%rUTy6X`j`S?oeHtc&@sMEu&Fl#;$m6#k;Zb=H*wcGcZGmFX$BdHhtQl6t&8} z$GX<Nk5xCOm0Fu(^hC>IIg2DlQPIe2U;(df)OJz>d}x+sY@Yh&jHapEMYeA>D>|Pd z*tKR>DPA>**Q^Pg4?|iP)$0>@_az8Osn_qUeoSllb2s<EY7p1g_6T=2{M{c52Ae%X z+@Zg@<U){td0z3G;c~!nCiQseLVYo*=p{@;(sLT=o3cPQD=V=Pp&>;9s4s`{8x^JT z#A!lNkki8aNa^{R^e_u3Si*Y<z^3aIGQ5E*FVkIJx~ESkFi0i~)Lv>nTG?cR6H{;4 z@^A9e&Lh1sfFUH4h>vx$*DRIP6#AoX<px$i-QzU>k=}Ihcp5wR`x94;*i~sSMjM${ z6go4}p5J!;4gayE;CJmXEIurjHWKll()PLShNcXK`Pq%A{9)ndu0&fKp0Xo6lw#y9 zhC-}yeecd(!?6oq?_%CQszKPZ?*TTv?`f+0|4!=)aa{XCTNUw0k1*uv>Ac8FReMSB z&ug0`A&?e5UIa1&zcH==H*BC@*IlVdDU@k7@M|m1^2mN=pq>!aXO&<X{}z4k#%1MF z@U>!rVy`V%QXdt@FVRN^4YQkVqt4;-1*@{@_OsX@{C_Z81HeYLf>iSi)VtH^dsv#p zlXKhn%tj;a)EMv*b(D<lh9VCDUF??R(2Z9bHL>u0tIUz&r6TB@Nu;f?e7wZ*;_pE9 z4UpU|10SV6uf@ZkBI8pxNf`j2^WcEXq)BT_70rlFol<n?%61ZG$TG6sDzTWQ*u5ID z;eTt05Fg&pJ}A(Bn|X;Gp%Dr>DCw0CB?6r6@>K@8r+PGLKwmD)4rwJ*0kh|nh{?}| zYJ0MYeVh`}`K5qjvI{hw@Q&%TEBQ=u4NZSyy~sFwMRhtHK}Oo;Yv_i69?;UL*{~zD z)R)&%@sbC>ZOq-zF2%LiHAyv(GWr#xo#1CKXhM)IbSOHE&(kD5-s?At--<=AFR1E; zh~D!WM)-)7YE?R9tUQ=0BAq|8LYyJbpI?1hJcN+oHa~0lagT&-%Hixvw-ZXw+X=9; zTc$~xSt6Ep85$<d@};xDy+QyN`)%z6f*k&>J;4tP&Qs%lWt<4w{Fc*2hUx%*832k( zPk%cN7gAzkS`5J!`eT_|d2|3Z0`#QWF-@Lq<JWfZ8bXf@O|G4~H|A@#zMe!CFsQ{l zRXrtW%3P%pePDE9&e)?2XoYz^%V-{GFB$h&SaaqEHN?LdchYHH=eTJYHtpvf2@Uhy zr)g%mwzipx8DW*OG;v~1=evzU_5PjvSK`f8*z0L3ZI`>wUu<(i#fhM@W7>4Lvca|+ zQcQlX23)1dGMRix$3J2qe-0Y1KE>Hy+srs5O)rIV;Has8?0ZLE{#~8wg97GKh(n__ zFpjy~=-Zap-y)qDiQ|oP8X)B%KP?Hqe({r6DQO5wLQ_FSXz2hr?l84Pa3Ul;#7zZ+ z?z3&6%H0i8?A0Dn#+K<k+ajQPy4w*r0aH6E)c>88PkLo$C(xm>Zu}L_o|0OO(uOGj z1RosC0`%)$fSqMON;Y@AV3!E%)L@b2taZ#58(E6Yx8W|}oPthkw3{Jaw_kDk0>i&4 zh=r-IYdhEYubi%Z=!AMbVYwd#?-I5lo?$5yx(~?*9HmDUAF7x#WHr&Uhq@A(dkAOb zrz9-L7)Y~rIF2=1D|Lc2iPDhX;@C&=olBhwX7FmNpN?70{r(%$8AHg2<m<kn&?j2m zSqml71GNO{JxWkmOruUl=2JdH?HhY|%Hli6508grKEv4SN>7SrnXqVVxWzw2E0o8^ zwC&-JiW^*l9d?;wfj~;~jNhhIEAn%tIS84(Njb*|Wa>R#_Rr?e)427w5OYK}S={62 zTYRvjWfQ}mES#0ceV6iL@YN*y0g1`FaOi0qMWg%ICvS>+KJRdwjy#L<Ny!qc%haUg z+hiL&_kyl(eD%yv(@Q+)4v6Eusax1MENpf2MVUF#;iLG{#Nn_h=PbKKo%j6zEm1+N zy-yu{Fc%=2>b;&hM#Ny!$T#f$S_5O%ZN!lKwlRNxa{)w?`s${gw%cMAn@i0s_j=o` zG>aiCBy>aD={V-KD3o@_N31H+^>x!l+xxT**%pBgq`cVg61wPW<))Myi1eXg9tRRA zp3*RU3zHeWYDk;djsi<qoJ1f2>lhAnjY5n1y)tTUh;W`SXL)*y<^4l1?dI7AHYRuc zM0Kaf?84Hd7PnrV)3|9-#ffeBsM#2~-Y&u|k&^_D${&~yrYBwTfq^2!LRxYDsu7%W z%t)nk?K-_CadG(m;(u>NopHW=d4IG2kEW{%YpZLzv=k^si#sju?poa4CAe#Gmlk&` z?hb+A?!~3JdvVv``sbzJf03J9<jI+{_pFgMYjFR0siqwJjt$pfP+{e<R*E9(DQl5` zLWC=eB%PL5gt$bOrmq6pT?XH)+@N+e-S2tmmg95}SJQ!w+TS0<mCUW_^gQ7A6apaz zq2v@Lx=8kDG~}4|>&Bt@bb+VTORvZ!x^A}xE2|sEt0unw(O)Cw<ct;8Lo0}kH-*|` zuR~I?26t5sHun^Nk2{q$MVb>Tj*)%Gqu94|B@?x?BgDAhW$HQ0KmeVEmkjk!&Ymn7 zxU6@nuWWt;YRdNfaqdk(AO$ml;l2Ap_M&^)*H~FP%m!L_eQ5h`)abC?=7rNixFZhp zFU;Wj7iLfe^Z7KSY#7hH(|1(;3fn9Cvn+(qo`P767gzPXpe3*tjcCg3qYS3P_1-wY zBls@kr~L<UT;5*bRu?xH%9yu<&oTogH{1d*aa@|RY4Lj0QZ?#0Dzc0M&=U7=28yFF z(Ev689rI~7-Im>EQRcz{Gc@oi(BfzwS0PT;;NjdMju*Pl<?}kh`O9^PS>^=i)t%f5 zE_*{rhv(wJWC!15WCC2+tb>k|+*4<xyj}bEh`XfV=xDe&kbjZ?{L%|Me-Lnq^XNV% zRWP&bS{{+NWqHS;#W8(i_-(-tlt0{C1<>Ef0(!rt9&b#qF6OrhwF_2o*y*eE30GN( ze(TV2-(yMHIPLJYL?fzw>7(2e9Qs=Dh?!7%g~Rf2jFi4Q-8SY3iuvp!-VJR5T{Ydq z4F{JyY}U|%_Spm|#`lpoLsZzeH^~niUZcjx5b#^K=CDQ?0n8l7<dANQ7Z&|V?R(7{ z&{}elGd}WaM#J_ccAkNN3Ija<UBwsxo7tbwdPqh@y`6|kRpZ<T11m#+y>u!PXctdK z3ZLV0L?uuW4AC8Td7f`QTm&lZ4b?)^+-#PJ?}mUlC$nB^{#{n85C18JK;=L|xKB3> z12cs#mx*va6@)h}uCJ&|sb5T(N6YL&gl1;<FmsRB{vP^5@5KoVxPgOz_li5{dcNNa z-{N#bIpuGW8heL;eL7pw5JD7_aJ0vdr||rr-L%^D?_sGeTD}{QDCw4H^*7E-0;x@N zPwZ^-&7?EFpb;+E1u*%Hx!+f1IRI2=huhra=Q?inEv#R%%<7I&C)=Zt6E0GJbF7J& zz}F^vD1wRr#y`z{#yln5@gT30-FE|u=h4>Dovy-*PPIJR_qmyaH`Qv`d7k=Na?8XI zvCC~0net!S6ZFpyZwqA!$lQn=0k@^~WUo%{>rWgTi_081)R{ezmS_Z`sq~bhvw_eS zQtC>-?ir{;B~^1lxz>$WA2O9dCzZbxv6l<>DQ*kPDyI(AWcpx7yKud@>A8h+Hn{Lo z_P6qD9i&cy#2yBfLp~C`n=z}*vcG(94Z0olRna9ooGZY@gb<p1&(8P6h?yU{K1b{V z3zZ>pc&sck1}-;DKRzX~a)=GI#-|1(7{}A7e?xE2P|dhq^gB{5P!ST>80i}O8dtG> zB5Fi=1CsRhh1rn(SD@N{QwoQQg|mekHDCg$11hq}h64<Vi89v`_oKh#seb=1kms(& z83t1y59JBggiPt|w(RnZkqQcO6Q@_jccD%XG;<tr|Db3;aVaDgJ$<A!T*extW?!G_ zbggIT*Qz`fKdl>JP4J-TaM7OE<hs0GOtke{wKCZ+q#a{TaXg)<MC_S5C6eX(LFeP^ z{jB_05z099>$?o>6~5Qmy>XqX8fl$VXN#=okI)-nAU;^&gpjhb$oxuW>F%PxB~81P z+J=NIBtay8>bCa_`72sf?D%S6i#d$8OcH^y^}e*1_^v)TNp9DO6ID0&zi@c)AJBY3 zhn(FAtdn`*s$v*A)A3Lup~QGdekLp?M6HHpwjL-b?>_&V#$AkCX98xzR3~I(qy5D` zPEyj~BX1cFAAd3=Y;MfMB4(&4*2Lq%B&LSkQ>NYz<803Lh&BXhxHh=_p$-7FFTSgP z!P61Ufty~J<@CZ<><2%=29d$-O{uZ&JW#Xy#kc(^gq$DM>RCu~EzAeAAo;t+$nca> zWwd1v)}vr&do~zGZFGU**~L%S2fY&*&b(ya(RFq`9n%L>68z=(GL$(bxWB6>3`o4x zTm)?)##D(U66%8pjNCRaF)o>*?rY9|`nbAEg77b_AovG7Q(qOb>CNpbri&`k;^=}+ zi&n$eP6A!=FdP6bV$!Yf&hBE(`;cE70z&d{dN=!T+`767wFTstY8c+~2+>usPF=qy z$3UKP<#(a68pm7g1bb}9*eBEFrgX13s@U+r!PhGiE=LCTdQ2xB5BM_#G};r#R^!jW zT(<|iOiT8}3g-b^n2L-J0gFwtI`%Sr^~Nx%XK`4>q3yo2n4uwe#@WBlIXbV0g4my< zZq!1X`CF66DQnQb&^QE$r|R*QJ~+S!7Sr6&+`G4}P+WJu#~uhcTx#+>O@2&srH$8p z{6+Kch@1QiBV&<0prZU{=|Odbkog=LmeSpZ2b#ibkBk-i^nqNUx<iAb5rn5wl)c{B z8?0IM7w(e(Y3-1|D3sRzzIbE`{djmJJ0IHId-Oik9GQO^g^1aOFpD%S7Jq%u&y$FN zJqDPIS*>5EF_QCcEP!?TO!QQ|q1;`;U=Sn154VJ|f7@ht#&U_sb~OCZ@*p_+wQ(|g zbp{_N%unZP)8%yZ7y7s!Y=>|Iv2iMH)RJtOG1-yU=P1{*szFM6+>@N0v?JV(vGJl3 zE<=WBxLFMHVhjLzcPZ16&$kk-DEW8EV&KMuSzD!hW|(ALPLC+mkM|$<px!8aUoih6 z62{=PIc2i3l{T!q4v|3~IPB<*k2}B>dB#$^6EyVGLOSHH!=?8m$X~DFkR_}A;8H*U z0n#VT@!e{nmjfAN5UHP&^HZ$|kG-R#@EW=VV-j`MtdoVD=|(!?$p93XXE7?f`-Per z2t07d!=6`SQ&{`*g<9Wn-4Z=LY<gm}V9mbRoi>c@vTDhy!i;?X#O?DK?fG;Fvs7dH z&|tGy-(6}r?|SHBt?`r#9}&(-+`{Blig5XvmSZzEJYsT1Pr!3vNP^eyVkYbm(UT`+ z9XyljO@wXhz>>s8NcX&)D?a2CfdE6Umo5&pqAAcEzbsoVFYE5@c(SxJyxH|1`bhbY z)b~God{cuYP4~4=hpNk>@>meAL3{PtM6*GiQ_f7R!1QCJu4F^_%7;LG-6{_-PL0S@ z)yxTE0x0*qY91vTt@UEX$~DJuy5bW`1VT3W#kwT}8jE3}LZ{NyLtQ!JcGms~Bf}|0 z(5Y6bY>f*=JT(2)MFwh3O8K|#q84mI)ZSoQr1|gVa>Xd#jkAplxcu1}2Yav3<y?RS zC4Fpj2A{#=QYu0b(VCOZ+9kQP#?8e6hb5QDO$L3jW_Cx|9k{B|5W10qew&!|g=qnN zBGWy<7Pxg;EN7I|k^CtrucDaol}9EwnTt?)dZz3^i>gXUsq8^Nq(034#0+p@f0O?F zBK5HaneX3J2$bbNhyy}cp0`IFr)L*37_S50EuU!6;Nv$5^xFy#k)6Z;q#qd108w17 z57_Xsb@sOc==@zB1oDasE6=M=z7}QItbHy$bJpD8zZTZJpb(4rlniQ8TpQFR`|Dxb zyZoS~zdSMLE13Da+k=lLBLngEZz>H@9SPhrKsz`JgJBV?Gp}Zy|C3lU&nO=ISDTQ| zqy`G$&z&qj$&<_EKgW#JEDyx!Lz(ZwszZvFXg(LgJz2A?88a_`6FR-W7#$FN?S2II z*!wlUxT}W=ZX}g^x?<1fP(wo4xo4t!1m>kR1(+fY->_EMq;Nq7HkYB}o6`3JYsS)d zof9<<_h~b>mK>asV^n^L)OU+n(w9vMp6@n3{Bt3|{|&HyxW6>lXk?89M%Wg<4phbJ zqy6Q55xgS4-?7;f$6>RPI6!@Gm&@4#wd63VCl~N=8Qnz|rO1H>xN5YyzyGut;;dkQ z%ImJa#&cT@P2Y-~0(e<2;3}Tsy!|fUWqIL?)ZS8)MLwgT?!cp-a?kL^rlGs1WV+Ne z8=kf8oNay3+lAAcvt}2KJ~ExJ$xTl}LE<7;8tokB!9~Z>W2$kNmk=WA`v;05Aq*nF zU{f60%g;4JROfbR#v2hv$uLkAMryVu=M(BT=3Z`pD<Og8!Y8rzZ3(TV96@4nUO3YA zYdY(u4bm}>W>6V;gT`l#+_#$R;j+|!<Q94Mitg9eVM?HrWwYeqdX*){(&U@MC=|`V z;f9hAwooTkOnPNW%vq!`<YMo9(O)ADYEuGM?LgW{)P%xaTkc*l;%c^&KXF!DMt6u+ zlv7TQ1mnIr*dhVol1VRPKA$(Gw_$D*Ze{~-Vz6VB7wzWfp8Mr)dp($F_~f(3li(r8 z<r8)YnTr_0c;BT!Ct|ps|MwMpdljUk8DY(;m4gDOd^vL|pTlqN+nq&QBOPPzx1|n` zr(D*j`UbZl`7kmX_<I*{B*<LlIzeEAC$!e<0Vf>s{N}>K4m#0khONO^CF3X>7cs$P zHNK}~c(OitCnte!Wr?XWz&jRRT(fpB475Fe8yeuI4SsPU<E{1<%KiG<hcBy~nEysj zN!|iR|LlpP@py{1J8eNHaHs9>)#Y34?M-)7g>#AE*zs%OM&Z#pk|y>1=*yU)`*Sw3 z{-kdg)TAfNFS%cu$2`GbY?AXcpb=f7%CY;BUF_maT76$Pv%6U4RS>Zr<a-^zBe3{1 z%B$^!&eGRJ&Qz`gyP>BvV*mSdEz?A^IW}02fW!8deirQXgo}gcJ@Jzkh)K3jS9>TC zsb6hNG-kT5X&oS=5|;uhDh|+Bj(ce1I&>7P8|`V(s}Ea$MEd+<6A~(O@osnSF6irN z>OU%Moi7t8eeVlZ9?^}inHko|&)2+{{e5V8;k80(0sGK0qhmp&I?_a#MLfKh+(?3c z-Vt;Dk3vWFFVZ?ET)Miigk++T4km*(-4The`i8s9Aj92{A?p0|<O8L8zbQAt9Cmm= zfJ;Ad*FIGut#o2IZCcU&*o@c}JD9bm-h+}QyISFr_5I8X&Z!C2CBqO6Of%i}>E~{_ zrt9PxEhtVZkW%>0)tue<AzhJ`M9<~wNN4#Ze%?FZ`BZ<}UgFqAgv;(fHg$<qHZvju zI9z-GtLeeTj);^`$d%!f1#;Fsd97ZAJl9B;K+42R46^j-_Eelq5eJ8K=1&QeWZ!yR z_vCFoYN{(41tFHxl73M1&55$<_N}hav`9$Y45dNy2r~t~hDbaf*~`qO;!N#JGJD(j z)f%)crX3$G+NIAEMQ%F^{98jM=O5$N|HI|n_{fU<mR3;OBQv`@@56VAzNF?%L#EQz zGi3BneC@He!5n7(9x<H|Pmf;XqV4*d^L<}34+YS2NC&dEV`o-f?ADV}$1{&E;UUeH z(x{SlEEkeCj>(7M(7Dc1QXNs!{&i^Mh{*@?*j31f6gW?U=s34LP;<#oYT7*Ai!Dd` zKO(5pygbPr5h>@D8S}bEr#*qAzF>UfPr55dxJ_2En)<6WPzm^ZDeYYEOjNvP!h~rX zs=c@fZN9g_sD6mSXU1m53X1+b79hs9gy9^KlO0VsFJF8Z0v;jpUCw8(MfU8Q+CtpD zy`@WKz*SwYyBBO06T*eI72!R?3BqN|)@hpbLtD!I=S=?BT{DHSt}7TMv<}0y%Z(Q9 z^Vc|u=AN)L$)vEd8CK-3sc<Nk{+1fdGWimwU1ykVLdYg4<6kdPV#9wMFz9$HzaR&` zdrA1j)qHtWuInrR)JZPZEbuPjBBsN{;b#jn`Dkcaenj!W5LldW4%NVTlh$kU_I95i zEGNBhXC4?(M80md)<WI=Q!UUoyg{ciZ#cmG_Jokn)u2y;A-jdcJ-$t2kl{6_Q{sm4 z2$gVSi`TPgDr#mcU^Cerq;dlpt|_q=`+KTGf1?$;D9{pZ<-BnvHJiVtK;9h;XKj$( zu2xYzoObeb9&sXg*VBxyQ&ITT_}Z;)RI4JR|C=6RkvJPB?`TToUus76ros*7&*h_6 zUIHC}Q^_T0)_O9Z5%X~XL=Din#TT3ncyM4s8P6@Tvb5gk;h`8hSokv3%891+oxk9A z`=w`_zIG@1dz<&b2qR0?USOvL!|t}vHBwfX-@ZU*aLgT?sUJLUN3rqBPrB@`gBg_K zZ?&+t=A+25t4HF9UaqhEFDmao!pSK#ua>K!2f~yc%^EK}@5RsxINqQC?trXabz4Jy zyyM813L$a~KZZw4HM%&fujPc3`=v|C<?-EbKoFxπOC+u3TpI8>*6MvIHw`6$MZ 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�akg4Km765!V#5ECoDjneu{{9*Rb!fnwd+1NFH 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�!C1~+%vx1Yc%$YqzafIIc9H4FI}d??rD{|*Qm!T z@P|$}4=Z!XKQr0;`cK`U_>N<C_s>$!iySgn&RyZ;yb2uF3m(2b8L5RHVtfBOvz%B4 zWeC$cIW99jycXq<OG~d~!!C{1Gte?k4$cY=t4e2k`n+M5T&;R}QW$=20h6JR*a8NZ zOpdPv&Vx0D3&Bbj%+*Tf%O(1y>w2A?Q<Zk#USA0L{vF&wsP4ovgNNa#jLD|~=js9a z4U?E{b9PeU1{Bgn7}DF_+e<E#w>S!dq?Aa)D&M-&kMg9$yOndRZq~Sv9|g9(@2s#& z#7>dfrXFiE(aqJ0RowJhSy}S(S2GMsxaT^AO@A*>^&9p@ZaR<t9>xBU2iqIR2#yE| z7rHwDIkVHIwx;&;-=D@a2)b{NWZ_A^1OYDlP`!D21;4dYYcDql2D3>^xUbj7mbk{r zcrk4(f9bSt9Ev@wmv7y<^EixGFCV?Tooem8u{b)wk!>m?K#G5tbYo?vyS=qRt%`|$ z;m87)e{iAG!pBl$X=&-2=Px!kH-AgEDGh38#K?GU4px$fGmPip&B5I6)5+O1S*qJH zo5f&Uy*xedbUy0L?$_SQy8T|Bq*%$1?7=ls3uc5}$wsDzCEdF29HOFSV6U*O^vknq z|0U%C2I9Y~z(mFvLCt*Y3ZFf_@LTyMPZs`WB`OEAN<SX{^~T9N3Y>RWlJnsLh8fnK z*{^28t#?b~^KCoN*Pm-8aeB+C=zW-FWR`n!_q@=%cRzo<7WbMAn-1;H-TAEEE0G<n z=0B>|x~LGuChj@iv9K(woQ3}J45Wik!OxOIN9pcZ6z#PXXv~$k&Yt(Y`TZ0q3JO1z z4Hvk5-#_>TGrgBJlrasWV`eTfU(49pqK=VuRgdI4kI3!})XeM{y{LDokywAgOSk-; z%$aCWTTif$45<IaoOMjs1?M?Eu_G1q>elMS&hmt2jq(>}>@oWDA{IBXkQ1gk_F;D| z2;Gvb)?Hx-HqtTLa~GV}?HydddRK!j$DjEpw#20hto4=eQf__LxNkoy`aCqU^itv_ zU2UtrJ^amb>;fA&lw82mNGSgT*38hm^V9W{FR0!3cFWTB@^@F-4arJQM-jm7{b*XR z1oSUcLr=as7cVxKY~D;&c16!?Ma$QZa#WYsYin&TT2#@m;y2a^<Gs`%=Kq}3eDyc~ z5Wa4#VaKvt)J>4ir@Ho5;^O(0nkOpWJI=jbc|of4OG{H7ML87}75aQhc4My@JzVDQ zj@`p>fW(GM(=H^lh9RO8)W2eaznK^04Wc)cM?J2>e)rLnLS4z)0DvwWH51+In$7Zb zC2z=R7}TynyJGfPm5AAYS;nG@v)`GoSlp~?O{1c^#fKjM9m{v_T!*C9T^fJ&{OPr% zw7VH6E*5Qi%z5=^B?i=G7CP)~E?c%_7*+_@=LX9>+EiRJ%YJ>MAC!#83O?ieQwUkf zlR#W4cLuRijBhUrvP>MNrrz87`64H|DMsA&S6_v01`TM-Y*KuiVZj&eia7qQPh3M? zc`t`+q(Q;|itOv_>n&NE7rLeYEAorP<MsKfBPUr{KHk7I#z}jC)r$+B>n?m)*%V0s z@<R-Vyx&DcGd7+zdU8Y3W%}L~yZfG3pS`|xj6tv`M_cI7EZrs7#+VtOowJ+gAkhWT z3H=9M>b~r4?Hj7l2jBgYc~hLMj|``j6xc*F&en>U{IMsP)rEz~3l1OCqNCvqCPBcR ztO?doRSomX4h&QQd764RjeBb(3<M-K30aToTIdNwl?{~5#t_vJDtE9qIjN$o+Z$PP zykpK<6beVk?v+|J|Dt48P;HD7&>I-|0Ztgr#qFf6HTLD1UOryl>-bwxtmR9F)r5=< zj=<vY>-em<uO#^F_0qh(b*nwoK+<Qo=Y`{VljT;+F)+|ssBwmQwgwe)Bt@&&TYr#{ zLllmG`S^^a6GW!`aPn7n`N8~)%rT*&qH2icDG1==mV7cl@UJek!7K;6VO;t1@v)48 zCGV&Ln<;vSC4sqZ5^^e9FX|~B+1)~`arFd$LIedfdmKB1BpFX>zM+<}*z0wDWcM(1 z$-F7H!hUc%vW4k&Gn^|GnT0ELFr#dvldEHdLy~sP6>rb&LWhq+O@Bf-<lAR^`Lt5c z%pNz(DN<gug-AlUlw2KZ=6#(NNb-%PaR!G%lkkFoTU<&3Tff8k4528f{Fxt*=GYh= zNJTP#((ymM^GcjFG&E01$X`WMU*Q2U&I)Xbgn}=89l+rV`n~mL1g61y+nj$3{v*p3 z*VV731xoKhv^4xc32f4nk2l4$XAJn;Q&CXx-`5}5!A1z`WSccG)lqBnqdlIuQ!vrq zOR;}QQ**;*^fz6-wy4bg2eA`vWgZZxs&3db-gpHTMqqUO?I0F$KPu+9YRSMtc?x`g z{|~(?)HOO#(~$bxN7dcNm?EXuEE9thPz!Zizx2t(pU4(vYW5=#4;x}4;0w=AMtT0$ z$LFuNKGDV>@2{|{Ag3PL{`X@U6hsiKY+t3pzeBxZc0H$X6%`}?d?&HJJw4v1XxAGh z6%|;{b-tNumzS51uc-=<RNxlMoVa9GSX_KGirrzRYv3tKb+1&FZtJ5~3D*fu+ucp) zDes+828Sggb0r`#FH#E+u#ixGTIHS_eb4rkiY(UVWII420}+Ah{Jf3$e=J9E;*Uo@ zT(Eox23M~A?lbTrUPi~Ky01(XKq;HUl0O9%Ma2ohnXIFvc!>Of4<Y~tZ9w9g64yCf z`<wQ6Og<IL-}{$pFdpsF9}vAKzqcz!TXDY-j&}5S1cQi_uyD3YvidVWk_#Z%ESQP9 z?jwTjt=INKO}y8_i!Qy|fwpwL64#l&e4Bbuk*`?8;4I&wdPfpJH$;hry2eKc?s{Dm zIdcy;zx;xNRrg*;FOTT<#`sXYPx$J+=Ks&!Kj{!)63ixhl<~wezM0Fx|C;C(i)+mv zt4S#hi;SulN00V;a-Efxt++V+v#R&87sPhW)niYHu%Ly3&Y)7R8%aQ1C|qTgfKT!> z0wXl>$1BXoXD?f|l`axlJOlq-`~;Sql5Ki!PH0X>Xs5)k0)kmbnBoA%i*HU{zJZ(9 zE3jReYuD&9enEyOr=E44?Mk}wK9zHfC-BuP@Z^gfw}J}g&zyAq$j`sEHr1_+n{R*A zk#ej4ZaOLDxF8G02y`h#hi#C8tncf^PVmI(NnX2QKT5uEfnYbm`M7ToY<_fx9AQKq z$1xKphqqp519!I8)S`v8s!W(yrhEBL4;b94EOCV&T26RQ?(Xj17$>Q{>c#KAGQH6` z9<QBw-=J#VgkVF-XDTWLNLH!P@oCNWC6-_ZbIf-N!IhP(G^}rJb?*AZct5gKfuh1? zXA?_AqM%Cl;0+liS`%`|i1hl^{&_|W)b%ZoW8%_*LHEn8i*O#h;K^U7!qE{EyRXa& zY{}GJeda4<P<qayP~gUU!Cu3AFQYG6wFR+Co{_q#kCA!2ZvVaH<Fk?ppS_IAj%MS= zcuAhw<GguW`CbjWmlaimf?mzG<smM7z@R8tN?Mh_-jGtx^?V7%cRP|?dr3iw1iVim z#;kh6-5H4nJrM>c-WXjl>}6bM_2m|B-Nbk=j?~5K6_~O|zbi@h82o7$_<>&>U0~Ug z4?&OYIpe>7Oh26DR<B)9Ql|-Bo9UJG>Jm9ZP3vxc#Nqy#vuZ4|yX#rTuUK#4T#KuV z^Q<DaH`4;@^6TGAYIhVaJo9Ucxo8Z*PayGY$3Q$x9)Q#Mv%RK3t;CL)m>3E)&G`22 z6?t~fWe3wmBrU5vMxyPxAs;j*^)|czf|dXBgLT9~?7C{iV{LYI;_T><gEW~%PN$St zSMJW%Dz8fwm~v{dG7z!&@${*7W#u8tg5G(sE5_@2j21tuF*69<J7**enK5~in>0ka zNEyKQqB}lwWui9SKnAI00Wpp7va)V}&$#UgUjd7I1SRbOI1(zlFYO^y=wny~zx}zh z+WjLT;hA4S__Fw8cM2RKTF`y>Z1BBYi>x1V@(QZuj^nl!G9PUvjUztCdjcmK27K@w zs(ShDqA@}t$H2DAJC03CfKXmcAlRYw0=jq8@2bIN1|-J=rT~NsbTXcTeEf?9yP9aP zi@kl4PQEq=Q9YF^6A#nckcX=pvTlVXfv5T^3@d=8I=9aC9>SZ}dmFCQ+;~B)JM2r{ z<+BzWP}iMr8@tw`!O7j7_Q5CX`;V)fB_s7qM75A7hXjL#C1R<Vhg~wy2$r$cO;n=H zV|c6C@!=wmdQ6BZEnpxQH3OG-u;0-%ThqNIu6`eK1%6<jl0XQa7s^~6hQTo~noF$m zI-GErS<HIft(kK)c*``9{*CPR&8iW0^TwVsD{yaDAG<n6U$VRJM{(7=zU8y?)Td8p zXhMmQBq#d%0T|<>v1e7%i=FxROqLdIc`z`7je3ZjvdCfBZ#WU6IG+yTqw*f*Z{?Q- zc+oc+esf#mrU^Z3(N^jf8Z2|-;zmSu;GTX(U!U>>{-{MAdoI2(uFT^QX(^x4;|dwC zxgMG=hs1nlQR^p9uO3oV4R>*qLCJwIfz(Jil4iwxxw>)mecIh4PYOQz`-gNNKF2LA zENqYaUK7kFdBq>BNnH%R>#?qjiAu2&&Nq;d)4!DFa(Qx^&?E!auZgJiw$Uc~9~>NZ z47{9M9Z*O))0NxYnDoP-#5qkr=~X~o)FvD011LARNeU$-Km?Eha&_d>%kUgM|Gxfx zCbs_stM}*)`<o%YEB`HLX&-;TY_7M2&nTOecupkSyy^8s%Zyi85OHqxr<W(G*d@Mh zJNO5)M9TVjch)=_KY2J5gz?wwEuT5L_3lT8w<ibGLEPSt>@+Q`Nys`?_f{racM5Bz z-i4)Ou)%jvKC0YY31kp*F(D=#c<*FdCX<NuIivWtblsPy_|!nrbE`(3F<N|ik_TL& zthHNEJ?7xB@I#)g{_zq^$!p4E^|3s`)5cA)l43?37dmTh+|9Y0?$YngXS4{GDnyv% zs!UKpU>zTSjuK!*GGrnf3_`PpJry5(7H%8K7^{WsN0Y93BW#z8>YeRgy=>J=n-%<7 z1|<MNvh7316jZ4y9-Gz<{8*K_VMx?9^uyQRmy9C7jV*CR4h;Y4@o}E5ju!LA*K8NB zu4cW{N;!^G`9s$UX1xCD)*L4{NeCi+AU_|31kvZ+6$9z{CJnzTEDVLSLzXe6Ox-<0 zzvzUCPre5dOVGv4{ws3K^Vw0Ul8GuN?lKyi)`=_=u9sQe-61dYSpBwktN0j>K3OA{ zN%~RPJLMQrN3*+jcAYhG5^j(G;G469-+#UKmQ#*{gOhtN&$(Mewt?;<Bj&XQ4mF~P zaUlgPptfiQbiaP6@OgQnx}R59HCpU-oRsAdghS8BgrjT}0fhOTwYjpr@&K={Je%l{ zkkdxlA}!{B<{P85R<nZHWoP^H(#nGW8e{VB6+Iq(d?+&|R@f#gI2aBi&*fMUq+%%P za+)$?CCzrOfapMF!x@AeBkBSt1S)g*#$fw(?{;^q!F$BNuTz<E-W{GhHtz37^?vmp z1imbZidWigCR;OkjN*g(U7yhJs8*R+j{S+|8S<NeEY+@Se>t266uNO*Pj`TGz~}h& zAXc~40T~%`YDUhjdv&kq)~G4McH8CtLe}(`;xUF-A1Khr!KSF8!LlMHLufW{Z|_4# z>G=(cRnh1}t<1IqSUE^>-*|F*pB%mdv$K1hF^XsFEtYm)z6RVUY*PCTLRpHc<*;$w zgA%LH-Xu=bOC53~<b5Ts+E0xkJyD8Ic$OVp296u}C$%znv$IEkLzYyd%4C*^5>E@{ z0J@P1SQ2$QN37pHO&^e0uRSj`oB5k~sy%&cCBpEGR0x3>cJYay4Mps@jo8U$&2Qf? zFE0;GR;RnT@h9?9;`E+^<PModAP8bgMwU|y%siWDUt;pWCswasznX-&!?f|)v|EWv zTU4wHs`nSCGN;-y5L&Q+zz<Ycup;Fa=&*vtv2?>s@6ktEwB>S;e^VIv{`3UfHHc&g zpxe{6=cYt$6s9ljXVXAIKP@a_VG;LyGW7fi906404S^CK{`pgZJ?X%<ytvVwZ!>(^ z(Ra}dLcf+>cdAC*WE;BkodZEAgdswKh^R{rp@ipYH#x9o=~2%<YNSfIPM@NqGo3pS zxnS)(&vnXI$*B0~6`QS`2a%hy@PmA&VoSYhL?BvJ=-!2X5V_K5J${316Ikaebu%z; zcmWsm?B7;?ZY%A6C-u(ts*xkZzu2YS6@UtFfCByoR7hBx_BJG~8Y-*@=Q69j#JM%1 zp5O;KO4z{x<rF}Jv*NQ}`<8PRjPUuY1s6jm{@Q|mueBfK>;WIPmp&?j(g8+<sy#@{ ztba>jXf!<T8t{wVdmVHTL^LP=4)C^o-dJA`_|UVjDPIEVxtUm?L^;utghIu~sTFJY zyYu@&1RR$Boo!l?xoMnK7R-s(3#G>z7#NHT+&gU)4{^Lk)(VD;I?ui>3_}h}z-Y1K zqOp41m4|bQ6vwdw9}_>4kW(UI8tewq9NF@8t6tTJm&clVyr>$e>`@3VSob`k2{k-( zHqWYs8(iHgdsHe2qHl+Yx*U)*pj{c$vs+=maJr=SA745t#2K%HcnSTiO0#^nt#<tQ z*puNmM^5q_W$G(2&xDW)sC=j$476&`BtJyP&CUJll&#wyqqN!UfI7(R#7a7!xY&6= zZ|a}zmYO6@=v+@xcV=*iCpDn}bAk&tkLbL=uoXsHmZ8|oY1oawGX6JjVuB7=xG&>x zb=G_iyBzI0BjF6Xk_Agn!E!I#<a*~?*1ZCknRkXhbQe1X*}a!<EsZrOs(QZ-`<L%* zb%a_(6_eiqo)5)E?UN;ih5Yw&fmH(`Lme;eaTjObP02)yH~d%_P{$L*PR%TN7l=U@ zigY2hlRJ!<A}LstiN7Rd6vx3YI-jai69lAv6Vq4jwbj#^1NQ0dzEU^kAT|M`>=9J& zfl%0zp{JhhpK8qkJHv(XKmESDHFpeRpl79U*ND1|emvXI6SbkgsMFJKJJlwz<P*hX zlsyBrJ3JcE=PlM3N9*rqtjpu<QB1%Vkkb)Bj5O4Fe0zmYq_8O|rCo38`!Z?{pa~YN zO8EK91ODtL{XCr=&50UuQoLZgFEX6F;8}H8u2TE!y<Nx>onW*01|>zjmiMmJCaFZ2 z#BU!MMXOo#pgLnEz1i6_%}Ojc>uTbCc3)rzIey4R2>#xqBf?$=sgQXZU5~`mF-H%K z47o~(F)a-IASq!(Vw>XVlH%gVxJ%uw4F;?4`GeRcMFsgmHZyzmf6-i-Y&}FteT1qU z;;qlgnfTFq3Q9^;_<q^*!ngafuI_DC|J*nxYS(whV%@5!u<%}AP4>omz^PsSF`b$@ zg)(Cjq!t&+_+%rwTr6>dkCrEnT2{Zrr6I!~d_3@8=)LxK<{*dryQE1)c6rWc_!p&I zWlO~H8y4^rPKflp{bgQ?vT=aa)x!i1xSO*W6f5ZySMouYwa91hzTJu~%~Idioa_3o zhg>B!o75H4cPZL3PzWeba)AbGio{vatU&s8g4hjR2n|im_IkKTKPULT5bE*GJmd8t zPCz|g{E}{m+J(RE`+@4iOx_RncE4zTzUJ{YIeC1#$Z@onhqdWrVkH;njh7oKbz!15 zJ-HTx#BSzCcUs<72YUB?8`J=}2WJOqL~Uar=a1cRG;8R}GWN#~LXk2BC8UOO^+98! zxX#`DHc%$%8ebz*u-(zrB;*@d@4L0udjGsMm-3t6(L(V7b-;7kYj(3I30`oHX}zfP zrjy&+b<3ZH3s6QyBPOf0TOCM!l!8{w=W0h`VolHukFW2v(XxSVqEb%yUP;%hepGbJ z0x^Gczi6(_b@L2=f3&x$o!l5}%JSj1wY4=^v2jvvoEF}?QC=ISq1<X!4RiCou1&^9 zRSo1=2N8?!ku=y*LXb+%-onDgupjatu`<q+X>I9NZ5eqc3z_HpLYDK!{_NDjW!)+h znddj@gWwMNz?3Ri;zzR8xjl#`%PpmwE-o@%a`_tkfq7aNY9L%DdLV_S;!$WsB(dr? zJwI5P;peaC9Rn2sgIr2x3yqx10WXg%_>z#<KWU*86Dxo)jJw%lnAHGZSjE)}i)N#z z@?_?<rzgcOZ>`S8%Gg8!cAcIBb|E{ZkFb5O$9U{V`DecGd`cZ_f_}eGJ5vB@qNc{U zm0{nlRWHtXVko!aR*&O1T7*gy2n5>_)DsH@4$Rn@zj<k|w@Qb$H<l{>sp!P^=&pJc zSNp9@F$mq~-x!3<<ofgVx0QZ|#ZD8W6}-B+9p8UEWi-%#&b9_7<Wpa7#H`79+V6~? z_kP3lSS=@id#xWzM0jnzZ$Q|Miv8l%o(3^~BnjX!^t^BbisAuu=KnyS>MYSle=qs6 zn_O79E(;!rcSF}miKB%=i-yeZ_L7i-s*J;s0{B#`GOrGTa1xq<0?`HhA7nLpwj&h! z`@eJA4}1>@I1C}U(`X*<GtXbVh!ywReD4G1z)qW?kH9jHe!MA`{i5fG5xy$JTOSo( zLfGt`>77)54=BLd{P#t3i0bVh2uRr9ygB&uMK$ZsA6835ryv+-&qiv&h=jQ$!ueb? zzBvrH?1@7h)#vNIa?=(w)4T5J*EN$(*60?GJO`u10Bn>0z_e-J5A4_HX`~(`rms@p zI<V`PkNfO-J+!!%!gW%C9A8z%aPn8MWaKZ?Z>;5zB3SEZa9uV5KSZb4<fVQ~Rj;p% zj=ptI$}KJ=&@p?QzSn&@<<6IPIQi|}?!T?iDymU<xcp&%0$@cFI}&aAy<(8e$r?2W zQ*NyZVr|xaobE}>o#o$1LwVGsoaRG%5e@Aih4wJ76Mp!cmfF>|=<bcyMpntO#>mjH zurpgGP;3&OuG?ydL$B6lp1N#Q6?w^=CGxZgy5ic6C@F`bW-d$bN#kFy8Rf5DJw|)} zoW;`!ZnYTub=*T8C9SV_iae-G=30#-s-8awHk#=!(XO(GJnPo#6wh*(dEkc+=Dj7( zfq`#M!jGsUX(_m2Er29=(3xjM%2R4<;e@UGN_o$pztLl4`9&wq4AR7b(ftcsR3~QX zR0!eqe2bM<ZAvE5Fw=M^{c=yY-=D5iv&)1zn6+7Vy01>|K#&uPI8wnkd$HS3*>e_> zwGC!VARVZu`{Q>tmKGKmFFyX{+V+*x<q5B#p!Ymh4vg}5rcwAnY^Y6l@e1Vi;jXqx zjLxTPZ{y0`O;4`A{UF5acFAfV;@AH{%u*D&2&i+J>2luf5qHzR;g9za24h&<V*dCM z>NBAB&O%wvx_X?bR=C$+h9xPpwm*@|RLO4dGYo!*33O*^nnM9ywyH`$1l=32c;-CN z&}iy(9RHo�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%0ATz-&#<c~*9Kv>Fz4gb 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 NE2=AGTz&BD{{b@}7jXap literal 43157 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 From 8ab9444f3bb2beea658be34593db1787b78449be Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 30 Mar 2024 01:27:27 +0100 Subject: [PATCH 0668/1103] cleanup: prittify get attribute value - Prittified how we get the attribute value. --- Shokofin/API/ShokoAPIManager.cs | 10 +++------- Shokofin/Resolvers/ShokoResolveManager.cs | 12 +++++------- Shokofin/StringExtensions.cs | 5 ++++- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index e07d047b..857ba855 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -346,13 +346,11 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu // Fast-path for VFS. if (path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { var fileName = Path.GetFileNameWithoutExtension(path); - if (!int.TryParse(fileName.GetAttributeValue("shoko-series"), out var seriesIdRaw)) + if (!fileName.TryGetAttributeValue("shoko-series", out var sI) || !int.TryParse(sI, out _)) return (null, null, null); - if (!int.TryParse(fileName.GetAttributeValue("shoko-file"), out var fileIdRaw)) + if (!fileName.TryGetAttributeValue("shoko-file", out var fI) || !int.TryParse(fI, out _)) return (null, null, null); - var sI = seriesIdRaw.ToString(); - var fI = fileIdRaw.ToString(); var fileInfo = await GetFileInfo(fI, sI).ConfigureAwait(false); if (fileInfo == null) return (null, null, null); @@ -686,11 +684,9 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul // Fast-path for VFS. if (path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { - var seriesSegment = Path.GetFileName(path).GetAttributeValue("shoko-series"); - if (!int.TryParse(seriesSegment, out var seriesIdRaw)) + if (!Path.GetFileName(path).TryGetAttributeValue("shoko-series", out seriesId) || !int.TryParse(seriesId, out _)) return null; - seriesId = seriesIdRaw.ToString(); PathToSeriesIdDictionary[path] = seriesId; SeriesIdToPathDictionary.TryAdd(seriesId, path); diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 81f9156d..b1aa0975 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -595,8 +595,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou return null; if (parent.Id == mediaFolder.Id && fileInfo.IsDirectory) { - var seriesSegment = fileInfo.Name.GetAttributeValue("shoko-series"); - if (!int.TryParse(seriesSegment, out var seriesId)) + if (!fileInfo.Name.TryGetAttributeValue("shoko-series", out var seriesId) || !int.TryParse(seriesId, out _)) return null; return new TvSeries() @@ -644,11 +643,10 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou var items = FileSystem.GetDirectories(vfsPath) .AsParallel() .SelectMany(dirInfo => { - var seriesSegment = dirInfo.Name.GetAttributeValue("shoko-series"); - if (!int.TryParse(seriesSegment, out var seriesId)) + if (!dirInfo.Name.TryGetAttributeValue("shoko-series", out var seriesId) || !int.TryParse(seriesId, out _)) return Array.Empty<BaseItem>(); - var season = ApiManager.GetSeasonInfoForSeries(seriesId.ToString()) + var season = ApiManager.GetSeasonInfoForSeries(seriesId) .ConfigureAwait(false) .GetAwaiter() .GetResult(); @@ -659,12 +657,12 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou return FileSystem.GetFiles(dirInfo.FullName) .AsParallel() .Select(fileInfo => { - if (!int.TryParse(fileInfo.Name.GetAttributeValue("shoko-file"), out var fileId)) + if (!fileInfo.Name.TryGetAttributeValue("shoko-file", out var fileId) || !int.TryParse(fileId, out _)) return null; // This will hopefully just re-use the pre-cached entries from the cache, but it may // also get it from remote if the cache was emptied for whatever reason. - var file = ApiManager.GetFileInfo(fileId.ToString(), seriesId.ToString()) + var file = ApiManager.GetFileInfo(fileId, seriesId) .ConfigureAwait(false) .GetAwaiter() .GetResult(); diff --git a/Shokofin/StringExtensions.cs b/Shokofin/StringExtensions.cs index f9c5cf1f..7842f946 100644 --- a/Shokofin/StringExtensions.cs +++ b/Shokofin/StringExtensions.cs @@ -67,7 +67,7 @@ public static string ReplaceInvalidPathCharacters(this string path) .Replace(@"?", "\uff1f") // ? (FULL WIDTH QUESTION MARK) .Replace(@".", "\u2024") // ․ (ONE DOT LEADER) .Trim(); - + /// <summary> /// Gets the attribute value for <paramref name="attribute"/> in <paramref name="text"/>. /// </summary> @@ -117,4 +117,7 @@ public static string ReplaceInvalidPathCharacters(this string path) return null; } + + public static bool TryGetAttributeValue(this string text, string attribute, out string? value) + => !string.IsNullOrEmpty(value = GetAttributeValue(text, attribute)); } \ No newline at end of file From eadd8ca3cde1b218a9bc68f1b393021a07e2396a Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 30 Mar 2024 01:20:44 +0000 Subject: [PATCH 0669/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 5af2e41a..96e9bddf 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.61", + "changelog": "cleanup: prittify get attribute value\n\n- Prittified how we get the attribute value.\n\nmisc: update logo\n\n- mascot accredited to @Queuecumbr\n\n- image accredited to @ElementalCrisis", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.61/shoko_3.0.1.61.zip", + "checksum": "9ad292bd6812e255ef90cea787c4d743", + "timestamp": "2024-03-30T01:20:42Z" + }, { "version": "3.0.1.60", "changelog": "fix: impl. actual attribute parsing\n\n- Implement actual attribute parsing on the file/folder names within the\n VFS, instead of the faulty and error prone guesswork I\n added in the initial PoC. It was bound to fail eventually.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.57/shoko_3.0.1.57.zip", "checksum": "24034540bd83bbd1aa80694bbafcb2c4", "timestamp": "2024-03-29T05:38:13Z" - }, - { - "version": "3.0.1.56", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.56/shoko_3.0.1.56.zip", - "checksum": "3b1322198a5614672a565cdafaaa92b4", - "timestamp": "2024-03-29T05:31:41Z" } ] } From df755ba191a0e5fb363b559db0d214b63c9f3222 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 30 Mar 2024 03:50:20 +0100 Subject: [PATCH 0670/1103] feat: impl. file path generator - Switch from getting all the file at once to using a generator that pre-fetches the next 10 pages. This changes shaved off ~10s (from 51s to 39s) on my small test library, but didn't affect the overall performance of the full library scan on my full library. This change in essense minimises the risk of getting all the files at onces timing out and also allows the rest of the symbolic link generation to start earlier. - Don't check the file system if the file exists every time we iterate the generator. Instead use the list of all known paths we created earlier by converting it to a set and just checking the set. This helps a lot when you're accessing the files over the network. - Add better logging for when we're generating symbolic links. --- Shokofin/API/ShokoAPIClient.cs | 8 +- Shokofin/Resolvers/ShokoResolveManager.cs | 198 ++++++++++++++-------- 2 files changed, 129 insertions(+), 77 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index af9f9779..9c071e37 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -267,15 +267,13 @@ public async Task<IReadOnlyList<File>> GetFilesForSeries(string seriesId) return listResult.List; } - public async Task<IReadOnlyList<File>> GetFilesForImportFolder(int importFolderId, string subPath) + public async Task<ListResult<File>> GetFilesForImportFolder(int importFolderId, string subPath, int page = 1) { if (UseOlderImportFolderFileEndpoints) { - var listResult1 = await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?pageSize=0&includeXRefs=true").ConfigureAwait(false); - return listResult1.List; + return await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?page={page}&pageSize=100&includeXRefs=true").ConfigureAwait(false); } - var listResult2 = await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?folderPath={Uri.EscapeDataString(subPath)}&pageSize=0&include=XRefs").ConfigureAwait(false); - return listResult2.List; + 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) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index b1aa0975..c0887314 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Emby.Naming.Common; @@ -123,14 +124,19 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) Logger.LogInformation("Looking for match for folder at {Path}.", folderPath); // Check if we should introduce the VFS for the media folder. + var start = DateTime.UtcNow; var allPaths = FileSystem.GetFilePaths(folderPath, true) .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) - .Take(100) - .ToList(); + .ToHashSet(); + + Logger.LogDebug("Found {FileCount} files in {Path} in {TimeSpan}.", allPaths.Count, folderPath, DateTime.UtcNow - start); + Logger.LogDebug("Asking remote server if it knows any of the {Count} sampled files in {Path}.", allPaths.Count > 100 ? 100 : allPaths.Count, folderPath); + start = DateTime.UtcNow; + int importFolderId = 0; var attempts = 0; string importFolderSubPath = string.Empty; - foreach (var path in allPaths) { + foreach (var path in allPaths.Take(100)) { attempts++; var partialPath = path[mediaFolder.Path.Length..]; var partialFolderPath = path[folderPath.Length..]; @@ -153,59 +159,113 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) } if (importFolderId == 0) { - Logger.LogWarning("Failed to find a match for folder at {Path} after {Amount} attempts.", folderPath, allPaths.Count); + Logger.LogWarning( + "Failed to find a match for folder at {Path} after {Amount} attempts in {TimeSpan}.", + folderPath, + attempts, + DateTime.UtcNow - start + ); cachedEntry.AbsoluteExpirationRelativeToNow = DefaultTTL; return null; } - Logger.LogInformation("Found a match for folder at {Path} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path},Attempts={Attempts})", folderPath, importFolderId, importFolderSubPath, mediaFolder.Path, attempts); + Logger.LogInformation( + "Found a match for folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path},Attempts={Attempts})", + folderPath, + DateTime.UtcNow - start, + importFolderId, + importFolderSubPath, + mediaFolder.Path, + attempts + ); vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); - var allFiles = await GetImportFolderFiles(importFolderId, importFolderSubPath, folderPath).ConfigureAwait(false); + var allFiles = GetImportFolderFiles(importFolderId, importFolderSubPath, folderPath, allPaths); await GenerateSymbolicLinks(mediaFolder, allFiles).ConfigureAwait(false); cachedEntry.AbsoluteExpirationRelativeToNow = DefaultTTL; return vfsPath; - }); + }).ConfigureAwait(false); } - private async Task<IReadOnlyList<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>> GetImportFolderFiles(int importFolderId, string importFolderSubPath, string mediaFolderPath) + private IEnumerable<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)> GetImportFolderFiles(int importFolderId, string importFolderSubPath, string mediaFolderPath, ISet<string> fileSet) { - Logger.LogDebug("Looking up recognised files for media folder… (ImportFolder={FolderId},RelativePath={RelativePath})", importFolderId, importFolderSubPath); var start = DateTime.UtcNow; - var allFilesForImportFolder = (await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath).ConfigureAwait(false)) - .AsParallel() - .SelectMany(file => - { + var firstPage = ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath); + var pageData = firstPage + .ConfigureAwait(false) + .GetAwaiter() + .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})", + fileSet.Count, + mediaFolderPath, + pageData.Total, + importFolderId, + importFolderSubPath, + pageData.List.Count == pageData.Total ? null : pageData.List.Count, + totalPages + ); + + // Ensure at most 5 pages are in-flight at any given time, until we're done fetching the pages. + var semaphore = new SemaphoreSlim(5); + var pages = new List<Task<ListResult<API.Models.File>>>() { firstPage }; + for (var page = 2; page <= totalPages; page++) + pages.Add(GetImportFolderFilesPage(importFolderId, importFolderSubPath, page, semaphore)); + + var totalFiles = 0; + do { + var task = Task.WhenAny(pages).ConfigureAwait(false).GetAwaiter().GetResult(); + pages.Remove(task); + semaphore.Release(); + pageData = task.Result; + + Logger.LogTrace( + "Iterating page {PageNumber} with size {PageSize} (ImportFolder={FolderId},RelativePath={RelativePath})", + totalPages - pages.Count, + pageData.List.Count, + importFolderId, + importFolderSubPath + ); + foreach (var file in pageData.List) { var location = file.Locations .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length == 0 || location.Path.StartsWith(importFolderSubPath))) .FirstOrDefault(); if (location == null || file.CrossReferences.Count == 0) - return Array.Empty<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)>(); + continue; var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); - return file.CrossReferences - .Select(xref => (sourceLocation, fileId: file.Id.ToString(), seriesId: xref.Series.Shoko.ToString(), episodeIds: xref.Episodes.Select(e => e.Shoko.ToString()).ToArray())); - }) - .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation)) - .ToList(); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + foreach (var xref in file.CrossReferences) + yield return (sourceLocation, fileId: file.Id.ToString(), seriesId: xref.Series.Shoko.ToString(), episodeIds: xref.Episodes.Select(e => e.Shoko.ToString()).ToArray()); + } + } while (pages.Count > 0); + var timeSpent = DateTime.UtcNow - start; - Logger.LogDebug( - "Found ≤{FileCount} files to potentially use within media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath})", - allFilesForImportFolder.Count, + Logger.LogTrace( + "Iterated {FileCount} files to potentially use within media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath})", + totalFiles, mediaFolderPath, timeSpent, importFolderId, importFolderSubPath ); - return allFilesForImportFolder; + } - private async Task GenerateSymbolicLinks(Folder mediaFolder, IReadOnlyList<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)> files) + private async Task<ListResult<API.Models.File>> GetImportFolderFilesPage(int importFolderId, string importFolderSubPath, int page, SemaphoreSlim semaphore) { - Logger.LogInformation("Creating structure for ≤{FileCount} files to potentially use within media folder at {Path}", files.Count, mediaFolder.Path); + await semaphore.WaitAsync().ConfigureAwait(false); + return await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath, page).ConfigureAwait(false); + } + private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)> files) + { var start = DateTime.UtcNow; var skipped = 0; var subtitles = 0; @@ -214,55 +274,49 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IReadOnlyList<(stri var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); var allPathsForVFS = new ConcurrentBag<(string sourceLocation, string symbolicLink)>(); var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); - await Task.WhenAll(files - .Select(async (tuple) => { - await semaphore.WaitAsync().ConfigureAwait(false); - - try { - // Skip any source files we that we cannot find. - if (!File.Exists(tuple.sourceLocation)) - return; - - var (sourceLocation, symbolicLinks) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId, tuple.episodeIds).ConfigureAwait(false); - // Skip any source files we weren't meant to have in the library. - if (string.IsNullOrEmpty(sourceLocation)) - return; - - var sourcePrefix = Path.GetFileNameWithoutExtension(sourceLocation); - var sourcePrefixLength = sourceLocation.Length - Path.GetExtension(sourceLocation).Length; - var subtitleLinks = FindSubtitlesForPath(sourceLocation); - foreach (var symbolicLink in symbolicLinks) { - var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; - if (!Directory.Exists(symbolicDirectory)) - Directory.CreateDirectory(symbolicDirectory); - - allPathsForVFS.Add((sourceLocation, symbolicLink)); - if (!File.Exists(symbolicLink)) - File.CreateSymbolicLink(symbolicLink, sourceLocation); - else - skipped++; - - if (subtitleLinks.Count > 0) { - var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); - foreach (var subtitleSource in subtitleLinks) { - var extName = subtitleSource[sourcePrefixLength..]; - var subtitleLink = Path.Combine(symbolicDirectory, symbolicName + extName); - - subtitles++; - allPathsForVFS.Add((subtitleSource, subtitleLink)); - if (!File.Exists(subtitleLink)) - File.CreateSymbolicLink(subtitleLink, subtitleSource); - else - skippedSubtitles++; - } + await Task.WhenAll(files.Select(async (tuple) => { + await semaphore.WaitAsync().ConfigureAwait(false); + + try { + // Skip any source files we weren't meant to have in the library. + var (sourceLocation, symbolicLinks) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId, tuple.episodeIds).ConfigureAwait(false); + if (string.IsNullOrEmpty(sourceLocation)) + return; + + var sourcePrefix = Path.GetFileNameWithoutExtension(sourceLocation); + var sourcePrefixLength = sourceLocation.Length - Path.GetExtension(sourceLocation).Length; + var subtitleLinks = FindSubtitlesForPath(sourceLocation); + foreach (var symbolicLink in symbolicLinks) { + var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; + if (!Directory.Exists(symbolicDirectory)) + Directory.CreateDirectory(symbolicDirectory); + + allPathsForVFS.Add((sourceLocation, symbolicLink)); + if (!File.Exists(symbolicLink)) + File.CreateSymbolicLink(symbolicLink, sourceLocation); + else + skipped++; + + if (subtitleLinks.Count > 0) { + var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); + foreach (var subtitleSource in subtitleLinks) { + var extName = subtitleSource[sourcePrefixLength..]; + var subtitleLink = Path.Combine(symbolicDirectory, symbolicName + extName); + + subtitles++; + allPathsForVFS.Add((subtitleSource, subtitleLink)); + if (!File.Exists(subtitleLink)) + File.CreateSymbolicLink(subtitleLink, subtitleSource); + else + skippedSubtitles++; } } } - finally { - semaphore.Release(); - } - }) - .ToList()) + } + finally { + semaphore.Release(); + } + })) .ConfigureAwait(false); var removedSubtitles = 0; @@ -285,7 +339,7 @@ await Task.WhenAll(files var timeSpent = DateTime.UtcNow - start; Logger.LogInformation( - "Created {CreatedMedia} ({CreatedSubtitles}), skipped {SkippedMedia} ({SkippedSubtitles}), and removed {RemovedMedia} ({RemovedSubtitles}) symbolic links for media folder at {Path} in {TimeSpan}", + "Created {CreatedMedia} ({CreatedSubtitles}), skipped {SkippedMedia} ({SkippedSubtitles}), and removed {RemovedMedia} ({RemovedSubtitles}) symbolic links in media folder at {Path} in {TimeSpan}", allPathsForVFS.Count - skipped - subtitles, subtitles - skippedSubtitles, skipped, From 06a055ebe737d46616ae336f2c0b4cc3ca0046ee Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 30 Mar 2024 04:42:30 +0100 Subject: [PATCH 0671/1103] refactor: throw if unresolved - Log and re-throw the error if we fail while resolving one or more base items. --- Shokofin/Resolvers/ShokoResolveManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index c0887314..807ed6c0 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -667,7 +667,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); } - return null; + throw; } } @@ -764,7 +764,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); } - return null; + throw; } } From ed3da710a577ec97a51cfe54a6e405610f895f7c Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 30 Mar 2024 05:31:34 +0000 Subject: [PATCH 0672/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 96e9bddf..633a3465 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.62", + "changelog": "refactor: throw if unresolved\n\n- Log and re-throw the error if we fail while resolving one or more base\n items.\n\nfeat: impl. file path generator\n\n- Switch from getting all the file at once to using a generator that\n pre-fetches the next 10 pages. This changes shaved off ~10s (from 51s\n to 39s) on my small test library, but didn't affect the overall\n performance of the full library scan on my full library. This change\n in essense minimises the risk of getting all the files at onces timing\n out and also allows the rest of the symbolic link generation to start\n earlier.\n\n- Don't check the file system if the file exists every time we iterate\n the generator. Instead use the list of all known paths we created\n earlier by converting it to a set and just checking the set. This\n helps a lot when you're accessing the files over the network.\n\n- Add better logging for when we're generating symbolic links.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.62/shoko_3.0.1.62.zip", + "checksum": "abacc3b06a0498bc24362816a793f301", + "timestamp": "2024-03-30T05:31:33Z" + }, { "version": "3.0.1.61", "changelog": "cleanup: prittify get attribute value\n\n- Prittified how we get the attribute value.\n\nmisc: update logo\n\n- mascot accredited to @Queuecumbr\n\n- image accredited to @ElementalCrisis", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.58/shoko_3.0.1.58.zip", "checksum": "1c32f315eac5dd815346c49776c07c24", "timestamp": "2024-03-29T11:19:48Z" - }, - { - "version": "3.0.1.57", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.57/shoko_3.0.1.57.zip", - "checksum": "24034540bd83bbd1aa80694bbafcb2c4", - "timestamp": "2024-03-29T05:38:13Z" } ] } From 115b5d6e2f9d38b207b8e79820c3273751103a57 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 30 Mar 2024 06:33:14 +0100 Subject: [PATCH 0673/1103] misc: advertise support for movie trailers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Advertise our support for local movie trailers¹. Finally I can close #10. ¹ _As long as the VFS is used._ [skip ci] --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bc6fd543..974e243f 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,13 @@ Learn more about Shoko at https://shokoanime.com/. ¹ _You need at least one movie in your library for this to currently work as expected. This is an issue with Jellyfin 10.8._ - - [/] Supports adding local trailers + - [X] Supports adding local trailers - [X] on Show items - [X] on Season items - - [ ] on Movie items + - [X] on Movie items - [X] Specials and extra features. From d7cf157a23fc8d39e99139090649ed0e1e936e63 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 01:41:19 +0100 Subject: [PATCH 0674/1103] misc: try adding icon and color to discord embeds - First attempt at adding color (will word) and an icon (not so sure) to discord embeds. --- .github/workflows/release-daily.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index d48122b3..1b4c5c92 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -142,7 +142,8 @@ jobs: with: webhook: ${{ secrets.DISCORD_WEBHOOK }} nodetail: true - title: New Unstable Shokofin Build! + color: "#aa5cc3" + title: <:jellyfin:1045360407814090953> New Unstable Shokofin Build! description: | **Version**: `${{ needs.current_info.outputs.version }}` (`${{ needs.current_info.outputs.sha_short }}`) From 22bac6ed64f25544d6410af30c4984262d2471d0 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 31 Mar 2024 00:42:13 +0000 Subject: [PATCH 0675/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 633a3465..68d6c718 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.63", + "changelog": "misc: try adding icon and color to discord embeds\n\n- First attempt at adding color (will word) and an icon (not so sure) to\n discord embeds.\n\nmisc: advertise support for movie trailers\n\n- Advertise our support for local movie trailers\u00b9. Finally I can close #10.\n\n\u00b9 _As long as the VFS is used._\n[skip ci]", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.63/shoko_3.0.1.63.zip", + "checksum": "7d24b26f8a7601b12b46be01416b0992", + "timestamp": "2024-03-31T00:42:11Z" + }, { "version": "3.0.1.62", "changelog": "refactor: throw if unresolved\n\n- Log and re-throw the error if we fail while resolving one or more base\n items.\n\nfeat: impl. file path generator\n\n- Switch from getting all the file at once to using a generator that\n pre-fetches the next 10 pages. This changes shaved off ~10s (from 51s\n to 39s) on my small test library, but didn't affect the overall\n performance of the full library scan on my full library. This change\n in essense minimises the risk of getting all the files at onces timing\n out and also allows the rest of the symbolic link generation to start\n earlier.\n\n- Don't check the file system if the file exists every time we iterate\n the generator. Instead use the list of all known paths we created\n earlier by converting it to a set and just checking the set. This\n helps a lot when you're accessing the files over the network.\n\n- Add better logging for when we're generating symbolic links.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.59/shoko_3.0.1.59.zip", "checksum": "e5e2bfba77cf5414b774bdec246be68d", "timestamp": "2024-03-29T21:30:32Z" - }, - { - "version": "3.0.1.58", - "changelog": "refactor: overdose on `.ConfigureAwait(false)`\n\n- Use `.ConfigureAwait(false)` where-ever we can. It sped up\n the discovery phase by 30 seconds over multiple runs, so I\n think it should be okay to commit this now.\n\nmisc: better tracking of subtitle files\n\n- Add better tracking of the link generation for subtitle files.\n\nmisc: change default cache expire\n\n- change the cache expire time from 1h30m to 2h30m to\n better accomodate a typical initial scan based on my testing.\n\nfix: fix vfs link creation\n\n- fix the faulty behaviour of alternatingly skipping and creating\n symlinks because of an accidental early return.\n\nmisc: add changelog to manifest releases\n\n[skip ci]\n\nmisc: add changelog to GH pre-releases\n\n[skip ci]", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.58/shoko_3.0.1.58.zip", - "checksum": "1c32f315eac5dd815346c49776c07c24", - "timestamp": "2024-03-29T11:19:48Z" } ] } From baf8fc373b3ace478c6d093bf3c17fdabb948fdb Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 01:45:08 +0100 Subject: [PATCH 0676/1103] misc: fix color for discord embeds. --- .github/workflows/release-daily.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 1b4c5c92..f85166a7 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -142,7 +142,7 @@ jobs: with: webhook: ${{ secrets.DISCORD_WEBHOOK }} nodetail: true - color: "#aa5cc3" + color: 0xaa5cc3 title: <:jellyfin:1045360407814090953> New Unstable Shokofin Build! description: | **Version**: `${{ needs.current_info.outputs.version }}` (`${{ needs.current_info.outputs.sha_short }}`) From 58903a6de2ec7c1c31866009dc132167495f8719 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 31 Mar 2024 00:45:51 +0000 Subject: [PATCH 0677/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 68d6c718..3f4165ce 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.64", + "changelog": "misc: fix color for discord embeds.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.64/shoko_3.0.1.64.zip", + "checksum": "e6c72aade8a4a964bdfd8f9057b39ce5", + "timestamp": "2024-03-31T00:45:50Z" + }, { "version": "3.0.1.63", "changelog": "misc: try adding icon and color to discord embeds\n\n- First attempt at adding color (will word) and an icon (not so sure) to\n discord embeds.\n\nmisc: advertise support for movie trailers\n\n- Advertise our support for local movie trailers\u00b9. Finally I can close #10.\n\n\u00b9 _As long as the VFS is used._\n[skip ci]", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.60/shoko_3.0.1.60.zip", "checksum": "762320590c2cb57b5dbc4c73f529bbb1", "timestamp": "2024-03-29T23:00:50Z" - }, - { - "version": "3.0.1.59", - "changelog": "misc: manually amends broken links in unstable manifest\n\nfix: forces version specific release url in manifest", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.59/shoko_3.0.1.59.zip", - "checksum": "e5e2bfba77cf5414b774bdec246be68d", - "timestamp": "2024-03-29T21:30:32Z" } ] } From 60a993f49d7ddbb104c916bc76270ac1fdee7c73 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 01:46:48 +0100 Subject: [PATCH 0678/1103] misc: change title format - Changed the title format to include the project name first, followed by the build type released. --- .github/workflows/release-daily.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index f85166a7..7c2081b1 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -143,7 +143,7 @@ jobs: webhook: ${{ secrets.DISCORD_WEBHOOK }} nodetail: true color: 0xaa5cc3 - title: <:jellyfin:1045360407814090953> New Unstable Shokofin Build! + title: "<:jellyfin:1045360407814090953> Shokofin: New Unstable Build!" description: | **Version**: `${{ needs.current_info.outputs.version }}` (`${{ needs.current_info.outputs.sha_short }}`) From cf99a7ac0794440436c1565f8954f623acd60879 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 31 Mar 2024 00:47:35 +0000 Subject: [PATCH 0679/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 3f4165ce..11ec16ea 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.65", + "changelog": "misc: change title format\n\n- Changed the title format to include the project name first,\n followed by the build type released.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.65/shoko_3.0.1.65.zip", + "checksum": "c9b029c65971248c8327fd7ae047f690", + "timestamp": "2024-03-31T00:47:34Z" + }, { "version": "3.0.1.64", "changelog": "misc: fix color for discord embeds.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.61/shoko_3.0.1.61.zip", "checksum": "9ad292bd6812e255ef90cea787c4d743", "timestamp": "2024-03-30T01:20:42Z" - }, - { - "version": "3.0.1.60", - "changelog": "fix: impl. actual attribute parsing\n\n- Implement actual attribute parsing on the file/folder names within the\n VFS, instead of the faulty and error prone guesswork I\n added in the initial PoC. It was bound to fail eventually.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.60/shoko_3.0.1.60.zip", - "checksum": "762320590c2cb57b5dbc4c73f529bbb1", - "timestamp": "2024-03-29T23:00:50Z" } ] } From 6f54fb9b5950032624339f7067f680efd55736c3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 03:43:05 +0200 Subject: [PATCH 0680/1103] misc: update read-me file for the project and the plugin repostory in jellyfin. the updated read-me will only be visible on the unstable repository until the next stable release though. --- README.md | 10 ++++------ build.yaml | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 974e243f..657439cb 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,10 @@ A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/docs/) with ## Read this before installing -**This plugin requires that you have already set up and are using Shoko -Server**, and that the directories/folders you intend to use in Jellyfin are -**fully indexed** (and optionally managed) by Shoko Server. **Otherwise, the -plugin won't be able to function properly**, meaning, the plugin won't be able -to any find metadata about any entries that are not indexed by Shoko Server -since there is no metadata to find. +**This plugin requires that you have already set up and are using Shoko Server**, +and that the files you intend to include in Jellyfin are **indexed** (and +optionally managed) by Shoko Server. **Otherwise, the plugin won't be able to +provide metadata for your files**, since there is no metadata to find for them. ### What Is Shoko? diff --git a/build.yaml b/build.yaml index 3093c286..27b3bc88 100644 --- a/build.yaml +++ b/build.yaml @@ -2,12 +2,20 @@ name: "Shoko" guid: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052" imageUrl: https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png targetAbi: "10.8.0.0" -owner: "shokoanime" +owner: "ShokoAnime" overview: "Manage your anime from Jellyfin using metadata from Shoko" description: > - A plugin to provide metadata from Shoko Server for your locally organized anime library in Jellyfin. + A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/docs/) with + [Shoko Server](https://shokoanime.com/downloads/shoko-server/). + + ## Read this before installing + + **This plugin requires that you have already set up and are using Shoko Server**, + and that the files you intend to include in Jellyfin are **indexed** (and + optionally managed) by Shoko Server. **Otherwise, the plugin won't be able to + provide metadata for your files**, since there is no metadata to find for them. + category: "Metadata" artifacts: - "Shokofin.dll" -changelog: > - NA +changelog: "" From 25c71ffa52d3c39e56d4dfbf3a1273ad58b65146 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 05:54:40 +0200 Subject: [PATCH 0681/1103] fix: fix ignored sub title removal --- Shokofin/Utils/Text.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index 81e0626b..489c2f20 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -61,7 +61,7 @@ public static class Text '⦎', // right angle bracket }; - private static HashSet<string> IgnoredSubTitles = new() { + private static readonly HashSet<string> IgnoredSubTitles = new(StringComparer.InvariantCultureIgnoreCase) { "Complete Movie", "OVA", }; @@ -326,7 +326,7 @@ private static string ConstructTitle(Func<string> getSeriesTitle, Func<string> g var mainTitle = getSeriesTitle()?.Trim(); var subTitle = getEpisodeTitle()?.Trim(); // Include sub-title if it does not strictly equals any ignored sub titles. - if (!string.IsNullOrWhiteSpace(subTitle) && !IgnoredSubTitles.Contains(mainTitle)) + if (!string.IsNullOrWhiteSpace(subTitle) && !IgnoredSubTitles.Contains(subTitle)) return $"{mainTitle}: {subTitle}"; return mainTitle; } From 8bd4c3b5fb51622eb6c0b0d1e5d75424ab66cbd5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 07:38:11 +0200 Subject: [PATCH 0682/1103] refactor: save media folder mappings - Store a which import folder each media folder is mapped to. This wil be used now to save requests needed to find the mapping, and in the future by the SignalR file event stream to know which files to update. - Throw if we're unable to filter an item, instead of gracefully returning early. This will prevent accidentail inclusions in the library at the expense of aborting a library scan if something goes awry. --- .../Configuration/MediaFolderConfiguration.cs | 45 ++++ Shokofin/Configuration/PluginConfiguration.cs | 4 + Shokofin/Resolvers/ShokoResolveManager.cs | 213 +++++++++++------- 3 files changed, 178 insertions(+), 84 deletions(-) create mode 100644 Shokofin/Configuration/MediaFolderConfiguration.cs diff --git a/Shokofin/Configuration/MediaFolderConfiguration.cs b/Shokofin/Configuration/MediaFolderConfiguration.cs new file mode 100644 index 00000000..4472aefc --- /dev/null +++ b/Shokofin/Configuration/MediaFolderConfiguration.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; +using System.Text.Json.Serialization; +using System.Xml.Serialization; + +#nullable enable +namespace Shokofin.Configuration; + +public class MediaFolderConfiguration +{ + /// <summary> + /// The jellyfin media folder id. + /// </summary> + public Guid MediaFolderId { get; set; } + + /// <summary> + /// The shoko import folder id the jellyfin media folder is linked to. + /// </summary> + public int ImportFolderId { get; set; } + + /// <summary> + /// The relative path from the root of the import folder the media folder is located at. + /// </summary> + public string ImportFolderRelativePath { get; set; } = string.Empty; + + /// <summary> + /// Indicates the Jellyfin Media Folder is mapped to a Shoko Import Folder. + /// </summary> + [XmlIgnore] + [JsonInclude] + public bool IsMapped => ImportFolderId != 0; + + /// <summary> + /// Indicates that SignalR file events is enabled for the folder. + /// </summary> + public bool IsFileEventsEnabled { get; set; } = true; + + /// <summary> + /// Check if a relative path within the import folder is potentially available in this media folder. + /// </summary> + /// <param name="relativePath"></param> + /// <returns></returns> + public bool IsEnabledForPath(string relativePath) + => string.IsNullOrEmpty(ImportFolderRelativePath) || relativePath.StartsWith(ImportFolderRelativePath + Path.DirectorySeparatorChar); +} \ No newline at end of file diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index e49a5892..bbf055a3 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,5 +1,6 @@ using MediaBrowser.Model.Plugins; using System; +using System.Collections.Generic; using System.Text.Json.Serialization; using Shokofin.API.Models; @@ -88,6 +89,8 @@ public virtual string PrettyHost public UserConfiguration[] UserList { get; set; } + public List<MediaFolderConfiguration> MediaFolders { get; set; } + public string[] IgnoredFolders { get; set; } public bool? LibraryFilteringMode { get; set; } @@ -139,6 +142,7 @@ public PluginConfiguration() CollectionGrouping = CollectionCreationType.None; MovieOrdering = OrderType.Default; UserList = Array.Empty<UserConfiguration>(); + MediaFolders = new(); IgnoredFolders = new [] { ".streams", "@recently-snapshot" }; LibraryFilteringMode = null; EXPERIMENTAL_AutoMergeVersions = false; diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 807ed6c0..c5b026b0 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Emby.Naming.Common; @@ -19,6 +18,7 @@ using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.API.Models; +using Shokofin.Configuration; using Shokofin.Utils; using File = System.IO.File; @@ -101,6 +101,18 @@ private 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)) { DataCache.Remove(folder.Id.ToString()); + var mediaFolderConfig = Plugin.Instance.Configuration.MediaFolders.FirstOrDefault(c => c.MediaFolderId == folder.Id); + if (mediaFolderConfig != null) + { + Logger.LogDebug( + "Removing stored configuration for folder at {Path} (ImportFolder={ImportFolderId},RelativePath={RelativePath})", + folder.Path, + mediaFolderConfig.ImportFolderId, + mediaFolderConfig.ImportFolderRelativePath + ); + Plugin.Instance.Configuration.MediaFolders.Remove(mediaFolderConfig); + Plugin.Instance.SaveConfiguration(); + } var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(folder); if (Directory.Exists(vfsPath)) { Logger.LogInformation("Removing VFS directory for folder at {Path}", folder.Path); @@ -112,81 +124,107 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) #endregion - #region Generate Structure + #region Media Folder Mapping - private async Task<string?> GenerateStructureForFolder(Folder mediaFolder, string folderPath) + public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFolder(Folder mediaFolder) { - // Return early if we've already generated the structure from the import folder itself. - if (DataCache.TryGetValue<string?>(mediaFolder.Path, out var vfsPath)) - return vfsPath; + var config = Plugin.Instance.Configuration; + var mediaFolderConfig = config.MediaFolders.FirstOrDefault(c => c.MediaFolderId == mediaFolder.Id); + if (mediaFolderConfig != null) + return mediaFolderConfig; - return await DataCache.GetOrCreateAsync(folderPath, async (cachedEntry) => { - Logger.LogInformation("Looking for match for folder at {Path}.", folderPath); - - // Check if we should introduce the VFS for the media folder. - var start = DateTime.UtcNow; - var allPaths = FileSystem.GetFilePaths(folderPath, true) - .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) - .ToHashSet(); - - Logger.LogDebug("Found {FileCount} files in {Path} in {TimeSpan}.", allPaths.Count, folderPath, DateTime.UtcNow - start); - Logger.LogDebug("Asking remote server if it knows any of the {Count} sampled files in {Path}.", allPaths.Count > 100 ? 100 : allPaths.Count, folderPath); - start = DateTime.UtcNow; - - int importFolderId = 0; - var attempts = 0; - string importFolderSubPath = string.Empty; - foreach (var path in allPaths.Take(100)) { - attempts++; - var partialPath = path[mediaFolder.Path.Length..]; - var partialFolderPath = path[folderPath.Length..]; - var files = await ApiClient.GetFileByPath(partialPath).ConfigureAwait(false); - var file = files.FirstOrDefault(); - if (file == null) - continue; - - var fileId = file.Id.ToString(); - var fileLocations = file.Locations - .Where(location => location.Path.EndsWith(partialFolderPath)) - .ToList(); - if (fileLocations.Count == 0) - continue; - - var fileLocation = fileLocations[0]; - importFolderId = fileLocation.ImportFolderId; - importFolderSubPath = fileLocation.Path[..^partialFolderPath.Length]; - break; - } + // Check if we should introduce the VFS for the media folder. + mediaFolderConfig = new() { MediaFolderId = mediaFolder.Id }; - if (importFolderId == 0) { - Logger.LogWarning( - "Failed to find a match for folder at {Path} after {Amount} attempts in {TimeSpan}.", - folderPath, - attempts, - DateTime.UtcNow - start - ); + 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(); - cachedEntry.AbsoluteExpirationRelativeToNow = DefaultTTL; - return null; - } + 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 == null) + continue; + + var fileId = file.Id.ToString(); + var fileLocations = file.Locations + .Where(location => location.Path.EndsWith(partialPath)) + .ToList(); + if (fileLocations.Count == 0) + continue; + + var fileLocation = fileLocations[0]; + mediaFolderConfig.ImportFolderId = fileLocation.ImportFolderId; + mediaFolderConfig.ImportFolderRelativePath = fileLocation.Path[..^partialPath.Length]; + break; + } + // Store and log the result. + config.MediaFolders.Add(mediaFolderConfig); + Plugin.Instance.SaveConfiguration(config); + if (mediaFolderConfig.IsMapped) { Logger.LogInformation( - "Found a match for folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path},Attempts={Attempts})", - folderPath, + "Found a match for media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path},Attempts={Attempts})", + mediaFolder.Path, DateTime.UtcNow - start, - importFolderId, - importFolderSubPath, + mediaFolderConfig.ImportFolderId, + mediaFolderConfig.ImportFolderRelativePath, mediaFolder.Path, attempts ); + } + else { + Logger.LogWarning( + "Failed to find a match for media folder at {Path} after {Amount} attempts in {TimeSpan}.", + mediaFolder.Path, + attempts, + DateTime.UtcNow - start + ); + } - vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); - var allFiles = GetImportFolderFiles(importFolderId, importFolderSubPath, folderPath, allPaths); - await GenerateSymbolicLinks(mediaFolder, allFiles).ConfigureAwait(false); + return mediaFolderConfig; + } + + #endregion + + #region Generate Structure - cachedEntry.AbsoluteExpirationRelativeToNow = DefaultTTL; + private async Task<string?> GenerateStructureForFolder(Folder mediaFolder, string folderPath) + { + // Return early if we've already generated the structure from the import folder itself. + if (DataCache.TryGetValue<string?>(mediaFolder.Path, out var vfsPath)) return vfsPath; - }).ConfigureAwait(false); + return await DataCache.GetOrCreateAsync( + folderPath, + async (_) => { + var mediaConfig = await GetOrCreateConfigurationForMediaFolder(mediaFolder); + if (!mediaConfig.IsMapped) + return null; + + // Check if we should introduce the VFS for the media folder. + var start = DateTime.UtcNow; + var allPaths = FileSystem.GetFilePaths(folderPath, true) + .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + .ToHashSet(); + Logger.LogDebug("Found {FileCount} files in folder at {Path} in {TimeSpan}.", allPaths.Count, folderPath, DateTime.UtcNow - start); + + var relativeFolderPath = mediaConfig.ImportFolderRelativePath + folderPath[mediaFolder.Path.Length..]; + vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + var allFiles = GetImportFolderFiles(mediaConfig.ImportFolderId, relativeFolderPath, folderPath, allPaths); + await GenerateSymbolicLinks(mediaFolder, allFiles).ConfigureAwait(false); + + return vfsPath; + }, + new() { + AbsoluteExpirationRelativeToNow = DefaultTTL, + } + ).ConfigureAwait(false); } private IEnumerable<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)> GetImportFolderFiles(int importFolderId, string importFolderSubPath, string mediaFolderPath, ISet<string> fileSet) @@ -486,18 +524,21 @@ private IReadOnlyList<string> FindSubtitlesForPath(string sourcePath) public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) { + // Check if the parent is not made yet, or the file info is missing. if (parent == null || fileInfo == null) return false; + // Check if the root is not made yet. This should **never** be false at + // this point in time, but if it is, then bail. var root = LibraryManager.RootFolder; - if (root == null || parent == root || parent.ParentId == root.Id) + if (root == null || parent.Id == root.Id) return false; - try { - // Assume anything within the VFS is already okay. - if (fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) - return false; + // Assume anything within the VFS is already okay. + if (fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) + return false; + try { // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. if (!Lookup.IsEnabledForItem(parent, out var isSoleProvider)) return false; @@ -515,23 +556,33 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file var fullPath = fileInfo.FullName; var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent, root); + // Ignore any media folders that aren't mapped to shoko. + var mediaFolderConfig = await GetOrCreateConfigurationForMediaFolder(mediaFolder); + if (!mediaFolderConfig.IsMapped) { + Logger.LogDebug("Skipped media folder for path {Path} (MediaFolder={MediaFolderId})", fileInfo.FullName, mediaFolderConfig.MediaFolderId); + return false; + } + + // Abort now if the VFS is enabled, since it will take care of moving + // from the physical library to the "virtual" library. + if (parent.ParentId == root.Id && Plugin.Instance.Configuration.VirtualFileSystem) + return false; + + // Scan the var shouldIgnore = Plugin.Instance.Configuration.LibraryFilteringMode ?? Plugin.Instance.Configuration.VirtualFileSystem || isSoleProvider; var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); if (fileInfo.IsDirectory) - return await ScanDirectory(partialPath, fullPath, collectionType, shouldIgnore).ConfigureAwait(false); + return await ShouldFilterDirectory(partialPath, fullPath, collectionType, shouldIgnore).ConfigureAwait(false); else - return await ScanFile(partialPath, fullPath, shouldIgnore).ConfigureAwait(false); + return await ShouldFilterFile(partialPath, fullPath, shouldIgnore).ConfigureAwait(false); } catch (Exception ex) { - if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) - { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - } - return false; + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + throw; } } - private async Task<bool> ScanDirectory(string partialPath, string fullPath, string? collectionType, bool shouldIgnore) + private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPath, string? collectionType, bool shouldIgnore) { var season = await ApiManager.GetSeasonInfoByPath(fullPath).ConfigureAwait(false); @@ -588,7 +639,7 @@ private async Task<bool> ScanDirectory(string partialPath, string fullPath, stri return false; } - private async Task<bool> ScanFile(string partialPath, string fullPath, bool shouldIgnore) + private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, bool shouldIgnore) { var (file, season, _) = await ApiManager.GetFileInfoByPath(fullPath).ConfigureAwait(false); @@ -663,10 +714,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou return null; } catch (Exception ex) { - if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) - { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - } + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); throw; } } @@ -760,10 +808,7 @@ private async Task<bool> ScanFile(string partialPath, string fullPath, bool shou return null; } catch (Exception ex) { - if (!(ex is System.Net.Http.HttpRequestException && ex.Message.Contains("Connection refused"))) - { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - } + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); throw; } } From 84a6aefd3c7fbda7c905e0a53c4804c55119bacc Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 07:40:20 +0200 Subject: [PATCH 0683/1103] fix: fix extras placement for movies/shows - Fix the incorrect placement of some extras in shows/seasons/movies. --- Shokofin/Resolvers/ShokoResolveManager.cs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index c5b026b0..937f9227 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -432,19 +432,22 @@ await Task.WhenAll(files.Select(async (tuple) => { var folders = new List<string>(); var episodeName = (episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode == "en")?.Value ?? $"Episode {episode.AniDB.Type} {episodeNumber}").ReplaceInvalidPathCharacters(); var extrasFolder = file.ExtraType switch { - ExtraType.BehindTheScenes => "behind the scenes", - ExtraType.Clip => "clips", - ExtraType.DeletedScene => "deleted scene", - ExtraType.Interview => "interviews", - ExtraType.Sample => "samples", - ExtraType.Scene => "scenes", + null => null, ExtraType.ThemeSong => "theme-music", ExtraType.ThemeVideo => "backdrops", ExtraType.Trailer => "trailers", - ExtraType.Unknown => "others", - null => null, _ => "extras", }; + var fileNameSuffic = file.ExtraType switch { + ExtraType.BehindTheScenes => "-behindthescenes", + ExtraType.Clip => "-clip", + ExtraType.DeletedScene => "-deletedscene", + ExtraType.Interview => "-interview", + ExtraType.Scene => "-scene", + ExtraType.Sample => "-other", + ExtraType.Unknown => "-other", + _ => string.Empty, + }; if (isMovieSeason && collectionType != CollectionType.TvShows) { if (!string.IsNullOrEmpty(extrasFolder)) { @@ -468,7 +471,7 @@ await Task.WhenAll(files.Select(async (tuple) => { } } - var fileName = $"{episodeName} [shoko-series-{seriesId}] [shoko-file-{fileId}]{Path.GetExtension(sourceLocation)}"; + var fileName = $"{episodeName} [shoko-series-{seriesId}] [shoko-file-{fileId}]{fileNameSuffic}{Path.GetExtension(sourceLocation)}"; var symbolicLinks = folders .Select(folderPath => Path.Combine(folderPath, fileName)) .ToArray(); From 69751fc0631c98c40cd49edf953eedfe9dc0d356 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 31 Mar 2024 05:46:41 +0000 Subject: [PATCH 0684/1103] misc: update unstable manifest --- manifest-unstable.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 11ec16ea..30a57922 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -2,12 +2,20 @@ { "guid": "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", "name": "Shoko", - "description": "A plugin to provide metadata from Shoko Server for your locally organized anime library in Jellyfin.\n", + "description": "A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/docs/) with [Shoko Server](https://shokoanime.com/downloads/shoko-server/).\n## Read this before installing\n**This plugin requires that you have already set up and are using Shoko Server**, and that the files you intend to include in Jellyfin are **indexed** (and optionally managed) by Shoko Server. **Otherwise, the plugin won't be able to provide metadata for your files**, since there is no metadata to find for them.\n", "overview": "Manage your anime from Jellyfin using metadata from Shoko", - "owner": "shokoanime", + "owner": "ShokoAnime", "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.66", + "changelog": "fix: fix extras placement for movies/shows\n\n- Fix the incorrect placement of some extras in shows/seasons/movies.\n\nrefactor: save media folder mappings\n\n- Store a which import folder each media folder is mapped to. This wil\n be used now to save requests needed to find the mapping, and in the\n future by the SignalR file event stream to know which files to update.\n\n- Throw if we're unable to filter an item, instead of gracefully\n returning early. This will prevent accidentail inclusions in the\n library at the expense of aborting a library scan if something\n goes awry.\n\nfix: fix ignored sub title removal\n\nmisc: update read-me file for the project\nand the plugin repostory in jellyfin. the updated read-me will only be\nvisible on the unstable repository until the next stable release though.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.66/shoko_3.0.1.66.zip", + "checksum": "5f7173d0dae94f4329d64f9f50eff9a5", + "timestamp": "2024-03-31T05:46:40Z" + }, { "version": "3.0.1.65", "changelog": "misc: change title format\n\n- Changed the title format to include the project name first,\n followed by the build type released.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.62/shoko_3.0.1.62.zip", "checksum": "abacc3b06a0498bc24362816a793f301", "timestamp": "2024-03-30T05:31:33Z" - }, - { - "version": "3.0.1.61", - "changelog": "cleanup: prittify get attribute value\n\n- Prittified how we get the attribute value.\n\nmisc: update logo\n\n- mascot accredited to @Queuecumbr\n\n- image accredited to @ElementalCrisis", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.61/shoko_3.0.1.61.zip", - "checksum": "9ad292bd6812e255ef90cea787c4d743", - "timestamp": "2024-03-30T01:20:42Z" } ] } From fd34b1e0ea2b74401555747a437ce71523e2de8b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 08:58:22 +0200 Subject: [PATCH 0685/1103] misc: change mount of shoko api controller --- Shokofin/Configuration/configController.js | 2 +- Shokofin/Web/ShokoApiController.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index e689efcb..b583217e 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -92,7 +92,7 @@ function getApiKey(username, password, userKey = false) { "Accept": "application/json", }, type: "POST", - url: ApiClient.getUrl("Plugin/Shokofin/GetApiKey"), + url: ApiClient.getUrl("Plugin/Shokofin/Host/GetApiKey"), }); } diff --git a/Shokofin/Web/ShokoApiController.cs b/Shokofin/Web/ShokoApiController.cs index 3e325c47..dc998192 100644 --- a/Shokofin/Web/ShokoApiController.cs +++ b/Shokofin/Web/ShokoApiController.cs @@ -17,7 +17,7 @@ namespace Shokofin.Web; /// Pushbullet notifications controller. /// </summary> [ApiController] -[Route("Plugin/Shokofin")] +[Route("Plugin/Shokofin/Host")] [Produces(MediaTypeNames.Application.Json)] public class ShokoApiController : ControllerBase { @@ -60,7 +60,7 @@ public async Task<ActionResult<ComponentVersion>> GetVersionAsync() } [HttpPost("GetApiKey")] - public async Task<ActionResult<ApiKey>> PostAsync([FromBody] ApiLoginRequest body) + public async Task<ActionResult<ApiKey>> GetApiKeyAsync([FromBody] ApiLoginRequest body) { try { Logger.LogDebug("Trying to create an API-key for user {Username}.", body.Username); From cda9cca7b0bc0fe172c3e0a54b26188d398d3bdc Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 09:23:44 +0200 Subject: [PATCH 0686/1103] feat: initial PoC SignalR connection manager - Add the initial PoC SignalR Connection Manager responsible for reacting to Shoko file, episode, and series update events, if enabled. Currently it only prints debug messages when enabled. And it can only be enabled by manually editing the settings xml. --- Shokofin/Configuration/PluginConfiguration.cs | 30 ++ Shokofin/Shokofin.csproj | 13 + .../Interfaces/IFileRelocationEventArgs.cs | 33 +++ .../Models/EpisodeInfoUpdatedEventArgs.cs | 25 ++ .../SignalR/Models/FileDetectedEventArgs.cs | 19 ++ Shokofin/SignalR/Models/FileEventArgs.cs | 26 ++ .../SignalR/Models/FileMatchedEventArgs.cs | 35 +++ Shokofin/SignalR/Models/FileMovedEventArgs.cs | 40 +++ .../SignalR/Models/FileNotMatchedEventArgs.cs | 22 ++ .../SignalR/Models/FileRenamedEventArgs.cs | 27 ++ .../Models/SeriesInfoUpdatedEventArgs.cs | 19 ++ Shokofin/SignalR/SignalRConnectionManager.cs | 266 ++++++++++++++++++ build.yaml | 3 + 13 files changed, 558 insertions(+) create mode 100644 Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs create mode 100644 Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs create mode 100644 Shokofin/SignalR/Models/FileDetectedEventArgs.cs create mode 100644 Shokofin/SignalR/Models/FileEventArgs.cs create mode 100644 Shokofin/SignalR/Models/FileMatchedEventArgs.cs create mode 100644 Shokofin/SignalR/Models/FileMovedEventArgs.cs create mode 100644 Shokofin/SignalR/Models/FileNotMatchedEventArgs.cs create mode 100644 Shokofin/SignalR/Models/FileRenamedEventArgs.cs create mode 100644 Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs create mode 100644 Shokofin/SignalR/SignalRConnectionManager.cs diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index bbf055a3..dc40bb53 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -95,6 +95,32 @@ public virtual string PrettyHost public bool? LibraryFilteringMode { get; set; } + #region SignalR + + /// <summary> + /// Enable the SignalR events from Shoko. + /// </summary> + /// <value></value> + public bool SignalR_AutoConnectEnabled { get; set; } + + /// <summary> + /// Reconnect intervals if the the stream gets disconnected. + /// </summary> + public List<int> SignalR_AutoReconnectInSeconds { get; set; } + + /// <summary> + /// Will automatically refresh entries if metadata is updated in Shoko. + /// </summary> + public bool SignalR_RefreshEnabled { get; set; } + + /// <summary> + /// Will notify Jellyfin about files that have been added/updated/removed + /// in shoko. + /// </summary> + public bool SignalR_FileWatcherEnabled { get; set; } + + #endregion + #region Experimental features public bool EXPERIMENTAL_AutoMergeVersions { get; set; } @@ -145,6 +171,10 @@ public PluginConfiguration() MediaFolders = new(); IgnoredFolders = new [] { ".streams", "@recently-snapshot" }; LibraryFilteringMode = null; + SignalR_AutoConnectEnabled = false; + SignalR_AutoReconnectInSeconds = new() { 0, 2, 10, 30, 60, 120, 300 }; + SignalR_RefreshEnabled = false; + SignalR_FileWatcherEnabled = false; EXPERIMENTAL_AutoMergeVersions = false; EXPERIMENTAL_SplitThenMergeMovies = true; EXPERIMENTAL_SplitThenMergeEpisodes = false; diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj index e5440106..515807b1 100644 --- a/Shokofin/Shokofin.csproj +++ b/Shokofin/Shokofin.csproj @@ -2,13 +2,26 @@ <PropertyGroup> <TargetFramework>net6.0</TargetFramework> <OutputType>Library</OutputType> + <SignalRVersion>6.0.28</SignalRVersion> </PropertyGroup> <ItemGroup> <PackageReference Include="Jellyfin.Controller" Version="10.8.0" /> + <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="$(SignalRVersion)" /> <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> </ItemGroup> + <Target Name="CopySignalRDLLsToOutputPath" AfterTargets="Build"> + <ItemGroup> + <BasePackage Include="$(NuGetPackageRoot)\microsoft.aspnetcore.signalr.client\$(SignalRVersion)\lib\$(TargetFramework)\Microsoft.AspNetCore.SignalR.Client.dll" /> + <CorePackage Include="$(NuGetPackageRoot)\microsoft.aspnetcore.signalr.client.core\$(SignalRVersion)\lib\$(TargetFramework)\Microsoft.AspNetCore.SignalR.Client.Core.dll" /> + <HttpPackage Include="$(NuGetPackageRoot)\microsoft.aspnetcore.http.connections.client\$(SignalRVersion)\lib\$(TargetFramework)\Microsoft.AspNetCore.Http.Connections.Client.dll" /> + </ItemGroup> + <Copy SourceFiles="@(BasePackage)" DestinationFolder="$(OutputPath)" /> + <Copy SourceFiles="@(CorePackage)" DestinationFolder="$(OutputPath)" /> + <Copy SourceFiles="@(HttpPackage)" DestinationFolder="$(OutputPath)" /> + </Target> + <ItemGroup> <None Remove="Configuration\configController.js" /> <None Remove="Configuration\configPage.html" /> diff --git a/Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs b/Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs new file mode 100644 index 00000000..d08d8f13 --- /dev/null +++ b/Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs @@ -0,0 +1,33 @@ + +#nullable enable +namespace Shokofin.SignalR.Interfaces; + +public interface IFileRelocationEventArgs +{ + /// <summary> + /// Shoko file id. + /// </summary> + int FileId { get; } + + /// <summary> + /// The ID of the new import folder the event was detected in. + /// </summary> + /// <value></value> + int ImportFolderId { get; } + + /// <summary> + /// The ID of the old import folder the event was detected in. + /// </summary> + /// <value></value> + int PreviousImportFolderId { get; } + + /// <summary> + /// The relative path of the new file from the import folder base location. + /// </summary> + string RelativePath { get; } + + /// <summary> + /// The relative path of the old file from the import folder base location. + /// </summary> + string PreviousRelativePath { get; } +} \ No newline at end of file diff --git a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs new file mode 100644 index 00000000..3fa28f9f --- /dev/null +++ b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +#nullable enable +namespace Shokofin.SignalR.Models; + +public class EpisodeInfoUpdatedEventArgs +{ + /// <summary> + /// Shoko episode id. + /// </summary> + [JsonPropertyName("EpisodeID")] + public int EpisodeId { get; set; } + + /// <summary> + /// Shoko series id. + /// </summary> + [JsonPropertyName("SeriesID")] + public int SeriesId { get; set; } + + /// <summary> + /// Shoko group id. + /// </summary> + [JsonPropertyName("GroupID")] + public int GroupId { get; set; } +} \ No newline at end of file diff --git a/Shokofin/SignalR/Models/FileDetectedEventArgs.cs b/Shokofin/SignalR/Models/FileDetectedEventArgs.cs new file mode 100644 index 00000000..ed64fcc7 --- /dev/null +++ b/Shokofin/SignalR/Models/FileDetectedEventArgs.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +#nullable enable +namespace Shokofin.SignalR.Models; + +public class FileDetectedEventArgs +{ + /// <summary> + /// The ID of the import folder the event was detected in. + /// </summary> + [JsonPropertyName("ImportFolderID")] + public int ImportFolderId { get; set; } + + /// <summary> + /// The relative path of the file from the import folder base location + /// </summary> + [JsonPropertyName("RelativePath")] + public string RelativePath { get; set; } = string.Empty; +} diff --git a/Shokofin/SignalR/Models/FileEventArgs.cs b/Shokofin/SignalR/Models/FileEventArgs.cs new file mode 100644 index 00000000..4a4e5a47 --- /dev/null +++ b/Shokofin/SignalR/Models/FileEventArgs.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +#nullable enable +namespace Shokofin.SignalR.Models; + +public class FileEventArgs +{ + /// <summary> + /// Shoko file id. + /// </summary> + [JsonPropertyName("FileID")] + public int FileId { get; set; } + + /// <summary> + /// The ID of the import folder the event was detected in. + /// </summary> + /// <value></value> + [JsonPropertyName("ImportFolderID")] + public int ImportFolderId { get; set; } + + /// <summary> + /// The relative path of the file from the import folder base location. + /// </summary> + [JsonPropertyName("RelativePath")] + public string RelativePath { get; set; } = string.Empty; +} diff --git a/Shokofin/SignalR/Models/FileMatchedEventArgs.cs b/Shokofin/SignalR/Models/FileMatchedEventArgs.cs new file mode 100644 index 00000000..bf6b74e4 --- /dev/null +++ b/Shokofin/SignalR/Models/FileMatchedEventArgs.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +#nullable enable +namespace Shokofin.SignalR.Models; + +public class FileMatchedEventArgs : FileEventArgs +{ + /// <summary> + /// Cross references of episodes linked to this file. + /// </summary> + [JsonPropertyName("CrossRefs")] + public List<FileCrossReference> CrossReferences { get; set; } = new(); + + public class FileCrossReference + { + /// <summary> + /// Shoko episode id. + /// </summary> + [JsonPropertyName("EpisodeID")] + public int EpisodeId { get; set; } + + /// <summary> + /// Shoko series id. + /// </summary> + [JsonPropertyName("SeriesID")] + public int SeriesId { get; set; } + + /// <summary> + /// Shoko group id. + /// </summary> + [JsonPropertyName("GroupID")] + public int GroupId { get; set; } + } +} \ No newline at end of file diff --git a/Shokofin/SignalR/Models/FileMovedEventArgs.cs b/Shokofin/SignalR/Models/FileMovedEventArgs.cs new file mode 100644 index 00000000..1f39ac6c --- /dev/null +++ b/Shokofin/SignalR/Models/FileMovedEventArgs.cs @@ -0,0 +1,40 @@ +using System.Text.Json.Serialization; +using Shokofin.SignalR.Interfaces; + +#nullable enable +namespace Shokofin.SignalR.Models; + +public class FileMovedEventArgs : IFileRelocationEventArgs +{ + /// <summary> + /// Shoko file id. + /// </summary> + [JsonPropertyName("FileID")] + public int FileId { get; set; } + + /// <summary> + /// The ID of the new import folder the event was detected in. + /// </summary> + /// <value></value> + [JsonPropertyName("NewImportFolderID")] + public int ImportFolderId { get; set; } + + /// <summary> + /// The ID of the old import folder the event was detected in. + /// </summary> + /// <value></value> + [JsonPropertyName("OldImportFolderID")] + public int PreviousImportFolderId { get; set; } + + /// <summary> + /// The relative path of the new file from the import folder base location. + /// </summary> + [JsonPropertyName("NewRelativePath")] + public string RelativePath { get; set; } = string.Empty; + + /// <summary> + /// The relative path of the old file from the import folder base location. + /// </summary> + [JsonPropertyName("OldRelativePath")] + public string PreviousRelativePath { get; set; } = string.Empty; +} diff --git a/Shokofin/SignalR/Models/FileNotMatchedEventArgs.cs b/Shokofin/SignalR/Models/FileNotMatchedEventArgs.cs new file mode 100644 index 00000000..57e8fe57 --- /dev/null +++ b/Shokofin/SignalR/Models/FileNotMatchedEventArgs.cs @@ -0,0 +1,22 @@ + +#nullable enable +namespace Shokofin.SignalR.Models; + +public class FileNotMatchedEventArgs : FileEventArgs +{ + /// <summary> + /// Number of times we've tried to auto-match this file up until now. + /// </summary> + public int AutoMatchAttempts { get; set; } + + /// <summary> + /// True if this file had existing cross-refernces before this match + /// attempt. + /// </summary> + public bool HasCrossReferences { get; set; } + + /// <summary> + /// True if we're currently UDP banned. + /// </summary> + public bool IsUDPBanned { get; set; } +} \ No newline at end of file diff --git a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs new file mode 100644 index 00000000..4b7d9d0d --- /dev/null +++ b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using Shokofin.SignalR.Interfaces; + +#nullable enable +namespace Shokofin.SignalR.Models; + +public class FileRenamedEventArgs : FileEventArgs, IFileRelocationEventArgs +{ + /// <summary> + /// The new File name. + /// </summary> + [JsonPropertyName("NewFileName")] + public string FileName { get; set; } = string.Empty; + + /// <summary> + /// The old file name. + /// </summary> + [JsonPropertyName("OldFileName")] + public string PreviousFileName { get; set; } = string.Empty; + + public int PreviousImportFolderId { get; set; } + + /// <summary> + /// The relative path of the old file from the import folder base location. + /// </summary> + public string PreviousRelativePath => RelativePath[^FileName.Length] + PreviousFileName; +} \ No newline at end of file diff --git a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs new file mode 100644 index 00000000..60d0ba91 --- /dev/null +++ b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +#nullable enable +namespace Shokofin.SignalR.Models; + +public class SeriesInfoUpdatedEventArgs +{ + /// <summary> + /// Shoko series id. + /// </summary> + [JsonPropertyName("SeriesID")] + public int SeriesId { get; set; } + + /// <summary> + /// Shoko group id. + /// </summary> + [JsonPropertyName("GroupID")] + public int GroupId { get; set; } +} \ No newline at end of file diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs new file mode 100644 index 00000000..07565a2b --- /dev/null +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -0,0 +1,266 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Plugins; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shokofin.Configuration; +using Shokofin.Resolvers; +using Shokofin.SignalR.Interfaces; +using Shokofin.SignalR.Models; + +#nullable enable +namespace Shokofin.SignalR; + +class SignalRConnectionManager +{ + private const string HubUrl = "/signalr/aggregate?feeds=shoko"; + + private readonly ILogger<SignalRConnectionManager> Logger; + + private readonly ShokoResolveManager ResolveManager; + + private readonly ILibraryManager LibraryManager; + + private readonly ILibraryMonitor LibraryMonitor; + + private HubConnection? Connection = null; + + private string LastConfigKey = string.Empty; + + public HubConnectionState State => Connection == null ? HubConnectionState.Disconnected : Connection.State; + + public SignalRConnectionManager(ILogger<SignalRConnectionManager> logger, ShokoResolveManager resolveManager, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor) + { + Logger = logger; + ResolveManager = resolveManager; + LibraryManager = libraryManager; + LibraryMonitor = libraryMonitor; + } + + public void Dispose() + { + Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; + } + + #region Connection + + private async Task ConnectAsync(PluginConfiguration config) + { + if (Connection != null || !CanConnect(config)) + return; + + var builder = new HubConnectionBuilder() + .WithUrl(config + HubUrl, connectionOptions => + connectionOptions.AccessTokenProvider = () => Task.FromResult<string?>(config.ApiKey) + ) + .AddJsonProtocol(); + + if (config.SignalR_AutoReconnectInSeconds.Count > 0) + builder.WithAutomaticReconnect(config.SignalR_AutoReconnectInSeconds.Select(seconds => TimeSpan.FromSeconds(seconds)).ToArray()); + + var connection = Connection = builder.Build(); + + connection.Closed += OnDisconnected; + connection.Reconnecting += OnReconnecting; + connection.Reconnected += OnReconnected; + + // Attach refresh events. + if (config.SignalR_RefreshEnabled) { + connection.On<FileMatchedEventArgs>("ShokoEvent:FileMatched", OnFileMatched); + connection.On<FileMovedEventArgs>("ShokoEvent:FileMoved", OnFileMoved); + connection.On<FileRenamedEventArgs>("ShokoEvent:FileRenamed", OnFileRenamed); + connection.On<FileEventArgs>("ShokoEvent:FileDeleted", OnFileDeleted); + } + + // Attach file events. + if (config.SignalR_FileWatcherEnabled) { + connection.On<EpisodeInfoUpdatedEventArgs>("ShokoEvent:EpisodeUpdated", OnEpisodeInfoUpdated); + connection.On<SeriesInfoUpdatedEventArgs>("ShokoEvent:SeriesUpdated", OnSeriesInfoUpdated); + } + + try { + await Connection.StartAsync(); + } + catch { + Disconnect(); + throw; + } + } + + private Task OnReconnected(string? connectionId) + { + Logger.LogInformation("Reconnected to Shoko Server. (Connection={ConnectionId})", connectionId); + return Task.CompletedTask; + } + + private Task OnReconnecting(Exception? exception) + { + Logger.LogWarning(exception, "Disconnected from Shoko Server. Attempting to reconnect…"); + return Task.CompletedTask; + } + + private Task OnDisconnected(Exception? exception) + { + // Gracefull disconnection. + if (exception == null) { + Logger.LogInformation("Gracefully disconnected from Shoko Server."); + + } + else { + Logger.LogWarning(exception, "Abruptly disconnected from Shoko Server."); + } + return Task.CompletedTask; + } + + public void Disconnect() + => DisconnectAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + + private async Task DisconnectAsync() + { + if (Connection == null) + return; + + var connection = Connection; + Connection = null; + + await connection.StopAsync(); + await connection.DisposeAsync(); + } + + public void ResetConnection(bool autoConnect) + => ResetConnection(Plugin.Instance.Configuration, autoConnect); + + private void ResetConnection(PluginConfiguration config, bool autoConnect) + => ResetConnectionAsync(config, autoConnect).ConfigureAwait(false).GetAwaiter().GetResult(); + + public Task ResetConnectionAsync(bool autoConnect) + => ResetConnectionAsync(Plugin.Instance.Configuration, autoConnect); + + private async Task ResetConnectionAsync(PluginConfiguration config, bool autoConnect) + { + await DisconnectAsync(); + if (autoConnect) + await ConnectAsync(config); + } + + public async Task RunAsync() + { + var config = Plugin.Instance.Configuration; + LastConfigKey = GenerateConfigKey(config); + Plugin.Instance.ConfigurationChanged += OnConfigurationChanged; + + await ResetConnectionAsync(config, config.SignalR_AutoConnectEnabled); + } + + private void OnConfigurationChanged(object? sender, BasePluginConfiguration baseConfig) + { + if (baseConfig is not PluginConfiguration config) + return; + var newConfigKey = GenerateConfigKey(config); + if (!string.Equals(newConfigKey, LastConfigKey)) + { + Logger.LogDebug("Detected change in SignalR configuration! (Config={Config})", newConfigKey); + LastConfigKey = newConfigKey; + ResetConnection(config, Connection != null); + } + } + + private static bool CanConnect(PluginConfiguration config) + => !string.IsNullOrEmpty(config.Host) && !string.IsNullOrEmpty(config.ApiKey); + + private static string GenerateConfigKey(PluginConfiguration config) + => $"CanConnect={CanConnect(config)},Refresh={config.SignalR_RefreshEnabled},FileWatcher={config.SignalR_FileWatcherEnabled},AutoReconnect={config.SignalR_AutoReconnectInSeconds.Select(s => s.ToString()).Join(',')}"; + + #endregion + + #region Events + + #region File Events + + private void OnFileMatched(FileMatchedEventArgs eventArgs) + { + Logger.LogDebug( + "File matched; {ImportFolderIdB} {PathB} (File={FileId})", + eventArgs.ImportFolderId, + eventArgs.RelativePath, + eventArgs.FileId + ); + + // check if the file is already in a known media library, and if yes, + // promote it from "unknown" to "known". also generate vfs entries now + // if needed. + } + + private void OnFileRelocated(IFileRelocationEventArgs eventArgs) + { + Logger.LogDebug( + "File relocated; {ImportFolderIdA} {PathA} → {ImportFolderIdB} {PathB} (File={FileId})", + eventArgs.PreviousImportFolderId, + eventArgs.PreviousRelativePath, + eventArgs.ImportFolderId, + eventArgs.RelativePath, + eventArgs.FileId + ); + + // check the previous and current locations, and report the changes. + + // also if the vfs is used, check the vfs for broken links, and fix it, + // or remove the broken links. we can do this a) generating the new links + // and/or b) checking the existing base items for their paths and checking if + // the links broke, and if the newly generated links is not in the list provided by the base items, then remove the broken link. + } + + private void OnFileMoved(FileMovedEventArgs eventArgs) + => OnFileRelocated(eventArgs); + + private void OnFileRenamed(FileRenamedEventArgs eventArgs) + => OnFileRelocated(eventArgs); + + private void OnFileDeleted(FileEventArgs eventArgs) + { + Logger.LogDebug( + "File deleted; {ImportFolderIdB} {PathB} (File={FileId})", + eventArgs.ImportFolderId, + eventArgs.RelativePath, + eventArgs.FileId + ); + // The location has been removed. + // check any base items with the exact path, and any VFS entries with a + // link leading to the exact path, or with broken links. + } + + #endregion + + #region Refresh Events + + private void OnEpisodeInfoUpdated(EpisodeInfoUpdatedEventArgs eventArgs) + { + Logger.LogDebug( + "Episode updated. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", + eventArgs.EpisodeId, + eventArgs.SeriesId, + eventArgs.GroupId + ); + + // Refresh all epoisodes and movies linked to the episode. + } + + private void OnSeriesInfoUpdated(SeriesInfoUpdatedEventArgs eventArgs) + { + Logger.LogDebug( + "Series updated. (Series={SeriesId},Group={GroupId})", + eventArgs.SeriesId, + eventArgs.GroupId + ); + + // Refresh the show and all entries beneath it, or all movies linked to + // the show. + } + + #endregion + + #endregion +} \ No newline at end of file diff --git a/build.yaml b/build.yaml index 27b3bc88..997c2ce1 100644 --- a/build.yaml +++ b/build.yaml @@ -18,4 +18,7 @@ description: > category: "Metadata" artifacts: - "Shokofin.dll" +- "Microsoft.AspNetCore.SignalR.Client.dll" +- "Microsoft.AspNetCore.SignalR.Client.Core.dll" +- "Microsoft.AspNetCore.Http.Connections.Client.dll" changelog: "" From b79574c1fda26e1ae037e380c45d41448476e890 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 31 Mar 2024 07:29:07 +0000 Subject: [PATCH 0687/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 30a57922..22bf33ad 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.67", + "changelog": "feat: initial PoC SignalR connection manager\n\n- Add the initial PoC SignalR Connection Manager responsible for\n reacting to Shoko file, episode, and series update events, if\n enabled. Currently it only prints debug messages when enabled. And\n it can only be enabled by manually editing the settings xml.\n\nmisc: change mount of shoko api controller", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.67/shoko_3.0.1.67.zip", + "checksum": "98ffec6ef0101bc2030276f11b08ea14", + "timestamp": "2024-03-31T07:29:06Z" + }, { "version": "3.0.1.66", "changelog": "fix: fix extras placement for movies/shows\n\n- Fix the incorrect placement of some extras in shows/seasons/movies.\n\nrefactor: save media folder mappings\n\n- Store a which import folder each media folder is mapped to. This wil\n be used now to save requests needed to find the mapping, and in the\n future by the SignalR file event stream to know which files to update.\n\n- Throw if we're unable to filter an item, instead of gracefully\n returning early. This will prevent accidentail inclusions in the\n library at the expense of aborting a library scan if something\n goes awry.\n\nfix: fix ignored sub title removal\n\nmisc: update read-me file for the project\nand the plugin repostory in jellyfin. the updated read-me will only be\nvisible on the unstable repository until the next stable release though.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.63/shoko_3.0.1.63.zip", "checksum": "7d24b26f8a7601b12b46be01416b0992", "timestamp": "2024-03-31T00:42:11Z" - }, - { - "version": "3.0.1.62", - "changelog": "refactor: throw if unresolved\n\n- Log and re-throw the error if we fail while resolving one or more base\n items.\n\nfeat: impl. file path generator\n\n- Switch from getting all the file at once to using a generator that\n pre-fetches the next 10 pages. This changes shaved off ~10s (from 51s\n to 39s) on my small test library, but didn't affect the overall\n performance of the full library scan on my full library. This change\n in essense minimises the risk of getting all the files at onces timing\n out and also allows the rest of the symbolic link generation to start\n earlier.\n\n- Don't check the file system if the file exists every time we iterate\n the generator. Instead use the list of all known paths we created\n earlier by converting it to a set and just checking the set. This\n helps a lot when you're accessing the files over the network.\n\n- Add better logging for when we're generating symbolic links.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.62/shoko_3.0.1.62.zip", - "checksum": "abacc3b06a0498bc24362816a793f301", - "timestamp": "2024-03-30T05:31:33Z" } ] } From 21e889362d501329da5b4df53a1e93a5800b22bd Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 09:50:23 +0200 Subject: [PATCH 0688/1103] fix: register & fix the signalr connection manager --- Shokofin/PluginServiceRegistrator.cs | 1 + Shokofin/SignalR/SignalRConnectionManager.cs | 7 +++++-- Shokofin/SignalR/SignalREntryPoint.cs | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 Shokofin/SignalR/SignalREntryPoint.cs diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index 3d171064..7133a4c7 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -17,5 +17,6 @@ public void RegisterServices(IServiceCollection serviceCollection) serviceCollection.AddSingleton<MergeVersions.MergeVersionsManager>(); serviceCollection.AddSingleton<Collections.CollectionManager>(); serviceCollection.AddSingleton<Resolvers.ShokoResolveManager>(); + serviceCollection.AddSingleton<SignalR.SignalRConnectionManager>(); } } diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 07565a2b..d75909d5 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -14,7 +14,7 @@ #nullable enable namespace Shokofin.SignalR; -class SignalRConnectionManager +public class SignalRConnectionManager : IDisposable { private const string HubUrl = "/signalr/aggregate?feeds=shoko"; @@ -43,6 +43,7 @@ public SignalRConnectionManager(ILogger<SignalRConnectionManager> logger, ShokoR public void Dispose() { Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; + Disconnect(); } #region Connection @@ -53,7 +54,7 @@ private async Task ConnectAsync(PluginConfiguration config) return; var builder = new HubConnectionBuilder() - .WithUrl(config + HubUrl, connectionOptions => + .WithUrl(config.Host + HubUrl, connectionOptions => connectionOptions.AccessTokenProvider = () => Task.FromResult<string?>(config.ApiKey) ) .AddJsonProtocol(); @@ -83,6 +84,8 @@ private async Task ConnectAsync(PluginConfiguration config) try { await Connection.StartAsync(); + + Logger.LogInformation("Connected to Shoko Server."); } catch { Disconnect(); diff --git a/Shokofin/SignalR/SignalREntryPoint.cs b/Shokofin/SignalR/SignalREntryPoint.cs new file mode 100644 index 00000000..c3a3516c --- /dev/null +++ b/Shokofin/SignalR/SignalREntryPoint.cs @@ -0,0 +1,19 @@ + +#nullable enable +using System.Threading.Tasks; +using MediaBrowser.Controller.Plugins; + +namespace Shokofin.SignalR; + +public class SignalREntryPoint : IServerEntryPoint +{ + private readonly SignalRConnectionManager ConnectionManager; + + public SignalREntryPoint(SignalRConnectionManager connectionManager) => ConnectionManager = connectionManager; + + public void Dispose() + => ConnectionManager.Dispose(); + + public Task RunAsync() + => ConnectionManager.RunAsync(); +} \ No newline at end of file From 2afc6750677eba68701c685e0177127d379e02f3 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 31 Mar 2024 07:51:16 +0000 Subject: [PATCH 0689/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 22bf33ad..2c313c9a 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.68", + "changelog": "fix: register & fix the signalr connection manager", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.68/shoko_3.0.1.68.zip", + "checksum": "f7f21944a55a43c1974b583d910f1bdc", + "timestamp": "2024-03-31T07:51:13Z" + }, { "version": "3.0.1.67", "changelog": "feat: initial PoC SignalR connection manager\n\n- Add the initial PoC SignalR Connection Manager responsible for\n reacting to Shoko file, episode, and series update events, if\n enabled. Currently it only prints debug messages when enabled. And\n it can only be enabled by manually editing the settings xml.\n\nmisc: change mount of shoko api controller", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.64/shoko_3.0.1.64.zip", "checksum": "e6c72aade8a4a964bdfd8f9057b39ce5", "timestamp": "2024-03-31T00:45:50Z" - }, - { - "version": "3.0.1.63", - "changelog": "misc: try adding icon and color to discord embeds\n\n- First attempt at adding color (will word) and an icon (not so sure) to\n discord embeds.\n\nmisc: advertise support for movie trailers\n\n- Advertise our support for local movie trailers\u00b9. Finally I can close #10.\n\n\u00b9 _As long as the VFS is used._\n[skip ci]", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.63/shoko_3.0.1.63.zip", - "checksum": "7d24b26f8a7601b12b46be01416b0992", - "timestamp": "2024-03-31T00:42:11Z" } ] } From 3e9074d2956f6e87237cb0e9b1727940f770da3f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 10:02:07 +0200 Subject: [PATCH 0690/1103] feat: add signalr api controller --- Shokofin/SignalR/SignalRConnectionManager.cs | 21 ++-- Shokofin/Web/SignalRApiController.cs | 104 +++++++++++++++++++ 2 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 Shokofin/Web/SignalRApiController.cs diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index d75909d5..3d9ae7cd 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -30,6 +30,10 @@ public class SignalRConnectionManager : IDisposable private string LastConfigKey = string.Empty; + public bool IsUsable => CanConnect(Plugin.Instance.Configuration); + + public bool IsActive => Connection != null; + public HubConnectionState State => Connection == null ? HubConnectionState.Disconnected : Connection.State; public SignalRConnectionManager(ILogger<SignalRConnectionManager> logger, ShokoResolveManager resolveManager, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor) @@ -121,7 +125,7 @@ private Task OnDisconnected(Exception? exception) public void Disconnect() => DisconnectAsync().ConfigureAwait(false).GetAwaiter().GetResult(); - private async Task DisconnectAsync() + public async Task DisconnectAsync() { if (Connection == null) return; @@ -133,19 +137,16 @@ private async Task DisconnectAsync() await connection.DisposeAsync(); } - public void ResetConnection(bool autoConnect) - => ResetConnection(Plugin.Instance.Configuration, autoConnect); - - private void ResetConnection(PluginConfiguration config, bool autoConnect) - => ResetConnectionAsync(config, autoConnect).ConfigureAwait(false).GetAwaiter().GetResult(); + public Task ResetConnectionAsync() + => ResetConnectionAsync(Plugin.Instance.Configuration, true); - public Task ResetConnectionAsync(bool autoConnect) - => ResetConnectionAsync(Plugin.Instance.Configuration, autoConnect); + private void ResetConnection(PluginConfiguration config, bool shouldConnect) + => ResetConnectionAsync(config, shouldConnect).ConfigureAwait(false).GetAwaiter().GetResult(); - private async Task ResetConnectionAsync(PluginConfiguration config, bool autoConnect) + private async Task ResetConnectionAsync(PluginConfiguration config, bool shouldConnect) { await DisconnectAsync(); - if (autoConnect) + if (shouldConnect) await ConnectAsync(config); } diff --git a/Shokofin/Web/SignalRApiController.cs b/Shokofin/Web/SignalRApiController.cs new file mode 100644 index 00000000..38d7b13a --- /dev/null +++ b/Shokofin/Web/SignalRApiController.cs @@ -0,0 +1,104 @@ +using System; +using System.Net.Http; +using System.Net.Mime; +using System.Reflection; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Models; +using Shokofin.SignalR; + +#nullable enable +namespace Shokofin.Web; + +/// <summary> +/// Pushbullet notifications controller. +/// </summary> +[ApiController] +[Route("Plugin/Shokofin/SignalR")] +[Produces(MediaTypeNames.Application.Json)] +public class SignalRApiController : ControllerBase +{ + private readonly ILogger<SignalRApiController> 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; + } + + /// <summary> + /// Get the current status of the connection to Shoko Server. + /// </summary> + [HttpGet("Status")] + public ShokoSignalRStatus GetStatus() + { + return new() + { + IsUsable = ConnectionManager.IsUsable, + IsActive = ConnectionManager.IsActive, + State = ConnectionManager.State, + }; + } + + /// <summary> + /// Connect or reconnect to Shoko Server. + /// </summary> + [HttpPost("Connect")] + public async Task<ActionResult> ConnectAsync() + { + try { + await ConnectionManager.ResetConnectionAsync(); + return Ok(); + } + catch (Exception ex) { + Logger.LogError(ex, "Failed to connect to server."); + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + /// <summary> + /// Disconnect from Shoko Server. + /// </summary> + [HttpPost("Disconnect")] + public async Task<ActionResult> DisconnectAsync() + { + try { + await ConnectionManager.DisconnectAsync(); + return Ok(); + } + catch (Exception ex) { + Logger.LogError(ex, "Failed to disconnect from server."); + return StatusCode(StatusCodes.Status500InternalServerError); + + } + } +} + +public class ShokoSignalRStatus +{ + /// <summary> + /// Determines if we can establish a connection to the server. + /// </summary> + public bool IsUsable { get; set; } + + /// <summary> + /// Determines if the connection manager is currently active. + /// </summary> + public bool IsActive { get; set; } + + /// <summary> + /// The current state of the connection. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public HubConnectionState State { get; set; } +} \ No newline at end of file From 3e100dc305aa271a9efc075db55ea72318a2905a Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 31 Mar 2024 08:29:26 +0000 Subject: [PATCH 0691/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 2c313c9a..d786148c 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.69", + "changelog": "feat: add signalr api controller", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.69/shoko_3.0.1.69.zip", + "checksum": "b62393f72bc30b182293d4c1c2ecaee3", + "timestamp": "2024-03-31T08:29:25Z" + }, { "version": "3.0.1.68", "changelog": "fix: register & fix the signalr connection manager", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.65/shoko_3.0.1.65.zip", "checksum": "c9b029c65971248c8327fd7ae047f690", "timestamp": "2024-03-31T00:47:34Z" - }, - { - "version": "3.0.1.64", - "changelog": "misc: fix color for discord embeds.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.64/shoko_3.0.1.64.zip", - "checksum": "e6c72aade8a4a964bdfd8f9057b39ce5", - "timestamp": "2024-03-31T00:45:50Z" } ] } From cf4cfff5d13ce01b465ced1dba8f1044a4681b40 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 13:18:30 +0200 Subject: [PATCH 0692/1103] chore: de-duplicate provider ids --- Shokofin/Collections/CollectionManager.cs | 17 +++++++++-------- Shokofin/ExternalIds/ShokoEpisodeId.cs | 6 ++++-- Shokofin/ExternalIds/ShokoFileId.cs | 6 ++++-- Shokofin/ExternalIds/ShokoGroupId.cs | 6 ++++-- Shokofin/ExternalIds/ShokoSeriesId.cs | 6 ++++-- Shokofin/IdLookup.cs | 19 ++++++++++--------- Shokofin/MergeVersions/MergeVersionManager.cs | 17 +++++++++-------- Shokofin/Providers/BoxSetProvider.cs | 9 +++++---- Shokofin/Providers/EpisodeProvider.cs | 7 ++++--- Shokofin/Providers/ExtraMetadataProvider.cs | 6 +++--- Shokofin/Providers/MovieProvider.cs | 7 ++++--- Shokofin/Providers/SeasonProvider.cs | 5 +++-- Shokofin/Providers/SeriesProvider.cs | 5 +++-- Shokofin/Resolvers/ShokoResolveManager.cs | 3 --- 14 files changed, 66 insertions(+), 53 deletions(-) diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index 48979e49..ba8a74d5 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.API.Info; +using Shokofin.ExternalIds; using Shokofin.Utils; #nullable enable @@ -162,7 +163,7 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, var collectionInfo = finalGroups[missingId]; var collection = await Collection.CreateCollectionAsync(new() { Name = collectionInfo.Name, - ProviderIds = new() { { "Shoko Group", missingId } }, + ProviderIds = new() { { ShokoGroupId.Name, missingId } }, }); toCheck.Add(missingId, collection); } @@ -268,7 +269,7 @@ private async Task ReconstructMovieGroupCollections(IProgress<double> progress, var collectionInfo = finalGroups[missingId]; var collection = await Collection.CreateCollectionAsync(new() { Name = collectionInfo.Name, - ProviderIds = new() { { "Shoko Group", missingId } }, + ProviderIds = new() { { ShokoGroupId.Name, missingId } }, }); toCheck.Add(missingId, collection); } @@ -381,7 +382,7 @@ private IReadOnlyList<Movie> GetMovies() return LibraryManager.GetItemList(new() { IncludeItemTypes = new[] { BaseItemKind.Movie }, - HasAnyProviderId = new Dictionary<string, string> { { "Shoko File", "" } }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, "" } }, IsVirtualItem = false, Recursive = true, }) @@ -395,7 +396,7 @@ private IReadOnlyList<Series> GetShows() return LibraryManager.GetItemList(new() { IncludeItemTypes = new[] { BaseItemKind.Series }, - HasAnyProviderId = new Dictionary<string, string> { { "Shoko Series", "" } }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, "" } }, IsVirtualItem = false, Recursive = true, }) @@ -409,12 +410,12 @@ private IReadOnlyDictionary<string, IReadOnlyList<BoxSet>> GetSeriesCollections( return LibraryManager.GetItemList(new() { IncludeItemTypes = new[] { BaseItemKind.BoxSet }, - HasAnyProviderId = new Dictionary<string, string> { { "Shoko Series", "" } }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, "" } }, IsVirtualItem = false, Recursive = true, }) .Cast<BoxSet>() - .Select(x => x.ProviderIds.TryGetValue("Shoko Series", out var seriesId) && !string.IsNullOrEmpty(seriesId) ? new { SeriesId = seriesId, BoxSet = x } : null) + .Select(x => x.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId) && !string.IsNullOrEmpty(seriesId) ? new { SeriesId = seriesId, BoxSet = x } : null) .Where(x => x != null) .GroupBy(x => x!.SeriesId, x => x!.BoxSet) .ToDictionary(x => x.Key, x => x.ToList() as IReadOnlyList<BoxSet>); @@ -426,12 +427,12 @@ private IReadOnlyDictionary<string, IReadOnlyList<BoxSet>> GetGroupCollections() { IncludeItemTypes = new[] { BaseItemKind.BoxSet }, - HasAnyProviderId = new Dictionary<string, string> { { "Shoko Group", "" } }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoGroupId.Name, "" } }, IsVirtualItem = false, Recursive = true, }) .Cast<BoxSet>() - .Select(x => x.ProviderIds.TryGetValue("Shoko Group", out var groupId) && !string.IsNullOrEmpty(groupId) ? new { GroupId = groupId, BoxSet = x } : null) + .Select(x => x.ProviderIds.TryGetValue(ShokoGroupId.Name, out var groupId) && !string.IsNullOrEmpty(groupId) ? new { GroupId = groupId, BoxSet = x } : null) .Where(x => x != null) .GroupBy(x => x!.GroupId, x => x!.BoxSet) .ToDictionary(x => x.Key, x => x.ToList() as IReadOnlyList<BoxSet>); diff --git a/Shokofin/ExternalIds/ShokoEpisodeId.cs b/Shokofin/ExternalIds/ShokoEpisodeId.cs index 070a8359..06dfcb97 100644 --- a/Shokofin/ExternalIds/ShokoEpisodeId.cs +++ b/Shokofin/ExternalIds/ShokoEpisodeId.cs @@ -9,14 +9,16 @@ namespace Shokofin.ExternalIds; public class ShokoEpisodeId : IExternalId { + public const string Name = "Shoko Episode"; + public bool Supports(IHasProviderIds item) => item is Episode or Movie; public string ProviderName - => "Shoko Episode"; + => Name; public string Key - => "Shoko Episode"; + => Name; public ExternalIdMediaType? Type => null; diff --git a/Shokofin/ExternalIds/ShokoFileId.cs b/Shokofin/ExternalIds/ShokoFileId.cs index 2d802278..ba8d8c24 100644 --- a/Shokofin/ExternalIds/ShokoFileId.cs +++ b/Shokofin/ExternalIds/ShokoFileId.cs @@ -10,14 +10,16 @@ namespace Shokofin.ExternalIds; public class ShokoFileId : IExternalId { + public const string Name = "Shoko File"; + public bool Supports(IHasProviderIds item) => item is Episode or Movie; public string ProviderName - => "Shoko File"; + => Name; public string Key - => "Shoko File"; + => Name; public ExternalIdMediaType? Type => null; diff --git a/Shokofin/ExternalIds/ShokoGroupId.cs b/Shokofin/ExternalIds/ShokoGroupId.cs index 211e6975..720fcdd8 100644 --- a/Shokofin/ExternalIds/ShokoGroupId.cs +++ b/Shokofin/ExternalIds/ShokoGroupId.cs @@ -9,14 +9,16 @@ namespace Shokofin.ExternalIds; public class ShokoGroupId : IExternalId { + public const string Name = "Shoko Group"; + public bool Supports(IHasProviderIds item) => item is Series or BoxSet; public string ProviderName - => "Shoko Group"; + => Name; public string Key - => "Shoko Group"; + => Name; public ExternalIdMediaType? Type => null; diff --git a/Shokofin/ExternalIds/ShokoSeriesId.cs b/Shokofin/ExternalIds/ShokoSeriesId.cs index ae7ad0c4..5bba81d8 100644 --- a/Shokofin/ExternalIds/ShokoSeriesId.cs +++ b/Shokofin/ExternalIds/ShokoSeriesId.cs @@ -9,14 +9,16 @@ namespace Shokofin.ExternalIds; public class ShokoSeriesId : IExternalId { + public const string Name = "Shoko Series"; + public bool Supports(IHasProviderIds item) => item is Series or Season or Movie; public string ProviderName - => "Shoko Series"; + => Name; public string Key - => "Shoko Series"; + => Name; public ExternalIdMediaType? Type => null; diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index c367f0ee..b5e90cbe 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -5,6 +5,7 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using Shokofin.API; +using Shokofin.ExternalIds; using Shokofin.Providers; namespace Shokofin; @@ -165,16 +166,16 @@ public bool TryGetSeriesIdFromEpisodeId(string episodeId, out string seriesId) public bool TryGetSeriesIdFor(Series series, out string seriesId) { - if (series.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { + if (series.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId) && !string.IsNullOrEmpty(seriesId)) { return true; } if (TryGetSeriesIdFor(series.Path, out seriesId)) { - // Set the "Shoko Group" and "Shoko Series" provider ids for the series, since it haven't been set again. It doesn't matter if it's not saved to the database, since we only need it while running the following code. + // Set the ShokoGroupId.Name and ShokoSeriesId.Name provider ids for the series, since it haven't been set again. It doesn't matter if it's not saved to the database, since we only need it while running the following code. if (ApiManager.TryGetDefaultSeriesIdForSeriesId(seriesId, out var defaultSeriesId)) { SeriesProvider.AddProviderIds(series, defaultSeriesId); } - // Same as above, but only set the "Shoko Series" id. + // Same as above, but only set the ShokoSeriesId.Name id. else { SeriesProvider.AddProviderIds(series, seriesId); } @@ -188,7 +189,7 @@ public bool TryGetSeriesIdFor(Series series, out string seriesId) public bool TryGetSeriesIdFor(Season season, out string seriesId) { - if (season.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { + if (season.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId) && !string.IsNullOrEmpty(seriesId)) { return true; } @@ -197,7 +198,7 @@ public bool TryGetSeriesIdFor(Season season, out string seriesId) public bool TryGetSeriesIdFor(Movie movie, out string seriesId) { - if (movie.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { + if (movie.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId) && !string.IsNullOrEmpty(seriesId)) { return true; } @@ -210,7 +211,7 @@ public bool TryGetSeriesIdFor(Movie movie, out string seriesId) public bool TryGetSeriesIdFor(BoxSet boxSet, out string seriesId) { - if (boxSet.ProviderIds.TryGetValue("Shoko Series", out seriesId) && !string.IsNullOrEmpty(seriesId)) { + if (boxSet.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId) && !string.IsNullOrEmpty(seriesId)) { return true; } @@ -243,7 +244,7 @@ public bool TryGetEpisodeIdFor(string path, out string episodeId) public bool TryGetEpisodeIdFor(BaseItem item, out string episodeId) { // This will account for virtual episodes and existing episodes - if (item.ProviderIds.TryGetValue("Shoko Episode", out episodeId) && !string.IsNullOrEmpty(episodeId)) { + if (item.ProviderIds.TryGetValue(ShokoEpisodeId.Name, out episodeId) && !string.IsNullOrEmpty(episodeId)) { return true; } @@ -263,7 +264,7 @@ public bool TryGetEpisodeIdsFor(string path, out List<string> episodeIds) public bool TryGetEpisodeIdsFor(BaseItem item, out List<string> episodeIds) { // This will account for virtual episodes and existing episodes - if (item.ProviderIds.TryGetValue("Shoko File", out var fileId) && ApiManager.TryGetEpisodeIdsForFileId(fileId, out episodeIds)) { + if (item.ProviderIds.TryGetValue(ShokoFileId.Name, out var fileId) && ApiManager.TryGetEpisodeIdsForFileId(fileId, out episodeIds)) { return true; } @@ -288,7 +289,7 @@ public bool TryGetPathForEpisodeId(string episodeId, out string path) public bool TryGetFileIdFor(BaseItem episode, out string fileId) { - if (episode.ProviderIds.TryGetValue("Shoko File", out fileId)) + if (episode.ProviderIds.TryGetValue(ShokoFileId.Name, out fileId)) return true; return ApiManager.TryGetFileIdForPath(episode.Path, out fileId); diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index 0191907a..c200cb0d 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -1,16 +1,17 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; -using Jellyfin.Data.Enums; -using System.Globalization; using MediaBrowser.Model.Entities; using MediaBrowser.Common.Progress; +using Shokofin.ExternalIds; #nullable enable namespace Shokofin.MergeVersions; @@ -127,7 +128,7 @@ private List<Movie> GetMoviesFromLibrary() IncludeItemTypes = new[] { BaseItemKind.Movie }, IsVirtualItem = false, Recursive = true, - HasAnyProviderId = new Dictionary<string, string> { {"Shoko Episode", "" } }, + HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, "" } }, }) .Cast<Movie>() .Where(Lookup.IsEnabledForItem) @@ -160,7 +161,7 @@ public async Task MergeAllMovies(IProgress<double> progress, CancellationToken c // Merge all movies with more than one version. var movies = GetMoviesFromLibrary(); var duplicationGroups = movies - .GroupBy(x => x.ProviderIds["Shoko Episode"]) + .GroupBy(x => x.ProviderIds[ShokoEpisodeId.Name]) .Where(x => x.Count() > 1) .ToList(); double currentCount = 0d; @@ -229,7 +230,7 @@ private async Task SplitAndMergeAllMovies(IProgress<double> progress, Cancellati // Merge all movies with more than one version (again). var duplicationGroups = movies - .GroupBy(movie => movie.ProviderIds["Shoko Episode"]) + .GroupBy(movie => movie.ProviderIds[ShokoEpisodeId.Name]) .Where(movie => movie.Count() > 1) .ToList(); currentCount = 0d; @@ -258,7 +259,7 @@ private List<Episode> GetEpisodesFromLibrary() { return LibraryManager.GetItemList(new() { IncludeItemTypes = new[] { BaseItemKind.Episode }, - HasAnyProviderId = new Dictionary<string, string> { {"Shoko Episode", "" } }, + HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, "" } }, IsVirtualItem = false, Recursive = true, }) @@ -295,7 +296,7 @@ public async Task MergeAllEpisodes(IProgress<double> progress, CancellationToken // of additional episodes. var episodes = GetEpisodesFromLibrary(); var duplicationGroups = episodes - .GroupBy(e => $"{e.ProviderIds["Shoko Episode"]}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}") + .GroupBy(e => $"{e.ProviderIds[ShokoEpisodeId.Name]}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}") .Where(e => e.Count() > 1) .ToList(); double currentCount = 0d; @@ -366,7 +367,7 @@ private async Task SplitAndMergeAllEpisodes(IProgress<double> progress, Cancella // Merge episodes with more than one version (again), and with the same // number of additional episodes. var duplicationGroups = episodes - .GroupBy(e => $"{e.ProviderIds["Shoko Episode"]}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}") + .GroupBy(e => $"{e.ProviderIds[ShokoEpisodeId.Name]}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}") .Where(e => e.Count() > 1) .ToList(); currentCount = 0d; diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 5694c881..1bca7f20 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -10,6 +10,7 @@ using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; +using Shokofin.ExternalIds; using Shokofin.Utils; #nullable enable @@ -52,7 +53,7 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info) var result = new MetadataResult<BoxSet>(); // First try to re-use any existing series id. - if (!info.ProviderIds.TryGetValue("Shoko Series", out var seriesId)) + if (!info.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId)) return result; var season = await ApiManager.GetSeasonInfoForSeries(seriesId); @@ -78,7 +79,7 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info) Tags = season.Tags.ToArray(), CommunityRating = season.AniDB.Rating.ToFloat(10), }; - result.Item.SetProviderId("Shoko Series", season.Id); + result.Item.SetProviderId(ShokoSeriesId.Name, season.Id); if (Plugin.Instance.Configuration.AddAniDBId) result.Item.SetProviderId("AniDB", season.AniDB.Id.ToString()); @@ -91,7 +92,7 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in { // Filter out all manually created collections. We don't help those. var result = new MetadataResult<BoxSet>(); - if (!info.ProviderIds.TryGetValue("Shoko Group", out var groupId)) + if (!info.ProviderIds.TryGetValue(ShokoGroupId.Name, out var groupId)) return result; var collection = await ApiManager.GetCollectionInfoForGroup(groupId); @@ -104,7 +105,7 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in Name = collection.Name, Overview = collection.Shoko.Description, }; - result.Item.SetProviderId("Shoko Group", collection.Id); + result.Item.SetProviderId(ShokoGroupId.Name, collection.Id); result.HasMetadata = true; return result; diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index be60585a..56e7192e 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -10,6 +10,7 @@ using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; +using Shokofin.ExternalIds; using Shokofin.Utils; using Info = Shokofin.API.Info; @@ -49,7 +50,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell Info.ShowInfo? showInfo = null; if (info.IsMissingEpisode || string.IsNullOrEmpty(info.Path)) { // We're unable to fetch the latest metadata for the virtual episode. - if (!info.ProviderIds.TryGetValue("Shoko Episode", out var episodeId)) + if (!info.ProviderIds.TryGetValue(ShokoEpisodeId.Name, out var episodeId)) return result; episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); @@ -235,9 +236,9 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie private static void AddProviderIds(IHasProviderIds item, string episodeId, string? fileId = null, string? anidbId = null, string? tmdbId = null) { var config = Plugin.Instance.Configuration; - item.SetProviderId("Shoko Episode", episodeId); + item.SetProviderId(ShokoEpisodeId.Name, episodeId); if (!string.IsNullOrEmpty(fileId)) - item.SetProviderId("Shoko File", fileId); + item.SetProviderId(ShokoFileId.Name, fileId); if (config.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") item.SetProviderId("AniDB", anidbId); if (config.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index d1792007..e9b89660 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; -using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -13,6 +12,7 @@ using MediaBrowser.Model.Globalization; using Microsoft.Extensions.Logging; using Shokofin.API; +using Shokofin.ExternalIds; using Shokofin.Utils; using Info = Shokofin.API.Info; @@ -615,7 +615,7 @@ private bool EpisodeExists(string episodeId, string seriesId, string? groupId) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Episode }, - HasAnyProviderId = new Dictionary<string, string> { ["Shoko Episode"] = episodeId }, + HasAnyProviderId = new Dictionary<string, string> { [ShokoEpisodeId.Name] = episodeId }, DtoOptions = new DtoOptions(true), }, true); @@ -644,7 +644,7 @@ private void RemoveDuplicateEpisodes(Episode episode, string episodeId) var query = new InternalItemsQuery { IsVirtualItem = true, ExcludeItemIds = new [] { episode.Id }, - HasAnyProviderId = new Dictionary<string, string> { ["Shoko Episode"] = episodeId }, + HasAnyProviderId = new Dictionary<string, string> { [ShokoEpisodeId.Name] = episodeId }, IncludeItemTypes = new [] {Jellyfin.Data.Enums.BaseItemKind.Episode }, GroupByPresentationUniqueKey = false, DtoOptions = new DtoOptions(true), diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 09844b53..cd6f8360 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -10,6 +10,7 @@ using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; +using Shokofin.ExternalIds; using Shokofin.Utils; #nullable enable @@ -65,9 +66,9 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio Studios = season.Studios.ToArray(), CommunityRating = rating, }; - result.Item.SetProviderId("Shoko File", file.Id); - result.Item.SetProviderId("Shoko Episode", episode.Id); - result.Item.SetProviderId("Shoko Series", season.Id); + result.Item.SetProviderId(ShokoFileId.Name, file.Id); + result.Item.SetProviderId(ShokoEpisodeId.Name, episode.Id); + result.Item.SetProviderId(ShokoSeriesId.Name, season.Id); result.HasMetadata = true; diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 27d762b1..cfb84d85 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -9,6 +9,7 @@ using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; +using Shokofin.ExternalIds; using Shokofin.Utils; using Info = Shokofin.API.Info; @@ -38,7 +39,7 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat if (!info.IndexNumber.HasValue || info.IndexNumber.HasValue && info.IndexNumber.Value == 0) return result; - if (!info.SeriesProviderIds.TryGetValue("Shoko Series", out var seriesId) || !info.IndexNumber.HasValue) { + if (!info.SeriesProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId) || !info.IndexNumber.HasValue) { Logger.LogDebug("Unable refresh Season {SeasonNumber} {SeasonName}", info.IndexNumber, info.Name); return result; } @@ -148,7 +149,7 @@ public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber CommunityRating = seasonInfo.AniDB.Rating?.ToFloat(10), }; } - season.ProviderIds.Add("Shoko Series", seasonInfo.Id); + season.ProviderIds.Add(ShokoSeriesId.Name, seasonInfo.Id); season.ProviderIds.Add("Shoko Season Offset", offset.ToString()); if (Plugin.Instance.Configuration.AddAniDBId) season.ProviderIds.Add("AniDB", seasonInfo.AniDB.Id.ToString()); diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index bccce524..7e3e2e66 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -12,6 +12,7 @@ using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; +using Shokofin.ExternalIds; using Shokofin.Utils; #nullable enable @@ -103,9 +104,9 @@ public static void AddProviderIds(IHasProviderIds item, string seriesId, string? item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{seriesId}"); var config = Plugin.Instance.Configuration; - item.SetProviderId("Shoko Series", seriesId); + item.SetProviderId(ShokoSeriesId.Name, seriesId); if (!string.IsNullOrEmpty(groupId)) - item.SetProviderId("Shoko Group", groupId); + item.SetProviderId(ShokoGroupId.Name, groupId); if (config.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") item.SetProviderId("AniDB", anidbId); if (config.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 937f9227..effb061e 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -779,9 +779,6 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b return new Movie() { Path = fileInfo.FullName, - ProviderIds = new() { - { "Shoko File", fileId.ToString() }, - } } as BaseItem; }) .ToArray(); From 431e0d03ea2fa3ba761d7037efa554a71a349f93 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 13:20:53 +0200 Subject: [PATCH 0693/1103] =?UTF-8?q?chore:=20`""`=20=E2=86=92=C2=A0`strin?= =?UTF-8?q?g.Empty`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/API/Info/SeasonInfo.cs | 2 +- Shokofin/API/Models/ApiException.cs | 4 +-- Shokofin/API/Models/ApiKey.cs | 2 +- Shokofin/API/Models/Episode.cs | 8 ++--- Shokofin/API/Models/File.cs | 18 +++++------ Shokofin/API/Models/Group.cs | 6 ++-- Shokofin/API/Models/Image.cs | 2 +- Shokofin/API/Models/Rating.cs | 2 +- Shokofin/API/Models/Role.cs | 6 ++-- Shokofin/API/Models/Series.cs | 12 ++++---- Shokofin/API/Models/Tag.cs | 4 +-- Shokofin/API/Models/Title.cs | 2 +- Shokofin/API/ShokoAPIManager.cs | 4 +-- Shokofin/Collections/CollectionManager.cs | 8 ++--- Shokofin/Configuration/PluginConfiguration.cs | 4 +-- Shokofin/MergeVersions/MergeVersionManager.cs | 4 +-- Shokofin/StringExtensions.cs | 30 +++++++++---------- Shokofin/Utils/Text.cs | 12 ++++---- 18 files changed, 65 insertions(+), 65 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 08b0286e..6b2367c3 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -209,7 +209,7 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li Name = role.Staff.Name, // The character will always be present if the role is a VA. // We make it a conditional check since otherwise will the compiler complain. - Role = role.Character?.Name ?? "", + Role = role.Character?.Name ?? string.Empty, ImageUrl = GetImagePath(role.Staff.Image), }, _ => null, diff --git a/Shokofin/API/Models/ApiException.cs b/Shokofin/API/Models/ApiException.cs index 827695fb..1a931af8 100644 --- a/Shokofin/API/Models/ApiException.cs +++ b/Shokofin/API/Models/ApiException.cs @@ -17,7 +17,7 @@ private record ValidationResponse { public Dictionary<string, string[]> errors = new(); - public string title = ""; + public string title = string.Empty; public HttpStatusCode status = HttpStatusCode.BadRequest; } @@ -70,7 +70,7 @@ public static ApiException FromResponse(HttpResponseMessage response) var stackTrace = string.Join('\n', lines); return new ApiException(response.StatusCode, new RemoteApiException(name ?? "InternalServerException", message, stackTrace)); } - return new ApiException(response.StatusCode, response.StatusCode.ToString() + "Exception", text.Split('\n').FirstOrDefault() ?? ""); + return new ApiException(response.StatusCode, response.StatusCode.ToString() + "Exception", text.Split('\n').FirstOrDefault() ?? string.Empty); } public class RemoteApiException : Exception diff --git a/Shokofin/API/Models/ApiKey.cs b/Shokofin/API/Models/ApiKey.cs index 1f9edb6e..dd603997 100644 --- a/Shokofin/API/Models/ApiKey.cs +++ b/Shokofin/API/Models/ApiKey.cs @@ -3,5 +3,5 @@ namespace Shokofin.API.Models; public class ApiKey { - public string apikey { get; set; } = ""; + public string apikey { get; set; } = string.Empty; } diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index 96884fbb..2aee338d 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -13,7 +13,7 @@ public class Episode /// </summary> public EpisodeIDs IDs { get; set; } = new(); - public string Name { get; set; } = ""; + public string Name { get; set; } = string.Empty; /// <summary> /// The duration of the episode. @@ -64,7 +64,7 @@ public class AniDB public List<Title> Titles { get; set; } = new(); - public string Description { get; set; } = ""; + public string Description { get; set; } = string.Empty; public Rating Rating { get; set; } = new(); } @@ -83,9 +83,9 @@ public class TvDB [JsonPropertyName("AbsoluteNumber")] public int AbsoluteEpisodeNumber { get; set; } - public string Title { get; set; } = ""; + public string Title { get; set; } = string.Empty; - public string Description { get; set; } = ""; + public string Description { get; set; } = string.Empty; public DateTime? AirDate { get; set; } diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index d8f65b2a..cdbd1d07 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -36,7 +36,7 @@ public class File /// <summary> /// Try to fit this file's resolution to something like 1080p, 480p, etc. /// </summary> - public string Resolution { get; set; } = ""; + public string Resolution { get; set; } = string.Empty; /// <summary> /// The duration of the file. @@ -76,7 +76,7 @@ public class Location /// The relative path from the base of the <see cref="ImportFolder"/> to /// where the <see cref="File"/> lies. /// </summary> - public string RelativePath { get; set; } = ""; + public string RelativePath { get; set; } = string.Empty; /// <summary> /// The relative path from the base of the <see cref="ImportFolder"/> to @@ -135,7 +135,7 @@ public class AniDB /// <summary> /// The original FileName. Useful for when you obtained from a shady source or when you renamed it without thinking. /// </summary> - public string OriginalFileName { get; set; } = ""; + public string OriginalFileName { get; set; } = string.Empty; /// <summary> /// Is the file marked as deprecated. Generally, yes if there's a V2, and this isn't it @@ -177,12 +177,12 @@ public class AniDBReleaseGroup /// <summary> /// The release group's Name (Unlimited Translation Works) /// </summary> - public string Name { get; set; } = ""; + public string Name { get; set; } = string.Empty; /// <summary> /// The release group's Name (UTW) /// </summary> - public string ShortName { get; set; } = ""; + public string ShortName { get; set; } = string.Empty; } /// <summary> @@ -191,13 +191,13 @@ public class AniDBReleaseGroup /// </summary> public class HashMap { - public string ED2K { get; set; } = ""; + public string ED2K { get; set; } = string.Empty; - public string SHA1 { get; set; } = ""; + public string SHA1 { get; set; } = string.Empty; - public string CRC32 { get; set; } = ""; + public string CRC32 { get; set; } = string.Empty; - public string MD5 { get; set; } = ""; + public string MD5 { get; set; } = string.Empty; } public class CrossReference diff --git a/Shokofin/API/Models/Group.cs b/Shokofin/API/Models/Group.cs index 527d030c..dcdccd62 100644 --- a/Shokofin/API/Models/Group.cs +++ b/Shokofin/API/Models/Group.cs @@ -3,15 +3,15 @@ namespace Shokofin.API.Models; public class Group { - public string Name { get; set; } = ""; + public string Name { get; set; } = string.Empty; public int Size { get; set; } public GroupIDs IDs { get; set; } = new(); - public string SortName { get; set; } = ""; + public string SortName { get; set; } = string.Empty; - public string Description { get; set; } = ""; + public string Description { get; set; } = string.Empty; public bool HasCustomName { get; set; } diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index c565c43b..b517ac01 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 <see cref="ImageType.Static"/> resources /// then it is the resource name. /// </summary> - public string ID { get; set; } = ""; + public string ID { get; set; } = string.Empty; /// <summary> diff --git a/Shokofin/API/Models/Rating.cs b/Shokofin/API/Models/Rating.cs index a6950826..5c6f05b3 100644 --- a/Shokofin/API/Models/Rating.cs +++ b/Shokofin/API/Models/Rating.cs @@ -16,7 +16,7 @@ public class Rating /// <summary> /// AniDB, etc. /// </summary> - public string Source { get; set; } = ""; + public string Source { get; set; } = string.Empty; /// <summary> /// number of votes diff --git a/Shokofin/API/Models/Role.cs b/Shokofin/API/Models/Role.cs index 191cc687..a85df5e6 100644 --- a/Shokofin/API/Models/Role.cs +++ b/Shokofin/API/Models/Role.cs @@ -9,7 +9,7 @@ public class Role /// Extra info about the role. For example, role can be voice actor, while role_details is Main Character /// </summary> [JsonPropertyName("RoleDetails")] - public string Name { get; set; } = ""; + public string Name { get; set; } = string.Empty; /// <summary> /// The role that the staff plays, cv, writer, director, etc @@ -37,7 +37,7 @@ public class Person /// Main Name, romanized if needed /// ex. Sawano Hiroyuki /// </summary> - public string Name { get; set; } = ""; + public string Name { get; set; } = string.Empty; /// <summary> /// Alternate Name, this can be any other name, whether kanji, an alias, etc @@ -49,7 +49,7 @@ public class Person /// A description, bio, etc /// ex. Sawano Hiroyuki was born September 12, 1980 in Tokyo, Japan. He is a composer and arranger. /// </summary> - public string Description { get; set; } = ""; + public string Description { get; set; } = string.Empty; /// <summary> /// Visual representation of the character or staff. Usually a profile diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index d2e4a052..679e3a68 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; } = ""; + public string Name { get; set; } = string.Empty; public int Size { get; set; } @@ -79,7 +79,7 @@ public class AniDB /// <summary> /// Main Title, usually matches x-jat /// </summary> - public string Title { get; set; } = ""; + public string Title { get; set; } = string.Empty; /// <summary> /// There should always be at least one of these, the <see cref="Title"/>. May be omitted if needed. @@ -89,7 +89,7 @@ public class AniDB /// <summary> /// Description. /// </summary> - public string Description { get; set; } = ""; + public string Description { get; set; } = string.Empty; /// <summary> /// Restricted content. Mainly porn. @@ -128,7 +128,7 @@ public class AniDBWithDate : AniDB /// <summary> /// Description. /// </summary> - public new string Description { get; set; } = ""; + public new string Description { get; set; } = string.Empty; /// <summary> /// There should always be at least one of these, the <see cref="Title"/>. May be omitted if needed. @@ -194,9 +194,9 @@ public class TvDB public DateTime? EndDate { get; set; } - public string Title { get; set; } = ""; + public string Title { get; set; } = string.Empty; - public string Description { get; set; } = ""; + public string Description { get; set; } = string.Empty; public Rating Rating { get; set; } = new(); } diff --git a/Shokofin/API/Models/Tag.cs b/Shokofin/API/Models/Tag.cs index 6b63fac5..0cb2c4d3 100644 --- a/Shokofin/API/Models/Tag.cs +++ b/Shokofin/API/Models/Tag.cs @@ -22,7 +22,7 @@ public class Tag /// <summary> /// The tag itself /// </summary> - public string Name { get; set; } = ""; + public string Name { get; set; } = string.Empty; /// <summary> /// What does the tag mean/what's it for @@ -63,5 +63,5 @@ public class Tag /// <summary> /// Source. AniDB, User, etc. /// </summary> - public string Source { get; set; } = ""; + public string Source { get; set; } = string.Empty; } diff --git a/Shokofin/API/Models/Title.cs b/Shokofin/API/Models/Title.cs index 4faa9028..6aa08721 100644 --- a/Shokofin/API/Models/Title.cs +++ b/Shokofin/API/Models/Title.cs @@ -9,7 +9,7 @@ public class Title /// The title. /// </summary> [JsonPropertyName("Name")] - public string Value { get; set; } = ""; + public string Value { get; set; } = string.Empty; /// <summary> /// 3-digit language code (x-jat, etc. are exceptions) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 857ba855..db7bb5eb 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -304,7 +304,7 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) foreach (var file in await APIClient.GetFilesForSeries(seriesId).ConfigureAwait(false)) { if (file.CrossReferences.Count == 1) foreach (var fileLocation in file.Locations) - pathSet.Add((Path.GetDirectoryName(fileLocation.Path) ?? "") + Path.DirectorySeparatorChar); + pathSet.Add((Path.GetDirectoryName(fileLocation.Path) ?? string.Empty) + Path.DirectorySeparatorChar); var xref = file.CrossReferences.First(xref => xref.Series.Shoko.ToString() == seriesId); foreach (var episodeXRef in xref.Episodes) episodeIds.Add(episodeXRef.Shoko.ToString()); @@ -394,7 +394,7 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu } // Find the correct series based on the path. - var selectedPath = (Path.GetDirectoryName(fileLocations.First().Path) ?? "") + Path.DirectorySeparatorChar; + var selectedPath = (Path.GetDirectoryName(fileLocations.First().Path) ?? string.Empty) + Path.DirectorySeparatorChar; foreach (var seriesXRef in file.CrossReferences) { var seriesId = seriesXRef.Series.Shoko.ToString(); diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index ba8a74d5..b16d9705 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -382,7 +382,7 @@ private IReadOnlyList<Movie> GetMovies() return LibraryManager.GetItemList(new() { IncludeItemTypes = new[] { BaseItemKind.Movie }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, "" } }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, string.Empty } }, IsVirtualItem = false, Recursive = true, }) @@ -396,7 +396,7 @@ private IReadOnlyList<Series> GetShows() return LibraryManager.GetItemList(new() { IncludeItemTypes = new[] { BaseItemKind.Series }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, "" } }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, string.Empty } }, IsVirtualItem = false, Recursive = true, }) @@ -410,7 +410,7 @@ private IReadOnlyDictionary<string, IReadOnlyList<BoxSet>> GetSeriesCollections( return LibraryManager.GetItemList(new() { IncludeItemTypes = new[] { BaseItemKind.BoxSet }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, "" } }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, string.Empty } }, IsVirtualItem = false, Recursive = true, }) @@ -427,7 +427,7 @@ private IReadOnlyDictionary<string, IReadOnlyList<BoxSet>> GetGroupCollections() { IncludeItemTypes = new[] { BaseItemKind.BoxSet }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoGroupId.Name, "" } }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoGroupId.Name, string.Empty } }, IsVirtualItem = false, Recursive = true, }) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index dc40bb53..1efd63c7 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -137,9 +137,9 @@ public PluginConfiguration() { Host = "http://127.0.0.1:8111"; HostVersion = null; - PublicHost = ""; + PublicHost = string.Empty; Username = "Default"; - ApiKey = ""; + ApiKey = string.Empty; HideArtStyleTags = false; HideMiscTags = false; HidePlotTags = true; diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index c200cb0d..e191ae48 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -128,7 +128,7 @@ private List<Movie> GetMoviesFromLibrary() IncludeItemTypes = new[] { BaseItemKind.Movie }, IsVirtualItem = false, Recursive = true, - HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, "" } }, + HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, string.Empty } }, }) .Cast<Movie>() .Where(Lookup.IsEnabledForItem) @@ -259,7 +259,7 @@ private List<Episode> GetEpisodesFromLibrary() { return LibraryManager.GetItemList(new() { IncludeItemTypes = new[] { BaseItemKind.Episode }, - HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, "" } }, + HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, string.Empty } }, IsVirtualItem = false, Recursive = true, }) diff --git a/Shokofin/StringExtensions.cs b/Shokofin/StringExtensions.cs index 7842f946..3102c8cf 100644 --- a/Shokofin/StringExtensions.cs +++ b/Shokofin/StringExtensions.cs @@ -9,37 +9,37 @@ public static class StringExtensions { public static void Deconstruct(this IList<string> list, out string first) { - first = list.Count > 0 ? list[0] : ""; + first = list.Count > 0 ? list[0] : string.Empty; } public static void Deconstruct(this IList<string> list, out string first, out string second) { - first = list.Count > 0 ? list[0] : ""; - second = list.Count > 1 ? list[1] : ""; + first = list.Count > 0 ? list[0] : string.Empty; + second = list.Count > 1 ? list[1] : string.Empty; } public static void Deconstruct(this IList<string> list, out string first, out string second, out string third) { - first = list.Count > 0 ? list[0] : ""; - second = list.Count > 1 ? list[1] : ""; - third = list.Count > 2 ? list[2] : ""; + first = list.Count > 0 ? list[0] : string.Empty; + second = list.Count > 1 ? list[1] : string.Empty; + third = list.Count > 2 ? list[2] : string.Empty; } public static void Deconstruct(this IList<string> list, out string first, out string second, out string third, out string forth) { - first = list.Count > 0 ? list[0] : ""; - second = list.Count > 1 ? list[1] : ""; - third = list.Count > 2 ? list[2] : ""; - forth = list.Count > 3 ? list[3] : ""; + first = list.Count > 0 ? list[0] : string.Empty; + second = list.Count > 1 ? list[1] : string.Empty; + third = list.Count > 2 ? list[2] : string.Empty; + forth = list.Count > 3 ? list[3] : string.Empty; } public static void Deconstruct(this IList<string> list, out string first, out string second, out string third, out string forth, out string fifth) { - first = list.Count > 0 ? list[0] : ""; - second = list.Count > 1 ? list[1] : ""; - third = list.Count > 2 ? list[2] : ""; - forth = list.Count > 3 ? list[3] : ""; - fifth = list.Count > 4 ? list[4] : ""; + first = list.Count > 0 ? list[0] : string.Empty; + second = list.Count > 1 ? list[1] : string.Empty; + third = list.Count > 2 ? list[2] : string.Empty; + forth = list.Count > 3 ? list[3] : string.Empty; + fifth = list.Count > 4 ? list[4] : string.Empty; } public static string Join(this IEnumerable<string> list, char separator) diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index 489c2f20..604facef 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -172,7 +172,7 @@ private static string GetDescription(string aniDbDescription, string otherDescri goto case TextSourceType.OnlyOther; break; case TextSourceType.PreferOther: - overview = otherDescription ?? ""; + overview = otherDescription ?? string.Empty; if (string.IsNullOrEmpty(overview)) goto case TextSourceType.OnlyAniDb; break; @@ -180,7 +180,7 @@ private static string GetDescription(string aniDbDescription, string otherDescri overview = SanitizeTextSummary(aniDbDescription); break; case TextSourceType.OnlyOther: - overview = otherDescription ?? ""; + overview = otherDescription ?? string.Empty; break; } return overview; @@ -194,7 +194,7 @@ private static string GetDescription(string aniDbDescription, string otherDescri public static string SanitizeTextSummary(string summary) { if (string.IsNullOrWhiteSpace(summary)) - return ""; + return string.Empty; var config = Plugin.Instance.Configuration; @@ -202,10 +202,10 @@ public static string SanitizeTextSummary(string summary) summary = Regex.Replace(summary, @"https?:\/\/\w+.\w+(?:\/?\w+)? \[([^\]]+)\]", match => match.Groups[1].Value); if (config.SynopsisCleanMiscLines) - summary = Regex.Replace(summary, @"^(\*|--|~) .*", "", RegexOptions.Multiline); + summary = Regex.Replace(summary, @"^(\*|--|~) .*", string.Empty, RegexOptions.Multiline); if (config.SynopsisRemoveSummary) - summary = Regex.Replace(summary, @"\n(Source|Note|Summary):.*", "", RegexOptions.Singleline); + summary = Regex.Replace(summary, @"\n(Source|Note|Summary):.*", string.Empty, RegexOptions.Singleline); if (config.SynopsisCleanMultiEmptyLines) summary = Regex.Replace(summary, @"\n{2,}", "\n", RegexOptions.Singleline); @@ -243,7 +243,7 @@ public static string JoinText(IEnumerable<string> textList) .ToList(); if (filteredList.Count == 0) - return ""; + return string.Empty; var index = 1; var outputText = filteredList[0]; From bd4d3534c28c497ddc5479586ff2bc3577a7630f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 13:23:12 +0200 Subject: [PATCH 0694/1103] fix: fix episode id lookups - the episode lookups should had been per file per series, not just per file. now it is. --- Shokofin/API/ShokoAPIManager.cs | 12 ++++++------ Shokofin/ExternalIds/ShokoSeriesId.cs | 2 +- Shokofin/IdLookup.cs | 2 +- Shokofin/Providers/EpisodeProvider.cs | 6 ++++-- Shokofin/Resolvers/ShokoResolveManager.cs | 2 +- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index db7bb5eb..15a04966 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -46,7 +46,7 @@ public class ShokoAPIManager : IDisposable private readonly ConcurrentDictionary<string, string> EpisodeIdToSeriesIdDictionary = new(); - private readonly ConcurrentDictionary<string, List<string>> FileIdToEpisodeIdDictionary = new(); + private readonly ConcurrentDictionary<string, List<string>> FileAndSeriesIdToEpisodeIdDictionary = new(); public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient, ILibraryManager libraryManager) { @@ -153,7 +153,7 @@ public void Clear(bool restore = true) DataCache.Dispose(); EpisodeIdToEpisodePathDictionary.Clear(); EpisodeIdToSeriesIdDictionary.Clear(); - FileIdToEpisodeIdDictionary.Clear(); + FileAndSeriesIdToEpisodeIdDictionary.Clear(); lock (MediaFolderListLock) { MediaFolderList.Clear(); } @@ -480,7 +480,7 @@ private async Task<FileInfo> CreateFileInfo(File file, string fileId, string ser fileInfo = new FileInfo(file, groupedEpisodeLists, seriesId); DataCache.Set<FileInfo>(cacheKey, fileInfo, DefaultTimeSpan); - FileIdToEpisodeIdDictionary.TryAdd(fileId, episodeList.Select(episode => episode.Id).ToList()); + FileAndSeriesIdToEpisodeIdDictionary[$"{fileId}:{seriesId}"] = episodeList.Select(episode => episode.Id).ToList(); return fileInfo; } @@ -544,13 +544,13 @@ public bool TryGetEpisodeIdsForPath(string path, out List<string>? episodeIds) return PathToEpisodeIdsDictionary.TryGetValue(path, out episodeIds); } - public bool TryGetEpisodeIdsForFileId(string fileId, out List<string>? episodeIds) + public bool TryGetEpisodeIdsForFileId(string fileId, string seriesId, out List<string>? episodeIds) { - if (string.IsNullOrEmpty(fileId)) { + if (string.IsNullOrEmpty(fileId) || string.IsNullOrEmpty(seriesId)) { episodeIds = null; return false; } - return FileIdToEpisodeIdDictionary.TryGetValue(fileId, out episodeIds); + return FileAndSeriesIdToEpisodeIdDictionary.TryGetValue($"{fileId}:{seriesId}", out episodeIds); } public bool TryGetEpisodePathForId(string episodeId, out string? path) diff --git a/Shokofin/ExternalIds/ShokoSeriesId.cs b/Shokofin/ExternalIds/ShokoSeriesId.cs index 5bba81d8..fb61a074 100644 --- a/Shokofin/ExternalIds/ShokoSeriesId.cs +++ b/Shokofin/ExternalIds/ShokoSeriesId.cs @@ -12,7 +12,7 @@ public class ShokoSeriesId : IExternalId public const string Name = "Shoko Series"; public bool Supports(IHasProviderIds item) - => item is Series or Season or Movie; + => item is Series or Season or Episode or Movie; public string ProviderName => Name; diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index b5e90cbe..96610a12 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -264,7 +264,7 @@ public bool TryGetEpisodeIdsFor(string path, out List<string> episodeIds) public bool TryGetEpisodeIdsFor(BaseItem item, out List<string> episodeIds) { // This will account for virtual episodes and existing episodes - if (item.ProviderIds.TryGetValue(ShokoFileId.Name, out var fileId) && ApiManager.TryGetEpisodeIdsForFileId(fileId, out episodeIds)) { + if (item.ProviderIds.TryGetValue(ShokoFileId.Name, out var fileId) && item.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId) && ApiManager.TryGetEpisodeIdsForFileId(fileId, seriesId, out episodeIds)) { return true; } diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 56e7192e..9976e953 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -228,17 +228,19 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie result.IndexNumberEnd = episodeNumberEnd; } - AddProviderIds(result, episodeId: episode.Id, fileId: file?.Id, anidbId: episode.AniDB.Id.ToString()); + AddProviderIds(result, episodeId: episode.Id, fileId: file?.Id, seriesId: file?.SeriesId, anidbId: episode.AniDB.Id.ToString()); return result; } - private static void AddProviderIds(IHasProviderIds item, string episodeId, string? fileId = null, string? anidbId = null, string? tmdbId = null) + private static void AddProviderIds(IHasProviderIds item, string episodeId, string? fileId = null, string? seriesId = null, string? anidbId = null, string? tmdbId = null) { var config = Plugin.Instance.Configuration; item.SetProviderId(ShokoEpisodeId.Name, episodeId); if (!string.IsNullOrEmpty(fileId)) item.SetProviderId(ShokoFileId.Name, fileId); + if (!string.IsNullOrEmpty(seriesId)) + item.SetProviderId(ShokoSeriesId.Name, seriesId); if (config.AddAniDBId && !string.IsNullOrEmpty(anidbId) && anidbId != "0") item.SetProviderId("AniDB", anidbId); if (config.AddTMDBId &&!string.IsNullOrEmpty(tmdbId) && tmdbId != "0") diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index effb061e..b7128f83 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -477,7 +477,7 @@ await Task.WhenAll(files.Select(async (tuple) => { .ToArray(); foreach (var symbolicLink in symbolicLinks) - ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, episodeIds); + ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, file.EpisodeList.Select(episode => episode.Id)); return (sourceLocation, symbolicLinks); } From 9f6198692eec126ff0a774bc5c4bd53a00ea94eb Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 13:24:54 +0200 Subject: [PATCH 0695/1103] refactor: convert create file info - Convert `CreateFileInfo` to the new cache getter pattern --- Shokofin/API/ShokoAPIManager.cs | 69 +++++++++++++++++---------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 15a04966..58a2303b 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -445,44 +445,45 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu private static readonly EpisodeType[] EpisodePickOrder = { EpisodeType.Special, EpisodeType.Normal, EpisodeType.Other }; - private async Task<FileInfo> CreateFileInfo(File file, string fileId, string seriesId) - { - var cacheKey = $"file:{fileId}:{seriesId}"; - if (DataCache.TryGetValue<FileInfo>(cacheKey, out var fileInfo)) - return fileInfo; + private Task<FileInfo> CreateFileInfo(File file, string fileId, string seriesId) + => DataCache.GetOrCreateAsync( + $"file:{fileId}:{seriesId}", + async (_) => { + Logger.LogTrace("Creating info object for file. (File={FileId},Series={SeriesId})", fileId, seriesId); + + // Find the cross-references for the selected series. + var seriesXRef = file.CrossReferences.FirstOrDefault(xref => xref.Series.Shoko.ToString() == seriesId) ?? + throw new Exception($"Unable to find any cross-references for the specified series for the file. (File={fileId},Series={seriesId})"); + + // Find a list of the episode info for each episode linked to the file for the series. + var episodeList = new List<EpisodeInfo>(); + foreach (var episodeXRef in seriesXRef.Episodes) { + var episodeId = episodeXRef.Shoko.ToString(); + var episodeInfo = await GetEpisodeInfo(episodeId).ConfigureAwait(false) ?? + throw new Exception($"Unable to find episode cross-reference for the specified series and episode for the file. (File={fileId},Episode={episodeId},Series={seriesId})"); + if (episodeInfo.Shoko.IsHidden) { + Logger.LogDebug("Skipped hidden episode linked to file. (File={FileId},Episode={EpisodeId},Series={SeriesId})", fileId, episodeId, seriesId); + continue; + } + episodeList.Add(episodeInfo); + } - Logger.LogTrace("Creating info object for file. (File={FileId},Series={SeriesId})", fileId, seriesId); + // Group and order the episodes. + var groupedEpisodeLists = episodeList + .GroupBy(episode => episode.AniDB.Type) + .OrderByDescending(a => Array.IndexOf(EpisodePickOrder, a.Key)) + .Select(epList => epList.OrderBy(episode => episode.AniDB.EpisodeNumber).ToList()) + .ToList(); - // Find the cross-references for the selected series. - var seriesXRef = file.CrossReferences.FirstOrDefault(xref => xref.Series.Shoko.ToString() == seriesId) ?? - throw new Exception($"Unable to find any cross-references for the specified series for the file. (File={fileId},Series={seriesId})"); + var fileInfo = new FileInfo(file, groupedEpisodeLists, seriesId); - // Find a list of the episode info for each episode linked to the file for the series. - var episodeList = new List<EpisodeInfo>(); - foreach (var episodeXRef in seriesXRef.Episodes) { - var episodeId = episodeXRef.Shoko.ToString(); - var episodeInfo = await GetEpisodeInfo(episodeId).ConfigureAwait(false) ?? - throw new Exception($"Unable to find episode cross-reference for the specified series and episode for the file. (File={fileId},Episode={episodeId},Series={seriesId})"); - if (episodeInfo.Shoko.IsHidden) { - Logger.LogDebug("Skipped hidden episode linked to file. (File={FileId},Episode={EpisodeId},Series={SeriesId})", fileId, episodeId, seriesId); - continue; + FileAndSeriesIdToEpisodeIdDictionary[$"{fileId}:{seriesId}"] = episodeList.Select(episode => episode.Id).ToList(); + return fileInfo; + }, + new() { + AbsoluteExpirationRelativeToNow = DefaultTimeSpan, } - episodeList.Add(episodeInfo); - } - - // Group and order the episodes. - var groupedEpisodeLists = episodeList - .GroupBy(episode => episode.AniDB.Type) - .OrderByDescending(a => Array.IndexOf(EpisodePickOrder, a.Key)) - .Select(epList => epList.OrderBy(episode => episode.AniDB.EpisodeNumber).ToList()) - .ToList(); - - fileInfo = new FileInfo(file, groupedEpisodeLists, seriesId); - - DataCache.Set<FileInfo>(cacheKey, fileInfo, DefaultTimeSpan); - FileAndSeriesIdToEpisodeIdDictionary[$"{fileId}:{seriesId}"] = episodeList.Select(episode => episode.Id).ToList(); - return fileInfo; - } + ); public bool TryGetFileIdForPath(string path, out string? fileId) { From d5c0d212973dbe961b8f195eb64c579de2a8e7b8 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 31 Mar 2024 11:26:23 +0000 Subject: [PATCH 0696/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index d786148c..593aefdb 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.70", + "changelog": "refactor: convert create file info\n\n- Convert `CreateFileInfo` to the new cache getter pattern\n\nfix: fix episode id lookups\n\n- the episode lookups should had been per file per series, not just per\n file. now it is.\n\nchore: `\"\"` \u2192\u00a0`string.Empty`\n\nchore: de-duplicate provider ids", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.70/shoko_3.0.1.70.zip", + "checksum": "75cd51b883822d38f716c884e829efc7", + "timestamp": "2024-03-31T11:26:21Z" + }, { "version": "3.0.1.69", "changelog": "feat: add signalr api controller", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.66/shoko_3.0.1.66.zip", "checksum": "5f7173d0dae94f4329d64f9f50eff9a5", "timestamp": "2024-03-31T05:46:40Z" - }, - { - "version": "3.0.1.65", - "changelog": "misc: change title format\n\n- Changed the title format to include the project name first,\n followed by the build type released.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.65/shoko_3.0.1.65.zip", - "checksum": "c9b029c65971248c8327fd7ae047f690", - "timestamp": "2024-03-31T00:47:34Z" } ] } From 0524fc7f9509a7d4f9f625757d98209e557aacc4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 13:30:23 +0200 Subject: [PATCH 0697/1103] chore: even more de-duplication of provider ids --- Shokofin/API/ShokoAPIManager.cs | 7 ++++--- Shokofin/Resolvers/ShokoResolveManager.cs | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 58a2303b..0ad23a63 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Shokofin.API.Info; using Shokofin.API.Models; +using Shokofin.ExternalIds; using Shokofin.Utils; using Path = System.IO.Path; @@ -346,9 +347,9 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu // Fast-path for VFS. if (path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { var fileName = Path.GetFileNameWithoutExtension(path); - if (!fileName.TryGetAttributeValue("shoko-series", out var sI) || !int.TryParse(sI, out _)) + if (!fileName.TryGetAttributeValue(ShokoSeriesId.Name, out var sI) || !int.TryParse(sI, out _)) return (null, null, null); - if (!fileName.TryGetAttributeValue("shoko-file", out var fI) || !int.TryParse(fI, out _)) + if (!fileName.TryGetAttributeValue(ShokoFileId.Name, out var fI) || !int.TryParse(fI, out _)) return (null, null, null); var fileInfo = await GetFileInfo(fI, sI).ConfigureAwait(false); @@ -685,7 +686,7 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul // Fast-path for VFS. if (path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { - if (!Path.GetFileName(path).TryGetAttributeValue("shoko-series", out seriesId) || !int.TryParse(seriesId, out _)) + if (!Path.GetFileName(path).TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) return null; PathToSeriesIdDictionary[path] = seriesId; diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index b7128f83..eb9fd0a8 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -19,6 +19,7 @@ using Shokofin.API; using Shokofin.API.Models; using Shokofin.Configuration; +using Shokofin.ExternalIds; using Shokofin.Utils; using File = System.IO.File; @@ -452,26 +453,26 @@ await Task.WhenAll(files.Select(async (tuple) => { if (isMovieSeason && collectionType != CollectionType.TvShows) { if (!string.IsNullOrEmpty(extrasFolder)) { foreach (var episodeInfo in season.EpisodeList) - folders.Add(Path.Combine(vfsPath, $"{showName} [shoko-series-{show.Id}] [shoko-episode-{episodeInfo.Id}]", extrasFolder)); + folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episodeInfo.Id}]", extrasFolder)); } else { - folders.Add(Path.Combine(vfsPath, $"{showName} [shoko-series-{show.Id}] [shoko-episode-{episode.Id}]")); + folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episode.Id}]")); episodeName = "Movie"; } } else { var seasonName = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}"; if (!string.IsNullOrEmpty(extrasFolder)) { - folders.Add(Path.Combine(vfsPath, $"{showName} [shoko-series-{show.Id}]", extrasFolder)); - folders.Add(Path.Combine(vfsPath, $"{showName} [shoko-series-{show.Id}]", seasonName, extrasFolder)); + folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", extrasFolder)); + folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", seasonName, extrasFolder)); } else { - folders.Add(Path.Combine(vfsPath, $"{showName} [shoko-series-{show.Id}]", seasonName)); + folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", seasonName)); episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber}"; } } - var fileName = $"{episodeName} [shoko-series-{seriesId}] [shoko-file-{fileId}]{fileNameSuffic}{Path.GetExtension(sourceLocation)}"; + var fileName = $"{episodeName} [{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{fileNameSuffic}{Path.GetExtension(sourceLocation)}"; var symbolicLinks = folders .Select(folderPath => Path.Combine(folderPath, fileName)) .ToArray(); @@ -703,7 +704,7 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b return null; if (parent.Id == mediaFolder.Id && fileInfo.IsDirectory) { - if (!fileInfo.Name.TryGetAttributeValue("shoko-series", out var seriesId) || !int.TryParse(seriesId, out _)) + if (!fileInfo.Name.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) return null; return new TvSeries() @@ -748,7 +749,7 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b var items = FileSystem.GetDirectories(vfsPath) .AsParallel() .SelectMany(dirInfo => { - if (!dirInfo.Name.TryGetAttributeValue("shoko-series", out var seriesId) || !int.TryParse(seriesId, out _)) + if (!dirInfo.Name.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) return Array.Empty<BaseItem>(); var season = ApiManager.GetSeasonInfoForSeries(seriesId) @@ -762,7 +763,7 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b return FileSystem.GetFiles(dirInfo.FullName) .AsParallel() .Select(fileInfo => { - if (!fileInfo.Name.TryGetAttributeValue("shoko-file", out var fileId) || !int.TryParse(fileId, out _)) + if (!fileInfo.Name.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) return null; // This will hopefully just re-use the pre-cached entries from the cache, but it may From 77f6d05b5b98bb8785a07c055647bd70d7b4fbfb Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 31 Mar 2024 11:31:30 +0000 Subject: [PATCH 0698/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 593aefdb..5faa617b 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.71", + "changelog": "chore: even more de-duplication of provider ids", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.71/shoko_3.0.1.71.zip", + "checksum": "3ec315f2cabb7b679d54dd20ceae9b5a", + "timestamp": "2024-03-31T11:31:28Z" + }, { "version": "3.0.1.70", "changelog": "refactor: convert create file info\n\n- Convert `CreateFileInfo` to the new cache getter pattern\n\nfix: fix episode id lookups\n\n- the episode lookups should had been per file per series, not just per\n file. now it is.\n\nchore: `\"\"` \u2192\u00a0`string.Empty`\n\nchore: de-duplicate provider ids", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.67/shoko_3.0.1.67.zip", "checksum": "98ffec6ef0101bc2030276f11b08ea14", "timestamp": "2024-03-31T07:29:06Z" - }, - { - "version": "3.0.1.66", - "changelog": "fix: fix extras placement for movies/shows\n\n- Fix the incorrect placement of some extras in shows/seasons/movies.\n\nrefactor: save media folder mappings\n\n- Store a which import folder each media folder is mapped to. This wil\n be used now to save requests needed to find the mapping, and in the\n future by the SignalR file event stream to know which files to update.\n\n- Throw if we're unable to filter an item, instead of gracefully\n returning early. This will prevent accidentail inclusions in the\n library at the expense of aborting a library scan if something\n goes awry.\n\nfix: fix ignored sub title removal\n\nmisc: update read-me file for the project\nand the plugin repostory in jellyfin. the updated read-me will only be\nvisible on the unstable repository until the next stable release though.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.66/shoko_3.0.1.66.zip", - "checksum": "5f7173d0dae94f4329d64f9f50eff9a5", - "timestamp": "2024-03-31T05:46:40Z" } ] } From dfa53118be034c376bbcc97b51cd83b44f3e2a9a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 13:34:01 +0200 Subject: [PATCH 0699/1103] chore: fix up IDE complaints for Text.cs --- Shokofin/Utils/Text.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index 604facef..e663b888 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -9,7 +9,7 @@ namespace Shokofin.Utils; public static class Text { - private static HashSet<char> PunctuationMarks = new() { + private static readonly HashSet<char> PunctuationMarks = new() { // Common punctuation marks '.', // period ',', // comma @@ -254,7 +254,7 @@ public static string JoinText(IEnumerable<string> textList) } if (filteredList.Count > 1) - outputText.TrimEnd(); + outputText = outputText.TrimEnd(); return outputText; } @@ -289,8 +289,8 @@ public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title // Display in metadata-preferred language, or fallback to default. case DisplayLanguageType.MetadataPreferred: { var allowAny = Plugin.Instance.Configuration.TitleAllowAny; - var getSeriesTitle = () => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, displayLanguage) ?? (allowAny ? GetTitleByLanguages(seriesTitles, displayLanguage) : null) ?? seriesTitle; - var getEpisodeTitle = () => GetTitleByLanguages(episodeTitles, displayLanguage) ?? episodeTitle; + string getSeriesTitle() => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, displayLanguage) ?? (allowAny ? GetTitleByLanguages(seriesTitles, displayLanguage) : null) ?? seriesTitle; + string getEpisodeTitle() => GetTitleByLanguages(episodeTitles, displayLanguage) ?? episodeTitle; var title = ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); if (string.IsNullOrEmpty(title)) goto case DisplayLanguageType.Default; @@ -299,14 +299,14 @@ public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title // Display in origin language. case DisplayLanguageType.Origin: { var allowAny = Plugin.Instance.Configuration.TitleAllowAny; - var getSeriesTitle = () => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, originLanguages) ?? (allowAny ? GetTitleByLanguages(seriesTitles, originLanguages) : null) ?? seriesTitle; - var getEpisodeTitle = () => GetTitleByLanguages(episodeTitles, originLanguages) ?? episodeTitle; + string getSeriesTitle() => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, originLanguages) ?? (allowAny ? GetTitleByLanguages(seriesTitles, originLanguages) : null) ?? seriesTitle; + string getEpisodeTitle() => GetTitleByLanguages(episodeTitles, originLanguages) ?? episodeTitle; return ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); } // Display the main title. case DisplayLanguageType.Main: { - var getSeriesTitle = () => GetTitleByType(seriesTitles, TitleType.Main) ?? seriesTitle; - var getEpisodeTitle = () => GetTitleByLanguages(episodeTitles, "en", mainTitleLanguage) ?? episodeTitle; + string getSeriesTitle() => GetTitleByType(seriesTitles, TitleType.Main) ?? seriesTitle; + string getEpisodeTitle() => GetTitleByLanguages(episodeTitles, "en", mainTitleLanguage) ?? episodeTitle; return ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); } } From e2eb6b79da28ee5e8e96b72561ea1927c6ac5354 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 13:38:36 +0200 Subject: [PATCH 0700/1103] chore: enable nullable for whole project --- Shokofin/API/Info/CollectionInfo.cs | 1 - Shokofin/API/Info/EpisodeInfo.cs | 1 - Shokofin/API/Info/FileInfo.cs | 1 - Shokofin/API/Info/SeasonInfo.cs | 1 - Shokofin/API/Info/ShowInfo.cs | 1 - Shokofin/API/Models/ApiException.cs | 1 - Shokofin/API/Models/Episode.cs | 1 - Shokofin/API/Models/File.cs | 1 - Shokofin/API/Models/Group.cs | 1 - Shokofin/API/Models/IDs.cs | 1 - Shokofin/API/Models/Images.cs | 1 - Shokofin/API/Models/ListResult.cs | 1 - Shokofin/API/Models/Rating.cs | 1 - Shokofin/API/Models/Relation.cs | 1 - Shokofin/API/Models/Role.cs | 1 - Shokofin/API/Models/Series.cs | 1 - Shokofin/API/Models/Tag.cs | 2 - Shokofin/API/Models/Title.cs | 1 - Shokofin/API/ShokoAPIClient.cs | 1 - Shokofin/API/ShokoAPIManager.cs | 1 - Shokofin/Collections/CollectionManager.cs | 1 - .../Configuration/MediaFolderConfiguration.cs | 1 - Shokofin/Configuration/PluginConfiguration.cs | 1 - Shokofin/Configuration/UserConfiguration.cs | 1 - Shokofin/IdLookup.cs | 60 ++++++++++++++----- Shokofin/MergeVersions/MergeVersionManager.cs | 1 - Shokofin/Plugin.cs | 1 - Shokofin/PluginServiceRegistrator.cs | 1 - Shokofin/Providers/BoxSetProvider.cs | 1 - Shokofin/Providers/EpisodeProvider.cs | 7 +-- Shokofin/Providers/ExtraMetadataProvider.cs | 1 - Shokofin/Providers/ImageProvider.cs | 1 - Shokofin/Providers/MovieProvider.cs | 1 - Shokofin/Providers/SeasonProvider.cs | 1 - Shokofin/Providers/SeriesProvider.cs | 1 - Shokofin/Resolvers/ShokoResolveManager.cs | 1 - Shokofin/Resolvers/ShokoResolver.cs | 1 - Shokofin/Shokofin.csproj | 1 + .../Interfaces/IFileRelocationEventArgs.cs | 1 - .../Models/EpisodeInfoUpdatedEventArgs.cs | 1 - .../SignalR/Models/FileDetectedEventArgs.cs | 1 - Shokofin/SignalR/Models/FileEventArgs.cs | 1 - .../SignalR/Models/FileMatchedEventArgs.cs | 1 - Shokofin/SignalR/Models/FileMovedEventArgs.cs | 1 - .../SignalR/Models/FileNotMatchedEventArgs.cs | 1 - .../SignalR/Models/FileRenamedEventArgs.cs | 1 - .../Models/SeriesInfoUpdatedEventArgs.cs | 1 - Shokofin/SignalR/SignalRConnectionManager.cs | 1 - Shokofin/SignalR/SignalREntryPoint.cs | 1 - Shokofin/StringExtensions.cs | 1 - Shokofin/Sync/SyncDirection.cs | 1 - Shokofin/Sync/SyncExtensions.cs | 1 - Shokofin/Sync/UserDataSyncManager.cs | 1 - Shokofin/Tasks/ClearPluginCacheTask.cs | 1 - Shokofin/Tasks/ExportUserDataTask.cs | 1 - Shokofin/Tasks/ImportUserDataTask.cs | 1 - Shokofin/Tasks/MergeAllTask.cs | 1 - Shokofin/Tasks/MergeEpisodesTask.cs | 1 - Shokofin/Tasks/MergeMoviesTask.cs | 1 - Shokofin/Tasks/PostScanTask.cs | 1 - Shokofin/Tasks/SplitAllTask.cs | 1 - Shokofin/Tasks/SplitEpisodesTask.cs | 1 - Shokofin/Tasks/SplitMoviesTask.cs | 1 - Shokofin/Tasks/SyncUserDataTask.cs | 1 - Shokofin/Utils/GuardedMemoryCache.cs | 1 - Shokofin/Utils/Ordering.cs | 1 - Shokofin/Utils/SeriesInfoRelationComparer.cs | 1 - Shokofin/Utils/Text.cs | 60 +++++++++---------- Shokofin/Web/ShokoApiController.cs | 1 - Shokofin/Web/SignalRApiController.cs | 1 - 70 files changed, 78 insertions(+), 117 deletions(-) diff --git a/Shokofin/API/Info/CollectionInfo.cs b/Shokofin/API/Info/CollectionInfo.cs index 34893563..4f8cffdf 100644 --- a/Shokofin/API/Info/CollectionInfo.cs +++ b/Shokofin/API/Info/CollectionInfo.cs @@ -3,7 +3,6 @@ using Shokofin.API.Models; using Shokofin.Utils; -#nullable enable namespace Shokofin.API.Info; public class CollectionInfo diff --git a/Shokofin/API/Info/EpisodeInfo.cs b/Shokofin/API/Info/EpisodeInfo.cs index fd4a0c1e..cc6a2f40 100644 --- a/Shokofin/API/Info/EpisodeInfo.cs +++ b/Shokofin/API/Info/EpisodeInfo.cs @@ -4,7 +4,6 @@ using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; -#nullable enable namespace Shokofin.API.Info; public class EpisodeInfo diff --git a/Shokofin/API/Info/FileInfo.cs b/Shokofin/API/Info/FileInfo.cs index 9b3189e0..c306e6e7 100644 --- a/Shokofin/API/Info/FileInfo.cs +++ b/Shokofin/API/Info/FileInfo.cs @@ -2,7 +2,6 @@ using System.Linq; using Shokofin.API.Models; -#nullable enable namespace Shokofin.API.Info; public class FileInfo diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 6b2367c3..4398c315 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -5,7 +5,6 @@ using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo; using PersonType = MediaBrowser.Model.Entities.PersonType; -#nullable enable namespace Shokofin.API.Info; public class SeasonInfo diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index bd6c03f6..e5cc2df9 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -7,7 +7,6 @@ using Shokofin.API.Models; using Shokofin.Utils; -#nullable enable namespace Shokofin.API.Info; public class ShowInfo diff --git a/Shokofin/API/Models/ApiException.cs b/Shokofin/API/Models/ApiException.cs index 1a931af8..38251cb3 100644 --- a/Shokofin/API/Models/ApiException.cs +++ b/Shokofin/API/Models/ApiException.cs @@ -6,7 +6,6 @@ using System.Net.Http; using System.Text.Json; -#nullable enable namespace Shokofin.API.Models; [Serializable] diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index 2aee338d..0c94a7be 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -#nullable enable namespace Shokofin.API.Models; public class Episode diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index cdbd1d07..596e7e1a 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -#nullable enable namespace Shokofin.API.Models; public class File diff --git a/Shokofin/API/Models/Group.cs b/Shokofin/API/Models/Group.cs index dcdccd62..7acdcf35 100644 --- a/Shokofin/API/Models/Group.cs +++ b/Shokofin/API/Models/Group.cs @@ -1,4 +1,3 @@ -#nullable enable namespace Shokofin.API.Models; public class Group diff --git a/Shokofin/API/Models/IDs.cs b/Shokofin/API/Models/IDs.cs index 031954e5..a3c77060 100644 --- a/Shokofin/API/Models/IDs.cs +++ b/Shokofin/API/Models/IDs.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; -#nullable enable namespace Shokofin.API.Models; public class IDs diff --git a/Shokofin/API/Models/Images.cs b/Shokofin/API/Models/Images.cs index 4d7e41cb..eeda6d2b 100644 --- a/Shokofin/API/Models/Images.cs +++ b/Shokofin/API/Models/Images.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; -#nullable enable namespace Shokofin.API.Models; public class Images diff --git a/Shokofin/API/Models/ListResult.cs b/Shokofin/API/Models/ListResult.cs index bfb7775d..64e44940 100644 --- a/Shokofin/API/Models/ListResult.cs +++ b/Shokofin/API/Models/ListResult.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; -#nullable enable namespace Shokofin.API.Models; /// <summary> diff --git a/Shokofin/API/Models/Rating.cs b/Shokofin/API/Models/Rating.cs index 5c6f05b3..9d8e852b 100644 --- a/Shokofin/API/Models/Rating.cs +++ b/Shokofin/API/Models/Rating.cs @@ -1,4 +1,3 @@ -#nullable enable namespace Shokofin.API.Models; public class Rating diff --git a/Shokofin/API/Models/Relation.cs b/Shokofin/API/Models/Relation.cs index 7c728ad5..fb3d8b5f 100644 --- a/Shokofin/API/Models/Relation.cs +++ b/Shokofin/API/Models/Relation.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; -#nullable enable namespace Shokofin.API.Models; /// <summary> diff --git a/Shokofin/API/Models/Role.cs b/Shokofin/API/Models/Role.cs index a85df5e6..143fceef 100644 --- a/Shokofin/API/Models/Role.cs +++ b/Shokofin/API/Models/Role.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; -#nullable enable namespace Shokofin.API.Models; public class Role diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index 679e3a68..1a838e57 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -#nullable enable namespace Shokofin.API.Models; public class Series diff --git a/Shokofin/API/Models/Tag.cs b/Shokofin/API/Models/Tag.cs index 0cb2c4d3..796fbed5 100644 --- a/Shokofin/API/Models/Tag.cs +++ b/Shokofin/API/Models/Tag.cs @@ -1,8 +1,6 @@ -#nullable enable using System; using System.Text.Json.Serialization; -#nullable enable namespace Shokofin.API.Models; public class Tag diff --git a/Shokofin/API/Models/Title.cs b/Shokofin/API/Models/Title.cs index 6aa08721..676bca5e 100644 --- a/Shokofin/API/Models/Title.cs +++ b/Shokofin/API/Models/Title.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; -#nullable enable namespace Shokofin.API.Models; public class Title diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 9c071e37..1c0613ed 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -13,7 +13,6 @@ using Shokofin.API.Models; using Shokofin.Utils; -#nullable enable namespace Shokofin.API; /// <summary> diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 0ad23a63..86179cc6 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -14,7 +14,6 @@ using Path = System.IO.Path; -#nullable enable namespace Shokofin.API; public class ShokoAPIManager : IDisposable diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index b16d9705..586be074 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -14,7 +14,6 @@ using Shokofin.ExternalIds; using Shokofin.Utils; -#nullable enable namespace Shokofin.Collections; public class CollectionManager diff --git a/Shokofin/Configuration/MediaFolderConfiguration.cs b/Shokofin/Configuration/MediaFolderConfiguration.cs index 4472aefc..f420e197 100644 --- a/Shokofin/Configuration/MediaFolderConfiguration.cs +++ b/Shokofin/Configuration/MediaFolderConfiguration.cs @@ -3,7 +3,6 @@ using System.Text.Json.Serialization; using System.Xml.Serialization; -#nullable enable namespace Shokofin.Configuration; public class MediaFolderConfiguration diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 1efd63c7..704980d3 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -10,7 +10,6 @@ using OrderType = Shokofin.Utils.Ordering.OrderType; using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; -#nullable enable namespace Shokofin.Configuration; public class PluginConfiguration : BasePluginConfiguration diff --git a/Shokofin/Configuration/UserConfiguration.cs b/Shokofin/Configuration/UserConfiguration.cs index f805f840..d8500303 100644 --- a/Shokofin/Configuration/UserConfiguration.cs +++ b/Shokofin/Configuration/UserConfiguration.cs @@ -1,7 +1,6 @@ using System; using System.ComponentModel.DataAnnotations; -#nullable enable namespace Shokofin.Configuration; /// <summary> diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index 96610a12..51e9789a 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -156,24 +156,32 @@ public bool IsEnabledForItem(BaseItem item, out bool isSoleProvider) public bool TryGetSeriesIdFor(string path, out string seriesId) { - return ApiManager.TryGetSeriesIdForPath(path, out seriesId); + if (ApiManager.TryGetSeriesIdForPath(path, out seriesId!)) + return true; + + seriesId = string.Empty; + return false; } public bool TryGetSeriesIdFromEpisodeId(string episodeId, out string seriesId) { - return ApiManager.TryGetSeriesIdForEpisodeId(episodeId, out seriesId); + if (ApiManager.TryGetSeriesIdForEpisodeId(episodeId, out seriesId!)) + return true; + + seriesId = string.Empty; + return false; } public bool TryGetSeriesIdFor(Series series, out string seriesId) { - if (series.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId) && !string.IsNullOrEmpty(seriesId)) { + if (series.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId!) && !string.IsNullOrEmpty(seriesId)) { return true; } if (TryGetSeriesIdFor(series.Path, out seriesId)) { // Set the ShokoGroupId.Name and ShokoSeriesId.Name provider ids for the series, since it haven't been set again. It doesn't matter if it's not saved to the database, since we only need it while running the following code. if (ApiManager.TryGetDefaultSeriesIdForSeriesId(seriesId, out var defaultSeriesId)) { - SeriesProvider.AddProviderIds(series, defaultSeriesId); + SeriesProvider.AddProviderIds(series, defaultSeriesId!); } // Same as above, but only set the ShokoSeriesId.Name id. else { @@ -189,7 +197,7 @@ public bool TryGetSeriesIdFor(Series series, out string seriesId) public bool TryGetSeriesIdFor(Season season, out string seriesId) { - if (season.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId) && !string.IsNullOrEmpty(seriesId)) { + if (season.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId!) && !string.IsNullOrEmpty(seriesId)) { return true; } @@ -198,7 +206,7 @@ public bool TryGetSeriesIdFor(Season season, out string seriesId) public bool TryGetSeriesIdFor(Movie movie, out string seriesId) { - if (movie.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId) && !string.IsNullOrEmpty(seriesId)) { + if (movie.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId!) && !string.IsNullOrEmpty(seriesId)) { return true; } @@ -211,13 +219,13 @@ public bool TryGetSeriesIdFor(Movie movie, out string seriesId) public bool TryGetSeriesIdFor(BoxSet boxSet, out string seriesId) { - if (boxSet.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId) && !string.IsNullOrEmpty(seriesId)) { + if (boxSet.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId!) && !string.IsNullOrEmpty(seriesId)) { return true; } if (TryGetSeriesIdFor(boxSet.Path, out seriesId)) { if (ApiManager.TryGetDefaultSeriesIdForSeriesId(seriesId, out var defaultSeriesId)) { - seriesId = defaultSeriesId; + seriesId = defaultSeriesId!; } return true; } @@ -230,7 +238,11 @@ public bool TryGetSeriesIdFor(BoxSet boxSet, out string seriesId) public bool TryGetPathForSeriesId(string seriesId, out string path) { - return ApiManager.TryGetSeriesPathForId(seriesId, out path); + if (ApiManager.TryGetSeriesPathForId(seriesId, out path!)) + return true; + + path = string.Empty; + return false; } #endregion @@ -238,13 +250,17 @@ public bool TryGetPathForSeriesId(string seriesId, out string path) public bool TryGetEpisodeIdFor(string path, out string episodeId) { - return ApiManager.TryGetEpisodeIdForPath(path, out episodeId); + if (ApiManager.TryGetEpisodeIdForPath(path, out episodeId!)) + return true; + + episodeId = string.Empty; + return false; } public bool TryGetEpisodeIdFor(BaseItem item, out string episodeId) { // This will account for virtual episodes and existing episodes - if (item.ProviderIds.TryGetValue(ShokoEpisodeId.Name, out episodeId) && !string.IsNullOrEmpty(episodeId)) { + if (item.ProviderIds.TryGetValue(ShokoEpisodeId.Name, out episodeId!) && !string.IsNullOrEmpty(episodeId)) { return true; } @@ -258,13 +274,17 @@ public bool TryGetEpisodeIdFor(BaseItem item, out string episodeId) public bool TryGetEpisodeIdsFor(string path, out List<string> episodeIds) { - return ApiManager.TryGetEpisodeIdsForPath(path, out episodeIds); + if (ApiManager.TryGetEpisodeIdsForPath(path, out episodeIds!)) + return true; + + episodeIds = new(); + return false; } public bool TryGetEpisodeIdsFor(BaseItem item, out List<string> episodeIds) { // This will account for virtual episodes and existing episodes - if (item.ProviderIds.TryGetValue(ShokoFileId.Name, out var fileId) && item.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId) && ApiManager.TryGetEpisodeIdsForFileId(fileId, seriesId, out episodeIds)) { + if (item.ProviderIds.TryGetValue(ShokoFileId.Name, out var fileId) && item.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId) && ApiManager.TryGetEpisodeIdsForFileId(fileId, seriesId, out episodeIds!)) { return true; } @@ -281,7 +301,11 @@ public bool TryGetEpisodeIdsFor(BaseItem item, out List<string> episodeIds) public bool TryGetPathForEpisodeId(string episodeId, out string path) { - return ApiManager.TryGetEpisodePathForId(episodeId, out path); + if (ApiManager.TryGetEpisodePathForId(episodeId, out path!)) + return true; + + path = string.Empty; + return false; } #endregion @@ -289,10 +313,14 @@ public bool TryGetPathForEpisodeId(string episodeId, out string path) public bool TryGetFileIdFor(BaseItem episode, out string fileId) { - if (episode.ProviderIds.TryGetValue(ShokoFileId.Name, out fileId)) + if (episode.ProviderIds.TryGetValue(ShokoFileId.Name, out fileId!)) return true; - return ApiManager.TryGetFileIdForPath(episode.Path, out fileId); + if (ApiManager.TryGetFileIdForPath(episode.Path, out fileId!)) + return true; + + fileId = string.Empty; + return false; } #endregion diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index e191ae48..484ec6d3 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -13,7 +13,6 @@ using MediaBrowser.Common.Progress; using Shokofin.ExternalIds; -#nullable enable namespace Shokofin.MergeVersions; /// <summary> diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index a30cecb9..31366ed6 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -9,7 +9,6 @@ using Microsoft.Extensions.Logging; using Shokofin.Configuration; -#nullable enable namespace Shokofin; public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index 7133a4c7..8f6646ec 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -1,7 +1,6 @@ using MediaBrowser.Common.Plugins; using Microsoft.Extensions.DependencyInjection; -#nullable enable namespace Shokofin; /// <inheritdoc /> diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 1bca7f20..61cce0c8 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -13,7 +13,6 @@ using Shokofin.ExternalIds; using Shokofin.Utils; -#nullable enable namespace Shokofin.Providers; public class BoxSetProvider : IRemoteMetadataProvider<BoxSet, BoxSetInfo> diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 9976e953..2260ef58 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -17,7 +17,6 @@ using SeriesType = Shokofin.API.Models.SeriesType; using EpisodeType = Shokofin.API.Models.EpisodeType; -#nullable enable namespace Shokofin.Providers; public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo> @@ -98,10 +97,10 @@ public static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Info.FileInfo? file, string metadataLanguage, Season? season, Guid episodeId) { var config = Plugin.Instance.Configuration; - string displayTitle, alternateTitle, description; + string? displayTitle, alternateTitle, description; if (config.TitleAddForMultipleEpisodes && file != null && file.EpisodeList.Count > 1) { - var displayTitles = new List<string>(file.EpisodeList.Count); - var alternateTitles = new List<string>(file.EpisodeList.Count); + var displayTitles = new List<string?>(); + var alternateTitles = new List<string?>(); foreach (var episodeInfo in file.EpisodeList) { string defaultEpisodeTitle = episodeInfo.Shoko.Name; diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index e9b89660..f14cbcf7 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -17,7 +17,6 @@ using Info = Shokofin.API.Info; -#nullable enable namespace Shokofin.Providers; public class ExtraMetadataProvider : IServerEntryPoint diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 53c79b54..ac563df3 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -13,7 +13,6 @@ using Shokofin.API; using System.Linq; -#nullable enable namespace Shokofin.Providers; public class ImageProvider : IRemoteImageProvider diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index cd6f8360..ec97efc8 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -13,7 +13,6 @@ using Shokofin.ExternalIds; using Shokofin.Utils; -#nullable enable namespace Shokofin.Providers; public class MovieProvider : IRemoteMetadataProvider<Movie, MovieInfo> diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index cfb84d85..d4bc3f37 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -14,7 +14,6 @@ using Info = Shokofin.API.Info; -#nullable enable namespace Shokofin.Providers; public class SeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo> diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 7e3e2e66..a23b96d9 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -15,7 +15,6 @@ using Shokofin.ExternalIds; using Shokofin.Utils; -#nullable enable namespace Shokofin.Providers; public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index eb9fd0a8..b79726fc 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -25,7 +25,6 @@ using File = System.IO.File; using TvSeries = MediaBrowser.Controller.Entities.TV.Series; -#nullable enable namespace Shokofin.Resolvers; public class ShokoResolveManager diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs index d169e069..cff0d862 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -5,7 +5,6 @@ using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.IO; -#nullable enable namespace Shokofin.Resolvers; #pragma warning disable CS8766 diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj index 515807b1..2347a53c 100644 --- a/Shokofin/Shokofin.csproj +++ b/Shokofin/Shokofin.csproj @@ -3,6 +3,7 @@ <TargetFramework>net6.0</TargetFramework> <OutputType>Library</OutputType> <SignalRVersion>6.0.28</SignalRVersion> + <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> diff --git a/Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs b/Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs index d08d8f13..7d3f84c1 100644 --- a/Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs +++ b/Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs @@ -1,5 +1,4 @@ -#nullable enable namespace Shokofin.SignalR.Interfaces; public interface IFileRelocationEventArgs diff --git a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs index 3fa28f9f..43fccb8b 100644 --- a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; -#nullable enable namespace Shokofin.SignalR.Models; public class EpisodeInfoUpdatedEventArgs diff --git a/Shokofin/SignalR/Models/FileDetectedEventArgs.cs b/Shokofin/SignalR/Models/FileDetectedEventArgs.cs index ed64fcc7..55cfc7d3 100644 --- a/Shokofin/SignalR/Models/FileDetectedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileDetectedEventArgs.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; -#nullable enable namespace Shokofin.SignalR.Models; public class FileDetectedEventArgs diff --git a/Shokofin/SignalR/Models/FileEventArgs.cs b/Shokofin/SignalR/Models/FileEventArgs.cs index 4a4e5a47..5a4e69a1 100644 --- a/Shokofin/SignalR/Models/FileEventArgs.cs +++ b/Shokofin/SignalR/Models/FileEventArgs.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; -#nullable enable namespace Shokofin.SignalR.Models; public class FileEventArgs diff --git a/Shokofin/SignalR/Models/FileMatchedEventArgs.cs b/Shokofin/SignalR/Models/FileMatchedEventArgs.cs index bf6b74e4..f1067c66 100644 --- a/Shokofin/SignalR/Models/FileMatchedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileMatchedEventArgs.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -#nullable enable namespace Shokofin.SignalR.Models; public class FileMatchedEventArgs : FileEventArgs diff --git a/Shokofin/SignalR/Models/FileMovedEventArgs.cs b/Shokofin/SignalR/Models/FileMovedEventArgs.cs index 1f39ac6c..b1247725 100644 --- a/Shokofin/SignalR/Models/FileMovedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileMovedEventArgs.cs @@ -1,7 +1,6 @@ using System.Text.Json.Serialization; using Shokofin.SignalR.Interfaces; -#nullable enable namespace Shokofin.SignalR.Models; public class FileMovedEventArgs : IFileRelocationEventArgs diff --git a/Shokofin/SignalR/Models/FileNotMatchedEventArgs.cs b/Shokofin/SignalR/Models/FileNotMatchedEventArgs.cs index 57e8fe57..559ef9bc 100644 --- a/Shokofin/SignalR/Models/FileNotMatchedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileNotMatchedEventArgs.cs @@ -1,5 +1,4 @@ -#nullable enable namespace Shokofin.SignalR.Models; public class FileNotMatchedEventArgs : FileEventArgs diff --git a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs index 4b7d9d0d..9db248f6 100644 --- a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs @@ -1,7 +1,6 @@ using System.Text.Json.Serialization; using Shokofin.SignalR.Interfaces; -#nullable enable namespace Shokofin.SignalR.Models; public class FileRenamedEventArgs : FileEventArgs, IFileRelocationEventArgs diff --git a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs index 60d0ba91..7bab9b68 100644 --- a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; -#nullable enable namespace Shokofin.SignalR.Models; public class SeriesInfoUpdatedEventArgs diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 3d9ae7cd..bc886c52 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -11,7 +11,6 @@ using Shokofin.SignalR.Interfaces; using Shokofin.SignalR.Models; -#nullable enable namespace Shokofin.SignalR; public class SignalRConnectionManager : IDisposable diff --git a/Shokofin/SignalR/SignalREntryPoint.cs b/Shokofin/SignalR/SignalREntryPoint.cs index c3a3516c..2a73bb42 100644 --- a/Shokofin/SignalR/SignalREntryPoint.cs +++ b/Shokofin/SignalR/SignalREntryPoint.cs @@ -1,5 +1,4 @@ -#nullable enable using System.Threading.Tasks; using MediaBrowser.Controller.Plugins; diff --git a/Shokofin/StringExtensions.cs b/Shokofin/StringExtensions.cs index 3102c8cf..3043b6e4 100644 --- a/Shokofin/StringExtensions.cs +++ b/Shokofin/StringExtensions.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using MediaBrowser.Common.Providers; -#nullable enable namespace Shokofin; public static class StringExtensions diff --git a/Shokofin/Sync/SyncDirection.cs b/Shokofin/Sync/SyncDirection.cs index 25fde95e..fc3882e1 100644 --- a/Shokofin/Sync/SyncDirection.cs +++ b/Shokofin/Sync/SyncDirection.cs @@ -1,6 +1,5 @@ using System; -#nullable enable namespace Shokofin.Sync; /// <summary> diff --git a/Shokofin/Sync/SyncExtensions.cs b/Shokofin/Sync/SyncExtensions.cs index 215e2e50..936ea01d 100644 --- a/Shokofin/Sync/SyncExtensions.cs +++ b/Shokofin/Sync/SyncExtensions.cs @@ -3,7 +3,6 @@ using MediaBrowser.Controller.Entities; using Shokofin.API.Models; -#nullable enable namespace Shokofin.Sync; public static class SyncExtensions diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index 62dbed9d..3632ff67 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -18,7 +18,6 @@ using UserStats = Shokofin.API.Models.File.UserStats; -#nullable enable namespace Shokofin.Sync; public class UserDataSyncManager diff --git a/Shokofin/Tasks/ClearPluginCacheTask.cs b/Shokofin/Tasks/ClearPluginCacheTask.cs index 3c38c66e..ed30f973 100644 --- a/Shokofin/Tasks/ClearPluginCacheTask.cs +++ b/Shokofin/Tasks/ClearPluginCacheTask.cs @@ -6,7 +6,6 @@ using Shokofin.API; using Shokofin.Resolvers; -#nullable enable namespace Shokofin.Tasks; /// <summary> diff --git a/Shokofin/Tasks/ExportUserDataTask.cs b/Shokofin/Tasks/ExportUserDataTask.cs index dafe1b41..6209f186 100644 --- a/Shokofin/Tasks/ExportUserDataTask.cs +++ b/Shokofin/Tasks/ExportUserDataTask.cs @@ -5,7 +5,6 @@ using MediaBrowser.Model.Tasks; using Shokofin.Sync; -#nullable enable namespace Shokofin.Tasks; /// <summary> diff --git a/Shokofin/Tasks/ImportUserDataTask.cs b/Shokofin/Tasks/ImportUserDataTask.cs index 6d836366..2c8e47de 100644 --- a/Shokofin/Tasks/ImportUserDataTask.cs +++ b/Shokofin/Tasks/ImportUserDataTask.cs @@ -5,7 +5,6 @@ using MediaBrowser.Model.Tasks; using Shokofin.Sync; -#nullable enable namespace Shokofin.Tasks; /// <summary> diff --git a/Shokofin/Tasks/MergeAllTask.cs b/Shokofin/Tasks/MergeAllTask.cs index 4496625b..5f45455f 100644 --- a/Shokofin/Tasks/MergeAllTask.cs +++ b/Shokofin/Tasks/MergeAllTask.cs @@ -5,7 +5,6 @@ using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; -#nullable enable namespace Shokofin.Tasks; /// <summary> diff --git a/Shokofin/Tasks/MergeEpisodesTask.cs b/Shokofin/Tasks/MergeEpisodesTask.cs index 86e5dcfb..00b95e14 100644 --- a/Shokofin/Tasks/MergeEpisodesTask.cs +++ b/Shokofin/Tasks/MergeEpisodesTask.cs @@ -5,7 +5,6 @@ using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; -#nullable enable namespace Shokofin.Tasks; /// <summary> diff --git a/Shokofin/Tasks/MergeMoviesTask.cs b/Shokofin/Tasks/MergeMoviesTask.cs index 2d016b3f..00aeb46a 100644 --- a/Shokofin/Tasks/MergeMoviesTask.cs +++ b/Shokofin/Tasks/MergeMoviesTask.cs @@ -5,7 +5,6 @@ using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; -#nullable enable namespace Shokofin.Tasks; /// <summary> diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs index af809700..350dbf2e 100644 --- a/Shokofin/Tasks/PostScanTask.cs +++ b/Shokofin/Tasks/PostScanTask.cs @@ -8,7 +8,6 @@ using Shokofin.MergeVersions; using Shokofin.Resolvers; -#nullable enable namespace Shokofin.Tasks; public class PostScanTask : ILibraryPostScanTask diff --git a/Shokofin/Tasks/SplitAllTask.cs b/Shokofin/Tasks/SplitAllTask.cs index fe7b7846..1056c89e 100644 --- a/Shokofin/Tasks/SplitAllTask.cs +++ b/Shokofin/Tasks/SplitAllTask.cs @@ -5,7 +5,6 @@ using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; -#nullable enable namespace Shokofin.Tasks; /// <summary> diff --git a/Shokofin/Tasks/SplitEpisodesTask.cs b/Shokofin/Tasks/SplitEpisodesTask.cs index 74aa28a1..be0cb5b0 100644 --- a/Shokofin/Tasks/SplitEpisodesTask.cs +++ b/Shokofin/Tasks/SplitEpisodesTask.cs @@ -5,7 +5,6 @@ using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; -#nullable enable namespace Shokofin.Tasks; /// <summary> diff --git a/Shokofin/Tasks/SplitMoviesTask.cs b/Shokofin/Tasks/SplitMoviesTask.cs index d03fff75..122d291c 100644 --- a/Shokofin/Tasks/SplitMoviesTask.cs +++ b/Shokofin/Tasks/SplitMoviesTask.cs @@ -5,7 +5,6 @@ using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; -#nullable enable namespace Shokofin.Tasks; /// <summary> diff --git a/Shokofin/Tasks/SyncUserDataTask.cs b/Shokofin/Tasks/SyncUserDataTask.cs index a10d2888..a7c0c65e 100644 --- a/Shokofin/Tasks/SyncUserDataTask.cs +++ b/Shokofin/Tasks/SyncUserDataTask.cs @@ -5,7 +5,6 @@ using MediaBrowser.Model.Tasks; using Shokofin.Sync; -#nullable enable namespace Shokofin.Tasks; /// <summary> diff --git a/Shokofin/Utils/GuardedMemoryCache.cs b/Shokofin/Utils/GuardedMemoryCache.cs index 570cddb1..9f869e15 100644 --- a/Shokofin/Utils/GuardedMemoryCache.cs +++ b/Shokofin/Utils/GuardedMemoryCache.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; -#nullable enable namespace Shokofin.Utils; sealed class GuardedMemoryCache : IDisposable, IMemoryCache diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index 7883d778..853820dc 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -4,7 +4,6 @@ using ExtraType = MediaBrowser.Model.Entities.ExtraType; -#nullable enable namespace Shokofin.Utils; public class Ordering diff --git a/Shokofin/Utils/SeriesInfoRelationComparer.cs b/Shokofin/Utils/SeriesInfoRelationComparer.cs index c288fdcf..8bb033e1 100644 --- a/Shokofin/Utils/SeriesInfoRelationComparer.cs +++ b/Shokofin/Utils/SeriesInfoRelationComparer.cs @@ -4,7 +4,6 @@ using Shokofin.API.Info; using Shokofin.API.Models; -#nullable enable namespace Shokofin.Utils; public class SeriesInfoRelationComparer : IComparer<SeasonInfo> diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index e663b888..b9be8f77 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -149,19 +149,19 @@ public enum DisplayTitleType { FullTitle = 3, } - public static string GetDescription(ShowInfo show) + public static string? GetDescription(ShowInfo show) => GetDescription(show.DefaultSeason); - public static string GetDescription(SeasonInfo season) + public static string? GetDescription(SeasonInfo season) => GetDescription(season.AniDB.Description, season.TvDB?.Description); - public static string GetDescription(EpisodeInfo episode) + public static string? GetDescription(EpisodeInfo episode) => GetDescription(episode.AniDB.Description, episode.TvDB?.Description); - public static string GetDescription(IEnumerable<EpisodeInfo> episodeList) + public static string? GetDescription(IEnumerable<EpisodeInfo> episodeList) => JoinText(episodeList.Select(episode => GetDescription(episode))); - private static string GetDescription(string aniDbDescription, string otherDescription) + private static string GetDescription(string aniDbDescription, string? otherDescription) { string overview; switch (Plugin.Instance.Configuration.DescriptionSource) { @@ -213,16 +213,16 @@ public static string SanitizeTextSummary(string summary) return summary.Trim(); } - public static ( string, string ) GetEpisodeTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) + public static (string?, string?) GetEpisodeTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) => GetTitles(seriesTitles, episodeTitles, null, episodeTitle, DisplayTitleType.SubTitle, metadataLanguage); - public static ( string, string ) GetSeriesTitles(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) + public static (string?, string?) GetSeriesTitles(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) => GetTitles(seriesTitles, null, seriesTitle, null, DisplayTitleType.MainTitle, metadataLanguage); - public static ( string, string ) GetMovieTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) + public static (string?, string?) GetMovieTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) => GetTitles(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplayTitleType.FullTitle, metadataLanguage); - public static ( string, string ) GetTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayTitleType outputType, string metadataLanguage) + public static (string?, string?) GetTitles(IEnumerable<Title>? seriesTitles, IEnumerable<Title>? episodeTitles, string? seriesTitle, string? episodeTitle, DisplayTitleType outputType, string metadataLanguage) { // Don't process anything if the series titles are not provided. if (seriesTitles == null) @@ -233,17 +233,17 @@ public static ( string, string ) GetTitles(IEnumerable<Title> seriesTitles, IEnu ); } - public static string JoinText(IEnumerable<string> textList) + public static string? JoinText(IEnumerable<string?> textList) { var filteredList = textList .Where(title => !string.IsNullOrWhiteSpace(title)) - .Select(title => title.Trim()) + .Select(title => title!.Trim()) // We distinct the list because some episode entries contain the **exact** same description. .Distinct() .ToList(); if (filteredList.Count == 0) - return string.Empty; + return null; var index = 1; var outputText = filteredList[0]; @@ -259,19 +259,19 @@ public static string JoinText(IEnumerable<string> textList) return outputText; } - public static string GetEpisodeTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) + public static string? GetEpisodeTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) => GetTitle(seriesTitles, episodeTitles, null, episodeTitle, DisplayTitleType.SubTitle, metadataLanguage); - public static string GetSeriesTitle(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) + public static string? GetSeriesTitle(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) => GetTitle(seriesTitles, null, seriesTitle, null, DisplayTitleType.MainTitle, metadataLanguage); - public static string GetMovieTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) + public static string? GetMovieTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplayTitleType.FullTitle, metadataLanguage); - public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayTitleType outputType, string metadataLanguage) + public static string? GetTitle(IEnumerable<Title>? seriesTitles, IEnumerable<Title>? episodeTitles, string? seriesTitle, string? episodeTitle, DisplayTitleType outputType, string metadataLanguage) => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage); - public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, DisplayLanguageType languageType, DisplayTitleType outputType, string displayLanguage) + public static string? GetTitle(IEnumerable<Title>? seriesTitles, IEnumerable<Title>? episodeTitles, string? seriesTitle, string? episodeTitle, DisplayLanguageType languageType, DisplayTitleType outputType, string displayLanguage) { // Don't process anything if the series titles are not provided. if (seriesTitles == null) @@ -289,8 +289,8 @@ public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title // Display in metadata-preferred language, or fallback to default. case DisplayLanguageType.MetadataPreferred: { var allowAny = Plugin.Instance.Configuration.TitleAllowAny; - string getSeriesTitle() => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, displayLanguage) ?? (allowAny ? GetTitleByLanguages(seriesTitles, displayLanguage) : null) ?? seriesTitle; - string getEpisodeTitle() => GetTitleByLanguages(episodeTitles, displayLanguage) ?? episodeTitle; + string? getSeriesTitle() => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, displayLanguage) ?? (allowAny ? GetTitleByLanguages(seriesTitles, displayLanguage) : null) ?? seriesTitle; + string? getEpisodeTitle() => GetTitleByLanguages(episodeTitles, displayLanguage) ?? episodeTitle; var title = ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); if (string.IsNullOrEmpty(title)) goto case DisplayLanguageType.Default; @@ -299,20 +299,20 @@ public static string GetTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title // Display in origin language. case DisplayLanguageType.Origin: { var allowAny = Plugin.Instance.Configuration.TitleAllowAny; - string getSeriesTitle() => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, originLanguages) ?? (allowAny ? GetTitleByLanguages(seriesTitles, originLanguages) : null) ?? seriesTitle; - string getEpisodeTitle() => GetTitleByLanguages(episodeTitles, originLanguages) ?? episodeTitle; + string? getSeriesTitle() => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, originLanguages) ?? (allowAny ? GetTitleByLanguages(seriesTitles, originLanguages) : null) ?? seriesTitle; + string? getEpisodeTitle() => GetTitleByLanguages(episodeTitles, originLanguages) ?? episodeTitle; return ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); } // Display the main title. case DisplayLanguageType.Main: { - string getSeriesTitle() => GetTitleByType(seriesTitles, TitleType.Main) ?? seriesTitle; - string getEpisodeTitle() => GetTitleByLanguages(episodeTitles, "en", mainTitleLanguage) ?? episodeTitle; + string? getSeriesTitle() => GetTitleByType(seriesTitles, TitleType.Main) ?? seriesTitle; + string? getEpisodeTitle() => GetTitleByLanguages(episodeTitles, "en", mainTitleLanguage) ?? episodeTitle; return ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); } } } - private static string ConstructTitle(Func<string> getSeriesTitle, Func<string> getEpisodeTitle, DisplayTitleType outputType) + private static string? ConstructTitle(Func<string?> getSeriesTitle, Func<string?> getEpisodeTitle, DisplayTitleType outputType) { switch (outputType) { // Return series title. @@ -335,30 +335,30 @@ private static string ConstructTitle(Func<string> getSeriesTitle, Func<string> g } } - public static string GetTitleByType(IEnumerable<Title> titles, TitleType type) + public static string? GetTitleByType(IEnumerable<Title> titles, TitleType type) { if (titles != null) { - string title = titles.FirstOrDefault(s => s.Type == type)?.Value; + var title = titles.FirstOrDefault(s => s.Type == type)?.Value; if (title != null) return title; } return null; } - public static string GetTitleByTypeAndLanguage(IEnumerable<Title> titles, TitleType type, params string[] langs) + public static string? GetTitleByTypeAndLanguage(IEnumerable<Title>? titles, TitleType type, params string[] langs) { if (titles != null) foreach (string lang in langs) { - string title = titles.FirstOrDefault(s => s.LanguageCode == lang && s.Type == type)?.Value; + var title = titles.FirstOrDefault(s => s.LanguageCode == lang && s.Type == type)?.Value; if (title != null) return title; } return null; } - public static string GetTitleByLanguages(IEnumerable<Title> titles, params string[] langs) + public static string? GetTitleByLanguages(IEnumerable<Title>? titles, params string[] langs) { if (titles != null) foreach (string lang in langs) { - string title = titles.FirstOrDefault(s => lang.Equals(s.LanguageCode, System.StringComparison.OrdinalIgnoreCase))?.Value; + var title = titles.FirstOrDefault(s => lang.Equals(s.LanguageCode, System.StringComparison.OrdinalIgnoreCase))?.Value; if (title != null) return title; } diff --git a/Shokofin/Web/ShokoApiController.cs b/Shokofin/Web/ShokoApiController.cs index dc998192..c3b80d47 100644 --- a/Shokofin/Web/ShokoApiController.cs +++ b/Shokofin/Web/ShokoApiController.cs @@ -10,7 +10,6 @@ using Shokofin.API; using Shokofin.API.Models; -#nullable enable namespace Shokofin.Web; /// <summary> diff --git a/Shokofin/Web/SignalRApiController.cs b/Shokofin/Web/SignalRApiController.cs index 38d7b13a..80fd1da8 100644 --- a/Shokofin/Web/SignalRApiController.cs +++ b/Shokofin/Web/SignalRApiController.cs @@ -12,7 +12,6 @@ using Shokofin.API.Models; using Shokofin.SignalR; -#nullable enable namespace Shokofin.Web; /// <summary> From 21affd0d67c055d5fb0a5d2259609405f0967655 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 31 Mar 2024 12:02:09 +0000 Subject: [PATCH 0701/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 5faa617b..4ca77ef9 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.72", + "changelog": "chore: enable nullable for whole project\n\nchore: fix up IDE complaints for Text.cs", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.72/shoko_3.0.1.72.zip", + "checksum": "a4df844c4f680c7cb916eca56f72b542", + "timestamp": "2024-03-31T12:02:07Z" + }, { "version": "3.0.1.71", "changelog": "chore: even more de-duplication of provider ids", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.68/shoko_3.0.1.68.zip", "checksum": "f7f21944a55a43c1974b583d910f1bdc", "timestamp": "2024-03-31T07:51:13Z" - }, - { - "version": "3.0.1.67", - "changelog": "feat: initial PoC SignalR connection manager\n\n- Add the initial PoC SignalR Connection Manager responsible for\n reacting to Shoko file, episode, and series update events, if\n enabled. Currently it only prints debug messages when enabled. And\n it can only be enabled by manually editing the settings xml.\n\nmisc: change mount of shoko api controller", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.67/shoko_3.0.1.67.zip", - "checksum": "98ffec6ef0101bc2030276f11b08ea14", - "timestamp": "2024-03-31T07:29:06Z" } ] } From f21a7b76a196ec7845d7b16ca53dcded431b0720 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 14:13:09 +0200 Subject: [PATCH 0702/1103] chore: remove unused signalr events [skip ci] --- .../SignalR/Models/FileDetectedEventArgs.cs | 18 ---------------- .../SignalR/Models/FileNotMatchedEventArgs.cs | 21 ------------------- 2 files changed, 39 deletions(-) delete mode 100644 Shokofin/SignalR/Models/FileDetectedEventArgs.cs delete mode 100644 Shokofin/SignalR/Models/FileNotMatchedEventArgs.cs diff --git a/Shokofin/SignalR/Models/FileDetectedEventArgs.cs b/Shokofin/SignalR/Models/FileDetectedEventArgs.cs deleted file mode 100644 index 55cfc7d3..00000000 --- a/Shokofin/SignalR/Models/FileDetectedEventArgs.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Shokofin.SignalR.Models; - -public class FileDetectedEventArgs -{ - /// <summary> - /// The ID of the import folder the event was detected in. - /// </summary> - [JsonPropertyName("ImportFolderID")] - public int ImportFolderId { get; set; } - - /// <summary> - /// The relative path of the file from the import folder base location - /// </summary> - [JsonPropertyName("RelativePath")] - public string RelativePath { get; set; } = string.Empty; -} diff --git a/Shokofin/SignalR/Models/FileNotMatchedEventArgs.cs b/Shokofin/SignalR/Models/FileNotMatchedEventArgs.cs deleted file mode 100644 index 559ef9bc..00000000 --- a/Shokofin/SignalR/Models/FileNotMatchedEventArgs.cs +++ /dev/null @@ -1,21 +0,0 @@ - -namespace Shokofin.SignalR.Models; - -public class FileNotMatchedEventArgs : FileEventArgs -{ - /// <summary> - /// Number of times we've tried to auto-match this file up until now. - /// </summary> - public int AutoMatchAttempts { get; set; } - - /// <summary> - /// True if this file had existing cross-refernces before this match - /// attempt. - /// </summary> - public bool HasCrossReferences { get; set; } - - /// <summary> - /// True if we're currently UDP banned. - /// </summary> - public bool IsUDPBanned { get; set; } -} \ No newline at end of file From 23ddb84f8529f09c7292ef3b75cbcb18fe0edb20 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 14:46:23 +0200 Subject: [PATCH 0703/1103] chore: even more cleanup after enabling nullable on all files [skip ci] --- Shokofin/API/Models/ApiKey.cs | 10 ++++++++-- Shokofin/API/Models/ComponentVersion.cs | 2 +- Shokofin/API/Models/Image.cs | 9 ++++----- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Shokofin/API/Models/ApiKey.cs b/Shokofin/API/Models/ApiKey.cs index dd603997..4e2e1c0f 100644 --- a/Shokofin/API/Models/ApiKey.cs +++ b/Shokofin/API/Models/ApiKey.cs @@ -1,7 +1,13 @@ -# nullable enable + +using System.Text.Json.Serialization; + namespace Shokofin.API.Models; public class ApiKey { - public string apikey { get; set; } = string.Empty; + /// <summary> + /// The Api Key Token. + /// </summary> + [JsonPropertyName("apikey")] + public string Token { get; set; } = string.Empty; } diff --git a/Shokofin/API/Models/ComponentVersion.cs b/Shokofin/API/Models/ComponentVersion.cs index 507ce50e..9a5ff22d 100644 --- a/Shokofin/API/Models/ComponentVersion.cs +++ b/Shokofin/API/Models/ComponentVersion.cs @@ -1,7 +1,7 @@ using System; +using System.Linq; using System.Text.Json.Serialization; -# nullable enable namespace Shokofin.API.Models; public class ComponentVersionSet diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index b517ac01..190026af 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -1,6 +1,5 @@ using System.Text.Json.Serialization; -# nullable enable namespace Shokofin.API.Models; public class Image @@ -9,7 +8,7 @@ public class Image /// AniDB, TvDB, TMDB, etc. /// </summary> public ImageSource Source { get; set; } = ImageSource.AniDB; - + /// <summary> /// Poster, Banner, etc. /// </summary> @@ -21,7 +20,7 @@ public class Image /// </summary> public string ID { get; set; } = string.Empty; - + /// <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"/>. @@ -47,7 +46,7 @@ public class Image /// <summary> /// The relative path from the image base directory if the image is present - /// on the server. + /// on the server. /// </summary> [JsonPropertyName("RelativeFilepath")] public string? LocalPath { get; set; } @@ -65,7 +64,7 @@ public virtual bool IsAvailable [JsonIgnore] public virtual string Path => $"/api/v3/Image/{Source.ToString()}/{Type.ToString()}/{ID}"; - + /// <summary> /// Get an URL to both download the image on the backend and preview it for /// the clients. From c0e5cff5660fb8a09e28c2d1cf479c3199dc19ba Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 31 Mar 2024 14:47:50 +0200 Subject: [PATCH 0704/1103] feat: add version checker --- Shokofin/API/Models/ComponentVersion.cs | 13 ++++ Shokofin/Tasks/VersionCheckTask.cs | 81 +++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 Shokofin/Tasks/VersionCheckTask.cs diff --git a/Shokofin/API/Models/ComponentVersion.cs b/Shokofin/API/Models/ComponentVersion.cs index 9a5ff22d..9080dd52 100644 --- a/Shokofin/API/Models/ComponentVersion.cs +++ b/Shokofin/API/Models/ComponentVersion.cs @@ -33,6 +33,19 @@ public class ComponentVersion /// Release date. /// </summary> public DateTime? ReleaseDate { get; set; } = null; + + public override string ToString() + { + var extraDetails = new string?[3] { + ReleaseChannel?.ToString(), + Commit?[0..7], + ReleaseDate?.ToUniversalTime().ToString("yyyy-MM-ddThh:mm:ssZ"), + }.Where(s => !string.IsNullOrEmpty(s)).OfType<string>().Join(", "); + if (extraDetails.Length == 0) + return $"Version {Version}"; + + return $"Version {Version} ({extraDetails})"; + } } [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/Shokofin/Tasks/VersionCheckTask.cs b/Shokofin/Tasks/VersionCheckTask.cs new file mode 100644 index 00000000..4c67940c --- /dev/null +++ b/Shokofin/Tasks/VersionCheckTask.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.MergeVersions; + +namespace Shokofin.Tasks; + +/// <summary> +/// Responsible for updating the known version of the remote Shoko Server +/// instance at startup and set intervals. +/// </summary> +public class VersionCheckTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Check Server Version"; + + /// <inheritdoc /> + public string Description => "Responsible for updating the known version of the remote Shoko Server instance at startup and set intervals."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoVersionCheck"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => true; + + private readonly ILogger<VersionCheckTask> Logger; + + private readonly ShokoAPIClient ApiClient; + + public VersionCheckTask(ILogger<VersionCheckTask> logger, ShokoAPIClient apiClient) + { + Logger = logger; + ApiClient = apiClient; + } + + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => new TaskTriggerInfo[2] { + new() { + Type = TaskTriggerInfo.TriggerStartup, + }, + new() { + Type = TaskTriggerInfo.TriggerDaily, + TimeOfDayTicks = new TimeSpan(3, 0, 0).Ticks, + }, + }; + + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + var version = await ApiClient.GetVersion(); + if (version != null && ( + Plugin.Instance.Configuration.HostVersion == null || + !string.Equals(version.ToString(), Plugin.Instance.Configuration.HostVersion.ToString()) + )) { + Logger.LogInformation("Found new Shoko Server version; {version}", version); + Plugin.Instance.Configuration.HostVersion = version; + Plugin.Instance.SaveConfiguration(); + } + } +} \ No newline at end of file From 881a261a79e4c75c3fd1d192aa9aeb4232df7a9a Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 31 Mar 2024 12:48:42 +0000 Subject: [PATCH 0705/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 4ca77ef9..3dbb318e 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.73", + "changelog": "feat: add version checker\n\nchore: even more cleanup after enabling nullable\non all files [skip ci]\n\nchore: remove unused signalr events [skip ci]", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.73/shoko_3.0.1.73.zip", + "checksum": "920191eff4502270f3888ed5d9a5a12d", + "timestamp": "2024-03-31T12:48:40Z" + }, { "version": "3.0.1.72", "changelog": "chore: enable nullable for whole project\n\nchore: fix up IDE complaints for Text.cs", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.69/shoko_3.0.1.69.zip", "checksum": "b62393f72bc30b182293d4c1c2ecaee3", "timestamp": "2024-03-31T08:29:25Z" - }, - { - "version": "3.0.1.68", - "changelog": "fix: register & fix the signalr connection manager", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.68/shoko_3.0.1.68.zip", - "checksum": "f7f21944a55a43c1974b583d910f1bdc", - "timestamp": "2024-03-31T07:51:13Z" } ] } From 6f3d4224a232e68620f7931fb3be87687ac28d0d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 5 Apr 2024 08:32:57 +0200 Subject: [PATCH 0706/1103] fix: validate links in case they broke --- Shokofin/Resolvers/ShokoResolveManager.cs | 88 ++++++++++++++++++----- 1 file changed, 72 insertions(+), 16 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index b79726fc..62dfc9eb 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -305,8 +305,10 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)> files) { var start = DateTime.UtcNow; - var skipped = 0; + var skippedLinks = 0; + var fixedLinks = 0; var subtitles = 0; + var fixedSubtitles = 0; var skippedSubtitles = 0; var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); @@ -330,10 +332,32 @@ await Task.WhenAll(files.Select(async (tuple) => { Directory.CreateDirectory(symbolicDirectory); allPathsForVFS.Add((sourceLocation, symbolicLink)); - if (!File.Exists(symbolicLink)) + if (!File.Exists(symbolicLink)) { File.CreateSymbolicLink(symbolicLink, sourceLocation); - else - skipped++; + } + else { + var shouldFix = false; + try { + var nextTarget = File.ResolveLinkTarget(symbolicLink, false); + if (!string.Equals(sourceLocation, nextTarget)) { + shouldFix = true; + + Logger.LogWarning("Fixing broken symbolic link {Link} for {LinkTarget} (RealTarget={RealTarget})", symbolicLink, sourceLocation, nextTarget); + } + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link}", symbolicLink); + shouldFix = true; + } + if (shouldFix) { + File.Delete(symbolicLink); + File.CreateSymbolicLink(symbolicLink, sourceLocation); + fixedLinks++; + } + else { + skippedLinks++; + } + } if (subtitleLinks.Count > 0) { var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); @@ -345,8 +369,25 @@ await Task.WhenAll(files.Select(async (tuple) => { allPathsForVFS.Add((subtitleSource, subtitleLink)); if (!File.Exists(subtitleLink)) File.CreateSymbolicLink(subtitleLink, subtitleSource); - else - skippedSubtitles++; + else { + var shouldFix = false; + try { + var nextTarget = File.ResolveLinkTarget(subtitleLink, false); + shouldFix = !string.Equals(subtitleSource, nextTarget); + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link} for {LinkTarget}", subtitleLink, subtitleSource); + shouldFix = true; + } + if (shouldFix) { + File.Delete(subtitleLink); + File.CreateSymbolicLink(subtitleLink, subtitleSource); + fixedSubtitles++; + } + else { + skippedSubtitles++; + } + } } } } @@ -357,19 +398,32 @@ await Task.WhenAll(files.Select(async (tuple) => { })) .ConfigureAwait(false); + var removedLinks = 0; var removedSubtitles = 0; - var toBeRemoved = FileSystem.GetFilePaths(ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder), true) - .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) - .Except(allPathsForVFS.Select(tuple => tuple.symbolicLink).ToHashSet()) + var toBeRemoved = FileSystem.GetFilePaths(vfsPath, true) + .Select(path => (path, extName: Path.GetExtension(path))) + .Where(tuple => _namingOptions.VideoFileExtensions.Contains(tuple.extName) || _namingOptions.SubtitleFileExtensions.Contains(tuple.extName)) + .ExceptBy(allPathsForVFS.Select(tuple => tuple.symbolicLink).ToHashSet(), tuple => tuple.path) .ToList(); - foreach (var symbolicLink in toBeRemoved) { - var subtitleLinks = FindSubtitlesForPath(symbolicLink); + foreach (var (symbolicLink, extName) in toBeRemoved) { + // Continue in case we already removed the (subtitle) file. + if (!File.Exists(symbolicLink)) + continue; File.Delete(symbolicLink); - foreach (var subtitleLink in subtitleLinks) { + // Stats tracking. + if (_namingOptions.VideoFileExtensions.Contains(extName)) { + var subtitleLinks = _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(symbolicLink)) ? FindSubtitlesForPath(symbolicLink) : Array.Empty<string>(); + + removedLinks++; + foreach (var subtitleLink in subtitleLinks) { + removedSubtitles++; + File.Delete(symbolicLink); + } + } + else { removedSubtitles++; - File.Delete(symbolicLink); } CleanupDirectoryStructure(symbolicLink); @@ -377,10 +431,12 @@ await Task.WhenAll(files.Select(async (tuple) => { var timeSpent = DateTime.UtcNow - start; Logger.LogInformation( - "Created {CreatedMedia} ({CreatedSubtitles}), skipped {SkippedMedia} ({SkippedSubtitles}), and removed {RemovedMedia} ({RemovedSubtitles}) symbolic links in media folder at {Path} in {TimeSpan}", - allPathsForVFS.Count - skipped - subtitles, + "Created {CreatedMedia} ({CreatedSubtitles}), fixed {FixedMedia} ({FixedSubtitles}), skipped {SkippedMedia} ({SkippedSubtitles}), and removed {RemovedMedia} ({RemovedSubtitles}) symbolic links in media folder at {Path} in {TimeSpan}", + allPathsForVFS.Count - skippedLinks - subtitles, subtitles - skippedSubtitles, - skipped, + fixedLinks, + fixedSubtitles, + skippedLinks, skippedSubtitles, toBeRemoved.Count, removedSubtitles, From 781b5de52812af10958e48e24736c96919a47f91 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 5 Apr 2024 08:46:38 +0200 Subject: [PATCH 0707/1103] feat: allow the VFS to be toggled per media folder - Allow the VFS to be toggled on/off **per media folder**. The global setting at the time of creating the media folder will be saved for the folder, so you can enable/disable the setting, create a library, then reverse the setting to have it only enabled/disabled on a single media folder. - Still no UI for the media folder settings to edit the settings for existing media folders. - People that want to experiment with the VFS on _and_ off can now create a symbolic link to the root of their media folder and enable VFS for that folder after some shenanigans. Do note that the global setting applies to any media folder that was mapped before this commit, so if you want to save the setting for those media folders then you need to edit the media folder settings directly (since there) is no UI for the media folders yet. --- .../Configuration/MediaFolderConfiguration.cs | 6 +++ Shokofin/Resolvers/ShokoResolveManager.cs | 38 ++++++++++++------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/Shokofin/Configuration/MediaFolderConfiguration.cs b/Shokofin/Configuration/MediaFolderConfiguration.cs index f420e197..bbec10bf 100644 --- a/Shokofin/Configuration/MediaFolderConfiguration.cs +++ b/Shokofin/Configuration/MediaFolderConfiguration.cs @@ -34,6 +34,12 @@ public class MediaFolderConfiguration /// </summary> public bool IsFileEventsEnabled { get; set; } = true; + /// <summary> + /// Enable or disable the virtual file system on a per-media-folder basis. + /// </summary> + /// <value></value> + public bool? IsVirtualFileSystemEnabled { get; set; } = null; + /// <summary> /// Check if a relative path within the import folder is potentially available in this media folder. /// </summary> diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 62dfc9eb..bfc0ebab 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -134,7 +134,10 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold return mediaFolderConfig; // Check if we should introduce the VFS for the media folder. - mediaFolderConfig = new() { MediaFolderId = mediaFolder.Id }; + mediaFolderConfig = new() { + MediaFolderId = mediaFolder.Id, + IsVirtualFileSystemEnabled = config.VirtualFileSystem, + }; var start = DateTime.UtcNow; var attempts = 0; @@ -195,7 +198,14 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold #region Generate Structure - private async Task<string?> GenerateStructureForFolder(Folder mediaFolder, string folderPath) + /// <summary> + /// Generates the VFS structure if the VFS is enabled globally or on the + /// <paramref name="mediaFolder"/>. + /// </summary> + /// <param name="mediaFolder">The media folder to generate a structure for.</param> + /// <param name="folderPath">The folder within the media folder to generate a structure for.</param> + /// <returns>The VFS path, if it succeeded.</returns> + private async Task<string?> GenerateStructureForFolderInVFS(Folder mediaFolder, string folderPath) { // Return early if we've already generated the structure from the import folder itself. if (DataCache.TryGetValue<string?>(mediaFolder.Path, out var vfsPath)) @@ -207,6 +217,10 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold if (!mediaConfig.IsMapped) return null; + // Return early if we're not going to generate them. + if (!(mediaConfig.IsVirtualFileSystemEnabled ?? Plugin.Instance.Configuration.VirtualFileSystem)) + return null; + // Check if we should introduce the VFS for the media folder. var start = DateTime.UtcNow; var allPaths = FileSystem.GetFilePaths(folderPath, true) @@ -728,9 +742,6 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b public async Task<BaseItem?> ResolveSingle(Folder? parent, string? collectionType, FileSystemMetadata fileInfo) { - if (!Plugin.Instance.Configuration.VirtualFileSystem) - return null; - if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || parent == null || fileInfo == null) return null; @@ -742,19 +753,19 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b if (!Lookup.IsEnabledForItem(parent)) return null; + // We're already within the VFS, so let jellyfin take it from here. var fullPath = fileInfo.FullName; - var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent, root); - if (mediaFolder == root) + if (!fullPath.StartsWith(Plugin.Instance.VirtualRoot)) return null; - // We're most likely already within the VFS, so abort here. - if (!fullPath.StartsWith(Plugin.Instance.VirtualRoot)) + var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent, root); + if (mediaFolder == root) return null; var searchPath = mediaFolder.Path != parent.Path ? Path.Combine(mediaFolder.Path, parent.Path[(mediaFolder.Path.Length + 1)..].Split(Path.DirectorySeparatorChar).Skip(1).Join(Path.DirectorySeparatorChar)) : mediaFolder.Path; - var vfsPath = await GenerateStructureForFolder(mediaFolder, searchPath).ConfigureAwait(false); + var vfsPath = await GenerateStructureForFolderInVFS(mediaFolder, searchPath).ConfigureAwait(false); if (string.IsNullOrEmpty(vfsPath)) return null; @@ -780,9 +791,6 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b public async Task<MultiItemResolverResult?> ResolveMultiple(Folder? parent, string? collectionType, List<FileSystemMetadata> fileInfoList) { - if (!Plugin.Instance.Configuration.VirtualFileSystem) - return null; - if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || parent == null) return null; @@ -796,7 +804,7 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b // Redirect children of a VFS managed media folder to the VFS. if (parent.ParentId == root.Id) { - var vfsPath = await GenerateStructureForFolder(parent, parent.Path).ConfigureAwait(false); + var vfsPath = await GenerateStructureForFolderInVFS(parent, parent.Path).ConfigureAwait(false); if (string.IsNullOrEmpty(vfsPath)) return null; @@ -861,6 +869,8 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b return new() { Items = items.Where(i => i is Movie).ToList(), ExtraFiles = items.OfType<TvSeries>().Select(s => FileSystem.GetFileSystemInfo(s.Path)).ToList() }; } + // TODO: Redirect to the base item in the VFS if needed. + return null; } catch (Exception ex) { From 9f7270633a09f19a022a26e84d09d21c06d3b13d Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 5 Apr 2024 06:55:37 +0000 Subject: [PATCH 0708/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 3dbb318e..7ccee567 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.74", + "changelog": "feat: allow the VFS to be toggled per media folder\n\n- Allow the VFS to be toggled on/off **per media folder**. The global\n setting at the time of creating the media folder will be saved for\n the folder, so you can enable/disable the setting, create a library,\n then reverse the setting to have it only enabled/disabled on a single\n media folder.\n\n- Still no UI for the media folder settings to edit the settings for\n existing media folders.\n\n- People that want to experiment with the VFS on _and_ off can now\n create a symbolic link to the root of their media folder and enable\n VFS for that folder after some shenanigans. Do note that the global\n setting applies to any media folder that was mapped before this\n commit, so if you want to save the setting for those media folders\n then you need to edit the media folder settings directly (since there)\n is no UI for the media folders yet.\n\nfix: validate links in case they broke", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.74/shoko_3.0.1.74.zip", + "checksum": "1645b769bea7627062ba40e643458d89", + "timestamp": "2024-04-05T06:55:35Z" + }, { "version": "3.0.1.73", "changelog": "feat: add version checker\n\nchore: even more cleanup after enabling nullable\non all files [skip ci]\n\nchore: remove unused signalr events [skip ci]", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.70/shoko_3.0.1.70.zip", "checksum": "75cd51b883822d38f716c884e829efc7", "timestamp": "2024-03-31T11:26:21Z" - }, - { - "version": "3.0.1.69", - "changelog": "feat: add signalr api controller", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.69/shoko_3.0.1.69.zip", - "checksum": "b62393f72bc30b182293d4c1c2ecaee3", - "timestamp": "2024-03-31T08:29:25Z" } ] } From 9cb9a4d3db7bba6375c0112e6845318d1b896be9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 5 Apr 2024 09:00:11 +0200 Subject: [PATCH 0709/1103] misc: try updating discord notifications --- .github/workflows/release-daily.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 7c2081b1..83ebe878 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -143,8 +143,8 @@ jobs: webhook: ${{ secrets.DISCORD_WEBHOOK }} nodetail: true color: 0xaa5cc3 - title: "<:jellyfin:1045360407814090953> Shokofin: New Unstable Build!" - description: | + content: | + # <:jellyfin:1045360407814090953> Shokofin: New Unstable Build! **Version**: `${{ needs.current_info.outputs.version }}` (`${{ needs.current_info.outputs.sha_short }}`) Update your plugin using the [unstable manifest](https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest-unstable.json) or by downloading the release from [GitHub Releases](https://github.com/ShokoAnime/Shokofin/releases/tag/${{ needs.current_info.outputs.version }}) and installing it manually! From 2cafb298efa23950df13f26c9d1d9e2107535632 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 5 Apr 2024 07:01:03 +0000 Subject: [PATCH 0710/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 7ccee567..47e2db47 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.75", + "changelog": "misc: try updating discord notifications", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.75/shoko_3.0.1.75.zip", + "checksum": "5c35bffc9cb81ddd724d0fd7df36f052", + "timestamp": "2024-04-05T07:01:01Z" + }, { "version": "3.0.1.74", "changelog": "feat: allow the VFS to be toggled per media folder\n\n- Allow the VFS to be toggled on/off **per media folder**. The global\n setting at the time of creating the media folder will be saved for\n the folder, so you can enable/disable the setting, create a library,\n then reverse the setting to have it only enabled/disabled on a single\n media folder.\n\n- Still no UI for the media folder settings to edit the settings for\n existing media folders.\n\n- People that want to experiment with the VFS on _and_ off can now\n create a symbolic link to the root of their media folder and enable\n VFS for that folder after some shenanigans. Do note that the global\n setting applies to any media folder that was mapped before this\n commit, so if you want to save the setting for those media folders\n then you need to edit the media folder settings directly (since there)\n is no UI for the media folders yet.\n\nfix: validate links in case they broke", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.71/shoko_3.0.1.71.zip", "checksum": "3ec315f2cabb7b679d54dd20ceae9b5a", "timestamp": "2024-03-31T11:31:28Z" - }, - { - "version": "3.0.1.70", - "changelog": "refactor: convert create file info\n\n- Convert `CreateFileInfo` to the new cache getter pattern\n\nfix: fix episode id lookups\n\n- the episode lookups should had been per file per series, not just per\n file. now it is.\n\nchore: `\"\"` \u2192\u00a0`string.Empty`\n\nchore: de-duplicate provider ids", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.70/shoko_3.0.1.70.zip", - "checksum": "75cd51b883822d38f716c884e829efc7", - "timestamp": "2024-03-31T11:26:21Z" } ] } From d1f7b2d8c626edb7c08e1f59c96c9b997bfcf04f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 5 Apr 2024 09:20:18 +0200 Subject: [PATCH 0711/1103] misc: try removing embed in discord - Try removing the embed at the end of each release message in discord. - Also @Queuecumbr (GH) / <@308830889281060864> (Discord) wanted to see @ mentions in action from a web hook. --- .github/workflows/release-daily.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 83ebe878..4521d5be 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -142,6 +142,7 @@ jobs: with: webhook: ${{ secrets.DISCORD_WEBHOOK }} nodetail: true + notimestamp: true color: 0xaa5cc3 content: | # <:jellyfin:1045360407814090953> Shokofin: New Unstable Build! @@ -150,4 +151,5 @@ jobs: Update your plugin using the [unstable manifest](https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest-unstable.json) or by downloading the release from [GitHub Releases](https://github.com/ShokoAnime/Shokofin/releases/tag/${{ needs.current_info.outputs.version }}) and installing it manually! **Changes since last build**: + ${{ needs.current_info.outputs.changelog }} \ No newline at end of file From 1b9fd907f0f3d82984a2ef80414d53c05c7ec113 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 5 Apr 2024 07:21:23 +0000 Subject: [PATCH 0712/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 47e2db47..1dc33217 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.76", + "changelog": "misc: try removing embed in discord\n\n- Try removing the embed at the end of each release\n message in discord.\n\n- Also @Queuecumbr (GH) / <@308830889281060864> (Discord) wanted to see\n @ mentions in action from a web hook.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.76/shoko_3.0.1.76.zip", + "checksum": "0a1a24e4a381194c585d3cb9681e00f2", + "timestamp": "2024-04-05T07:21:21Z" + }, { "version": "3.0.1.75", "changelog": "misc: try updating discord notifications", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.72/shoko_3.0.1.72.zip", "checksum": "a4df844c4f680c7cb916eca56f72b542", "timestamp": "2024-03-31T12:02:07Z" - }, - { - "version": "3.0.1.71", - "changelog": "chore: even more de-duplication of provider ids", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.71/shoko_3.0.1.71.zip", - "checksum": "3ec315f2cabb7b679d54dd20ceae9b5a", - "timestamp": "2024-03-31T11:31:28Z" } ] } From 09ea49b061808413a4f6fa34beacf86f6f67a139 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 5 Apr 2024 09:25:55 +0200 Subject: [PATCH 0713/1103] misc: take 2 on removing embed in discord MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - This time for real… hopefully. --- .github/workflows/release-daily.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 4521d5be..935d11eb 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -143,7 +143,7 @@ jobs: webhook: ${{ secrets.DISCORD_WEBHOOK }} nodetail: true notimestamp: true - color: 0xaa5cc3 + title: "" content: | # <:jellyfin:1045360407814090953> Shokofin: New Unstable Build! **Version**: `${{ needs.current_info.outputs.version }}` (`${{ needs.current_info.outputs.sha_short }}`) From c8230c83d57d618b9343e2eb74b697e30a5cda47 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 5 Apr 2024 07:26:41 +0000 Subject: [PATCH 0714/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 1dc33217..450c406a 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.77", + "changelog": "misc: take 2 on removing embed in discord\n\n- This time for real\u2026 hopefully.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.77/shoko_3.0.1.77.zip", + "checksum": "9323325ce698ca405875a232a4f4f6df", + "timestamp": "2024-04-05T07:26:40Z" + }, { "version": "3.0.1.76", "changelog": "misc: try removing embed in discord\n\n- Try removing the embed at the end of each release\n message in discord.\n\n- Also @Queuecumbr (GH) / <@308830889281060864> (Discord) wanted to see\n @ mentions in action from a web hook.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.73/shoko_3.0.1.73.zip", "checksum": "920191eff4502270f3888ed5d9a5a12d", "timestamp": "2024-03-31T12:48:40Z" - }, - { - "version": "3.0.1.72", - "changelog": "chore: enable nullable for whole project\n\nchore: fix up IDE complaints for Text.cs", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.72/shoko_3.0.1.72.zip", - "checksum": "a4df844c4f680c7cb916eca56f72b542", - "timestamp": "2024-03-31T12:02:07Z" } ] } From abebd60281e86e79b88545d97475537f55bfcb05 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 5 Apr 2024 09:35:25 +0200 Subject: [PATCH 0715/1103] misc: take 3 This time using a new GH Action. So time to test out a few things; - List item [Link](<https://discord.com>) **Bold** _Italic_ ||Spoiler|| --- .github/workflows/release-daily.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 935d11eb..3a0c19ba 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -138,13 +138,11 @@ jobs: steps: - name: Notify Discord Users - uses: sarisia/actions-status-discord@v1 + uses: Ilshidur/action-discord@08d9328877d6954120eef2b07abbc79249bb6210 + env: + DISCORD_WEBHOO: ${{ secrets.DISCORD_WEBHOOK }} with: - webhook: ${{ secrets.DISCORD_WEBHOOK }} - nodetail: true - notimestamp: true - title: "" - content: | + args: | # <:jellyfin:1045360407814090953> Shokofin: New Unstable Build! **Version**: `${{ needs.current_info.outputs.version }}` (`${{ needs.current_info.outputs.sha_short }}`) From d2013cbbaa21ea807f942b5012b719b5af2ef3d3 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 5 Apr 2024 07:36:14 +0000 Subject: [PATCH 0716/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 450c406a..d0c59af7 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.78", + "changelog": "misc: take 3\nThis time using a new GH Action. So time to test out a few things;\n\n- List item\n\n[Link](<https://discord.com>)\n\n**Bold**\n\n_Italic_\n\n||Spoiler||", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.78/shoko_3.0.1.78.zip", + "checksum": "3e22b60ab94067ab9731fc09f37f7dbf", + "timestamp": "2024-04-05T07:36:13Z" + }, { "version": "3.0.1.77", "changelog": "misc: take 2 on removing embed in discord\n\n- This time for real\u2026 hopefully.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.74/shoko_3.0.1.74.zip", "checksum": "1645b769bea7627062ba40e643458d89", "timestamp": "2024-04-05T06:55:35Z" - }, - { - "version": "3.0.1.73", - "changelog": "feat: add version checker\n\nchore: even more cleanup after enabling nullable\non all files [skip ci]\n\nchore: remove unused signalr events [skip ci]", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.73/shoko_3.0.1.73.zip", - "checksum": "920191eff4502270f3888ed5d9a5a12d", - "timestamp": "2024-03-31T12:48:40Z" } ] } From 09fbab2d2fbf1dc65e5a9f94e9ace09a2e43501a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 5 Apr 2024 09:38:53 +0200 Subject: [PATCH 0717/1103] misc: take 4: action! This time using a new GH Action. So time to test out a few things; - List item [Link](<https://discord.com>) **Bold** _Italic_ ||Spoiler|| **Note**: I totally didn't just typo that env. var.. --- .github/workflows/release-daily.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 3a0c19ba..65023257 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -140,7 +140,7 @@ jobs: - name: Notify Discord Users uses: Ilshidur/action-discord@08d9328877d6954120eef2b07abbc79249bb6210 env: - DISCORD_WEBHOO: ${{ secrets.DISCORD_WEBHOOK }} + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} with: args: | # <:jellyfin:1045360407814090953> Shokofin: New Unstable Build! From fba49e5879a484439512bb04286ee0cc7832d214 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 5 Apr 2024 07:39:47 +0000 Subject: [PATCH 0718/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index d0c59af7..787b3ec7 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.79", + "changelog": "misc: take 4: action!\nThis time using a new GH Action. So time to test out a few things;\n\n- List item\n\n[Link](<https://discord.com>)\n\n**Bold**\n\n_Italic_\n\n||Spoiler||\n\n**Note**: I totally didn't just typo that env. var..", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.79/shoko_3.0.1.79.zip", + "checksum": "ac4d3e9cb4d16eb565b5805448c5293c", + "timestamp": "2024-04-05T07:39:45Z" + }, { "version": "3.0.1.78", "changelog": "misc: take 3\nThis time using a new GH Action. So time to test out a few things;\n\n- List item\n\n[Link](<https://discord.com>)\n\n**Bold**\n\n_Italic_\n\n||Spoiler||", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.75/shoko_3.0.1.75.zip", "checksum": "5c35bffc9cb81ddd724d0fd7df36f052", "timestamp": "2024-04-05T07:01:01Z" - }, - { - "version": "3.0.1.74", - "changelog": "feat: allow the VFS to be toggled per media folder\n\n- Allow the VFS to be toggled on/off **per media folder**. The global\n setting at the time of creating the media folder will be saved for\n the folder, so you can enable/disable the setting, create a library,\n then reverse the setting to have it only enabled/disabled on a single\n media folder.\n\n- Still no UI for the media folder settings to edit the settings for\n existing media folders.\n\n- People that want to experiment with the VFS on _and_ off can now\n create a symbolic link to the root of their media folder and enable\n VFS for that folder after some shenanigans. Do note that the global\n setting applies to any media folder that was mapped before this\n commit, so if you want to save the setting for those media folders\n then you need to edit the media folder settings directly (since there)\n is no UI for the media folders yet.\n\nfix: validate links in case they broke", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.74/shoko_3.0.1.74.zip", - "checksum": "1645b769bea7627062ba40e643458d89", - "timestamp": "2024-04-05T06:55:35Z" } ] } From 2f7bb676dd465f4197cfb826ce42387ed821709f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 5 Apr 2024 09:41:29 +0200 Subject: [PATCH 0719/1103] misc: fix links in dev release notification --- .github/workflows/release-daily.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 65023257..407a6179 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -146,7 +146,7 @@ jobs: # <:jellyfin:1045360407814090953> Shokofin: New Unstable Build! **Version**: `${{ needs.current_info.outputs.version }}` (`${{ needs.current_info.outputs.sha_short }}`) - Update your plugin using the [unstable manifest](https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest-unstable.json) or by downloading the release from [GitHub Releases](https://github.com/ShokoAnime/Shokofin/releases/tag/${{ needs.current_info.outputs.version }}) and installing it manually! + Update your plugin using the [unstable manifest](<https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest-unstable.json>) or by downloading the release from [GitHub Releases](<https://github.com/ShokoAnime/Shokofin/releases/tag/${{ needs.current_info.outputs.version }}>) and installing it manually! **Changes since last build**: From 821d6aa5f73f062b41e6a0ef3a1ad56b4b11fad8 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 5 Apr 2024 07:42:14 +0000 Subject: [PATCH 0720/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 787b3ec7..d1fb585f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.80", + "changelog": "misc: fix links in dev release notification", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.80/shoko_3.0.1.80.zip", + "checksum": "600c57484fddfd0fc53004da083bc056", + "timestamp": "2024-04-05T07:42:12Z" + }, { "version": "3.0.1.79", "changelog": "misc: take 4: action!\nThis time using a new GH Action. So time to test out a few things;\n\n- List item\n\n[Link](<https://discord.com>)\n\n**Bold**\n\n_Italic_\n\n||Spoiler||\n\n**Note**: I totally didn't just typo that env. var..", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.76/shoko_3.0.1.76.zip", "checksum": "0a1a24e4a381194c585d3cb9681e00f2", "timestamp": "2024-04-05T07:21:21Z" - }, - { - "version": "3.0.1.75", - "changelog": "misc: try updating discord notifications", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.75/shoko_3.0.1.75.zip", - "checksum": "5c35bffc9cb81ddd724d0fd7df36f052", - "timestamp": "2024-04-05T07:01:01Z" } ] } From a939fca24f5900f8aa58e682f383e7ebf47d4039 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 5 Apr 2024 09:51:20 +0200 Subject: [PATCH 0721/1103] misc: update logo [skip ci] --- LogoWide.png | Bin 109118 -> 142390 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/LogoWide.png b/LogoWide.png index f91941e8283605932c96d9fa739ddc02f217bf81..3d4c26dadc070b129dd9066389c92d25f23a1d0a 100644 GIT binary patch literal 142390 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�Ii7kB1QjvkpJub6ZuU*O09ClBXav&;=9>H9l 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¸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 F{2%4+)nfnv literal 109118 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πOC+u3TpI8>*6MvIHw`6$MZ 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�akg4Km765!V#5ECoDjneu{{9*Rb!fnwd+1NFH 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�!C1~+%vx1Yc%$YqzafIIc9H4FI}d??rD{|*Qm!T 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�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%0ATz-&#<c~*9Kv>Fz4gb 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 NE2=AGTz&BD{{b@}7jXap From bac7aabb3a2b154357d0f5ccc49f7a62eecf5df3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 6 Apr 2024 13:01:58 +0200 Subject: [PATCH 0722/1103] refactor: update plugin config - Give the plugin configuration a face-lift by adding proper regions and comments, and also rename a few fields in the code, but not in the settings file itself (as to not break anything). - Added and hooked up per-media-folder library filtering. Still no UI to update it for existing libraries, and the global option is now treated as a default option. It is recommended to either edit the option in the xml file after updating, or removing the media folder settings from the file and letting it re-generate itself on the next library scan. - Tweaked the VFS per-media-folder setting to not fallback to the global option, and treat the global option as a default option for new media libraries. Also, not in this commit, but the settings page will get another refactor to add the per-media-folder settings and SignalR settings sometime in the future, but for now you will just have to deal with the fact that a media library is locked in on some settings when you create it, unless you wipe or edit the media folder settings stored in the xml file. --- .github/workflows/release-daily.yml | 3 +- Shokofin/API/Models/Image.cs | 2 +- Shokofin/API/ShokoAPIClient.cs | 22 +- .../Configuration/MediaFolderConfiguration.cs | 9 +- Shokofin/Configuration/PluginConfiguration.cs | 212 ++++++++++++++---- Shokofin/Configuration/configController.js | 66 +++--- Shokofin/Configuration/configPage.html | 4 +- Shokofin/ExternalIds/ShokoEpisodeId.cs | 2 +- Shokofin/ExternalIds/ShokoFileId.cs | 2 +- Shokofin/ExternalIds/ShokoGroupId.cs | 2 +- Shokofin/ExternalIds/ShokoSeriesId.cs | 2 +- Shokofin/Resolvers/ShokoResolveManager.cs | 6 +- Shokofin/SignalR/SignalRConnectionManager.cs | 4 +- Shokofin/Tasks/VersionCheckTask.cs | 6 +- 14 files changed, 238 insertions(+), 104 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 407a6179..b465f978 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -64,8 +64,7 @@ jobs: EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) echo "CHANGELOG<<$EOF" >> "$GITHUB_OUTPUT" git log $PREVIOUS_COMMIT..$NEXT_COMMIT --pretty=format:"%B" | grep -v "misc: update unstable manifest" | head -c -2 >> "$GITHUB_OUTPUT" - echo "" >> "$GITHUB_OUTPUT" - echo "$EOF" >> "$GITHUB_OUTPUT" + echo -e "\n$EOF" >> "$GITHUB_OUTPUT" build_plugin: runs-on: ubuntu-latest diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index 190026af..dfe81465 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -75,7 +75,7 @@ public virtual string Path /// </remarks> /// <returns>The image URL</returns> public string ToURLString() - => string.Concat(Plugin.Instance.Configuration.PrettyHost, Path); + => string.Concat(Plugin.Instance.Configuration.PrettyUrl, Path); } /// <summary> diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 1c0613ed..6273c082 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -25,7 +25,7 @@ public class ShokoAPIClient : IDisposable private readonly ILogger<ShokoAPIClient> Logger; private static DateTime? ServerCommitDate => - Plugin.Instance.Configuration.HostVersion?.ReleaseDate; + Plugin.Instance.Configuration.ServerVersion?.ReleaseDate; private static readonly DateTime StableCutOffDate = DateTime.Parse("2023-12-16T00:00:00.000Z"); @@ -107,19 +107,19 @@ private Task<HttpResponseMessage> Get(string url, HttpMethod method, string? api if (string.IsNullOrEmpty(apiKey)) throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); - var version = Plugin.Instance.Configuration.HostVersion; + var version = Plugin.Instance.Configuration.ServerVersion; if (version == null) { version = await GetVersion().ConfigureAwait(false) ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); - Plugin.Instance.Configuration.HostVersion = version; + Plugin.Instance.Configuration.ServerVersion = version; Plugin.Instance.SaveConfiguration(); } try { Logger.LogTrace("Trying to {Method} {URL}", method, url); - var remoteUrl = string.Concat(Plugin.Instance.Configuration.Host, url); + var remoteUrl = string.Concat(Plugin.Instance.Configuration.Url, url); using var requestMessage = new HttpRequestMessage(method, remoteUrl); requestMessage.Content = new StringContent(string.Empty); @@ -162,19 +162,19 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method if (string.IsNullOrEmpty(apiKey)) throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); - var version = Plugin.Instance.Configuration.HostVersion; + var version = Plugin.Instance.Configuration.ServerVersion; if (version == null) { version = await GetVersion().ConfigureAwait(false) ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); - Plugin.Instance.Configuration.HostVersion = version; + Plugin.Instance.Configuration.ServerVersion = version; Plugin.Instance.SaveConfiguration(); } try { Logger.LogTrace("Trying to get {URL}", url); - var remoteUrl = string.Concat(Plugin.Instance.Configuration.Host, url); + var remoteUrl = string.Concat(Plugin.Instance.Configuration.Url, url); if (method == HttpMethod.Get) throw new HttpRequestException("Get requests cannot contain a body."); @@ -202,13 +202,13 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method public async Task<ApiKey?> GetApiKey(string username, string password, bool forUser = false) { - var version = Plugin.Instance.Configuration.HostVersion; + var version = Plugin.Instance.Configuration.ServerVersion; if (version == null) { version = await GetVersion().ConfigureAwait(false) ?? throw new HttpRequestException("Unable to connect to Shoko Server to read the version.", null, HttpStatusCode.BadGateway); - Plugin.Instance.Configuration.HostVersion = version; + Plugin.Instance.Configuration.ServerVersion = version; Plugin.Instance.SaveConfiguration(); } @@ -218,7 +218,7 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method {"pass", password}, {"device", forUser ? "Shoko Jellyfin Plugin (Shokofin) - User Key" : "Shoko Jellyfin Plugin (Shokofin)"}, }); - var apiBaseUrl = Plugin.Instance.Configuration.Host; + var apiBaseUrl = Plugin.Instance.Configuration.Url; var response = await _httpClient.PostAsync($"{apiBaseUrl}/api/auth", new StringContent(postData, Encoding.UTF8, "application/json")).ConfigureAwait(false); if (response.StatusCode != HttpStatusCode.OK) return null; @@ -228,7 +228,7 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method public async Task<ComponentVersion?> GetVersion() { - var apiBaseUrl = Plugin.Instance.Configuration.Host; + var apiBaseUrl = Plugin.Instance.Configuration.Url; var response = await _httpClient.GetAsync($"{apiBaseUrl}/api/v3/Init/Version"); if (response.StatusCode == HttpStatusCode.OK) { try { diff --git a/Shokofin/Configuration/MediaFolderConfiguration.cs b/Shokofin/Configuration/MediaFolderConfiguration.cs index bbec10bf..b22d5248 100644 --- a/Shokofin/Configuration/MediaFolderConfiguration.cs +++ b/Shokofin/Configuration/MediaFolderConfiguration.cs @@ -37,8 +37,13 @@ public class MediaFolderConfiguration /// <summary> /// Enable or disable the virtual file system on a per-media-folder basis. /// </summary> - /// <value></value> - public bool? IsVirtualFileSystemEnabled { get; set; } = null; + public bool IsVirtualFileSystemEnabled { get; set; } = true; + + /// <summary> + /// Enable or disable the library filterin on a per-media-folder basis. Do + /// note that this will only take effect if the VFS is not used. + /// </summary> + public bool? IsLibraryFilteringEnabled { get; set; } = null; /// <summary> /// Check if a relative path within the import folder is potentially available in this media folder. diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 704980d3..c73903f1 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,7 +1,7 @@ using MediaBrowser.Model.Plugins; -using System; using System.Collections.Generic; using System.Text.Json.Serialization; +using System.Xml.Serialization; using Shokofin.API.Models; using TextSourceType = Shokofin.Utils.Text.TextSourceType; @@ -14,92 +14,210 @@ namespace Shokofin.Configuration; public class PluginConfiguration : BasePluginConfiguration { - public string Host { get; set; } + #region Connection - public ComponentVersion? HostVersion { get; set; } + /// <summary> + /// The URL for where to connect to shoko internally. + /// And externally if no <seealso cref="PublicUrl"/> is set. + /// </summary> + [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; } - public string PublicHost { get; set; } + [XmlElement("PublicHost")] + public string PublicUrl { get; set; } [JsonIgnore] - public virtual string PrettyHost - => string.IsNullOrEmpty(PublicHost) ? Host : PublicHost; + public virtual string PrettyUrl + => string.IsNullOrEmpty(PublicUrl) ? Url : PublicUrl; + /// <summary> + /// The last known user name we used to try and connect to the server. + /// </summary> public string Username { get; set; } + /// <summary> + /// The API key used to authenticate our requests to the server. + /// This will be an empty string if we're not authenticated yet. + /// </summary> public string ApiKey { get; set; } - public bool HideArtStyleTags { get; set; } + #endregion - public bool HideMiscTags { get; set; } + #region Plugin Interoperability - public bool HidePlotTags { get; set; } + /// <summary> + /// Add AniDB ids to entries that support it. This is best to use when you + /// don't use shoko groups. + /// </summary> + public bool AddAniDBId { get; set; } - public bool HideAniDbTags { get; set; } + /// <summary> + /// Add TMDb ids to entries that support it. + /// </summary> + public bool AddTMDBId { get; set; } - public bool HideSettingTags { get; set; } + #endregion - public bool HideProgrammingTags { get; set; } - - public bool HideUnverifiedTags { get; set; } + #region Metadata + + /// <summary> + /// Determines how we'll be selecting our main title for entries. + /// </summary> + public DisplayLanguageType TitleMainType { get; set; } + + /// <summary> + /// Determines how we'll be selecting the alternate title for our entries. + /// </summary> + public DisplayLanguageType TitleAlternateType { get; set; } + + /// <summary> + /// Allow choosing any title in the selected language if no official + /// title is available. + /// </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 epiode with a + /// prefix and number. + /// </summary> + public bool MarkSpecialsWhenGrouped { get; set; } + + /// <summary> + /// The description source. This will be replaced in the future. + /// </summary> + public TextSourceType DescriptionSource { get; set; } + + /// <summary> + /// Clean up links within the AniDB description for entries. + /// </summary> public bool SynopsisCleanLinks { get; set; } + /// <summary> + /// Clean up misc. lines within the AniDB description for entries. + /// </summary> public bool SynopsisCleanMiscLines { get; set; } + /// <summary> + /// Remove the "summary" preface text in the AniDB description for entries. + /// </summary> public bool SynopsisRemoveSummary { get; set; } + /// <summary> + /// Collapse up multiple empty lines into a single line in the AniDB + /// description for entries. + /// </summary> public bool SynopsisCleanMultiEmptyLines { get; set; } - public bool AddAniDBId { get; set; } + #endregion - public bool AddTMDBId { get; set; } + #region Tags - public TextSourceType DescriptionSource { get; set; } + public bool HideArtStyleTags { get; set; } - public bool VirtualFileSystem { get; set; } + public bool HideMiscTags { get; set; } - public int VirtualFileSystemThreads { get; set; } + public bool HidePlotTags { get; set; } + + public bool HideAniDbTags { get; set; } + + public bool HideSettingTags { get; set; } + + public bool HideProgrammingTags { get; set; } + + public bool HideUnverifiedTags { get; set; } + + #endregion + + #region User + /// <summary> + /// User configuration. + /// </summary> + public List<UserConfiguration> UserList { get; set; } + + #endregion + + #region Library + + /// <summary> + /// Use Shoko Groups to group Shoko Series together to create the show entries. + /// </summary> public bool UseGroupsForShows { get; set; } + /// <summary> + /// Separate movies out of show type libraries. + /// </summary> public bool SeparateMovies { get; set; } - public OrderType SeasonOrdering { get; set; } + /// <summary> + /// Determines how collections are made. + /// </summary> + public CollectionCreationType CollectionGrouping { get; set; } - public bool MarkSpecialsWhenGrouped { get; set; } + /// <summary> + /// Determines how seasons are ordered within a show. + /// </summary> + public OrderType SeasonOrdering { get; set; } + /// <summary> + /// Determines how specials are placed within seasons, if at all. + /// </summary> public SpecialOrderType SpecialsPlacement { get; set; } - public CollectionCreationType CollectionGrouping { get; set; } + /// <summary> + /// Add missing season and episode entries so the user can see at a glance + /// what is missing, and so the "Upcoming" section of the library works as + /// intended. + /// </summary> + public bool AddMissingMetadata { get; set; } - public OrderType MovieOrdering { get; set; } + public List<string> IgnoredFolders { get; set; } - public DisplayLanguageType TitleMainType { get; set; } + #endregion - public DisplayLanguageType TitleAlternateType { get; set; } + #region Media Folder /// <summary> - /// Allow choosing any title in the selected language if no official - /// title is available. + /// Enable/disable the VFS for new media-folders/libraries. /// </summary> - public bool TitleAllowAny { get; set; } + public bool VirtualFileSystem { get; set; } - public UserConfiguration[] UserList { get; set; } + /// <summary> + /// Number of threads to concurrently generate links for the VFS. + /// </summary> + public int VirtualFileSystemThreads { get; set; } - public List<MediaFolderConfiguration> MediaFolders { get; set; } + /// <summary> + /// Enable/disable the filtering for new media-folders/libraries. + /// </summary> + [XmlElement("LibraryFilteringMode")] + public bool? LibraryFiltering { get; set; } - public string[] IgnoredFolders { get; set; } + /// <summary> + /// Per media folder configuration. + /// </summary> + public List<MediaFolderConfiguration> MediaFolders { get; set; } - public bool? LibraryFilteringMode { get; set; } + #endregion #region SignalR /// <summary> /// Enable the SignalR events from Shoko. /// </summary> - /// <value></value> public bool SignalR_AutoConnectEnabled { get; set; } /// <summary> @@ -122,21 +240,33 @@ public virtual string PrettyHost #region Experimental features + /// <summary> + /// Automagically merge alternate versions after a library scan. + /// </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> + /// Coming soon™. + /// </summary> public bool EXPERIMENTAL_MergeSeasons { get; set; } #endregion public PluginConfiguration() { - Host = "http://127.0.0.1:8111"; - HostVersion = null; - PublicHost = string.Empty; + Url = "http://127.0.0.1:8111"; + ServerVersion = null; + PublicUrl = string.Empty; Username = "Default"; ApiKey = string.Empty; HideArtStyleTags = false; @@ -163,18 +293,18 @@ public PluginConfiguration() SeparateMovies = false; SeasonOrdering = OrderType.Default; SpecialsPlacement = SpecialOrderType.AfterSeason; + AddMissingMetadata = true; MarkSpecialsWhenGrouped = true; CollectionGrouping = CollectionCreationType.None; - MovieOrdering = OrderType.Default; - UserList = Array.Empty<UserConfiguration>(); + UserList = new(); MediaFolders = new(); - IgnoredFolders = new [] { ".streams", "@recently-snapshot" }; - LibraryFilteringMode = null; + IgnoredFolders = new() { ".streams", "@recently-snapshot" }; + LibraryFiltering = null; SignalR_AutoConnectEnabled = false; SignalR_AutoReconnectInSeconds = new() { 0, 2, 10, 30, 60, 120, 300 }; SignalR_RefreshEnabled = false; SignalR_FileWatcherEnabled = false; - EXPERIMENTAL_AutoMergeVersions = false; + EXPERIMENTAL_AutoMergeVersions = true; EXPERIMENTAL_SplitThenMergeMovies = true; EXPERIMENTAL_SplitThenMergeEpisodes = false; EXPERIMENTAL_MergeSeasons = false; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index b583217e..19552929 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -100,10 +100,10 @@ async function defaultSubmit(form) { let config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); if (config.ApiKey !== "") { - let publicHost = form.querySelector("#PublicHost").value; - if (publicHost.endsWith("/")) { - publicHost = publicHost.slice(0, -1); - form.querySelector("#PublicHost").value = publicHost; + 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); const filteringModeRaw = form.querySelector("#LibraryFilteringMode").value; @@ -144,7 +144,7 @@ async function defaultSubmit(form) { config.HideProgrammingTags = form.querySelector("#HideProgrammingTags").checked; // Advanced settings - config.PublicHost = publicHost; + config.PublicUrl = publicUrl; config.IgnoredFolders = ignoredFolders; form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); @@ -195,33 +195,33 @@ async function defaultSubmit(form) { } else { // Connection settings - let host = form.querySelector("#Host").value; - if (!host) { - host = "http://localhost:8111"; + let url = form.querySelector("#Url").value; + if (!url) { + url = "http://localhost:8111"; } else { try { - let url = new URL(host); - host = url.href; + let url = new URL(url); + url = url.href; } catch (err) { try { - let url = new URL(`http://${host}:8111`); - host = url.href; + let url = new URL(`http://${url}:8111`); + url = url.href; } catch (err2) { throw err; } } } - if (host.endsWith("/")) { - host = host.slice(0, -1); + if (url.endsWith("/")) { + url = url.slice(0, -1); } - // Update the host if needed. - if (config.Host !== host) { - config.Host = host; - form.querySelector("#Host").value = host; + // Update the url if needed. + if (config.Url !== url) { + config.Url = url; + form.querySelector("#Url").value = url; let result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); Dashboard.processPluginConfigurationUpdateResult(result); } @@ -253,7 +253,7 @@ async function resetConnectionSettings(form) { // Connection settings config.ApiKey = ""; - config.HostVersion = null; + config.ServerVersion = null; const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); Dashboard.processPluginConfigurationUpdateResult(result); @@ -263,10 +263,10 @@ async function resetConnectionSettings(form) { async function syncSettings(form) { const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - let publicHost = form.querySelector("#PublicHost").value; - if (publicHost.endsWith("/")) { - publicHost = publicHost.slice(0, -1); - form.querySelector("#PublicHost").value = publicHost; + 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); const filteringModeRaw = form.querySelector("#LibraryFilteringMode").value; @@ -307,7 +307,7 @@ async function syncSettings(form) { config.HideProgrammingTags = form.querySelector("#HideProgrammingTags").checked; // Advanced settings - config.PublicHost = publicHost; + config.PublicUrl = publicUrl; config.IgnoredFolders = ignoredFolders; form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); @@ -388,12 +388,12 @@ export default function (page) { const userSelector = form.querySelector("#UserSelector"); // Refresh the view after we changed the settings, so the view reflect the new settings. const refreshSettings = (config) => { - if (config.HostVersion) { - let version = `Version ${config.HostVersion.Version}`; + if (config.ServerVersion) { + let version = `Version ${config.ServerVersion.Version}`; const extraDetails = [ - config.HostVersion.ReleaseChannel || "", - config.HostVersion. - Commit ? config.HostVersion.Commit.slice(0, 7) : "", + config.ServerVersion.ReleaseChannel || "", + config.ServerVersion. + Commit ? config.ServerVersion.Commit.slice(0, 7) : "", ].filter(s => s).join(", "); if (extraDetails) version += ` (${extraDetails})`; @@ -403,7 +403,7 @@ export default function (page) { form.querySelector("#ServerVersion").value = "Version N/A"; } if (config.ApiKey) { - form.querySelector("#Host").setAttribute("disabled", ""); + form.querySelector("#Url").setAttribute("disabled", ""); form.querySelector("#Username").setAttribute("disabled", ""); form.querySelector("#Password").value = ""; form.querySelector("#ConnectionSetContainer").setAttribute("hidden", ""); @@ -418,7 +418,7 @@ export default function (page) { form.querySelector("#ExperimentalSection").removeAttribute("hidden"); } else { - form.querySelector("#Host").removeAttribute("disabled"); + form.querySelector("#Url").removeAttribute("disabled"); form.querySelector("#Username").removeAttribute("disabled"); form.querySelector("#ConnectionSetContainer").removeAttribute("hidden"); form.querySelector("#ConnectionResetContainer").setAttribute("hidden", ""); @@ -482,7 +482,7 @@ export default function (page) { const users = await ApiClient.getUsers(); // Connection settings - form.querySelector("#Host").value = config.Host; + form.querySelector("#Url").value = config.Url; form.querySelector("#Username").value = config.Username; form.querySelector("#Password").value = ""; @@ -536,7 +536,7 @@ export default function (page) { form.querySelector("#HideProgrammingTags").checked = config.HideProgrammingTags; // Advanced settings - form.querySelector("#PublicHost").value = config.PublicHost; + form.querySelector("#PublicUrl").value = config.PublicUrl; form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); // Experimental settings diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 19bdde9f..0956b2b5 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -12,7 +12,7 @@ <h2 class="sectionTitle">Shoko</h2> <h3>Connection Settings</h3> </legend> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="Host" required label="Host:" /> + <input is="emby-input" type="text" id="Url" required label="Host:" /> <div class="fieldDescription">This is the URL leading to where Shoko is running. You can input a full url, or just the dns name/ip.</div> </div> <div class="inputContainer inputContainer-withDescription"> @@ -382,7 +382,7 @@ <h3>Tag Settings</h3> <h3>Advanced Settings</h3> </legend> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="PublicHost" label="Public Shoko host URL:" /> + <input is="emby-input" type="text" id="PublicUrl" label="Public Shoko host URL:" /> <div class="fieldDescription">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. If provided, then it should also be possible for Jellyfin to use the URL to access shoko, since this will be needed to grab images from the Shoko instance. It should include both the protocol and the IP/DNS name.</div> </div> <div class="inputContainer inputContainer-withDescription"> diff --git a/Shokofin/ExternalIds/ShokoEpisodeId.cs b/Shokofin/ExternalIds/ShokoEpisodeId.cs index 06dfcb97..78cb06a5 100644 --- a/Shokofin/ExternalIds/ShokoEpisodeId.cs +++ b/Shokofin/ExternalIds/ShokoEpisodeId.cs @@ -24,5 +24,5 @@ public ExternalIdMediaType? Type => null; public virtual string UrlFormatString - => $"{Plugin.Instance.Configuration.PrettyHost}/webui/redirect/episode/{{0}}"; + => $"{Plugin.Instance.Configuration.PrettyUrl}/webui/redirect/episode/{{0}}"; } \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoFileId.cs b/Shokofin/ExternalIds/ShokoFileId.cs index ba8d8c24..358273ed 100644 --- a/Shokofin/ExternalIds/ShokoFileId.cs +++ b/Shokofin/ExternalIds/ShokoFileId.cs @@ -25,5 +25,5 @@ public ExternalIdMediaType? Type => null; public virtual string UrlFormatString - => $"{Plugin.Instance.Configuration.PrettyHost}/webui/redirect/file/{{0}}"; + => $"{Plugin.Instance.Configuration.PrettyUrl}/webui/redirect/file/{{0}}"; } \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoGroupId.cs b/Shokofin/ExternalIds/ShokoGroupId.cs index 720fcdd8..164e5889 100644 --- a/Shokofin/ExternalIds/ShokoGroupId.cs +++ b/Shokofin/ExternalIds/ShokoGroupId.cs @@ -24,5 +24,5 @@ public ExternalIdMediaType? Type => null; public virtual string UrlFormatString - => $"{Plugin.Instance.Configuration.PrettyHost}/webui/collection/group/{{0}}"; + => $"{Plugin.Instance.Configuration.PrettyUrl}/webui/collection/group/{{0}}"; } \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoSeriesId.cs b/Shokofin/ExternalIds/ShokoSeriesId.cs index fb61a074..1f468202 100644 --- a/Shokofin/ExternalIds/ShokoSeriesId.cs +++ b/Shokofin/ExternalIds/ShokoSeriesId.cs @@ -24,5 +24,5 @@ public ExternalIdMediaType? Type => null; public virtual string UrlFormatString - => $"{Plugin.Instance.Configuration.PrettyHost}/webui/collection/series/{{0}}"; + => $"{Plugin.Instance.Configuration.PrettyUrl}/webui/collection/series/{{0}}"; } \ No newline at end of file diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index bfc0ebab..8133463a 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -137,6 +137,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold mediaFolderConfig = new() { MediaFolderId = mediaFolder.Id, IsVirtualFileSystemEnabled = config.VirtualFileSystem, + IsLibraryFilteringEnabled = config.LibraryFiltering, }; var start = DateTime.UtcNow; @@ -218,7 +219,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold return null; // Return early if we're not going to generate them. - if (!(mediaConfig.IsVirtualFileSystemEnabled ?? Plugin.Instance.Configuration.VirtualFileSystem)) + if (!mediaConfig.IsVirtualFileSystemEnabled) return null; // Check if we should introduce the VFS for the media folder. @@ -641,8 +642,7 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file if (parent.ParentId == root.Id && Plugin.Instance.Configuration.VirtualFileSystem) return false; - // Scan the - var shouldIgnore = Plugin.Instance.Configuration.LibraryFilteringMode ?? Plugin.Instance.Configuration.VirtualFileSystem || isSoleProvider; + var shouldIgnore = mediaFolderConfig.IsLibraryFilteringEnabled ?? mediaFolderConfig.IsVirtualFileSystemEnabled || isSoleProvider; var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); if (fileInfo.IsDirectory) return await ShouldFilterDirectory(partialPath, fullPath, collectionType, shouldIgnore).ConfigureAwait(false); diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index bc886c52..78b6ef4b 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -57,7 +57,7 @@ private async Task ConnectAsync(PluginConfiguration config) return; var builder = new HubConnectionBuilder() - .WithUrl(config.Host + HubUrl, connectionOptions => + .WithUrl(config.Url + HubUrl, connectionOptions => connectionOptions.AccessTokenProvider = () => Task.FromResult<string?>(config.ApiKey) ) .AddJsonProtocol(); @@ -172,7 +172,7 @@ private void OnConfigurationChanged(object? sender, BasePluginConfiguration base } private static bool CanConnect(PluginConfiguration config) - => !string.IsNullOrEmpty(config.Host) && !string.IsNullOrEmpty(config.ApiKey); + => !string.IsNullOrEmpty(config.Url) && !string.IsNullOrEmpty(config.ApiKey); private static string GenerateConfigKey(PluginConfiguration config) => $"CanConnect={CanConnect(config)},Refresh={config.SignalR_RefreshEnabled},FileWatcher={config.SignalR_FileWatcherEnabled},AutoReconnect={config.SignalR_AutoReconnectInSeconds.Select(s => s.ToString()).Join(',')}"; diff --git a/Shokofin/Tasks/VersionCheckTask.cs b/Shokofin/Tasks/VersionCheckTask.cs index 4c67940c..95929cc3 100644 --- a/Shokofin/Tasks/VersionCheckTask.cs +++ b/Shokofin/Tasks/VersionCheckTask.cs @@ -70,11 +70,11 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can { var version = await ApiClient.GetVersion(); if (version != null && ( - Plugin.Instance.Configuration.HostVersion == null || - !string.Equals(version.ToString(), Plugin.Instance.Configuration.HostVersion.ToString()) + Plugin.Instance.Configuration.ServerVersion == null || + !string.Equals(version.ToString(), Plugin.Instance.Configuration.ServerVersion.ToString()) )) { Logger.LogInformation("Found new Shoko Server version; {version}", version); - Plugin.Instance.Configuration.HostVersion = version; + Plugin.Instance.Configuration.ServerVersion = version; Plugin.Instance.SaveConfiguration(); } } From f8dfcd24fd9dd0f08a5141409660c6b085c51dd9 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 6 Apr 2024 11:02:41 +0000 Subject: [PATCH 0723/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index d1fb585f..75e30d99 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.81", + "changelog": "refactor: update plugin config\n\n- Give the plugin configuration a face-lift by adding proper regions\n and comments, and also rename a few fields in the code, but not\n in the settings file itself (as to not break anything).\n\n- Added and hooked up per-media-folder library filtering. Still no UI\n to update it for existing libraries, and the global option is now\n treated as a default option. It is recommended to either edit the\n option in the xml file after updating, or removing the media folder\n settings from the file and letting it re-generate itself on the next\n library scan.\n\n- Tweaked the VFS per-media-folder setting to not fallback to the global\n option, and treat the global option as a default option for new media\n libraries.\n\nAlso, not in this commit, but the settings page will get another refactor\nto add the per-media-folder settings and SignalR settings sometime in\nthe future, but for now you will just have to deal with the fact that a\nmedia library is locked in on some settings when you create it, unless\nyou wipe or edit the media folder settings stored in the xml file.\n\nmisc: update logo [skip ci]", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.81/shoko_3.0.1.81.zip", + "checksum": "f37c6beefb43997ea3c07e855bf921aa", + "timestamp": "2024-04-06T11:02:39Z" + }, { "version": "3.0.1.80", "changelog": "misc: fix links in dev release notification", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.77/shoko_3.0.1.77.zip", "checksum": "9323325ce698ca405875a232a4f4f6df", "timestamp": "2024-04-05T07:26:40Z" - }, - { - "version": "3.0.1.76", - "changelog": "misc: try removing embed in discord\n\n- Try removing the embed at the end of each release\n message in discord.\n\n- Also @Queuecumbr (GH) / <@308830889281060864> (Discord) wanted to see\n @ mentions in action from a web hook.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.76/shoko_3.0.1.76.zip", - "checksum": "0a1a24e4a381194c585d3cb9681e00f2", - "timestamp": "2024-04-05T07:21:21Z" } ] } From 52006c3fa5e8efac43f24707ace05851e1aca32a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 6 Apr 2024 14:09:34 +0200 Subject: [PATCH 0724/1103] misc: code style fixes - Fix bracket open style in the code where I mind slipped back into standard C# style. No bracket open on a seperate line allowed inside method bodies. None. --- Shokofin/API/Info/ShowInfo.cs | 3 +-- Shokofin/API/Models/ApiException.cs | 3 +-- Shokofin/API/ShokoAPIClient.cs | 21 +++++---------- Shokofin/API/ShokoAPIManager.cs | 9 +++---- Shokofin/Collections/CollectionManager.cs | 27 +++++++------------ Shokofin/MergeVersions/MergeVersionManager.cs | 3 +-- Shokofin/Providers/EpisodeProvider.cs | 3 +-- Shokofin/Resolvers/ShokoResolveManager.cs | 24 +++++++---------- Shokofin/Sync/UserDataSyncManager.cs | 18 +++++-------- Shokofin/Utils/GuardedMemoryCache.cs | 24 ++++++----------- 10 files changed, 47 insertions(+), 88 deletions(-) diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index e5cc2df9..317f8520 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -175,8 +175,7 @@ public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool u } // Fallback to the first series if we can't get a base point for seasons. - if (foundIndex == -1) - { + if (foundIndex == -1) { logger.LogWarning("Unable to get a base-point for seasons within the group for the filter, so falling back to the first series in the group. This is most likely due to library separation being enabled. (Group={GroupID})", groupId); foundIndex = 0; } diff --git a/Shokofin/API/Models/ApiException.cs b/Shokofin/API/Models/ApiException.cs index 38251cb3..035576ca 100644 --- a/Shokofin/API/Models/ApiException.cs +++ b/Shokofin/API/Models/ApiException.cs @@ -61,8 +61,7 @@ public static ApiException FromResponse(HttpResponseMessage response) return new ApiException(response.StatusCode, "ValidationError", title, validationErrors); } var index = text.IndexOf("HEADERS"); - if (index != -1) - { + if (index != -1) { var (firstLine, lines) = text.Substring(0, index).TrimEnd().Split('\n'); var (name, splitMessage) = firstLine?.Split(':') ?? new string[] {}; var message = string.Join(':', splitMessage).Trim(); diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 6273c082..4af3298e 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -108,8 +108,7 @@ private Task<HttpResponseMessage> Get(string url, HttpMethod method, string? api throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); var version = Plugin.Instance.Configuration.ServerVersion; - if (version == null) - { + if (version == null) { version = await GetVersion().ConfigureAwait(false) ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); @@ -131,8 +130,7 @@ private Task<HttpResponseMessage> Get(string url, HttpMethod method, string? api cachedEntry.SlidingExpiration = DefaultTimeSpan; return response; } - catch (HttpRequestException ex) - { + catch (HttpRequestException ex) { Logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); throw; } @@ -163,8 +161,7 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); var version = Plugin.Instance.Configuration.ServerVersion; - if (version == null) - { + if (version == null) { version = await GetVersion().ConfigureAwait(false) ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); @@ -191,8 +188,7 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); return response; } - catch (HttpRequestException ex) - { + catch (HttpRequestException ex) { Logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); throw; } @@ -203,8 +199,7 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method public async Task<ApiKey?> GetApiKey(string username, string password, bool forUser = false) { var version = Plugin.Instance.Configuration.ServerVersion; - if (version == null) - { + if (version == null) { version = await GetVersion().ConfigureAwait(false) ?? throw new HttpRequestException("Unable to connect to Shoko Server to read the version.", null, HttpStatusCode.BadGateway); @@ -277,12 +272,10 @@ public async Task<ListResult<File>> GetFilesForImportFolder(int importFolderId, public async Task<File.UserStats?> GetFileUserStats(string fileId, string? apiKey = null) { - try - { + try { return await Get<File.UserStats>($"/api/v3/File/{fileId}/UserStats", apiKey).ConfigureAwait(false); } - catch (ApiException e) - { + catch (ApiException e) { // File user stats were not found. if (e.StatusCode == HttpStatusCode.NotFound) { if (!e.Message.Contains("FileUserStats")) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 86179cc6..44b1ef87 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -374,8 +374,7 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu // Check if we found a match. var file = result?.FirstOrDefault(); - if (file == null || file.CrossReferences.Count == 0) - { + if (file == null || file.CrossReferences.Count == 0) { Logger.LogTrace("Found no match for {Path}", partialPath); return (null, null, null); } @@ -701,12 +700,10 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul // Return the first match where the series unique paths partially match // the input path. - foreach (var series in result) - { + foreach (var series in result) { seriesId = series.IDs.Shoko.ToString(); var pathSet = await GetPathSetForSeries(seriesId).ConfigureAwait(false); - foreach (var uniquePath in pathSet) - { + foreach (var uniquePath in pathSet) { // Remove the trailing slash before matching. if (!uniquePath[..^1].EndsWith(partialPath)) continue; diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index 586be074..f741d68d 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -39,8 +39,7 @@ public CollectionManager(ILibraryManager libraryManager, ICollectionManager coll public async Task ReconstructCollections(IProgress<double> progress, CancellationToken cancellationToken) { - try - { + try { switch (Plugin.Instance.Configuration.CollectionGrouping) { default: @@ -53,8 +52,7 @@ public async Task ReconstructCollections(IProgress<double> progress, Cancellatio break; } } - catch (Exception ex) - { + catch (Exception ex) { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); } } @@ -74,8 +72,7 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, // create a tree-map of how it's supposed to be. var config = Plugin.Instance.Configuration; var movieDict = new Dictionary<Movie, (FileInfo, SeasonInfo, ShowInfo)>(); - foreach (var movie in movies) - { + foreach (var movie in movies) { if (!Lookup.TryGetEpisodeIdsFor(movie, out var episodeIds)) continue; @@ -100,8 +97,7 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, .ContinueWith(task => task.Result.ToDictionary(x => x!.Id, x => x!)); var finalGroups = new Dictionary<string, CollectionInfo>(); - foreach (var initialGroup in groupsDict.Values) - { + foreach (var initialGroup in groupsDict.Values) { var currentGroup = initialGroup; if (finalGroups.ContainsKey(currentGroup.Id)) continue; @@ -157,8 +153,7 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, } // Add the missing collections. - foreach (var missingId in toAdd) - { + foreach (var missingId in toAdd) { var collectionInfo = finalGroups[missingId]; var collection = await Collection.CreateCollectionAsync(new() { Name = collectionInfo.Name, @@ -192,8 +187,7 @@ private async Task ReconstructMovieGroupCollections(IProgress<double> progress, // create a tree-map of how it's supposed to be. var config = Plugin.Instance.Configuration; var movieDict = new Dictionary<Movie, (FileInfo, SeasonInfo, ShowInfo)>(); - foreach (var movie in movies) - { + foreach (var movie in movies) { if (!Lookup.TryGetEpisodeIdsFor(movie, out var episodeIds)) continue; @@ -218,8 +212,7 @@ private async Task ReconstructMovieGroupCollections(IProgress<double> progress, .ContinueWith(task => task.Result.ToDictionary(x => x!.Id, x => x!)); var finalGroups = new Dictionary<string, CollectionInfo>(); - foreach (var initialGroup in groupsDict.Values) - { + foreach (var initialGroup in groupsDict.Values) { var currentGroup = initialGroup; if (finalGroups.ContainsKey(currentGroup.Id)) continue; @@ -263,8 +256,7 @@ private async Task ReconstructMovieGroupCollections(IProgress<double> progress, await RemoveCollection(boxSet, toRemoveSet, groupId: groupId); // Add the missing collections. - foreach (var missingId in toAdd) - { + foreach (var missingId in toAdd) { var collectionInfo = finalGroups[missingId]; var collection = await Collection.CreateCollectionAsync(new() { Name = collectionInfo.Name, @@ -311,8 +303,7 @@ private async Task CleanupMovies() { // Check the movies with a shoko series id set, and remove the collection name from them. var movies = GetMovies(); - foreach (var movie in movies) - { + foreach (var movie in movies) { if (string.IsNullOrEmpty(movie.CollectionName)) continue; diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index 484ec6d3..8a07df3e 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -451,8 +451,7 @@ await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, Cancel private async Task RemoveAlternateSources(Video video) { // Find the primary video. - if (video.LinkedAlternateVersions.Length == 0) - { + if (video.LinkedAlternateVersions.Length == 0) { // Ensure we're not running on an unlinked item. if (string.IsNullOrEmpty(video.PrimaryVersionId)) return; diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 2260ef58..9975594f 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -101,8 +101,7 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie if (config.TitleAddForMultipleEpisodes && file != null && file.EpisodeList.Count > 1) { var displayTitles = new List<string?>(); var alternateTitles = new List<string?>(); - foreach (var episodeInfo in file.EpisodeList) - { + foreach (var episodeInfo in file.EpisodeList) { string defaultEpisodeTitle = episodeInfo.Shoko.Name; if ( // Movies diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 8133463a..ef2fd4f1 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -102,8 +102,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) 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)) { DataCache.Remove(folder.Id.ToString()); var mediaFolderConfig = Plugin.Instance.Configuration.MediaFolders.FirstOrDefault(c => c.MediaFolderId == folder.Id); - if (mediaFolderConfig != null) - { + if (mediaFolderConfig != null) { Logger.LogDebug( "Removing stored configuration for folder at {Path} (ImportFolder={ImportFolderId},RelativePath={RelativePath})", folder.Path, @@ -487,20 +486,18 @@ await Task.WhenAll(files.Select(async (tuple) => { if (season == null || episode == null) return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); - var isSpecial = episode.IsSpecial; - var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); - var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters(); - if (string.IsNullOrEmpty(showName)) + if (string.IsNullOrEmpty(showName)) { showName = $"Shoko Series {show.Id}"; - else if (show.DefaultSeason.AniDB.AirDate.HasValue) - { + } + else if (show.DefaultSeason.AniDB.AirDate.HasValue) { var yearText = $" ({show.DefaultSeason.AniDB.AirDate.Value.Year})"; if (!showName.EndsWith(yearText)) showName += yearText; } var folders = new List<string>(); + var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); var episodeName = (episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode == "en")?.Value ?? $"Episode {episode.AniDB.Type} {episodeNumber}").ReplaceInvalidPathCharacters(); var extrasFolder = file.ExtraType switch { null => null, @@ -531,6 +528,8 @@ await Task.WhenAll(files.Select(async (tuple) => { } } else { + var isSpecial = episode.IsSpecial; + var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); var seasonName = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}"; if (!string.IsNullOrEmpty(extrasFolder)) { folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", extrasFolder)); @@ -668,8 +667,7 @@ private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPa Logger.LogDebug("Unable to find shoko series for {Path}, trying {DirCount} sub-directories.", partialPath, entries.Count); foreach (var entry in entries) { season = await ApiManager.GetSeasonInfoByPath(entry.FullName).ConfigureAwait(false); - if (season != null) - { + if (season != null) { Logger.LogDebug("Found shoko series {SeriesName} for sub-directory of path {Path} (Series={SeriesId})", season.Shoko.Name, partialPath, season.Id); break; } @@ -773,8 +771,7 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b if (!fileInfo.Name.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) return null; - return new TvSeries() - { + return new TvSeries() { Path = fileInfo.FullName, }; } @@ -840,8 +837,7 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b if (file == null || file.ExtraType != null) return null; - return new Movie() - { + return new Movie() { Path = fileInfo.FullName, } as BaseItem; }) diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index 3632ff67..a134ed2a 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -335,8 +335,7 @@ public async Task ScanAndSync(SyncDirection direction, IProgress<double> progres var numComplete = 0; var numTotal = videos.Count * enabledUsers.Count; - foreach (var video in videos) - { + foreach (var video in videos) { cancellationToken.ThrowIfCancellationRequested(); if (!(Lookup.TryGetFileIdFor(video, out var fileId) && Lookup.TryGetEpisodeIdFor(video, out var episodeId))) @@ -487,8 +486,7 @@ private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDire if (localUserStats == null) break; // Export the local stats if there is no remote stats or if the local stats are newer. - if (remoteUserStats == null) - { + if (remoteUserStats == null) { remoteUserStats = localUserStats.ToFileUserStats(); // Don't sync if the local state is considered empty and there is no remote state. if (remoteUserStats.IsEmpty) @@ -507,14 +505,12 @@ private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDire if (remoteUserStats == null) break; // Create a new local stats entry if there is no local entry. - if (localUserStats == null) - { + if (localUserStats == null) { UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats = remoteUserStats.ToUserData(video, userConfig.UserId), UserDataSaveReason.Import, CancellationToken.None); Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); } // Else merge the remote stats into the local stats entry. - else if (!localUserStats.LastPlayedDate.HasValue || remoteUserStats.LastUpdatedAt > localUserStats.LastPlayedDate.Value) - { + else if (!localUserStats.LastPlayedDate.HasValue || remoteUserStats.LastUpdatedAt > localUserStats.LastPlayedDate.Value) { UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); } @@ -542,15 +538,13 @@ private async Task SyncVideo(Video video, UserConfiguration userConfig, SyncDire break; // Export if the local state is fresher then the remote state. - if (localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) - { + if (localUserStats.LastPlayedDate.Value > remoteUserStats.LastUpdatedAt) { remoteUserStats = localUserStats.ToFileUserStats(); remoteUserStats = await APIClient.PutFileUserStats(fileId, remoteUserStats, userConfig.Token); Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Export.ToString(), video.Name, userConfig.UserId, fileId, episodeId); } // Else import if the remote state is fresher then the local state. - else if (localUserStats.LastPlayedDate.Value < remoteUserStats.LastUpdatedAt) - { + else if (localUserStats.LastPlayedDate.Value < remoteUserStats.LastUpdatedAt) { UserDataManager.SaveUserData(userConfig.UserId, video, localUserStats.MergeWithFileUserStats(remoteUserStats), UserDataSaveReason.Import, CancellationToken.None); Logger.LogDebug("{SyncDirection} user data for video {VideoName} successful. (User={UserId},File={FileId},Episode={EpisodeId})", SyncDirection.Import.ToString(), video.Name, userConfig.UserId, fileId, episodeId); } diff --git a/Shokofin/Utils/GuardedMemoryCache.cs b/Shokofin/Utils/GuardedMemoryCache.cs index 9f869e15..790d3e01 100644 --- a/Shokofin/Utils/GuardedMemoryCache.cs +++ b/Shokofin/Utils/GuardedMemoryCache.cs @@ -27,8 +27,7 @@ public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICac semaphore.Wait(); - try - { + try { if (Cache.TryGetValue<TItem>(key, out value)) { foundAction(value); return value; @@ -42,8 +41,7 @@ public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICac entry.Value = value; return value; } - finally - { + finally { semaphore.Release(); RemoveSemaphore(key); } @@ -60,8 +58,7 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> found await semaphore.WaitAsync(); - try - { + try { if (Cache.TryGetValue<TItem>(key, out value)) { foundAction(value); return value; @@ -75,8 +72,7 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> found entry.Value = value; return value; } - finally - { + finally { semaphore.Release(); RemoveSemaphore(key); } @@ -91,8 +87,7 @@ public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFacto semaphore.Wait(); - try - { + try { if (Cache.TryGetValue<TItem>(key, out value)) return value; @@ -104,8 +99,7 @@ public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFacto entry.Value = value; return value; } - finally - { + finally { semaphore.Release(); RemoveSemaphore(key); } @@ -120,8 +114,7 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, T await semaphore.WaitAsync(); - try - { + try { if (Cache.TryGetValue<TItem>(key, out value)) return value; @@ -133,8 +126,7 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, T entry.Value = value; return value; } - finally - { + finally { semaphore.Release(); RemoveSemaphore(key); } From d8c356d1574af77a1bebc71f3a3879eca4e66be5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 6 Apr 2024 14:12:37 +0200 Subject: [PATCH 0725/1103] misc: remove unused variable(s) --- Shokofin/Resolvers/ShokoResolveManager.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index ef2fd4f1..e680a12d 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -241,7 +241,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold ).ConfigureAwait(false); } - private IEnumerable<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)> GetImportFolderFiles(int importFolderId, string importFolderSubPath, string mediaFolderPath, ISet<string> fileSet) + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetImportFolderFiles(int importFolderId, string importFolderSubPath, string mediaFolderPath, ISet<string> fileSet) { var start = DateTime.UtcNow; var firstPage = ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath); @@ -294,7 +294,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold totalFiles++; foreach (var xref in file.CrossReferences) - yield return (sourceLocation, fileId: file.Id.ToString(), seriesId: xref.Series.Shoko.ToString(), episodeIds: xref.Episodes.Select(e => e.Shoko.ToString()).ToArray()); + yield return (sourceLocation, fileId: file.Id.ToString(), seriesId: xref.Series.Shoko.ToString()); } } while (pages.Count > 0); @@ -316,7 +316,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold return await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath, page).ConfigureAwait(false); } - private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string sourceLocation, string fileId, string seriesId, string[] episodeIds)> files) + private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string sourceLocation, string fileId, string seriesId)> files) { var start = DateTime.UtcNow; var skippedLinks = 0; @@ -333,7 +333,7 @@ await Task.WhenAll(files.Select(async (tuple) => { try { // Skip any source files we weren't meant to have in the library. - var (sourceLocation, symbolicLinks) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId, tuple.episodeIds).ConfigureAwait(false); + var (sourceLocation, symbolicLinks) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); if (string.IsNullOrEmpty(sourceLocation)) return; @@ -459,7 +459,7 @@ await Task.WhenAll(files.Select(async (tuple) => { ); } - private async Task<(string sourceLocation, string[] symbolicLinks)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId, string[] episodeIds) + private async Task<(string sourceLocation, string[] symbolicLinks)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId) { var season = await ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); if (season == null) From f5dbd50158573715fdfc47606f2488d9f32d5ff0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 6 Apr 2024 14:25:48 +0200 Subject: [PATCH 0726/1103] fix: fix link validation --- Shokofin/Resolvers/ShokoResolveManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index e680a12d..2cf9142d 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -353,10 +353,10 @@ await Task.WhenAll(files.Select(async (tuple) => { var shouldFix = false; try { var nextTarget = File.ResolveLinkTarget(symbolicLink, false); - if (!string.Equals(sourceLocation, nextTarget)) { + if (!string.Equals(sourceLocation, nextTarget?.FullName)) { shouldFix = true; - Logger.LogWarning("Fixing broken symbolic link {Link} for {LinkTarget} (RealTarget={RealTarget})", symbolicLink, sourceLocation, nextTarget); + Logger.LogWarning("Fixing broken symbolic link {Link} for {LinkTarget} (RealTarget={RealTarget})", symbolicLink, sourceLocation, nextTarget?.FullName); } } catch (Exception ex) { From 97ba5b9564f575e18602f84d0daf92de56b49a1e Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 6 Apr 2024 12:43:54 +0000 Subject: [PATCH 0727/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 75e30d99..f0048703 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.82", + "changelog": "fix: fix link validation\n\nmisc: remove unused variable(s)\n\nmisc: code style fixes\n\n- Fix bracket open style in the code where I mind slipped back into\n standard C# style. No bracket open on a seperate line allowed inside\n method bodies. None.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.82/shoko_3.0.1.82.zip", + "checksum": "852ec0f991da1fb794adbdccef83c71d", + "timestamp": "2024-04-06T12:43:52Z" + }, { "version": "3.0.1.81", "changelog": "refactor: update plugin config\n\n- Give the plugin configuration a face-lift by adding proper regions\n and comments, and also rename a few fields in the code, but not\n in the settings file itself (as to not break anything).\n\n- Added and hooked up per-media-folder library filtering. Still no UI\n to update it for existing libraries, and the global option is now\n treated as a default option. It is recommended to either edit the\n option in the xml file after updating, or removing the media folder\n settings from the file and letting it re-generate itself on the next\n library scan.\n\n- Tweaked the VFS per-media-folder setting to not fallback to the global\n option, and treat the global option as a default option for new media\n libraries.\n\nAlso, not in this commit, but the settings page will get another refactor\nto add the per-media-folder settings and SignalR settings sometime in\nthe future, but for now you will just have to deal with the fact that a\nmedia library is locked in on some settings when you create it, unless\nyou wipe or edit the media folder settings stored in the xml file.\n\nmisc: update logo [skip ci]", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.78/shoko_3.0.1.78.zip", "checksum": "3e22b60ab94067ab9731fc09f37f7dbf", "timestamp": "2024-04-05T07:36:13Z" - }, - { - "version": "3.0.1.77", - "changelog": "misc: take 2 on removing embed in discord\n\n- This time for real\u2026 hopefully.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.77/shoko_3.0.1.77.zip", - "checksum": "9323325ce698ca405875a232a4f4f6df", - "timestamp": "2024-04-05T07:26:40Z" } ] } From be94f57f6887a6a26c3837ddd17720510439db16 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 6 Apr 2024 14:47:34 +0200 Subject: [PATCH 0728/1103] fix: also fix it for subtitle files --- Shokofin/Resolvers/ShokoResolveManager.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 2cf9142d..537da82f 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -381,13 +381,18 @@ await Task.WhenAll(files.Select(async (tuple) => { subtitles++; allPathsForVFS.Add((subtitleSource, subtitleLink)); - if (!File.Exists(subtitleLink)) + if (!File.Exists(subtitleLink)) { File.CreateSymbolicLink(subtitleLink, subtitleSource); + } else { var shouldFix = false; try { var nextTarget = File.ResolveLinkTarget(subtitleLink, false); - shouldFix = !string.Equals(subtitleSource, nextTarget); + if (!string.Equals(subtitleSource, nextTarget?.FullName)) { + shouldFix = true; + + Logger.LogWarning("Fixing broken symbolic link {Link} for {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); From ac9b560877a81a112d8e5fe46d988e25de75943e Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 6 Apr 2024 12:48:18 +0000 Subject: [PATCH 0729/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index f0048703..6fe872be 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.83", + "changelog": "fix: also fix it for subtitle files", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.83/shoko_3.0.1.83.zip", + "checksum": "3f6e36ae51862f36cc03672ee7fdfe67", + "timestamp": "2024-04-06T12:48:17Z" + }, { "version": "3.0.1.82", "changelog": "fix: fix link validation\n\nmisc: remove unused variable(s)\n\nmisc: code style fixes\n\n- Fix bracket open style in the code where I mind slipped back into\n standard C# style. No bracket open on a seperate line allowed inside\n method bodies. None.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.79/shoko_3.0.1.79.zip", "checksum": "ac4d3e9cb4d16eb565b5805448c5293c", "timestamp": "2024-04-05T07:39:45Z" - }, - { - "version": "3.0.1.78", - "changelog": "misc: take 3\nThis time using a new GH Action. So time to test out a few things;\n\n- List item\n\n[Link](<https://discord.com>)\n\n**Bold**\n\n_Italic_\n\n||Spoiler||", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.78/shoko_3.0.1.78.zip", - "checksum": "3e22b60ab94067ab9731fc09f37f7dbf", - "timestamp": "2024-04-05T07:36:13Z" } ] } From 6076be37fad35d31a57566847643fad696b14ee3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 6 Apr 2024 15:52:56 +0200 Subject: [PATCH 0730/1103] misc: remove redundant tasks --- Shokofin/Tasks/MergeAllTask.cs | 65 ---------------------------------- Shokofin/Tasks/SplitAllTask.cs | 65 ---------------------------------- 2 files changed, 130 deletions(-) delete mode 100644 Shokofin/Tasks/MergeAllTask.cs delete mode 100644 Shokofin/Tasks/SplitAllTask.cs diff --git a/Shokofin/Tasks/MergeAllTask.cs b/Shokofin/Tasks/MergeAllTask.cs deleted file mode 100644 index 5f45455f..00000000 --- a/Shokofin/Tasks/MergeAllTask.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.Tasks; -using Shokofin.MergeVersions; - -namespace Shokofin.Tasks; - -/// <summary> -/// Class MergeAllTask. -/// </summary> -public class MergeAllTask : IScheduledTask, IConfigurableScheduledTask -{ - /// <summary> - /// The merge-versions manager. - /// </summary> - private readonly MergeVersionsManager VersionsManager; - - /// <summary> - /// Initializes a new instance of the <see cref="MergeAllTask" /> class. - /// </summary> - public MergeAllTask(MergeVersionsManager userSyncManager) - { - VersionsManager = userSyncManager; - } - - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - 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> - public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - await VersionsManager.MergeAll(progress, cancellationToken); - } - - /// <inheritdoc /> - public string Name => "Merge both movies and episodes"; - - /// <inheritdoc /> - public string Description => "Merge all movie and episode entries with the same Shoko Episode ID set."; - - /// <inheritdoc /> - public string Category => "Shokofin"; - - /// <inheritdoc /> - public string Key => "ShokoMergeAll"; - - /// <inheritdoc /> - public bool IsHidden => false; - - /// <inheritdoc /> - public bool IsEnabled => false; - - /// <inheritdoc /> - public bool IsLogged => true; -} diff --git a/Shokofin/Tasks/SplitAllTask.cs b/Shokofin/Tasks/SplitAllTask.cs deleted file mode 100644 index 1056c89e..00000000 --- a/Shokofin/Tasks/SplitAllTask.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.Tasks; -using Shokofin.MergeVersions; - -namespace Shokofin.Tasks; - -/// <summary> -/// Class SplitAllTask. -/// </summary> -public class SplitAllTask : IScheduledTask, IConfigurableScheduledTask -{ - /// <inheritdoc /> - public string Name => "Split both movies and episodes"; - - /// <inheritdoc /> - public string Description => "Split all movie and episode entries with a Shoko Episode ID set."; - - /// <inheritdoc /> - public string Category => "Shokofin"; - - /// <inheritdoc /> - public string Key => "ShokoSplitAll"; - - /// <inheritdoc /> - public bool IsHidden => false; - - /// <inheritdoc /> - public bool IsEnabled => false; - - /// <inheritdoc /> - public bool IsLogged => true; - - /// <summary> - /// The merge-versions manager. - /// </summary> - private readonly MergeVersionsManager VersionsManager; - - /// <summary> - /// Initializes a new instance of the <see cref="SplitAllTask" /> class. - /// </summary> - public SplitAllTask(MergeVersionsManager userSyncManager) - { - VersionsManager = userSyncManager; - } - - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - 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> - public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - await VersionsManager.SplitAll(progress, cancellationToken); - } -} From 2b743b7b59543692db37b103ecfe4727d17fa098 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 7 Apr 2024 15:04:44 +0200 Subject: [PATCH 0731/1103] refacor: more changes to the plugin config - Added _some_ per media folder configurations to the UI. This will tell you the mapped import folder name, id, and relative path within the folder the media folder is mapped to, along with the settings you can tweak for media resolution. - Laid some more groundwork for the SignalR events to be better supported per media folder. no settings for any of the SignalR stuff yet though. That will be in a future commit. --- Shokofin/API/Models/ImportFolder.cs | 17 ++ Shokofin/API/ShokoAPIClient.cs | 12 + .../Configuration/MediaFolderConfiguration.cs | 18 ++ Shokofin/Configuration/PluginConfiguration.cs | 4 +- Shokofin/Configuration/configController.js | 145 ++++++++--- Shokofin/Configuration/configPage.html | 243 +++++++++++------- Shokofin/Resolvers/ShokoResolveManager.cs | 12 +- Shokofin/SignalR/SignalRConnectionManager.cs | 51 ++-- Shokofin/Tasks/VersionCheckTask.cs | 30 ++- 9 files changed, 382 insertions(+), 150 deletions(-) create mode 100644 Shokofin/API/Models/ImportFolder.cs diff --git a/Shokofin/API/Models/ImportFolder.cs b/Shokofin/API/Models/ImportFolder.cs new file mode 100644 index 00000000..dce9850e --- /dev/null +++ b/Shokofin/API/Models/ImportFolder.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models; + +public class ImportFolder +{ + /// <summary> + /// The ID of the import folder. + /// </summary> + [JsonPropertyName("ID")] + public int Id { get; set; } + + /// <summary> + /// The friendly name of the import folder, if any. + /// </summary> + public string? Name { get; set; } +} \ No newline at end of file diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 4af3298e..52ed20d3 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -239,6 +239,18 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method return null; } + public async Task<ImportFolder?> GetImportFolder(int id) + { + try { + return await Get<ImportFolder>($"/api/v3/ImportFolder/{id}"); + } + catch (ApiException e) { + if (e.StatusCode == HttpStatusCode.NotFound) + return null; + throw; + } + } + public Task<File> GetFile(string id) { if (UseOlderSeriesAndFileEndpoints) diff --git a/Shokofin/Configuration/MediaFolderConfiguration.cs b/Shokofin/Configuration/MediaFolderConfiguration.cs index b22d5248..74296c62 100644 --- a/Shokofin/Configuration/MediaFolderConfiguration.cs +++ b/Shokofin/Configuration/MediaFolderConfiguration.cs @@ -12,11 +12,24 @@ public class MediaFolderConfiguration /// </summary> public Guid MediaFolderId { get; set; } + /// <summary> + /// The jellyfin media folder path. Stored only for showing in the settings + /// page of the plugin… since it's very hard to get in there otherwise. + /// </summary> + public string MediaFolderPath { get; set; } = string.Empty; + /// <summary> /// The shoko import folder id the jellyfin media folder is linked to. /// </summary> public int ImportFolderId { get; set; } + /// <summary> + /// The friendly name of the import folder, if any. Stored only for showing + /// in the setttings page of the plugin… since it's very hard to get in + /// there otherwise. + /// </summary> + public string? ImportFolderName { get; set; } + /// <summary> /// The relative path from the root of the import folder the media folder is located at. /// </summary> @@ -34,6 +47,11 @@ public class MediaFolderConfiguration /// </summary> public bool IsFileEventsEnabled { get; set; } = true; + /// <summary> + /// Indicates that SignalR refresh events is enabled for the folder. + /// </summary> + public bool IsRefreshEventsEnabled { get; set; } = true; + /// <summary> /// Enable or disable the virtual file system on a per-media-folder basis. /// </summary> diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index c73903f1..30521c1a 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -234,7 +234,7 @@ public virtual string PrettyUrl /// Will notify Jellyfin about files that have been added/updated/removed /// in shoko. /// </summary> - public bool SignalR_FileWatcherEnabled { get; set; } + public bool SignalR_FileEvents { get; set; } #endregion @@ -303,7 +303,7 @@ public PluginConfiguration() SignalR_AutoConnectEnabled = false; SignalR_AutoReconnectInSeconds = new() { 0, 2, 10, 30, 60, 120, 300 }; SignalR_RefreshEnabled = false; - SignalR_FileWatcherEnabled = false; + SignalR_FileEvents = false; EXPERIMENTAL_AutoMergeVersions = true; EXPERIMENTAL_SplitThenMergeMovies = true; EXPERIMENTAL_SplitThenMergeEpisodes = false; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 19552929..7ff4e66e 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -42,7 +42,7 @@ async function loadUserConfig(form, userId, config) { Dashboard.hideLoadingMsg(); return; } - + Dashboard.showLoadingMsg(); // Get the configuration to use. @@ -79,6 +79,39 @@ async function loadUserConfig(form, userId, config) { Dashboard.hideLoadingMsg(); } +async function loadMediaFolderConfig(form, mediaFolderId, config) { + if (!mediaFolderId) { + 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); + if (!mediaFolderConfig) { + form.querySelector("#MediaFolderDefaultSettingsContainer").removeAttribute("hidden"); + form.querySelector("#MediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); + 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("#MediaFolderLibraryFiltering").value = `${mediaFolderConfig.IsLibraryFilteringEnabled != null ? mediaFolderConfig.IsLibraryFilteringEnabled : null}`; + + // Show the user settings now if it was previously hidden. + form.querySelector("#MediaFolderDefaultSettingsContainer").setAttribute("hidden", ""); + form.querySelector("#MediaFolderPerFolderSettingsContainer").removeAttribute("hidden"); + + Dashboard.hideLoadingMsg(); +} + function getApiKey(username, password, userKey = false) { return ApiClient.fetch({ dataType: "json", @@ -106,34 +139,44 @@ async function defaultSubmit(form) { form.querySelector("#PublicUrl").value = publicUrl; } const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); - const filteringModeRaw = form.querySelector("#LibraryFilteringMode").value; - const filteringMode = filteringModeRaw === "true" ? true : filteringModeRaw === "false" ? false : null; // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; config.TitleAlternateType = form.querySelector("#TitleAlternateType").value; config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; + config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; config.DescriptionSource = form.querySelector("#DescriptionSource").value; config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMiscLines = form.querySelector("#MinimalAniDBDescriptions").checked; config.SynopsisRemoveSummary = form.querySelector("#MinimalAniDBDescriptions").checked; - + // Provider settings config.AddAniDBId = form.querySelector("#AddAniDBId").checked; config.AddTMDBId = form.querySelector("#AddTMDBId").checked; // Library settings - config.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; - config.LibraryFilteringMode = filteringMode; config.UseGroupsForShows = form.querySelector("#UseGroupsForShows").checked; config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; config.SeparateMovies = form.querySelector("#SeparateMovies").checked; config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; - config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; - + + // Media Folder settings + const mediaFolderId = form.querySelector("#MediaFolderSelector").value; + const mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; + if (mediaFolderConfig) { + const filteringMode = form.querySelector("#MediaFolderLibraryFiltering").value; + mediaFolderConfig.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; + mediaFolderConfig.IsLibraryFilteringEnabled = filteringMode === "true" ? true : filteringMode === "false" ? false : null; + } + else { + const filteringMode = form.querySelector("#LibraryFiltering").value; + config.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; + config.LibraryFiltering = filteringMode === "true" ? true : filteringMode === "false" ? false : null; + } + // Tag settings config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; config.HideArtStyleTags = form.querySelector("#HideArtStyleTags").checked; @@ -142,7 +185,7 @@ async function defaultSubmit(form) { config.HideAniDbTags = form.querySelector("#HideAniDbTags").checked; config.HideSettingTags = form.querySelector("#HideSettingTags").checked; config.HideProgrammingTags = form.querySelector("#HideProgrammingTags").checked; - + // Advanced settings config.PublicUrl = publicUrl; config.IgnoredFolders = ignoredFolders; @@ -162,7 +205,7 @@ async function defaultSubmit(form) { 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; @@ -173,11 +216,11 @@ async function defaultSubmit(form) { 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 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; @@ -250,7 +293,7 @@ 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; @@ -269,8 +312,7 @@ async function syncSettings(form) { form.querySelector("#PublicUrl").value = publicUrl; } const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); - const filteringModeRaw = form.querySelector("#LibraryFilteringMode").value; - const filteringMode = filteringModeRaw === "true" ? true : filteringModeRaw === "false" ? false : null; + const fitleringMode = form.querySelector("#LibraryFiltering").value; // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; @@ -289,7 +331,7 @@ async function syncSettings(form) { // Library settings config.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; - config.LibraryFilteringMode = filteringMode; + config.LibraryFiltering = fitleringMode === "true" ? true : fitleringMode === "false" ? false : null; config.UseGroupsForShows = form.querySelector("#UseGroupsForShows").checked; config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; @@ -339,6 +381,27 @@ async function unlinkUser(form) { return config; } +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; + if (mediaFolderConfig) { + const filteringMode = form.querySelector("#MediaFolderLibraryFiltering").value; + mediaFolderConfig.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; + mediaFolderConfig.IsLibraryFilteringEnabled = filteringMode === "true" ? true : filteringMode === "false" ? false : null; + } + else { + const filteringMode = form.querySelector("#LibraryFiltering").value; + config.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; + config.LibraryFiltering = filteringMode === "true" ? true : filteringMode === "false" ? false : null; + } + + 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; @@ -350,7 +413,7 @@ async function syncUserSettings(form) { 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; @@ -361,7 +424,7 @@ async function syncUserSettings(form) { 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; @@ -386,6 +449,8 @@ async function syncUserSettings(form) { export default function (page) { const form = page.querySelector("#ShokoConfigForm"); const userSelector = form.querySelector("#UserSelector"); + const mediaFolderSelector = form.querySelector("#MediaFolderSelector"); + // Refresh the view after we changed the settings, so the view reflect the new settings. const refreshSettings = (config) => { if (config.ServerVersion) { @@ -412,6 +477,7 @@ export default function (page) { form.querySelector("#MetadataSection").removeAttribute("hidden"); form.querySelector("#ProviderSection").removeAttribute("hidden"); form.querySelector("#LibrarySection").removeAttribute("hidden"); + form.querySelector("#MediaFolderSection").removeAttribute("hidden"); form.querySelector("#UserSection").removeAttribute("hidden"); form.querySelector("#TagSection").removeAttribute("hidden"); form.querySelector("#AdvancedSection").removeAttribute("hidden"); @@ -426,6 +492,7 @@ export default function (page) { form.querySelector("#MetadataSection").setAttribute("hidden", ""); form.querySelector("#ProviderSection").setAttribute("hidden", ""); form.querySelector("#LibrarySection").setAttribute("hidden", ""); + form.querySelector("#MediaFolderSection").setAttribute("hidden", ""); form.querySelector("#UserSection").setAttribute("hidden", ""); form.querySelector("#TagSection").setAttribute("hidden", ""); form.querySelector("#AdvancedSection").setAttribute("hidden", ""); @@ -434,6 +501,9 @@ export default function (page) { const userId = form.querySelector("#UserSelector").value; loadUserConfig(form, userId, config); + + const mediaFolderId = form.querySelector("#MediaFolderSelector").value; + loadMediaFolderConfig(form, mediaFolderId, config); }; const onError = (err) => { @@ -446,6 +516,10 @@ export default function (page) { loadUserConfig(page, this.value); }); + mediaFolderSelector.addEventListener("change", function () { + loadMediaFolderConfig(page, this.value); + }) + form.querySelector("#UserEnableSynchronization").addEventListener("change", function () { const disabled = !this.checked; form.querySelector("#SyncUserDataOnImport").disabled = disabled; @@ -455,16 +529,6 @@ export default function (page) { form.querySelector("#SyncUserDataInitialSkipEventCount").disabled = disabled; }); - form.querySelector("#VirtualFileSystem").addEventListener("change", function () { - form.querySelector("#LibraryFilteringMode").disabled = this.checked; - if (this.checked) { - form.querySelector("#LibraryFilteringModeContainer").setAttribute("hidden", ""); - } - else { - form.querySelector("#LibraryFilteringModeContainer").removeAttribute("hidden"); - } - }); - form.querySelector("#UseGroupsForShows").addEventListener("change", function () { form.querySelector("#SeasonOrdering").disabled = !this.checked; if (this.checked) { @@ -491,6 +555,7 @@ export default function (page) { form.querySelector("#TitleAlternateType").value = config.TitleAlternateType; form.querySelector("#TitleAllowAny").checked = config.TitleAllowAny; form.querySelector("#TitleAddForMultipleEpisodes").checked = config.TitleAddForMultipleEpisodes != null ? config.TitleAddForMultipleEpisodes : true; + form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; form.querySelector("#DescriptionSource").value = config.DescriptionSource; form.querySelector("#CleanupAniDBDescriptions").checked = config.SynopsisCleanMultiEmptyLines || config.SynopsisCleanLinks; form.querySelector("#MinimalAniDBDescriptions").checked = config.SynopsisRemoveSummary || config.SynopsisCleanMiscLines; @@ -500,15 +565,6 @@ export default function (page) { form.querySelector("#AddTMDBId").checked = config.AddTMDBId; // Library settings - form.querySelector("#VirtualFileSystem").checked = config.VirtualFileSystem != null ? config.VirtualFileSystem : true; - form.querySelector("#LibraryFilteringMode").value = `${config.LibraryFilteringMode != null ? config.LibraryFilteringMode : null}`; - form.querySelector("#LibraryFilteringMode").disabled = form.querySelector("#VirtualFileSystem").checked; - if (form.querySelector("#VirtualFileSystem").checked) { - form.querySelector("#LibraryFilteringModeContainer").setAttribute("hidden", ""); - } - else { - form.querySelector("#LibraryFilteringModeContainer").removeAttribute("hidden"); - } form.querySelector("#UseGroupsForShows").checked = config.UseGroupsForShows || false; form.querySelector("#SeasonOrdering").value = config.SeasonOrdering; form.querySelector("#SeasonOrdering").disabled = !form.querySelector("#UseGroupsForShows").checked; @@ -521,10 +577,16 @@ export default function (page) { form.querySelector("#CollectionGrouping").value = config.CollectionGrouping || "Default"; form.querySelector("#SeparateMovies").checked = config.SeparateMovies != null ? config.SeparateMovies : true; form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" ? "AfterSeason" : config.SpecialsPlacement; - form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; + + // Media Folder settings + form.querySelector("#VirtualFileSystem").checked = config.VirtualFileSystem != null ? config.VirtualFileSystem : true; + form.querySelector("#LibraryFiltering").value = `${config.LibraryFiltering != null ? config.LibraryFiltering : null}`; + + // Media Folder settings + mediaFolderSelector.innerHTML += config.MediaFolders.map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.MediaFolderPath}</option>`).join(""); // User settings - userSelector.innerHTML += users.map((user) => `<option value="${user.Id}">${user.Name}</option>`); + userSelector.innerHTML += users.map((user) => `<option value="${user.Id}">${user.Name}</option>`).join(""); // Tag settings form.querySelector("#HideUnverifiedTags").checked = config.HideUnverifiedTags; @@ -578,6 +640,9 @@ export default function (page) { case "unlink-user": unlinkUser(form).then(refreshSettings).catch(onError); break; + case "media-folder-settings": + Dashboard.showLoadingMsg(); + syncMediaFolderSettings(form).then(refreshSettings).case(onError); case "user-settings": Dashboard.showLoadingMsg(); syncUserSettings(form).then(refreshSettings).catch(onError); diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 0956b2b5..c201dab9 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -90,6 +90,13 @@ <h3>Metadata Settings</h3> </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="MarkSpecialsWhenGrouped" /> + <span>Mark specials</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add a number to the title of each specials episode</div> + </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="CleanupAniDBDescriptions" /> @@ -102,30 +109,7 @@ <h3>Metadata Settings</h3> <input is="emby-checkbox" type="checkbox" id="MinimalAniDBDescriptions" /> <span>Minimalistic AniDB descriptions</span> </label> - <div class="fieldDescription checkboxFieldDescription">Remove any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summery'</div> - </div> - <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> - <span>Save</span> - </button> - </fieldset> - <fieldset id="ProviderSection" class="verticalSection verticalSection-extrabottompadding" hidden> - <legend> - <h3>Plugin Compatibility Settings</h3> - </legend> - <div class="fieldDescription verticalSection-extrabottompadding">We can optionally set some ids for interoperability with other plugins.</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 class="fieldDescription checkboxFieldDescription">Trim any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summery'</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -135,63 +119,8 @@ <h3>Plugin Compatibility Settings</h3> <legend> <h3>Library 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"> - <option value="None" selected>Do not create collections</option> - <option value="ShokoSeries">Create collections for movies based upon Shoko's series</option> - <option value="ShokoGroup">Create collections for movies and shows based upon Shoko's groups and series</option> - </select> - <div class="fieldDescription">Determines how to group entities into collections.</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"> - <option value="AfterSeason">Always place specials after the normal episodes (Default)</option> - <option value="InBetweenSeasonByAirDate">Use release dates to place specials</option> - <option value="InBetweenSeasonByOtherData">Loosely use the TvDB/TMDB data available in Shoko to place specials</option> - <option value="InBetweenSeasonMixed">Either loosely use the TvDB/TMDB data available in Shoko or fallback to using release dates to place specials</option> - <option value="Excluded">Exclude specials from the seasons</option> - </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"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="VirtualFileSystem" /> - <span>Virtual File System™ (VFS)</span> - </label> - <div class="fieldDescription checkboxFieldDescription"> - <div>Enables the use of the Virtual File System™ for any media libraries managed by the plugin.</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 id="LibraryFilteringModeContainer" class="selectContainer selectContainer-withDescription" hidden> - <label class="selectLabel" for="LibraryFilteringMode">Filtering mode:</label> - <select is="emby-select" id="LibraryFilteringMode" name="LibraryFilteringMode" class="emby-select-withcolor emby-select" disabled> - <option value="null">Auto</option> - <option value="true" selected>Strict</option> - <option value="false">Disabled</option> - </select> - <div class="fieldDescription"> - <div>Choose how the plugin filters out videos in your 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. - </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 disabling filtering entail?</summary> - Disabling the filtering means the plugin will not filter out anything from the library. Use this only if you're having trouble. - </details> - </div> + <div class="fieldDescription verticalSection-extrabottompadding"> + Placeholder description. </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> @@ -236,17 +165,132 @@ <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"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> - <span>Mark specials</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Add a number to the title of each Special episode</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"> + <option value="AfterSeason">Always place specials after the normal episodes (Default)</option> + <option value="InBetweenSeasonByAirDate">Use release dates to place specials</option> + <option value="InBetweenSeasonByOtherData">Loosely use the TvDB/TMDB data available in Shoko to place specials</option> + <option value="InBetweenSeasonMixed">Either loosely use the TvDB/TMDB data available in Shoko or fallback to using release dates to place specials</option> + <option value="Excluded">Exclude specials from the seasons</option> + </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="selectContainer selectContainer-withDescription"> + <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="ShokoSeries">Create collections for movies based upon Shoko's series</option> + <option value="ShokoGroup">Create collections for movies and shows based upon Shoko's groups and series</option> + </select> + <div class="fieldDescription">Determines how to group entities into collections.</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> + <legend> + <h3>Media Folder Settings</h3> + </legend> + <div class="fieldDescription verticalSection-extrabottompadding"> + Placeholder description. + </div> + <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> + </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="VirtualFileSystem" /> + <span>Virtual File System™</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> + <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="LibraryFiltering">Library Filtering:</label> + <select is="emby-select" id="LibraryFiltering" name="LibraryFiltering" class="emby-select-withcolor emby-select"> + <option value="null">Auto</option> + <option value="true" selected>Strict</option> + <option value="false">Disabled</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 disabling filtering entail?</summary> + Disabling the 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> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="MediaFolderVirtualFileSystem" /> + <span>Virtual File System™</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>Enables the use of the Virtual File System™ for the library.</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="MediaFolderLibraryFiltering">Library Filtering:</label> + <select is="emby-select" id="MediaFolderLibraryFiltering" name="MediaFolderLibraryFilteringMode" class="emby-select-withcolor emby-select"> + <option value="null">Auto</option> + <option value="true" selected>Strict</option> + <option value="false">Disabled</option> + </select> + <div class="fieldDescription"> + <div>Choose how the plugin filters out videos in your 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. + </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 disabling filtering entail?</summary> + Disabling the filtering means the plugin will not filter out anything from the library. Use this only if you're having trouble. + </details> + </div> + </div> + </div> + <button is="emby-button" type="submit" name="media-folder-settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> <fieldset id="UserSection" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>User Settings</h3> @@ -377,6 +421,31 @@ <h3>Tag Settings</h3> <span>Save</span> </button> </fieldset> + <fieldset id="ProviderSection" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>Plugin Compatibility Settings</h3> + </legend> + <div class="fieldDescription verticalSection-extrabottompadding"> + We can optionally set some ids for interoperability with other plugins. + </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> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> <fieldset id="AdvancedSection" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Advanced Settings</h3> diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 537da82f..ea9d4ff4 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -135,8 +135,11 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold // Check if we should introduce the VFS for the media folder. mediaFolderConfig = new() { MediaFolderId = mediaFolder.Id, + MediaFolderPath = mediaFolder.Path, IsVirtualFileSystemEnabled = config.VirtualFileSystem, IsLibraryFilteringEnabled = config.LibraryFiltering, + IsFileEventsEnabled = config.SignalR_FileEvents, + IsRefreshEventsEnabled = config.SignalR_RefreshEnabled, }; var start = DateTime.UtcNow; @@ -168,6 +171,13 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold break; } + try { + var importFolder = await ApiClient.GetImportFolder(mediaFolderConfig.ImportFolderId); + if (importFolder != null) + mediaFolderConfig.ImportFolderName = importFolder.Name; + } + catch { } + // Store and log the result. config.MediaFolders.Add(mediaFolderConfig); Plugin.Instance.SaveConfiguration(config); @@ -643,7 +653,7 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file // Abort now if the VFS is enabled, since it will take care of moving // from the physical library to the "virtual" library. - if (parent.ParentId == root.Id && Plugin.Instance.Configuration.VirtualFileSystem) + if (parent.ParentId == root.Id && mediaFolderConfig.IsVirtualFileSystemEnabled) return false; var shouldIgnore = mediaFolderConfig.IsLibraryFilteringEnabled ?? mediaFolderConfig.IsVirtualFileSystemEnabled || isSoleProvider; diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 78b6ef4b..8081f93f 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -27,7 +27,7 @@ public class SignalRConnectionManager : IDisposable private HubConnection? Connection = null; - private string LastConfigKey = string.Empty; + private string CachedKey = string.Empty; public bool IsUsable => CanConnect(Plugin.Instance.Configuration); @@ -72,18 +72,14 @@ private async Task ConnectAsync(PluginConfiguration config) connection.Reconnected += OnReconnected; // Attach refresh events. - if (config.SignalR_RefreshEnabled) { - connection.On<FileMatchedEventArgs>("ShokoEvent:FileMatched", OnFileMatched); - connection.On<FileMovedEventArgs>("ShokoEvent:FileMoved", OnFileMoved); - connection.On<FileRenamedEventArgs>("ShokoEvent:FileRenamed", OnFileRenamed); - connection.On<FileEventArgs>("ShokoEvent:FileDeleted", OnFileDeleted); - } + connection.On<EpisodeInfoUpdatedEventArgs>("ShokoEvent:EpisodeUpdated", OnEpisodeInfoUpdated); + connection.On<SeriesInfoUpdatedEventArgs>("ShokoEvent:SeriesUpdated", OnSeriesInfoUpdated); // Attach file events. - if (config.SignalR_FileWatcherEnabled) { - connection.On<EpisodeInfoUpdatedEventArgs>("ShokoEvent:EpisodeUpdated", OnEpisodeInfoUpdated); - connection.On<SeriesInfoUpdatedEventArgs>("ShokoEvent:SeriesUpdated", OnSeriesInfoUpdated); - } + connection.On<FileMatchedEventArgs>("ShokoEvent:FileMatched", OnFileMatched); + connection.On<FileMovedEventArgs>("ShokoEvent:FileMoved", OnFileMoved); + connection.On<FileRenamedEventArgs>("ShokoEvent:FileRenamed", OnFileRenamed); + connection.On<FileEventArgs>("ShokoEvent:FileDeleted", OnFileDeleted); try { await Connection.StartAsync(); @@ -152,7 +148,7 @@ private async Task ResetConnectionAsync(PluginConfiguration config, bool shouldC public async Task RunAsync() { var config = Plugin.Instance.Configuration; - LastConfigKey = GenerateConfigKey(config); + CachedKey = ConstructKey(config); Plugin.Instance.ConfigurationChanged += OnConfigurationChanged; await ResetConnectionAsync(config, config.SignalR_AutoConnectEnabled); @@ -162,20 +158,20 @@ private void OnConfigurationChanged(object? sender, BasePluginConfiguration base { if (baseConfig is not PluginConfiguration config) return; - var newConfigKey = GenerateConfigKey(config); - if (!string.Equals(newConfigKey, LastConfigKey)) + var currentKey = ConstructKey(config); + if (!string.Equals(currentKey, CachedKey)) { - Logger.LogDebug("Detected change in SignalR configuration! (Config={Config})", newConfigKey); - LastConfigKey = newConfigKey; + Logger.LogDebug("Detected change in SignalR configuration! (Config={Config})", currentKey); + CachedKey = currentKey; ResetConnection(config, Connection != null); } } private static bool CanConnect(PluginConfiguration config) - => !string.IsNullOrEmpty(config.Url) && !string.IsNullOrEmpty(config.ApiKey); + => !string.IsNullOrEmpty(config.Url) && !string.IsNullOrEmpty(config.ApiKey) && config.ServerVersion != null; - private static string GenerateConfigKey(PluginConfiguration config) - => $"CanConnect={CanConnect(config)},Refresh={config.SignalR_RefreshEnabled},FileWatcher={config.SignalR_FileWatcherEnabled},AutoReconnect={config.SignalR_AutoReconnectInSeconds.Select(s => s.ToString()).Join(',')}"; + private static string ConstructKey(PluginConfiguration config) + => $"CanConnect={CanConnect(config)},AutoReconnect={config.SignalR_AutoReconnectInSeconds.Select(s => s.ToString()).Join(',')}"; #endregion @@ -192,6 +188,10 @@ private void OnFileMatched(FileMatchedEventArgs eventArgs) eventArgs.FileId ); + // also check if the locations we've found are mapped, and if they are + // check if the file events are enabled for the media folder before + // emitting events for the paths within the media filder. + // check if the file is already in a known media library, and if yes, // promote it from "unknown" to "known". also generate vfs entries now // if needed. @@ -210,6 +210,10 @@ private void OnFileRelocated(IFileRelocationEventArgs eventArgs) // check the previous and current locations, and report the changes. + // also check if the locations we've found are mapped, and if they are + // check if the file events are enabled for the media folder before + // emitting events for the paths within the media filder. + // also if the vfs is used, check the vfs for broken links, and fix it, // or remove the broken links. we can do this a) generating the new links // and/or b) checking the existing base items for their paths and checking if @@ -231,6 +235,11 @@ private void OnFileDeleted(FileEventArgs eventArgs) eventArgs.FileId ); // The location has been removed. + + // also check if the locations we've found are mapped, and if they are + // check if the file events are enabled for the media folder before + // emitting events for the paths within the media filder. + // check any base items with the exact path, and any VFS entries with a // link leading to the exact path, or with broken links. } @@ -259,6 +268,10 @@ private void OnSeriesInfoUpdated(SeriesInfoUpdatedEventArgs eventArgs) eventArgs.GroupId ); + // look up the series/season/movie, then check the media folder they're + // in to check if the refresh event is enabled for the media folder, and + // only send out the events if it's enabled. + // Refresh the show and all entries beneath it, or all movies linked to // the show. } diff --git a/Shokofin/Tasks/VersionCheckTask.cs b/Shokofin/Tasks/VersionCheckTask.cs index 95929cc3..1cc87fe3 100644 --- a/Shokofin/Tasks/VersionCheckTask.cs +++ b/Shokofin/Tasks/VersionCheckTask.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; using Shokofin.API; -using Shokofin.MergeVersions; +using Shokofin.API.Models; namespace Shokofin.Tasks; @@ -68,6 +69,7 @@ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() /// <returns>Task.</returns> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { + var updated = false; var version = await ApiClient.GetVersion(); if (version != null && ( Plugin.Instance.Configuration.ServerVersion == null || @@ -75,6 +77,32 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can )) { Logger.LogInformation("Found new Shoko Server version; {version}", version); Plugin.Instance.Configuration.ServerVersion = version; + updated = true; + } + + var mediaFolders = Plugin.Instance.Configuration.MediaFolders; + var importFolderNameMap = await Task + .WhenAll( + mediaFolders + .Select(m => m.ImportFolderId) + .Distinct() + .Except(new int[1] { 0 }) + .Select(id => ApiClient.GetImportFolder(id)) + .ToList() + ) + .ContinueWith(task => task.Result.OfType<ImportFolder>().ToDictionary(i => i.Id, i => i.Name)) + .ConfigureAwait(false); + foreach (var mediaFolder in mediaFolders) { + if (!importFolderNameMap.TryGetValue(mediaFolder.ImportFolderId, out var importFolderName)) + importFolderName = null; + + if (!string.Equals(mediaFolder.ImportFolderName, importFolderName)) { + Logger.LogInformation("Found new name for import folder; {name} (ImportFolder={ImportFolderId})", importFolderName, mediaFolder.ImportFolderId); + mediaFolder.ImportFolderName = importFolderName; + updated = true; + } + } + if (updated) { Plugin.Instance.SaveConfiguration(); } } From 0cb2bbe3f2ed774ec5fc302b238d9703162355df Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 7 Apr 2024 15:05:09 +0200 Subject: [PATCH 0732/1103] misc: fix complaints from the IDE for the file model --- Shokofin/API/Models/File.cs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index 596e7e1a..559c312f 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -67,7 +67,6 @@ public class Location /// The id of the <see cref="ImportFolder"/> this <see cref="File"/> /// resides in. /// </summary> - /// <value></value> [JsonPropertyName("ImportFolderID")] public int ImportFolderId { get; set; } @@ -83,18 +82,14 @@ public class Location /// the start. /// </summary> public string Path => - __path != null ? ( - __path - ) : ( - __path = System.IO.Path.DirectorySeparatorChar + RelativePath - .Replace('/', System.IO.Path.DirectorySeparatorChar) - .Replace('\\', System.IO.Path.DirectorySeparatorChar) - ); + CachedPath ??= System.IO.Path.DirectorySeparatorChar + RelativePath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); /// <summary> /// Cached path for later re-use. /// </summary> - private string? __path { get; set; } + private string? CachedPath { get; set; } /// <summary> /// True if the server can access the the <see cref="Location.Path"/> at From a43f176b8247fe8438b1b2ad566cf39d62504155 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 7 Apr 2024 15:05:23 +0200 Subject: [PATCH 0733/1103] fix: make sure specials are last (again) --- Shokofin/Providers/SeasonProvider.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index d4bc3f37..162bfc4d 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -35,9 +35,25 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat { try { var result = new MetadataResult<Season>(); - if (!info.IndexNumber.HasValue || info.IndexNumber.HasValue && info.IndexNumber.Value == 0) + if (!info.IndexNumber.HasValue) return result; + // Special handling of the "Specials" season (pun intended). + if (info.IndexNumber.Value == 0) { + // We're forcing the sort names to start with "ZZ" to make it + // always appear last in the UI. + var seasonName = info.Name; + result.Item = new Season { + Name = seasonName, + IndexNumber = info.IndexNumber, + SortName = $"ZZ - {seasonName}", + ForcedSortName = $"ZZ - {seasonName}", + }; + result.HasMetadata = true; + + return result; + } + if (!info.SeriesProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId) || !info.IndexNumber.HasValue) { Logger.LogDebug("Unable refresh Season {SeasonNumber} {SeasonName}", info.IndexNumber, info.Name); return result; From 021e52a40d2509457aa43a5f5ddfa422b488c11a Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 7 Apr 2024 13:06:22 +0000 Subject: [PATCH 0734/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 6fe872be..75e877f3 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.84", + "changelog": "fix: make sure specials are last (again)\n\nmisc: fix complaints from the IDE for the file model\n\nrefacor: more changes to the plugin config\n\n- Added _some_ per media folder configurations to the UI. This\n will tell you the mapped import folder name, id, and relative path\n within the folder the media folder is mapped to, along with the\n settings you can tweak for media resolution.\n\n- Laid some more groundwork for the SignalR events to be better\n supported per media folder. no settings for any of the SignalR stuff\n yet though. That will be in a future commit.\n\nmisc: remove redundant tasks", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.84/shoko_3.0.1.84.zip", + "checksum": "8a8c4d1f37cb53b4747c4f2a885a3544", + "timestamp": "2024-04-07T13:06:21Z" + }, { "version": "3.0.1.83", "changelog": "fix: also fix it for subtitle files", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.80/shoko_3.0.1.80.zip", "checksum": "600c57484fddfd0fc53004da083bc056", "timestamp": "2024-04-05T07:42:12Z" - }, - { - "version": "3.0.1.79", - "changelog": "misc: take 4: action!\nThis time using a new GH Action. So time to test out a few things;\n\n- List item\n\n[Link](<https://discord.com>)\n\n**Bold**\n\n_Italic_\n\n||Spoiler||\n\n**Note**: I totally didn't just typo that env. var..", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.79/shoko_3.0.1.79.zip", - "checksum": "ac4d3e9cb4d16eb565b5805448c5293c", - "timestamp": "2024-04-05T07:39:45Z" } ] } From 6a842413a123224e1b7d6f70125135ecbcfd839b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 7 Apr 2024 15:16:10 +0200 Subject: [PATCH 0735/1103] fix: fix duplication bug on settings deserialise - I don't know why it happens if it's a `List<string>` but not a `string[]`, but I don't care enough to study the internals of `System.Xml` to find out. --- Shokofin/Configuration/PluginConfiguration.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 30521c1a..641cca96 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -184,7 +184,7 @@ public virtual string PrettyUrl /// </summary> public bool AddMissingMetadata { get; set; } - public List<string> IgnoredFolders { get; set; } + public string[] IgnoredFolders { get; set; } #endregion @@ -298,7 +298,7 @@ public PluginConfiguration() CollectionGrouping = CollectionCreationType.None; UserList = new(); MediaFolders = new(); - IgnoredFolders = new() { ".streams", "@recently-snapshot" }; + IgnoredFolders = new[] { ".streams", "@recently-snapshot" }; LibraryFiltering = null; SignalR_AutoConnectEnabled = false; SignalR_AutoReconnectInSeconds = new() { 0, 2, 10, 30, 60, 120, 300 }; From 3ee5c7e55b73e4a118e798ed4cf5fa2e8a88e552 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 7 Apr 2024 13:16:53 +0000 Subject: [PATCH 0736/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 75e877f3..43c682bd 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.85", + "changelog": "fix: fix duplication bug on settings deserialise\n\n- I don't know why it happens if it's a `List<string>` but not a\n `string[]`, but I don't care enough to study the internals of\n `System.Xml` to find out.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.85/shoko_3.0.1.85.zip", + "checksum": "b0caa5a4ffb380b7fa28e69489212874", + "timestamp": "2024-04-07T13:16:52Z" + }, { "version": "3.0.1.84", "changelog": "fix: make sure specials are last (again)\n\nmisc: fix complaints from the IDE for the file model\n\nrefacor: more changes to the plugin config\n\n- Added _some_ per media folder configurations to the UI. This\n will tell you the mapped import folder name, id, and relative path\n within the folder the media folder is mapped to, along with the\n settings you can tweak for media resolution.\n\n- Laid some more groundwork for the SignalR events to be better\n supported per media folder. no settings for any of the SignalR stuff\n yet though. That will be in a future commit.\n\nmisc: remove redundant tasks", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.81/shoko_3.0.1.81.zip", "checksum": "f37c6beefb43997ea3c07e855bf921aa", "timestamp": "2024-04-06T11:02:39Z" - }, - { - "version": "3.0.1.80", - "changelog": "misc: fix links in dev release notification", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.80/shoko_3.0.1.80.zip", - "checksum": "600c57484fddfd0fc53004da083bc056", - "timestamp": "2024-04-05T07:42:12Z" } ] } From e1a0a62429a942c922470b3dce014346f902b7a1 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 7 Apr 2024 15:46:15 +0200 Subject: [PATCH 0737/1103] fix: fix connection settings --- Shokofin/Configuration/configController.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 7ff4e66e..7f7474fd 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -244,13 +244,13 @@ async function defaultSubmit(form) { } else { try { - let url = new URL(url); - url = url.href; + let actualUrl = new URL(url); + url = actualUrl.href; } catch (err) { try { - let url = new URL(`http://${url}:8111`); - url = url.href; + let actualUrl = new URL(`http://${url}:8111`); + url = actualUrl.href; } catch (err2) { throw err; From a6de68cdf5253e2ee15ade5e15b44727af5293e5 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 7 Apr 2024 13:46:56 +0000 Subject: [PATCH 0738/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 43c682bd..2c2cbf66 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.86", + "changelog": "fix: fix connection settings", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.86/shoko_3.0.1.86.zip", + "checksum": "846ee8f2a7160738d1e65d2caa4e1dd1", + "timestamp": "2024-04-07T13:46:55Z" + }, { "version": "3.0.1.85", "changelog": "fix: fix duplication bug on settings deserialise\n\n- I don't know why it happens if it's a `List<string>` but not a\n `string[]`, but I don't care enough to study the internals of\n `System.Xml` to find out.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.82/shoko_3.0.1.82.zip", "checksum": "852ec0f991da1fb794adbdccef83c71d", "timestamp": "2024-04-06T12:43:52Z" - }, - { - "version": "3.0.1.81", - "changelog": "refactor: update plugin config\n\n- Give the plugin configuration a face-lift by adding proper regions\n and comments, and also rename a few fields in the code, but not\n in the settings file itself (as to not break anything).\n\n- Added and hooked up per-media-folder library filtering. Still no UI\n to update it for existing libraries, and the global option is now\n treated as a default option. It is recommended to either edit the\n option in the xml file after updating, or removing the media folder\n settings from the file and letting it re-generate itself on the next\n library scan.\n\n- Tweaked the VFS per-media-folder setting to not fallback to the global\n option, and treat the global option as a default option for new media\n libraries.\n\nAlso, not in this commit, but the settings page will get another refactor\nto add the per-media-folder settings and SignalR settings sometime in\nthe future, but for now you will just have to deal with the fact that a\nmedia library is locked in on some settings when you create it, unless\nyou wipe or edit the media folder settings stored in the xml file.\n\nmisc: update logo [skip ci]", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.81/shoko_3.0.1.81.zip", - "checksum": "f37c6beefb43997ea3c07e855bf921aa", - "timestamp": "2024-04-06T11:02:39Z" } ] } From 81408579604f4bb2ac496e4e8cf8e644d2304d68 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 7 Apr 2024 16:07:20 +0200 Subject: [PATCH 0739/1103] fix: fix duplication bug on signalr auto-reconnect --- Shokofin/Configuration/PluginConfiguration.cs | 4 ++-- Shokofin/SignalR/SignalRConnectionManager.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 641cca96..c25a416c 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -223,7 +223,7 @@ public virtual string PrettyUrl /// <summary> /// Reconnect intervals if the the stream gets disconnected. /// </summary> - public List<int> SignalR_AutoReconnectInSeconds { get; set; } + public int[] SignalR_AutoReconnectInSeconds { get; set; } /// <summary> /// Will automatically refresh entries if metadata is updated in Shoko. @@ -301,7 +301,7 @@ public PluginConfiguration() IgnoredFolders = new[] { ".streams", "@recently-snapshot" }; LibraryFiltering = null; SignalR_AutoConnectEnabled = false; - SignalR_AutoReconnectInSeconds = new() { 0, 2, 10, 30, 60, 120, 300 }; + SignalR_AutoReconnectInSeconds = new[] { 0, 2, 10, 30, 60, 120, 300 }; SignalR_RefreshEnabled = false; SignalR_FileEvents = false; EXPERIMENTAL_AutoMergeVersions = true; diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 8081f93f..306a68b4 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -62,8 +62,8 @@ private async Task ConnectAsync(PluginConfiguration config) ) .AddJsonProtocol(); - if (config.SignalR_AutoReconnectInSeconds.Count > 0) - builder.WithAutomaticReconnect(config.SignalR_AutoReconnectInSeconds.Select(seconds => TimeSpan.FromSeconds(seconds)).ToArray()); + if (config.SignalR_AutoReconnectInSeconds.Length > 0) + builder = builder.WithAutomaticReconnect(config.SignalR_AutoReconnectInSeconds.Select(seconds => TimeSpan.FromSeconds(seconds)).ToArray()); var connection = Connection = builder.Build(); From aae0707b87bdb0a2d4c0c16f71e7b8ae152bb84e Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 7 Apr 2024 14:08:03 +0000 Subject: [PATCH 0740/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 2c2cbf66..3c22629e 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.87", + "changelog": "fix: fix duplication bug on signalr auto-reconnect", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.87/shoko_3.0.1.87.zip", + "checksum": "bce7350f1ef5ba677024e331d1c69733", + "timestamp": "2024-04-07T14:08:01Z" + }, { "version": "3.0.1.86", "changelog": "fix: fix connection settings", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.83/shoko_3.0.1.83.zip", "checksum": "3f6e36ae51862f36cc03672ee7fdfe67", "timestamp": "2024-04-06T12:48:17Z" - }, - { - "version": "3.0.1.82", - "changelog": "fix: fix link validation\n\nmisc: remove unused variable(s)\n\nmisc: code style fixes\n\n- Fix bracket open style in the code where I mind slipped back into\n standard C# style. No bracket open on a seperate line allowed inside\n method bodies. None.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.82/shoko_3.0.1.82.zip", - "checksum": "852ec0f991da1fb794adbdccef83c71d", - "timestamp": "2024-04-06T12:43:52Z" } ] } From b447f928b931fac385f59d1ce50f601d768afe48 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 7 Apr 2024 17:10:46 +0200 Subject: [PATCH 0741/1103] fix: filter to only video files before declaring them as videos - Filter the files in the movie directory so we only have video files to avoid false positives, because the subtitle files we renamed also have the ids set. --- Shokofin/Resolvers/ShokoResolveManager.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index ea9d4ff4..db0d61fd 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -838,6 +838,10 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b return FileSystem.GetFiles(dirInfo.FullName) .AsParallel() .Select(fileInfo => { + // Only allow the video files, since the subtitle files also have the ids set. + if (!_namingOptions.VideoFileExtensions.Contains(Path.GetExtension(fileInfo.Name))) + return null; + if (!fileInfo.Name.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) return null; From 310a8525af9320d8fa887ae8f8b219377585e29f Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 7 Apr 2024 15:11:30 +0000 Subject: [PATCH 0742/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 3c22629e..33742a2a 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.88", + "changelog": "fix: filter to only video files before declaring them as videos\n\n- Filter the files in the movie directory so we only have video files to\n avoid false positives, because the subtitle files we renamed also have\n the ids set.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.88/shoko_3.0.1.88.zip", + "checksum": "0ee96a0bc6ddceaee675b9d7703fadbb", + "timestamp": "2024-04-07T15:11:29Z" + }, { "version": "3.0.1.87", "changelog": "fix: fix duplication bug on signalr auto-reconnect", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.84/shoko_3.0.1.84.zip", "checksum": "8a8c4d1f37cb53b4747c4f2a885a3544", "timestamp": "2024-04-07T13:06:21Z" - }, - { - "version": "3.0.1.83", - "changelog": "fix: also fix it for subtitle files", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.83/shoko_3.0.1.83.zip", - "checksum": "3f6e36ae51862f36cc03672ee7fdfe67", - "timestamp": "2024-04-06T12:48:17Z" } ] } From 510fd3b8018ff964c4b82437e086857cf5b32809 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 7 Apr 2024 17:33:53 +0200 Subject: [PATCH 0743/1103] fix: bypass cache for file user stats --- Shokofin/API/ShokoAPIClient.cs | 81 ++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 13 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 52ed20d3..635663a5 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -75,14 +75,26 @@ public void Dispose() Clear(false); } - private Task<ReturnType> Get<ReturnType>(string url, string? apiKey = null) + private Task<ReturnType> Get<ReturnType>(string url, string? apiKey = null, bool skipCache = false) => Get<ReturnType>(url, HttpMethod.Get, apiKey); - private Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? apiKey = null) - => _cache.GetOrCreateAsync( + private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? apiKey = null, bool skipCache = false) + { + if (skipCache) { + var response = await Get(url, method, apiKey, true).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.OK) + throw ApiException.FromResponse(response); + var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + responseStream.Seek(0, System.IO.SeekOrigin.Begin); + var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream).ConfigureAwait(false) ?? + throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); + return value; + } + + return await _cache.GetOrCreateAsync( $"apiKey={apiKey ?? "default"},method={method},url={url},object", (_) => Logger.LogTrace("Reusing object for {Method} {URL}", method, url), - async (cachedEntry) => { + async (_) => { var response = await Get(url, method, apiKey).ConfigureAwait(false); if (response.StatusCode != HttpStatusCode.OK) throw ApiException.FromResponse(response); @@ -90,16 +102,56 @@ private Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? responseStream.Seek(0, System.IO.SeekOrigin.Begin); var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream).ConfigureAwait(false) ?? throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); - cachedEntry.SlidingExpiration = DefaultTimeSpan; return value; + }, + new() { + SlidingExpiration = DefaultTimeSpan, } ); + } + + private async Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null, bool skipCache = false) + { + if (skipCache) { + // Use the default key if no key was provided. + apiKey ??= Plugin.Instance.Configuration.ApiKey; + + // Check if we have a key to use. + if (string.IsNullOrEmpty(apiKey)) + throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); + + var version = Plugin.Instance.Configuration.ServerVersion; + if (version == null) { + version = await GetVersion().ConfigureAwait(false) + ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); - private Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null) - => _cache.GetOrCreateAsync( + Plugin.Instance.Configuration.ServerVersion = version; + Plugin.Instance.SaveConfiguration(); + } + + try { + Logger.LogTrace("Trying to {Method} {URL}", method, url); + var remoteUrl = string.Concat(Plugin.Instance.Configuration.Url, url); + + using var requestMessage = new HttpRequestMessage(method, remoteUrl); + requestMessage.Content = new StringContent(string.Empty); + requestMessage.Headers.Add("apikey", apiKey); + var response = await _httpClient.SendAsync(requestMessage).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); + Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); + return response; + } + catch (HttpRequestException ex) { + Logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); + throw; + } + } + + return await _cache.GetOrCreateAsync( $"apiKey={apiKey ?? "default"},method={method},url={url},httpRequest", (response) => Logger.LogTrace("Reusing response for {Method} {URL}", method, url), - async (cachedEntry) => { + async (_) => { // Use the default key if no key was provided. apiKey ??= Plugin.Instance.Configuration.ApiKey; @@ -127,15 +179,18 @@ private Task<HttpResponseMessage> Get(string url, HttpMethod method, string? api if (response.StatusCode == HttpStatusCode.Unauthorized) throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); - cachedEntry.SlidingExpiration = DefaultTimeSpan; return response; } catch (HttpRequestException ex) { Logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); throw; } + }, + new() { + SlidingExpiration = DefaultTimeSpan, } ); + } private Task<ReturnType> Post<Type, ReturnType>(string url, Type body, string? apiKey = null) => Post<Type, ReturnType>(url, HttpMethod.Post, body, apiKey); @@ -285,7 +340,7 @@ public async Task<ListResult<File>> GetFilesForImportFolder(int importFolderId, public async Task<File.UserStats?> GetFileUserStats(string fileId, string? apiKey = null) { try { - return await Get<File.UserStats>($"/api/v3/File/{fileId}/UserStats", apiKey).ConfigureAwait(false); + return await Get<File.UserStats>($"/api/v3/File/{fileId}/UserStats", apiKey, true).ConfigureAwait(false); } catch (ApiException e) { // File user stats were not found. @@ -305,13 +360,13 @@ public async Task<ListResult<File>> GetFilesForImportFolder(int importFolderId, public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, bool watched, string apiKey) { - var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&watched={watched}", HttpMethod.Patch, apiKey).ConfigureAwait(false); + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&watched={watched}", HttpMethod.Patch, apiKey, true).ConfigureAwait(false); return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); } public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long progress, string apiKey) { - var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress).TotalMilliseconds)}", HttpMethod.Patch, apiKey).ConfigureAwait(false); + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress).TotalMilliseconds)}", HttpMethod.Patch, apiKey, true).ConfigureAwait(false); return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); } @@ -319,7 +374,7 @@ public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eve { if (!progress.HasValue) return await ScrobbleFile(fileId, episodeId, eventName, watched, apiKey).ConfigureAwait(false); - var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress.Value).TotalMilliseconds)}&watched={watched}", HttpMethod.Patch, apiKey).ConfigureAwait(false); + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress.Value).TotalMilliseconds)}&watched={watched}", HttpMethod.Patch, apiKey, true).ConfigureAwait(false); return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); } From 6230912f56295e02484dc1bb3e6af49fbb1e1ab5 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 7 Apr 2024 15:34:35 +0000 Subject: [PATCH 0744/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 33742a2a..fa8e3c13 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.89", + "changelog": "fix: bypass cache for file user stats", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.89/shoko_3.0.1.89.zip", + "checksum": "ee7de4c4a02c5cc9b19a83d4f8dc87fb", + "timestamp": "2024-04-07T15:34:33Z" + }, { "version": "3.0.1.88", "changelog": "fix: filter to only video files before declaring them as videos\n\n- Filter the files in the movie directory so we only have video files to\n avoid false positives, because the subtitle files we renamed also have\n the ids set.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.85/shoko_3.0.1.85.zip", "checksum": "b0caa5a4ffb380b7fa28e69489212874", "timestamp": "2024-04-07T13:16:52Z" - }, - { - "version": "3.0.1.84", - "changelog": "fix: make sure specials are last (again)\n\nmisc: fix complaints from the IDE for the file model\n\nrefacor: more changes to the plugin config\n\n- Added _some_ per media folder configurations to the UI. This\n will tell you the mapped import folder name, id, and relative path\n within the folder the media folder is mapped to, along with the\n settings you can tweak for media resolution.\n\n- Laid some more groundwork for the SignalR events to be better\n supported per media folder. no settings for any of the SignalR stuff\n yet though. That will be in a future commit.\n\nmisc: remove redundant tasks", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.84/shoko_3.0.1.84.zip", - "checksum": "8a8c4d1f37cb53b4747c4f2a885a3544", - "timestamp": "2024-04-07T13:06:21Z" } ] } From 1f68f8ca6dffb11e6f9673b7d19faa35312aaedd Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 7 Apr 2024 18:32:07 +0200 Subject: [PATCH 0745/1103] misc: temporerily disable the http request cache --- Shokofin/API/ShokoAPIClient.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 635663a5..1c046370 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -75,10 +75,10 @@ public void Dispose() Clear(false); } - private Task<ReturnType> Get<ReturnType>(string url, string? apiKey = null, bool skipCache = false) - => Get<ReturnType>(url, HttpMethod.Get, apiKey); + private Task<ReturnType> Get<ReturnType>(string url, string? apiKey = null, bool skipCache = true) + => Get<ReturnType>(url, HttpMethod.Get, apiKey, skipCache); - private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? apiKey = null, bool skipCache = false) + private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? apiKey = null, bool skipCache = true) { if (skipCache) { var response = await Get(url, method, apiKey, true).ConfigureAwait(false); @@ -110,7 +110,7 @@ private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, st ); } - private async Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null, bool skipCache = false) + private async Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null, bool skipCache = true) { if (skipCache) { // Use the default key if no key was provided. From 041031acfe12edfc94eacdd50a5acc27e6787d86 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 7 Apr 2024 16:32:50 +0000 Subject: [PATCH 0746/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index fa8e3c13..426aeee8 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.90", + "changelog": "misc: temporerily disable the http request cache", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.90/shoko_3.0.1.90.zip", + "checksum": "937ca86982d0e6b763a071e01c0c42b9", + "timestamp": "2024-04-07T16:32:49Z" + }, { "version": "3.0.1.89", "changelog": "fix: bypass cache for file user stats", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.86/shoko_3.0.1.86.zip", "checksum": "846ee8f2a7160738d1e65d2caa4e1dd1", "timestamp": "2024-04-07T13:46:55Z" - }, - { - "version": "3.0.1.85", - "changelog": "fix: fix duplication bug on settings deserialise\n\n- I don't know why it happens if it's a `List<string>` but not a\n `string[]`, but I don't care enough to study the internals of\n `System.Xml` to find out.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.85/shoko_3.0.1.85.zip", - "checksum": "b0caa5a4ffb380b7fa28e69489212874", - "timestamp": "2024-04-07T13:16:52Z" } ] } From b1b1c8597c097c3f51f772f1ac6920d2ac0cc4ce Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 8 Apr 2024 13:40:07 +0200 Subject: [PATCH 0747/1103] fix: only merge with entries in the same media folder --- Shokofin/MergeVersions/MergeVersionManager.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index 8a07df3e..1576fc40 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -160,7 +160,7 @@ public async Task MergeAllMovies(IProgress<double> progress, CancellationToken c // Merge all movies with more than one version. var movies = GetMoviesFromLibrary(); var duplicationGroups = movies - .GroupBy(x => x.ProviderIds[ShokoEpisodeId.Name]) + .GroupBy(x => (x.GetTopParent()?.Path, x.ProviderIds[ShokoEpisodeId.Name])) .Where(x => x.Count() > 1) .ToList(); double currentCount = 0d; @@ -229,7 +229,7 @@ private async Task SplitAndMergeAllMovies(IProgress<double> progress, Cancellati // Merge all movies with more than one version (again). var duplicationGroups = movies - .GroupBy(movie => movie.ProviderIds[ShokoEpisodeId.Name]) + .GroupBy(movie => (movie.GetTopParent()?.Path, movie.ProviderIds[ShokoEpisodeId.Name])) .Where(movie => movie.Count() > 1) .ToList(); currentCount = 0d; @@ -295,7 +295,7 @@ public async Task MergeAllEpisodes(IProgress<double> progress, CancellationToken // of additional episodes. var episodes = GetEpisodesFromLibrary(); var duplicationGroups = episodes - .GroupBy(e => $"{e.ProviderIds[ShokoEpisodeId.Name]}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}") + .GroupBy(e => (e.GetTopParent()?.Path, $"{e.ProviderIds[ShokoEpisodeId.Name]}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}")) .Where(e => e.Count() > 1) .ToList(); double currentCount = 0d; @@ -366,7 +366,7 @@ private async Task SplitAndMergeAllEpisodes(IProgress<double> progress, Cancella // Merge episodes with more than one version (again), and with the same // number of additional episodes. var duplicationGroups = episodes - .GroupBy(e => $"{e.ProviderIds[ShokoEpisodeId.Name]}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}") + .GroupBy(e => (e.GetTopParent()?.Path, $"{e.ProviderIds[ShokoEpisodeId.Name]}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}")) .Where(e => e.Count() > 1) .ToList(); currentCount = 0d; From fbfdabd54d438e85899c57e9b66c26db3dba573a Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 8 Apr 2024 11:40:57 +0000 Subject: [PATCH 0748/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 426aeee8..17e5df2b 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.91", + "changelog": "fix: only merge with entries in the same media folder", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.91/shoko_3.0.1.91.zip", + "checksum": "5a3776354ca845bae71c46474059bb9b", + "timestamp": "2024-04-08T11:40:55Z" + }, { "version": "3.0.1.90", "changelog": "misc: temporerily disable the http request cache", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.87/shoko_3.0.1.87.zip", "checksum": "bce7350f1ef5ba677024e331d1c69733", "timestamp": "2024-04-07T14:08:01Z" - }, - { - "version": "3.0.1.86", - "changelog": "fix: fix connection settings", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.86/shoko_3.0.1.86.zip", - "checksum": "846ee8f2a7160738d1e65d2caa4e1dd1", - "timestamp": "2024-04-07T13:46:55Z" } ] } From 339e1207bf3e5f43f23537bb17b920da1d0f6dc9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 8 Apr 2024 23:27:54 +0200 Subject: [PATCH 0749/1103] misc: re-enable the request cache, but only for objects --- Shokofin/API/ShokoAPIClient.cs | 116 ++++++++++----------------------- 1 file changed, 36 insertions(+), 80 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 1c046370..8701346e 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -75,13 +75,13 @@ public void Dispose() Clear(false); } - private Task<ReturnType> Get<ReturnType>(string url, string? apiKey = null, bool skipCache = true) + private Task<ReturnType> Get<ReturnType>(string url, string? apiKey = null, bool skipCache = false) => Get<ReturnType>(url, HttpMethod.Get, apiKey, skipCache); - private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? apiKey = null, bool skipCache = true) + private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? apiKey = null, bool skipCache = false) { if (skipCache) { - var response = await Get(url, method, apiKey, true).ConfigureAwait(false); + var response = await Get(url, method, apiKey).ConfigureAwait(false); if (response.StatusCode != HttpStatusCode.OK) throw ApiException.FromResponse(response); var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); @@ -95,6 +95,7 @@ private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, st $"apiKey={apiKey ?? "default"},method={method},url={url},object", (_) => Logger.LogTrace("Reusing object for {Method} {URL}", method, url), async (_) => { + Logger.LogTrace("Creating object for {Method} {URL}", method, url); var response = await Get(url, method, apiKey).ConfigureAwait(false); if (response.StatusCode != HttpStatusCode.OK) throw ApiException.FromResponse(response); @@ -110,86 +111,41 @@ private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, st ); } - private async Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null, bool skipCache = true) + private async Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null) { - if (skipCache) { - // Use the default key if no key was provided. - apiKey ??= Plugin.Instance.Configuration.ApiKey; - - // Check if we have a key to use. - if (string.IsNullOrEmpty(apiKey)) - throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); + // Use the default key if no key was provided. + apiKey ??= Plugin.Instance.Configuration.ApiKey; - var version = Plugin.Instance.Configuration.ServerVersion; - if (version == null) { - version = await GetVersion().ConfigureAwait(false) - ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); + // Check if we have a key to use. + if (string.IsNullOrEmpty(apiKey)) + throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); - Plugin.Instance.Configuration.ServerVersion = version; - Plugin.Instance.SaveConfiguration(); - } + var version = Plugin.Instance.Configuration.ServerVersion; + if (version == null) { + version = await GetVersion().ConfigureAwait(false) + ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); - try { - Logger.LogTrace("Trying to {Method} {URL}", method, url); - var remoteUrl = string.Concat(Plugin.Instance.Configuration.Url, url); - - using var requestMessage = new HttpRequestMessage(method, remoteUrl); - requestMessage.Content = new StringContent(string.Empty); - requestMessage.Headers.Add("apikey", apiKey); - var response = await _httpClient.SendAsync(requestMessage).ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.Unauthorized) - throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); - Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); - return response; - } - catch (HttpRequestException ex) { - Logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); - throw; - } + Plugin.Instance.Configuration.ServerVersion = version; + Plugin.Instance.SaveConfiguration(); } - return await _cache.GetOrCreateAsync( - $"apiKey={apiKey ?? "default"},method={method},url={url},httpRequest", - (response) => Logger.LogTrace("Reusing response for {Method} {URL}", method, url), - async (_) => { - // Use the default key if no key was provided. - apiKey ??= Plugin.Instance.Configuration.ApiKey; - - // Check if we have a key to use. - if (string.IsNullOrEmpty(apiKey)) - throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); - - var version = Plugin.Instance.Configuration.ServerVersion; - if (version == null) { - version = await GetVersion().ConfigureAwait(false) - ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); - - Plugin.Instance.Configuration.ServerVersion = version; - Plugin.Instance.SaveConfiguration(); - } - - try { - Logger.LogTrace("Trying to {Method} {URL}", method, url); - var remoteUrl = string.Concat(Plugin.Instance.Configuration.Url, url); - - using var requestMessage = new HttpRequestMessage(method, remoteUrl); - requestMessage.Content = new StringContent(string.Empty); - requestMessage.Headers.Add("apikey", apiKey); - var response = await _httpClient.SendAsync(requestMessage).ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.Unauthorized) - throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); - Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); - return response; - } - catch (HttpRequestException ex) { - Logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); - throw; - } - }, - new() { - SlidingExpiration = DefaultTimeSpan, - } - ); + try { + Logger.LogTrace("Trying to {Method} {URL}", method, url); + var remoteUrl = string.Concat(Plugin.Instance.Configuration.Url, url); + + using var requestMessage = new HttpRequestMessage(method, remoteUrl); + requestMessage.Content = new StringContent(string.Empty); + requestMessage.Headers.Add("apikey", apiKey); + var response = await _httpClient.SendAsync(requestMessage).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); + Logger.LogTrace("API returned response with status code {StatusCode}", response.StatusCode); + return response; + } + catch (HttpRequestException ex) { + Logger.LogWarning(ex, "Unable to connect to complete the request to Shoko."); + throw; + } } private Task<ReturnType> Post<Type, ReturnType>(string url, Type body, string? apiKey = null) @@ -360,13 +316,13 @@ public async Task<ListResult<File>> GetFilesForImportFolder(int importFolderId, public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, bool watched, string apiKey) { - var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&watched={watched}", HttpMethod.Patch, apiKey, true).ConfigureAwait(false); + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&watched={watched}", HttpMethod.Patch, apiKey).ConfigureAwait(false); return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); } public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eventName, long progress, string apiKey) { - var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress).TotalMilliseconds)}", HttpMethod.Patch, apiKey, true).ConfigureAwait(false); + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress).TotalMilliseconds)}", HttpMethod.Patch, apiKey).ConfigureAwait(false); return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); } @@ -374,7 +330,7 @@ public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eve { if (!progress.HasValue) return await ScrobbleFile(fileId, episodeId, eventName, watched, apiKey).ConfigureAwait(false); - var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress.Value).TotalMilliseconds)}&watched={watched}", HttpMethod.Patch, apiKey, true).ConfigureAwait(false); + var response = await Get($"/api/v3/File/{fileId}/Scrobble?event={eventName}&episodeID={episodeId}&resumePosition={Math.Round(new TimeSpan(progress.Value).TotalMilliseconds)}&watched={watched}", HttpMethod.Patch, apiKey).ConfigureAwait(false); return response != null && (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.NoContent); } From 72a80c9cbd53b00d76687f25832cb265b38c3486 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 8 Apr 2024 23:41:39 +0200 Subject: [PATCH 0750/1103] fix: remove dangerous sub-structure generation - Removed the dangerous sub-path structure generation that could had ran if you tried to refresh a series, resulting in **all** files not belonging to the series being removed, because they were not in the list of files to keep. The side-effect of this change is that it will regenerate the whole VFS structure if needed when you're updating a single series. --- Shokofin/Resolvers/ShokoResolveManager.cs | 31 ++++++++--------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index db0d61fd..90602a92 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -215,13 +215,9 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold /// <param name="mediaFolder">The media folder to generate a structure for.</param> /// <param name="folderPath">The folder within the media folder to generate a structure for.</param> /// <returns>The VFS path, if it succeeded.</returns> - private async Task<string?> GenerateStructureForFolderInVFS(Folder mediaFolder, string folderPath) - { - // Return early if we've already generated the structure from the import folder itself. - if (DataCache.TryGetValue<string?>(mediaFolder.Path, out var vfsPath)) - return vfsPath; - return await DataCache.GetOrCreateAsync( - folderPath, + private Task<string?> GenerateStructureForFolderInVFS(Folder mediaFolder) + => DataCache.GetOrCreateAsync( + mediaFolder.Path, async (_) => { var mediaConfig = await GetOrCreateConfigurationForMediaFolder(mediaFolder); if (!mediaConfig.IsMapped) @@ -233,14 +229,13 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold // Check if we should introduce the VFS for the media folder. var start = DateTime.UtcNow; - var allPaths = FileSystem.GetFilePaths(folderPath, true) + var allPaths = FileSystem.GetFilePaths(mediaFolder.Path, true) .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) .ToHashSet(); - Logger.LogDebug("Found {FileCount} files in folder at {Path} in {TimeSpan}.", allPaths.Count, folderPath, DateTime.UtcNow - start); + Logger.LogDebug("Found {FileCount} files in media folder at {Path} in {TimeSpan}.", allPaths.Count, mediaFolder.Path, DateTime.UtcNow - start); - var relativeFolderPath = mediaConfig.ImportFolderRelativePath + folderPath[mediaFolder.Path.Length..]; - vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); - var allFiles = GetImportFolderFiles(mediaConfig.ImportFolderId, relativeFolderPath, folderPath, allPaths); + var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + var allFiles = GetImportFolderFiles(mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); await GenerateSymbolicLinks(mediaFolder, allFiles).ConfigureAwait(false); return vfsPath; @@ -248,8 +243,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold new() { AbsoluteExpirationRelativeToNow = DefaultTTL, } - ).ConfigureAwait(false); - } + ); private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetImportFolderFiles(int importFolderId, string importFolderSubPath, string mediaFolderPath, ISet<string> fileSet) { @@ -771,14 +765,11 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b if (!fullPath.StartsWith(Plugin.Instance.VirtualRoot)) return null; - var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent, root); + var (mediaFolder, _) = ApiManager.FindMediaFolder(fullPath, parent, root); if (mediaFolder == root) return null; - var searchPath = mediaFolder.Path != parent.Path - ? Path.Combine(mediaFolder.Path, parent.Path[(mediaFolder.Path.Length + 1)..].Split(Path.DirectorySeparatorChar).Skip(1).Join(Path.DirectorySeparatorChar)) - : mediaFolder.Path; - var vfsPath = await GenerateStructureForFolderInVFS(mediaFolder, searchPath).ConfigureAwait(false); + var vfsPath = await GenerateStructureForFolderInVFS(mediaFolder).ConfigureAwait(false); if (string.IsNullOrEmpty(vfsPath)) return null; @@ -816,7 +807,7 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b // Redirect children of a VFS managed media folder to the VFS. if (parent.ParentId == root.Id) { - var vfsPath = await GenerateStructureForFolderInVFS(parent, parent.Path).ConfigureAwait(false); + var vfsPath = await GenerateStructureForFolderInVFS(parent).ConfigureAwait(false); if (string.IsNullOrEmpty(vfsPath)) return null; From 8267f5db0fa8eb9fb9e77057252d4f7086520379 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 8 Apr 2024 23:42:04 +0200 Subject: [PATCH 0751/1103] fix: fix stats after generation --- Shokofin/Resolvers/ShokoResolveManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 90602a92..43f3ea72 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -455,8 +455,8 @@ await Task.WhenAll(files.Select(async (tuple) => { var timeSpent = DateTime.UtcNow - start; Logger.LogInformation( "Created {CreatedMedia} ({CreatedSubtitles}), fixed {FixedMedia} ({FixedSubtitles}), skipped {SkippedMedia} ({SkippedSubtitles}), and removed {RemovedMedia} ({RemovedSubtitles}) symbolic links in media folder at {Path} in {TimeSpan}", - allPathsForVFS.Count - skippedLinks - subtitles, - subtitles - skippedSubtitles, + allPathsForVFS.Count - skippedLinks - fixedLinks - subtitles, + subtitles - fixedSubtitles - skippedSubtitles, fixedLinks, fixedSubtitles, skippedLinks, From a8fa867ca23715a40c182bfc1d864f9b6149624b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 8 Apr 2024 21:43:07 +0000 Subject: [PATCH 0752/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 17e5df2b..3069c5f6 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.92", + "changelog": "fix: fix stats after generation\n\nfix: remove dangerous sub-structure generation\n\n- Removed the dangerous sub-path structure generation\n that could had ran if you tried to refresh a series, resulting\n in **all** files not belonging to the series being removed,\n because they were not in the list of files to keep.\n The side-effect of this change is that it will regenerate the\n whole VFS structure if needed when you're updating a single series.\n\nmisc: re-enable the request cache, but only for objects", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.92/shoko_3.0.1.92.zip", + "checksum": "25000b60e661f6c7c95631bfd6cc5efa", + "timestamp": "2024-04-08T21:43:05Z" + }, { "version": "3.0.1.91", "changelog": "fix: only merge with entries in the same media folder", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.88/shoko_3.0.1.88.zip", "checksum": "0ee96a0bc6ddceaee675b9d7703fadbb", "timestamp": "2024-04-07T15:11:29Z" - }, - { - "version": "3.0.1.87", - "changelog": "fix: fix duplication bug on signalr auto-reconnect", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.87/shoko_3.0.1.87.zip", - "checksum": "bce7350f1ef5ba677024e331d1c69733", - "timestamp": "2024-04-07T14:08:01Z" } ] } From 73cc260f930486bfc1890968cb876e5c9e6560ac Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 8 Apr 2024 23:48:52 +0200 Subject: [PATCH 0753/1103] fix: don't throw if signalr service could not be started --- Shokofin/SignalR/SignalRConnectionManager.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 306a68b4..954db286 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -82,13 +82,13 @@ private async Task ConnectAsync(PluginConfiguration config) connection.On<FileEventArgs>("ShokoEvent:FileDeleted", OnFileDeleted); try { - await Connection.StartAsync(); + await connection.StartAsync().ConfigureAwait(false); Logger.LogInformation("Connected to Shoko Server."); } - catch { - Disconnect(); - throw; + catch (Exception ex) { + Logger.LogError(ex, "Unable to connect to Shoko Server at this time. Please reconnect manually."); + await DisconnectAsync().ConfigureAwait(false); } } @@ -128,7 +128,9 @@ public async Task DisconnectAsync() var connection = Connection; Connection = null; - await connection.StopAsync(); + if (connection.State != HubConnectionState.Disconnected) + await connection.StopAsync(); + await connection.DisposeAsync(); } From 4b0da9818ce6225c68a53d73a80995065d6937da Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 8 Apr 2024 21:50:00 +0000 Subject: [PATCH 0754/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 3069c5f6..effeef51 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.93", + "changelog": "fix: don't throw if signalr service could not be started", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.93/shoko_3.0.1.93.zip", + "checksum": "3203f9969ddb7bf3d76f24e1a8181761", + "timestamp": "2024-04-08T21:49:58Z" + }, { "version": "3.0.1.92", "changelog": "fix: fix stats after generation\n\nfix: remove dangerous sub-structure generation\n\n- Removed the dangerous sub-path structure generation\n that could had ran if you tried to refresh a series, resulting\n in **all** files not belonging to the series being removed,\n because they were not in the list of files to keep.\n The side-effect of this change is that it will regenerate the\n whole VFS structure if needed when you're updating a single series.\n\nmisc: re-enable the request cache, but only for objects", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.89/shoko_3.0.1.89.zip", "checksum": "ee7de4c4a02c5cc9b19a83d4f8dc87fb", "timestamp": "2024-04-07T15:34:33Z" - }, - { - "version": "3.0.1.88", - "changelog": "fix: filter to only video files before declaring them as videos\n\n- Filter the files in the movie directory so we only have video files to\n avoid false positives, because the subtitle files we renamed also have\n the ids set.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.88/shoko_3.0.1.88.zip", - "checksum": "0ee96a0bc6ddceaee675b9d7703fadbb", - "timestamp": "2024-04-07T15:11:29Z" } ] } From d715bb532cd94261797b07941a2b60cffe5b99b0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 9 Apr 2024 10:29:33 +0200 Subject: [PATCH 0755/1103] fix: change to more permissive folder/file names --- Shokofin/Resolvers/ShokoResolveManager.cs | 28 +++++++++++------------ Shokofin/StringExtensions.cs | 14 ++---------- 2 files changed, 16 insertions(+), 26 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 43f3ea72..48a21ecb 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -468,6 +468,9 @@ await Task.WhenAll(files.Select(async (tuple) => { ); } + // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 chacters. + private const int NameCutOff = 64; + private async Task<(string sourceLocation, string[] symbolicLinks)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId) { var season = await ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); @@ -495,19 +498,17 @@ await Task.WhenAll(files.Select(async (tuple) => { if (season == null || episode == null) return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); - var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters(); - if (string.IsNullOrEmpty(showName)) { - showName = $"Shoko Series {show.Id}"; - } - else if (show.DefaultSeason.AniDB.AirDate.HasValue) { - var yearText = $" ({show.DefaultSeason.AniDB.AirDate.Value.Year})"; - if (!showName.EndsWith(yearText)) - showName += yearText; - } - - var folders = new List<string>(); + var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters() ?? $"Shoko Series {show.Id}"; var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); var episodeName = (episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode == "en")?.Value ?? $"Episode {episode.AniDB.Type} {episodeNumber}").ReplaceInvalidPathCharacters(); + + // For those **really** long names we have to cut if off at some point… + if (showName.Length >= NameCutOff) + showName = showName[..NameCutOff].Split(' ').SkipLast(1).Join(' ') + "…"; + if (episodeName.Length >= NameCutOff) + episodeName = episodeName[..NameCutOff].Split(' ').SkipLast(1).Join(' ') + "…"; + + var folders = new List<string>(); var extrasFolder = file.ExtraType switch { null => null, ExtraType.ThemeSong => "theme-music", @@ -515,7 +516,7 @@ await Task.WhenAll(files.Select(async (tuple) => { ExtraType.Trailer => "trailers", _ => "extras", }; - var fileNameSuffic = file.ExtraType switch { + var fileNameSuffix = file.ExtraType switch { ExtraType.BehindTheScenes => "-behindthescenes", ExtraType.Clip => "-clip", ExtraType.DeletedScene => "-deletedscene", @@ -525,7 +526,6 @@ await Task.WhenAll(files.Select(async (tuple) => { ExtraType.Unknown => "-other", _ => string.Empty, }; - if (isMovieSeason && collectionType != CollectionType.TvShows) { if (!string.IsNullOrEmpty(extrasFolder)) { foreach (var episodeInfo in season.EpisodeList) @@ -550,7 +550,7 @@ await Task.WhenAll(files.Select(async (tuple) => { } } - var fileName = $"{episodeName} [{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{fileNameSuffic}{Path.GetExtension(sourceLocation)}"; + var fileName = $"{episodeName} [{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{fileNameSuffix}{Path.GetExtension(sourceLocation)}"; var symbolicLinks = folders .Select(folderPath => Path.Combine(folderPath, fileName)) .ToArray(); diff --git a/Shokofin/StringExtensions.cs b/Shokofin/StringExtensions.cs index 3043b6e4..f58f285d 100644 --- a/Shokofin/StringExtensions.cs +++ b/Shokofin/StringExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Text.RegularExpressions; using MediaBrowser.Common.Providers; namespace Shokofin; @@ -54,18 +55,7 @@ public static string Join(this IEnumerable<string> list, string? separator, int => string.Join(separator, list, startIndex, count); public static string ReplaceInvalidPathCharacters(this string path) - => path - .Replace(@"*", "\u1F7AF") // 🞯 (LIGHT FIVE SPOKED ASTERISK) - .Replace(@"|", "\uFF5C") // | (FULLWIDTH VERTICAL LINE) - .Replace(@"\", "\u29F9") // ⧹ (BIG REVERSE SOLIDUS) - .Replace(@"/", "\u29F8") // ⧸ (BIG SOLIDUS) - .Replace(@":", "\u0589") // ։ (ARMENIAN FULL STOP) - .Replace("\"", "\u2033") // ″ (DOUBLE PRIME) - .Replace(@">", "\u203a") // › (SINGLE RIGHT-POINTING ANGLE QUOTATION MARK) - .Replace(@"<", "\u2039") // ‹ (SINGLE LEFT-POINTING ANGLE QUOTATION MARK) - .Replace(@"?", "\uff1f") // ? (FULL WIDTH QUESTION MARK) - .Replace(@".", "\u2024") // ․ (ONE DOT LEADER) - .Trim(); + => Regex.Replace(path.Trim(), @"[*|\\/:~<>?!\.…""]{2,}", "_", RegexOptions.Singleline); /// <summary> /// Gets the attribute value for <paramref name="attribute"/> in <paramref name="text"/>. From e91d157cd4b4d93a22bed8d3e0b452c61edeb500 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 9 Apr 2024 08:30:41 +0000 Subject: [PATCH 0756/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index effeef51..8bb863a6 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.94", + "changelog": "fix: change to more permissive folder/file names", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.94/shoko_3.0.1.94.zip", + "checksum": "bf3d9ffa87b6c1b1bab1cb37025711c6", + "timestamp": "2024-04-09T08:30:39Z" + }, { "version": "3.0.1.93", "changelog": "fix: don't throw if signalr service could not be started", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.90/shoko_3.0.1.90.zip", "checksum": "937ca86982d0e6b763a071e01c0c42b9", "timestamp": "2024-04-07T16:32:49Z" - }, - { - "version": "3.0.1.89", - "changelog": "fix: bypass cache for file user stats", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.89/shoko_3.0.1.89.zip", - "checksum": "ee7de4c4a02c5cc9b19a83d4f8dc87fb", - "timestamp": "2024-04-07T15:34:33Z" } ] } From 816c8d6f513ce7c00089341e7ac9cac92fc77857 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 9 Apr 2024 12:26:39 +0200 Subject: [PATCH 0757/1103] fix: update user password field type --- 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 c201dab9..ba698f18 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -357,7 +357,7 @@ <h3>User Settings</h3> <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="text" 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> From e8a050618917309844d39fb18c7a737e8b01b28f Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 9 Apr 2024 10:30:18 +0000 Subject: [PATCH 0758/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 8bb863a6..0ce5a394 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.95", + "changelog": "fix: update user password field type", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.95/shoko_3.0.1.95.zip", + "checksum": "769d3cc409b02dc48e8d1aa1c77923e9", + "timestamp": "2024-04-09T10:30:16Z" + }, { "version": "3.0.1.94", "changelog": "fix: change to more permissive folder/file names", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.91/shoko_3.0.1.91.zip", "checksum": "5a3776354ca845bae71c46474059bb9b", "timestamp": "2024-04-08T11:40:55Z" - }, - { - "version": "3.0.1.90", - "changelog": "misc: temporerily disable the http request cache", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.90/shoko_3.0.1.90.zip", - "checksum": "937ca86982d0e6b763a071e01c0c42b9", - "timestamp": "2024-04-07T16:32:49Z" } ] } From d9c864a018d14e7cef82af48760727a46a2d8ff6 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 9 Apr 2024 15:58:05 +0200 Subject: [PATCH 0759/1103] fix: force ascii for vfs paths --- Shokofin/StringExtensions.cs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/Shokofin/StringExtensions.cs b/Shokofin/StringExtensions.cs index f58f285d..26eaa253 100644 --- a/Shokofin/StringExtensions.cs +++ b/Shokofin/StringExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using MediaBrowser.Common.Providers; @@ -54,8 +55,32 @@ public static string Join(this IEnumerable<string> list, char separator, int sta public static string Join(this IEnumerable<string> list, string? separator, int startIndex, int count) => string.Join(separator, list, startIndex, count); + public static string Join(this IEnumerable<char> list, char separator) + => string.Join(separator, list); + + public static string Join(this IEnumerable<char> list, string? separator) + => string.Join(separator, list); + + public static string Join(this IEnumerable<char> list, char separator, int startIndex, int count) + => string.Join(separator, list, startIndex, count); + + public static string Join(this IEnumerable<char> list, string? separator, int startIndex, int count) + => string.Join(separator, list, startIndex, count); + + private static char? IsAllowedCharacter(this char c) + => c == 32 || c > 47 && c < 58 || c > 64 && c < 91 || c > 96 && c < 123 ? c : '_'; + + public static string ForceASCII(this string value) + => value.Select(c => c.IsAllowedCharacter()).OfType<char>().Join(""); + + private static string CompactUnderscore(this string path) + => Regex.Replace(path, @"_{2,}", "_", RegexOptions.Singleline); + + public static string CompactWhitespaces(this string path) + => Regex.Replace(path, @"\s{2,}", " ", RegexOptions.Singleline); + public static string ReplaceInvalidPathCharacters(this string path) - => Regex.Replace(path.Trim(), @"[*|\\/:~<>?!\.…""]{2,}", "_", RegexOptions.Singleline); + => path.ForceASCII().CompactUnderscore().CompactWhitespaces().Trim(); /// <summary> /// Gets the attribute value for <paramref name="attribute"/> in <paramref name="text"/>. From 619f97cf16c837eae5c599dd89b904cbe0d09e0f Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 9 Apr 2024 14:00:17 +0000 Subject: [PATCH 0760/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 0ce5a394..3c2d9116 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.96", + "changelog": "fix: force ascii for vfs paths", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.96/shoko_3.0.1.96.zip", + "checksum": "f0624ecbdd03c022423df5ed60323db6", + "timestamp": "2024-04-09T14:00:16Z" + }, { "version": "3.0.1.95", "changelog": "fix: update user password field type", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.92/shoko_3.0.1.92.zip", "checksum": "25000b60e661f6c7c95631bfd6cc5efa", "timestamp": "2024-04-08T21:43:05Z" - }, - { - "version": "3.0.1.91", - "changelog": "fix: only merge with entries in the same media folder", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.91/shoko_3.0.1.91.zip", - "checksum": "5a3776354ca845bae71c46474059bb9b", - "timestamp": "2024-04-08T11:40:55Z" } ] } From aa9b495e805dcefed0a6799dd70f7c86779d8db7 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 9 Apr 2024 20:45:14 +0200 Subject: [PATCH 0761/1103] fix: use older endpoints for stable --- Shokofin/API/ShokoAPIClient.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 8701346e..762e1c30 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -24,18 +24,18 @@ public class ShokoAPIClient : IDisposable private readonly ILogger<ShokoAPIClient> Logger; - private static DateTime? ServerCommitDate => - Plugin.Instance.Configuration.ServerVersion?.ReleaseDate; + private static ComponentVersion? ServerVersion => + Plugin.Instance.Configuration.ServerVersion; private static readonly DateTime StableCutOffDate = DateTime.Parse("2023-12-16T00:00:00.000Z"); private static bool UseOlderSeriesAndFileEndpoints => - ServerCommitDate.HasValue && ServerCommitDate.Value < StableCutOffDate; + ServerVersion != null && ((ServerVersion.ReleaseChannel == ReleaseChannel.Stable && ServerVersion.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 => - ServerCommitDate.HasValue && ServerCommitDate.Value < ImportFolderCutOffDate; + ServerVersion != null && ((ServerVersion.ReleaseChannel == ReleaseChannel.Stable && ServerVersion.Version == "4.2.2.0") || (ServerVersion.ReleaseDate.HasValue && ServerVersion.ReleaseDate.Value < ImportFolderCutOffDate)); private GuardedMemoryCache _cache = new(new MemoryCacheOptions() { ExpirationScanFrequency = ExpirationScanFrequency, From 4eb6208f25a868b65b642d1684557ea752bec1a4 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 9 Apr 2024 18:46:13 +0000 Subject: [PATCH 0762/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 3c2d9116..13ea5262 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.97", + "changelog": "fix: use older endpoints for stable", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.97/shoko_3.0.1.97.zip", + "checksum": "f5d22c7b6e98afc33b004d956605c36c", + "timestamp": "2024-04-09T18:46:12Z" + }, { "version": "3.0.1.96", "changelog": "fix: force ascii for vfs paths", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.93/shoko_3.0.1.93.zip", "checksum": "3203f9969ddb7bf3d76f24e1a8181761", "timestamp": "2024-04-08T21:49:58Z" - }, - { - "version": "3.0.1.92", - "changelog": "fix: fix stats after generation\n\nfix: remove dangerous sub-structure generation\n\n- Removed the dangerous sub-path structure generation\n that could had ran if you tried to refresh a series, resulting\n in **all** files not belonging to the series being removed,\n because they were not in the list of files to keep.\n The side-effect of this change is that it will regenerate the\n whole VFS structure if needed when you're updating a single series.\n\nmisc: re-enable the request cache, but only for objects", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.92/shoko_3.0.1.92.zip", - "checksum": "25000b60e661f6c7c95631bfd6cc5efa", - "timestamp": "2024-04-08T21:43:05Z" } ] } From 46d19de0081490bfc9a883b4949421cbed5157da Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 9 Apr 2024 22:39:40 +0200 Subject: [PATCH 0763/1103] misc: more logging for link generation stage --- Shokofin/Resolvers/ShokoResolveManager.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 48a21ecb..43a5c7d5 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -303,7 +303,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold } while (pages.Count > 0); var timeSpent = DateTime.UtcNow - start; - Logger.LogTrace( + Logger.LogDebug( "Iterated {FileCount} files to potentially use within media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath})", totalFiles, mediaFolderPath, @@ -351,6 +351,7 @@ await Task.WhenAll(files.Select(async (tuple) => { allPathsForVFS.Add((sourceLocation, symbolicLink)); if (!File.Exists(symbolicLink)) { + Logger.LogDebug("Linking {Link} → {LinkTarget}", symbolicLink, sourceLocation); File.CreateSymbolicLink(symbolicLink, sourceLocation); } else { @@ -360,7 +361,7 @@ await Task.WhenAll(files.Select(async (tuple) => { if (!string.Equals(sourceLocation, nextTarget?.FullName)) { shouldFix = true; - Logger.LogWarning("Fixing broken symbolic link {Link} for {LinkTarget} (RealTarget={RealTarget})", symbolicLink, sourceLocation, nextTarget?.FullName); + Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", symbolicLink, sourceLocation, nextTarget?.FullName); } } catch (Exception ex) { @@ -386,6 +387,7 @@ await Task.WhenAll(files.Select(async (tuple) => { subtitles++; allPathsForVFS.Add((subtitleSource, subtitleLink)); if (!File.Exists(subtitleLink)) { + Logger.LogDebug("Linking {Link} → {LinkTarget}", subtitleLink, subtitleSource); File.CreateSymbolicLink(subtitleLink, subtitleSource); } else { @@ -395,7 +397,7 @@ await Task.WhenAll(files.Select(async (tuple) => { if (!string.Equals(subtitleSource, nextTarget?.FullName)) { shouldFix = true; - Logger.LogWarning("Fixing broken symbolic link {Link} for {LinkTarget} (RealTarget={RealTarget})", subtitleLink, subtitleSource, nextTarget?.FullName); + Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", subtitleLink, subtitleSource, nextTarget?.FullName); } } catch (Exception ex) { From 682313ad163c4c311f5b26365393739ae5903f2a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 9 Apr 2024 22:39:57 +0200 Subject: [PATCH 0764/1103] misc: remove unneeded stored offset --- Shokofin/Providers/SeasonProvider.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 162bfc4d..cdf080e6 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -165,7 +165,6 @@ public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber }; } season.ProviderIds.Add(ShokoSeriesId.Name, seasonInfo.Id); - season.ProviderIds.Add("Shoko Season Offset", offset.ToString()); if (Plugin.Instance.Configuration.AddAniDBId) season.ProviderIds.Add("AniDB", seasonInfo.AniDB.Id.ToString()); From f7988b4c3cbb8fce26c583b20bc4b0c4f7ff34ed Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 9 Apr 2024 22:51:03 +0200 Subject: [PATCH 0765/1103] refactor: simplify extra metadata provider --- Shokofin/Providers/ExtraMetadataProvider.cs | 633 +++++--------------- 1 file changed, 157 insertions(+), 476 deletions(-) diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs index f14cbcf7..23347d7a 100644 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ b/Shokofin/Providers/ExtraMetadataProvider.cs @@ -1,14 +1,14 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Globalization; using Microsoft.Extensions.Logging; using Shokofin.API; @@ -19,7 +19,7 @@ namespace Shokofin.Providers; -public class ExtraMetadataProvider : IServerEntryPoint +public class ExtraMetadataProvider : ICustomMetadataProvider<Series>, ICustomMetadataProvider<Season>, ICustomMetadataProvider<Episode> { private readonly ShokoAPIManager ApiManager; @@ -31,6 +31,8 @@ public class ExtraMetadataProvider : IServerEntryPoint private readonly ILogger<ExtraMetadataProvider> Logger; + string IMetadataProvider.Name => Plugin.MetadataProviderName; + public ExtraMetadataProvider(ShokoAPIManager apiManager, IIdLookup lookUp, ILibraryManager libraryManager, ILocalizationManager localizationManager, ILogger<ExtraMetadataProvider> logger) { ApiManager = apiManager; @@ -40,404 +42,46 @@ public ExtraMetadataProvider(ShokoAPIManager apiManager, IIdLookup lookUp, ILibr Logger = logger; } - public Task RunAsync() - { - LibraryManager.ItemAdded += OnLibraryManagerItemAdded; - LibraryManager.ItemUpdated += OnLibraryManagerItemUpdated; - LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; - - return Task.CompletedTask; - } - - public void Dispose() - { - GC.SuppressFinalize(this); - LibraryManager.ItemAdded -= OnLibraryManagerItemAdded; - LibraryManager.ItemUpdated -= OnLibraryManagerItemUpdated; - LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; - } - - #region Locking - - private readonly ConcurrentDictionary<string, HashSet<string>> LockedIdDictionary = new(); - - public bool TryLockActionForIdOFType(string type, string id, string action) - { - var key = $"{type}:{id}"; - if (!LockedIdDictionary.TryGetValue(key, out var hashSet)) { - LockedIdDictionary.TryAdd(key, new HashSet<string>()); - if (!LockedIdDictionary.TryGetValue(key, out hashSet)) - throw new Exception("Unable to set hash set"); - } - return hashSet.Add(action); - } - - public bool TryUnlockActionForIdOFType(string type, string id, string action) - { - var key = $"{type}:{id}"; - if (LockedIdDictionary.TryGetValue(key, out var hashSet)) - return hashSet.Remove(action); - return false; - } - - public bool IsActionForIdOfTypeLocked(string type, string id, string action) - { - var key = $"{type}:{id}"; - if (LockedIdDictionary.TryGetValue(key, out var hashSet)) - return hashSet.Contains(action); - return false; - } - - #endregion - - private void OnLibraryManagerItemAdded(object? sender, ItemChangeEventArgs e) - { - if (e == null || e.Item == null || e.Parent == null || e.UpdateReason.HasFlag(ItemUpdateType.None)) - return; - - switch (e.Item) { - case Series series: { - // Abort if we're unable to get the shoko series id - if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) - return; - - if (!TryLockActionForIdOFType("series", seriesId, "update")) - return; - - try { - UpdateSeries(series, seriesId); - } - finally { - TryUnlockActionForIdOFType("series", seriesId, "update"); - } - - return; - } - case Season season: { - // We're not interested in the dummy season. - if (!season.IndexNumber.HasValue) - return; - - if (e.Parent is not Series series) - return; - - // Abort if we're unable to get the shoko series id - if (!Lookup.TryGetSeriesIdFor(season.Series, out var seriesId)) - return; - - if (IsActionForIdOfTypeLocked("series", seriesId, "update")) - return; - - var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; - if (!TryLockActionForIdOFType("season", seasonId, "update")) - return; - - try { - UpdateSeason(season, series, seriesId); - } - finally { - TryUnlockActionForIdOFType("season", seasonId, "update"); - } - - return; - } - case Episode episode: { - // Abort if we're unable to get the shoko episode id - if (!(Lookup.TryGetEpisodeIdFor(episode, out var episodeId) && Lookup.TryGetSeriesIdFromEpisodeId(episodeId, out var seriesId))) - return; - - if (IsActionForIdOfTypeLocked("series", seriesId, "update")) - return; - - if (episode.ParentIndexNumber.HasValue) { - var seasonId = $"{seriesId}:{episode.ParentIndexNumber.Value}"; - if (IsActionForIdOfTypeLocked("season", seasonId, "update")) - return; - } - - if (!TryLockActionForIdOFType("episode", episodeId, "update")) - return; - - try { - RemoveDuplicateEpisodes(episode, episodeId); - } - finally { - TryUnlockActionForIdOFType("episode", episodeId, "update"); - } - - return; - } - } - } - - private void OnLibraryManagerItemUpdated(object? sender, ItemChangeEventArgs e) - { - if (e == null || e.Item == null || e.Parent == null || e.UpdateReason.HasFlag(ItemUpdateType.None)) - return; - - switch (e.Item) { - case Series series: { - // Abort if we're unable to get the shoko episode id - if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) - return; - - if (!TryLockActionForIdOFType("series", seriesId, "update")) - return; - - try { - UpdateSeries(series, seriesId); - - RemoveDuplicateSeasons(series, seriesId); - } - finally { - TryUnlockActionForIdOFType("series", seriesId, "update"); - } - - return; - } - case Season season: { - // We're not interested in the dummy season. - if (!season.IndexNumber.HasValue) - return; - - // Abort if we're unable to get the shoko series id - if (!Lookup.TryGetSeriesIdFor(season.Series, out var seriesId)) - return; - - if (IsActionForIdOfTypeLocked("series", seriesId, "update")) - return; - - var seasonId = $"{seriesId}:{season.IndexNumber.Value}"; - if (!TryLockActionForIdOFType("season", seasonId, "update")) - return; - - try { - var series = season.Series; - UpdateSeason(season, series, seriesId); - - RemoveDuplicateSeasons(season, series, season.IndexNumber.Value, seriesId); - } - finally { - TryUnlockActionForIdOFType("season", seasonId, "update"); - } - - return; - } - case Episode episode: { - // Abort if we're unable to get the shoko episode id - if (!(Lookup.TryGetEpisodeIdFor(episode, out var episodeId) && Lookup.TryGetSeriesIdFromEpisodeId(episodeId, out var seriesId))) - return; - - if (IsActionForIdOfTypeLocked("series", seriesId, "update")) - return; - - if (episode.ParentIndexNumber.HasValue) { - var seasonId = $"{seriesId}:{episode.ParentIndexNumber.Value}"; - if (IsActionForIdOfTypeLocked("season", seasonId, "update")) - return; - } - - if (!TryLockActionForIdOFType("episode", episodeId, "update")) - return; - - try { - RemoveDuplicateEpisodes(episode, episodeId); - } - finally { - TryUnlockActionForIdOFType("episode", episodeId, "update"); - } - - return; - } - } - } + #region Series - private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) + public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptions options, CancellationToken cancellationToken) { - if (e == null || e.Item == null || e.Parent == null) - return; - - if (e.Item.IsVirtualItem) - return; - - switch (e.Item) { - // Clean up after removing a series. - case Series series: { - if (!Lookup.TryGetSeriesIdFor(series, out var _)) - return; - - foreach (var season in series.Children.OfType<Season>()) - OnLibraryManagerItemRemoved(this, new ItemChangeEventArgs { Item = season, Parent = series, UpdateReason = ItemUpdateType.None }); - - return; - } - // Create a new virtual season if the real one was deleted and clean up extras if the season was deleted. - case Season season: { - // Abort if we're unable to get the shoko episode id - if (!(Lookup.TryGetSeriesIdFor(season.Series, out var seriesId) && (e.Parent is Series series))) - return; - - if (season.IndexNumber.HasValue) - UpdateSeason(season, series, seriesId, true); - - return; - } - // Similarly, create a new virtual episode if the real one was deleted. - case Episode episode: { - if (!Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) - return; - - RemoveDuplicateEpisodes(episode, episodeId); + // Abort if we're unable to get the shoko series id + if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + return ItemUpdateType.None; - UpdateEpisode(episode, episodeId); - - return; - } - } - } - - private void UpdateSeries(Series series, string seriesId) - { // Provide metadata for a series using Shoko's Group feature - var showInfo = ApiManager.GetShowInfoForSeries(seriesId) - .GetAwaiter() - .GetResult(); + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); if (showInfo == null || showInfo.SeasonList.Count == 0) { Logger.LogWarning("Unable to find show info for series. (Series={SeriesID})", seriesId); - return; + return ItemUpdateType.None; } // Get the existing seasons and episode ids - var (seasons, episodeIds) = GetExistingSeasonsAndEpisodeIds(series); - - // Add missing seasons - foreach (var (seasonNumber, season) in CreateMissingSeasons(showInfo, series, seasons)) - seasons.TryAdd(seasonNumber, season); - - // Handle specials when grouped. - if (seasons.TryGetValue(0, out var zeroSeason)) { - foreach (var seasonInfo in showInfo.SeasonList) { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) - episodeIds.Add(episodeId); - - foreach (var episodeInfo in seasonInfo.SpecialsList) { - if (episodeIds.Contains(episodeInfo.Id)) - continue; - - AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, zeroSeason); - } - } - } - - // Add missing episodes - foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; - - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) - episodeIds.Add(episodeId); - - foreach (var episodeInfo in seasonInfo.EpisodeList) { - if (episodeIds.Contains(episodeInfo.Id)) + var itemUpdated = ItemUpdateType.None; + if (Plugin.Instance.Configuration.AddMissingMetadata) { + var hasSpecials = false; + var (seasons, _) = GetExistingSeasonsAndEpisodeIds(series); + foreach (var pair in showInfo.SeasonOrderDictionary) { + if (seasons.ContainsKey(pair.Key)) continue; - - AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); - } - } - } - - private void UpdateSeason(Season season, Series series, string seriesId, bool deleted = false) - { - var seasonNumber = season.IndexNumber!.Value; - var showInfo = ApiManager.GetShowInfoForSeries(seriesId) - .GetAwaiter() - .GetResult(); - if (showInfo == null || showInfo.SeasonList.Count == 0) { - Logger.LogWarning("Unable to find show info for season. (Series={SeriesId})", seriesId); - return; - } - - // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - var existingEpisodes = new HashSet<string>(); - foreach (var episode in season.Children.OfType<Episode>()) { - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - foreach (var episodeId in episodeIds) - existingEpisodes.Add(episodeId); - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) - existingEpisodes.Add(episodeId); - } - - // Special handling of specials (pun intended). - if (seasonNumber == 0) { - if (deleted && AddVirtualSeason(0, series) is Season virtualSeason) - season = virtualSeason; - - foreach (var sI in showInfo.SeasonList) { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(sI.Id)) - existingEpisodes.Add(episodeId); - - foreach (var episodeInfo in sI.SpecialsList) { - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; - - AddVirtualEpisode(showInfo, sI, episodeInfo, season); - } - } - } - // Every other "season". - else { - var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); - if (seasonInfo == null) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); - return; + if (pair.Value.SpecialsList.Count > 0) + hasSpecials = true; + var offset = pair.Key - showInfo.SeasonNumberBaseDictionary[pair.Value.Id]; + var season = AddVirtualSeason(pair.Value, offset, pair.Key, series); + if (season != null) + itemUpdated |= ItemUpdateType.MetadataImport; } - var offset = seasonNumber - showInfo.SeasonNumberBaseDictionary[seasonInfo.Id]; - if (deleted && AddVirtualSeason(seasonInfo, offset, seasonNumber, series) is Season virtualSeason) - season = virtualSeason; - - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) - existingEpisodes.Add(episodeId); - - foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList).Concat(seasonInfo.OthersList)) { - var episodeParentIndex = episodeInfo.IsSpecial ? 0 : Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); - if (episodeParentIndex != seasonNumber) - continue; - - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; - - AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season); + if (hasSpecials && !seasons.ContainsKey(0)) { + var season = AddVirtualSeason(0, series); + if (season != null) + itemUpdated |= ItemUpdateType.MetadataImport; } } - } - private void UpdateEpisode(Episode episode, string episodeId) - { - var showInfo = ApiManager.GetShowInfoForEpisode(episodeId) - .GetAwaiter() - .GetResult(); - if (showInfo == null || showInfo.SeasonList.Count == 0) { - Logger.LogWarning("Unable to find show info for episode. (Episode={EpisodeId})", episode); - return; - } - var seasonInfo = ApiManager.GetSeasonInfoForEpisode(episodeId) - .GetAwaiter() - .GetResult(); - if (seasonInfo == null) { - Logger.LogWarning("Unable to find season info for episode. (Episode={EpisodeId})", episode); - return; - } - var episodeInfo = seasonInfo.EpisodeList.FirstOrDefault(e => e.Id == episodeId); - if (episodeInfo == null) { - Logger.LogWarning("Unable to find episode info for episode. (Episode={EpisodeId})", episode); - return; - } - var episodeIds = ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id); - if (!episodeIds.Contains(episodeId)) - AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, episode.Season); + return itemUpdated; } - private (Dictionary<int, Season>, HashSet<string>) GetExistingSeasonsAndEpisodeIds(Series series) { var seasons = new Dictionary<int, Season>(); @@ -463,29 +107,6 @@ private void UpdateEpisode(Episode episode, string episodeId) return (seasons, episodes); } - #region Seasons - - private IEnumerable<(int, Season)> CreateMissingSeasons(Info.ShowInfo showInfo, Series series, Dictionary<int, Season> seasons) - { - bool hasSpecials = false; - foreach (var pair in showInfo.SeasonOrderDictionary) { - if (seasons.ContainsKey(pair.Key)) - continue; - if (pair.Value.SpecialsList.Count > 0) - hasSpecials = true; - var offset = pair.Key - showInfo.SeasonNumberBaseDictionary[pair.Value.Id]; - var season = AddVirtualSeason(pair.Value, offset, pair.Key, series); - if (season == null) - continue; - yield return (pair.Key, season); - } - if (hasSpecials && !seasons.ContainsKey(0)) { - var season = AddVirtualSeason(0, series); - if (season != null) - yield return (0, season); - } - } - private bool SeasonExists(string seriesPresentationUniqueKey, string seriesName, int seasonNumber) { var searchList = LibraryManager.GetItemList(new InternalItemsQuery { @@ -512,9 +133,7 @@ private bool SeasonExists(string seriesPresentationUniqueKey, string seriesName, if (seasonNumber == 0) seasonName = LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName; else - seasonName = string.Format( - LocalizationManager.GetLocalizedString("NameSeasonNumber"), - seasonNumber.ToString(CultureInfo.InvariantCulture)); + seasonName = string.Format(LocalizationManager.GetLocalizedString("NameSeasonNumber"), seasonNumber.ToString(CultureInfo.InvariantCulture)); var season = new Season { Name = seasonName, @@ -554,61 +173,109 @@ private bool SeasonExists(string seriesPresentationUniqueKey, string seriesName, return season; } - public void RemoveDuplicateSeasons(Series series, string seriesId) + #endregion + + #region Season + + public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptions options, CancellationToken cancellationToken) { - var seasonNumbers = new HashSet<int>(); - var seasons = series - .GetSeasons(null, new DtoOptions(true)) - .OfType<Season>() - .OrderBy(s => s.IsVirtualItem); - foreach (var season in seasons) { - if (!season.IndexNumber.HasValue) - continue; - - var seasonNumber = season.IndexNumber.Value; - if (!seasonNumbers.Add(seasonNumber)) - continue; - - RemoveDuplicateSeasons(season, series, seasonNumber, seriesId); + // We're not interested in the dummy season. + if (!season.IndexNumber.HasValue) + return ItemUpdateType.None; + + // Abort if we're unable to get the shoko series id + var series = season.Series; + if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + return ItemUpdateType.None; + + var seasonNumber = season.IndexNumber!.Value; + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for season. (Series={SeriesId})", seriesId); + return ItemUpdateType.None; } - } - public void RemoveDuplicateSeasons(Season season, Series series, int seasonNumber, string seriesId) - { - var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, - ExcludeItemIds = new [] { season.Id }, - IndexNumber = seasonNumber, - DtoOptions = new DtoOptions(true), - }, true).Where(item => !item.IndexNumber.HasValue).ToList(); + var itemUpdated = ItemUpdateType.None; + if (Plugin.Instance.Configuration.AddMissingMetadata) { + // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. + var existingEpisodes = new HashSet<string>(); + foreach (var episode in season.Children.OfType<Episode>()) { + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + foreach (var episodeId in episodeIds) + existingEpisodes.Add(episodeId); + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) + existingEpisodes.Add(episodeId); + } - if (searchList.Count == 0) - return; + // Special handling of specials (pun intended). + if (seasonNumber == 0) { + foreach (var sI in showInfo.SeasonList) { + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(sI.Id)) + existingEpisodes.Add(episodeId); - Logger.LogWarning("Removing {Count:00} duplicate seasons from Series {SeriesName} (Series={SeriesId})", searchList.Count, series.Name, seriesId); - var deleteOptions = new DeleteOptions { - DeleteFileLocation = false, - }; - foreach (var item in searchList) - LibraryManager.DeleteItem(item, deleteOptions); + foreach (var episodeInfo in sI.SpecialsList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + if (AddVirtualEpisode(showInfo, sI, episodeInfo, season)) + itemUpdated |= ItemUpdateType.MetadataImport; + } + } + } + // Every other "season". + else { + var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); + if (seasonInfo == null) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); + return ItemUpdateType.None; + } + + var offset = seasonNumber - showInfo.SeasonNumberBaseDictionary[seasonInfo.Id]; + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) + existingEpisodes.Add(episodeId); - var episodeNumbers = new HashSet<int?>(); - // Ordering by `IsVirtualItem` will put physical episodes first. - foreach (var episode in season.GetEpisodes(null, new DtoOptions(true)).OfType<Episode>().OrderBy(e => e.IsVirtualItem)) { - // Abort if we're unable to get the shoko episode id - if (!Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) - continue; + foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList).Concat(seasonInfo.OthersList)) { + var episodeParentIndex = episodeInfo.IsSpecial ? 0 : Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); + if (episodeParentIndex != seasonNumber) + continue; - // Only iterate over the same index number once. - if (!episodeNumbers.Add(episode.IndexNumber)) - continue; + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; - RemoveDuplicateEpisodes(episode, episodeId); + if (AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season)) + itemUpdated |= ItemUpdateType.MetadataImport; + } + } + } + + // Remove the virtual season/episode that matches the newly updated item + var searchList = LibraryManager + .GetItemList( + new() { + ParentId = season.ParentId, + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, + ExcludeItemIds = new [] { season.Id }, + IndexNumber = seasonNumber, + DtoOptions = new DtoOptions(true), + }, + true + ) + .Where(item => !item.IndexNumber.HasValue) + .ToList(); + if (searchList.Count > 0) + { + Logger.LogInformation("Removing {Count:00} duplicate seasons from Series {SeriesName} (Series={SeriesId})", searchList.Count, series.Name, seriesId); + + var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; + foreach (var item in searchList) + LibraryManager.DeleteItem(item, deleteOptions); + + itemUpdated |= ItemUpdateType.MetadataEdit; } - } - #endregion - #region Episodes + + return itemUpdated; + } private bool EpisodeExists(string episodeId, string seriesId, string? groupId) { @@ -625,10 +292,10 @@ private bool EpisodeExists(string episodeId, string seriesId, string? groupId) return false; } - private void AddVirtualEpisode(Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, Season season) + private bool AddVirtualEpisode(Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, Season season) { if (EpisodeExists(episodeInfo.Id, seasonInfo.Id, showInfo.GroupId)) - return; + return false; var episodeId = LibraryManager.GetNewItemId(season.Series.Id + " Season " + seasonInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); var episode = EpisodeProvider.CreateMetadata(showInfo, seasonInfo, episodeInfo, season, episodeId); @@ -636,31 +303,45 @@ private void AddVirtualEpisode(Info.ShowInfo showInfo, Info.SeasonInfo seasonInf Logger.LogInformation("Adding virtual Episode {EpisodeNumber:000} in Season {SeasonNumber:00} for Series {SeriesName}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episode.IndexNumber, season.Name, showInfo.Name, episodeInfo.Id, seasonInfo.Id, showInfo.GroupId); season.AddChild(episode); + + return true; } - private void RemoveDuplicateEpisodes(Episode episode, string episodeId) - { - var query = new InternalItemsQuery { - IsVirtualItem = true, - ExcludeItemIds = new [] { episode.Id }, - HasAnyProviderId = new Dictionary<string, string> { [ShokoEpisodeId.Name] = episodeId }, - IncludeItemTypes = new [] {Jellyfin.Data.Enums.BaseItemKind.Episode }, - GroupByPresentationUniqueKey = false, - DtoOptions = new DtoOptions(true), - }; + #endregion - var existingVirtualItems = LibraryManager.GetItemList(query); + #region Episode - var deleteOptions = new DeleteOptions { - DeleteFileLocation = false, - }; + public Task<ItemUpdateType> FetchAsync(Episode episode, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + // Abort if we're unable to get the shoko episode id + if (!Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) + return Task.FromResult(ItemUpdateType.None); // Remove the virtual season/episode that matches the newly updated item - foreach (var item in existingVirtualItems) - LibraryManager.DeleteItem(item, deleteOptions); + var searchList = LibraryManager + .GetItemList( + new() { + ParentId = episode.ParentId, + IsVirtualItem = true, + ExcludeItemIds = new[] { episode.Id }, + HasAnyProviderId = new() { { ShokoEpisodeId.Name, episodeId } }, + IncludeItemTypes = new[] { Jellyfin.Data.Enums.BaseItemKind.Episode }, + GroupByPresentationUniqueKey = false, + DtoOptions = new DtoOptions(true), + }, + true + ); + if (searchList.Count > 0) { + Logger.LogInformation("Removing {Count:00} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", searchList.Count, episode.Name, episodeId); + + var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; + foreach (var item in searchList) + LibraryManager.DeleteItem(item, deleteOptions); + + return Task.FromResult(ItemUpdateType.MetadataEdit); + } - if (existingVirtualItems.Count > 0) - Logger.LogInformation("Removed {Count:00} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", existingVirtualItems.Count, episode.Name, episodeId); + return Task.FromResult(ItemUpdateType.None); } #endregion From 50797805fa4afab33c6dcb761258205624d28dc2 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 9 Apr 2024 20:52:03 +0000 Subject: [PATCH 0766/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 13ea5262..1ab57ee8 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.98", + "changelog": "refactor: simplify extra metadata provider\n\nmisc: remove unneeded stored offset\n\nmisc: more logging for link generation stage", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.98/shoko_3.0.1.98.zip", + "checksum": "383409f47b08c2e92e60d391d16e061a", + "timestamp": "2024-04-09T20:52:02Z" + }, { "version": "3.0.1.97", "changelog": "fix: use older endpoints for stable", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.94/shoko_3.0.1.94.zip", "checksum": "bf3d9ffa87b6c1b1bab1cb37025711c6", "timestamp": "2024-04-09T08:30:39Z" - }, - { - "version": "3.0.1.93", - "changelog": "fix: don't throw if signalr service could not be started", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.93/shoko_3.0.1.93.zip", - "checksum": "3203f9969ddb7bf3d76f24e1a8181761", - "timestamp": "2024-04-08T21:49:58Z" } ] } From 0293399f701c74a06a7feeda4c4f129b4ff9132e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 9 Apr 2024 22:59:09 +0200 Subject: [PATCH 0767/1103] refactor: split-up extra metadata provider --- Shokofin/Providers/EpisodeProvider.cs | 42 ++- Shokofin/Providers/ExtraMetadataProvider.cs | 348 -------------------- Shokofin/Providers/SeasonProvider.cs | 141 +++++++- Shokofin/Providers/SeriesProvider.cs | 149 ++++++++- 4 files changed, 327 insertions(+), 353 deletions(-) delete mode 100644 Shokofin/Providers/ExtraMetadataProvider.cs diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 9975594f..e57ed61f 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -16,6 +16,7 @@ using Info = Shokofin.API.Info; using SeriesType = Shokofin.API.Models.SeriesType; using EpisodeType = Shokofin.API.Models.EpisodeType; +using MediaBrowser.Controller.Library; namespace Shokofin.Providers; @@ -29,11 +30,17 @@ public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo> private readonly ShokoAPIManager ApiManager; - public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProvider> logger, ShokoAPIManager apiManager) + private readonly IIdLookup Lookup; + + private readonly ILibraryManager LibraryManager; + + public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager) { HttpClientFactory = httpClientFactory; Logger = logger; ApiManager = apiManager; + Lookup = lookup; + LibraryManager = libraryManager; } public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) @@ -250,4 +257,37 @@ public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo search public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); + + public Task<ItemUpdateType> FetchAsync(Episode episode, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + // Abort if we're unable to get the shoko episode id + if (!Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) + return Task.FromResult(ItemUpdateType.None); + + // Remove any extra virtual episodes that matches the newly refreshed episode. + var searchList = LibraryManager + .GetItemList( + new() { + ParentId = episode.ParentId, + IsVirtualItem = true, + ExcludeItemIds = new[] { episode.Id }, + HasAnyProviderId = new() { { ShokoEpisodeId.Name, episodeId } }, + IncludeItemTypes = new[] { Jellyfin.Data.Enums.BaseItemKind.Episode }, + GroupByPresentationUniqueKey = false, + DtoOptions = new(true), + }, + true + ); + if (searchList.Count > 0) { + Logger.LogInformation("Removing {Count:00} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", searchList.Count, episode.Name, episodeId); + + var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; + foreach (var item in searchList) + LibraryManager.DeleteItem(item, deleteOptions); + + return Task.FromResult(ItemUpdateType.MetadataEdit); + } + + return Task.FromResult(ItemUpdateType.None); + } } diff --git a/Shokofin/Providers/ExtraMetadataProvider.cs b/Shokofin/Providers/ExtraMetadataProvider.cs deleted file mode 100644 index 23347d7a..00000000 --- a/Shokofin/Providers/ExtraMetadataProvider.cs +++ /dev/null @@ -1,348 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Globalization; -using Microsoft.Extensions.Logging; -using Shokofin.API; -using Shokofin.ExternalIds; -using Shokofin.Utils; - -using Info = Shokofin.API.Info; - -namespace Shokofin.Providers; - -public class ExtraMetadataProvider : ICustomMetadataProvider<Series>, ICustomMetadataProvider<Season>, ICustomMetadataProvider<Episode> -{ - private readonly ShokoAPIManager ApiManager; - - private readonly IIdLookup Lookup; - - private readonly ILibraryManager LibraryManager; - - private readonly ILocalizationManager LocalizationManager; - - private readonly ILogger<ExtraMetadataProvider> Logger; - - string IMetadataProvider.Name => Plugin.MetadataProviderName; - - public ExtraMetadataProvider(ShokoAPIManager apiManager, IIdLookup lookUp, ILibraryManager libraryManager, ILocalizationManager localizationManager, ILogger<ExtraMetadataProvider> logger) - { - ApiManager = apiManager; - Lookup = lookUp; - LibraryManager = libraryManager; - LocalizationManager = localizationManager; - Logger = logger; - } - - #region Series - - public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptions options, CancellationToken cancellationToken) - { - // Abort if we're unable to get the shoko series id - if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) - return ItemUpdateType.None; - - // Provide metadata for a series using Shoko's Group feature - var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); - if (showInfo == null || showInfo.SeasonList.Count == 0) { - Logger.LogWarning("Unable to find show info for series. (Series={SeriesID})", seriesId); - return ItemUpdateType.None; - } - - // Get the existing seasons and episode ids - var itemUpdated = ItemUpdateType.None; - if (Plugin.Instance.Configuration.AddMissingMetadata) { - var hasSpecials = false; - var (seasons, _) = GetExistingSeasonsAndEpisodeIds(series); - foreach (var pair in showInfo.SeasonOrderDictionary) { - if (seasons.ContainsKey(pair.Key)) - continue; - if (pair.Value.SpecialsList.Count > 0) - hasSpecials = true; - var offset = pair.Key - showInfo.SeasonNumberBaseDictionary[pair.Value.Id]; - var season = AddVirtualSeason(pair.Value, offset, pair.Key, series); - if (season != null) - itemUpdated |= ItemUpdateType.MetadataImport; - } - - if (hasSpecials && !seasons.ContainsKey(0)) { - var season = AddVirtualSeason(0, series); - if (season != null) - itemUpdated |= ItemUpdateType.MetadataImport; - } - } - - return itemUpdated; - } - private (Dictionary<int, Season>, HashSet<string>) GetExistingSeasonsAndEpisodeIds(Series series) - { - var seasons = new Dictionary<int, Season>(); - var episodes = new HashSet<string>(); - foreach (var item in series.GetRecursiveChildren()) switch (item) { - case Season season: - if (season.IndexNumber.HasValue) - seasons.TryAdd(season.IndexNumber.Value, season); - // Add all known episode ids for the season. - if (Lookup.TryGetSeriesIdFor(season, out var seriesId)) - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesId)) - episodes.Add(episodeId); - break; - case Episode episode: - // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - foreach (var episodeId in episodeIds) - episodes.Add(episodeId); - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) - episodes.Add(episodeId); - break; - } - return (seasons, episodes); - } - - private bool SeasonExists(string seriesPresentationUniqueKey, string seriesName, int seasonNumber) - { - var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, - IndexNumber = seasonNumber, - SeriesPresentationUniqueKey = seriesPresentationUniqueKey, - DtoOptions = new DtoOptions(true), - }, true); - - if (searchList.Count > 0) { - Logger.LogDebug("Season {SeasonName} for Series {SeriesName} was created in another concurrent thread, skipping.", searchList[0].Name, seriesName); - return true; - } - - return false; - } - - private Season? AddVirtualSeason(int seasonNumber, Series series) - { - if (SeasonExists(series.GetPresentationUniqueKey(), series.Name, seasonNumber)) - return null; - - string seasonName; - if (seasonNumber == 0) - seasonName = LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName; - else - seasonName = string.Format(LocalizationManager.GetLocalizedString("NameSeasonNumber"), seasonNumber.ToString(CultureInfo.InvariantCulture)); - - var season = new Season { - Name = seasonName, - IndexNumber = seasonNumber, - SortName = seasonName, - ForcedSortName = seasonName, - Id = LibraryManager.GetNewItemId( - series.Id + "Season " + seasonNumber.ToString(CultureInfo.InvariantCulture), - typeof(Season)), - IsVirtualItem = true, - SeriesId = series.Id, - SeriesName = series.Name, - SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), - DateModified = DateTime.UtcNow, - DateLastSaved = DateTime.UtcNow, - }; - - Logger.LogInformation("Adding virtual Season {SeasonNumber:00} to Series {SeriesName}.", seasonNumber, series.Name); - - series.AddChild(season); - - return season; - } - - private Season? AddVirtualSeason(Info.SeasonInfo seasonInfo, int offset, int seasonNumber, Series series) - { - if (SeasonExists(series.GetPresentationUniqueKey(), series.Name, seasonNumber)) - return null; - - var seasonId = LibraryManager.GetNewItemId(series.Id + "Season " + seasonNumber.ToString(CultureInfo.InvariantCulture), typeof(Season)); - var season = SeasonProvider.CreateMetadata(seasonInfo, seasonNumber, offset, series, seasonId); - - Logger.LogInformation("Adding virtual Season {SeasonNumber:00} to Series {SeriesName}. (Series={SeriesId})", seasonNumber, series.Name, seasonInfo.Id); - - series.AddChild(season); - - return season; - } - - #endregion - - #region Season - - public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptions options, CancellationToken cancellationToken) - { - // We're not interested in the dummy season. - if (!season.IndexNumber.HasValue) - return ItemUpdateType.None; - - // Abort if we're unable to get the shoko series id - var series = season.Series; - if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) - return ItemUpdateType.None; - - var seasonNumber = season.IndexNumber!.Value; - var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); - if (showInfo == null || showInfo.SeasonList.Count == 0) { - Logger.LogWarning("Unable to find show info for season. (Series={SeriesId})", seriesId); - return ItemUpdateType.None; - } - - var itemUpdated = ItemUpdateType.None; - if (Plugin.Instance.Configuration.AddMissingMetadata) { - // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - var existingEpisodes = new HashSet<string>(); - foreach (var episode in season.Children.OfType<Episode>()) { - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - foreach (var episodeId in episodeIds) - existingEpisodes.Add(episodeId); - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) - existingEpisodes.Add(episodeId); - } - - // Special handling of specials (pun intended). - if (seasonNumber == 0) { - foreach (var sI in showInfo.SeasonList) { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(sI.Id)) - existingEpisodes.Add(episodeId); - - foreach (var episodeInfo in sI.SpecialsList) { - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; - - if (AddVirtualEpisode(showInfo, sI, episodeInfo, season)) - itemUpdated |= ItemUpdateType.MetadataImport; - } - } - } - // Every other "season". - else { - var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); - if (seasonInfo == null) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); - return ItemUpdateType.None; - } - - var offset = seasonNumber - showInfo.SeasonNumberBaseDictionary[seasonInfo.Id]; - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) - existingEpisodes.Add(episodeId); - - foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList).Concat(seasonInfo.OthersList)) { - var episodeParentIndex = episodeInfo.IsSpecial ? 0 : Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); - if (episodeParentIndex != seasonNumber) - continue; - - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; - - if (AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season)) - itemUpdated |= ItemUpdateType.MetadataImport; - } - } - } - - // Remove the virtual season/episode that matches the newly updated item - var searchList = LibraryManager - .GetItemList( - new() { - ParentId = season.ParentId, - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, - ExcludeItemIds = new [] { season.Id }, - IndexNumber = seasonNumber, - DtoOptions = new DtoOptions(true), - }, - true - ) - .Where(item => !item.IndexNumber.HasValue) - .ToList(); - if (searchList.Count > 0) - { - Logger.LogInformation("Removing {Count:00} duplicate seasons from Series {SeriesName} (Series={SeriesId})", searchList.Count, series.Name, seriesId); - - var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; - foreach (var item in searchList) - LibraryManager.DeleteItem(item, deleteOptions); - - itemUpdated |= ItemUpdateType.MetadataEdit; - } - - - return itemUpdated; - } - - private bool EpisodeExists(string episodeId, string seriesId, string? groupId) - { - var searchList = LibraryManager.GetItemList(new InternalItemsQuery { - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Episode }, - HasAnyProviderId = new Dictionary<string, string> { [ShokoEpisodeId.Name] = episodeId }, - DtoOptions = new DtoOptions(true), - }, true); - - if (searchList.Count > 0) { - Logger.LogDebug("A virtual or physical episode entry already exists for Episode {EpisodeName}. Ignoring. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", searchList[0].Name, episodeId, seriesId, groupId); - return true; - } - return false; - } - - private bool AddVirtualEpisode(Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, Season season) - { - if (EpisodeExists(episodeInfo.Id, seasonInfo.Id, showInfo.GroupId)) - return false; - - var episodeId = LibraryManager.GetNewItemId(season.Series.Id + " Season " + seasonInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); - var episode = EpisodeProvider.CreateMetadata(showInfo, seasonInfo, episodeInfo, season, episodeId); - - Logger.LogInformation("Adding virtual Episode {EpisodeNumber:000} in Season {SeasonNumber:00} for Series {SeriesName}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episode.IndexNumber, season.Name, showInfo.Name, episodeInfo.Id, seasonInfo.Id, showInfo.GroupId); - - season.AddChild(episode); - - return true; - } - - #endregion - - #region Episode - - public Task<ItemUpdateType> FetchAsync(Episode episode, MetadataRefreshOptions options, CancellationToken cancellationToken) - { - // Abort if we're unable to get the shoko episode id - if (!Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) - return Task.FromResult(ItemUpdateType.None); - - // Remove the virtual season/episode that matches the newly updated item - var searchList = LibraryManager - .GetItemList( - new() { - ParentId = episode.ParentId, - IsVirtualItem = true, - ExcludeItemIds = new[] { episode.Id }, - HasAnyProviderId = new() { { ShokoEpisodeId.Name, episodeId } }, - IncludeItemTypes = new[] { Jellyfin.Data.Enums.BaseItemKind.Episode }, - GroupByPresentationUniqueKey = false, - DtoOptions = new DtoOptions(true), - }, - true - ); - if (searchList.Count > 0) { - Logger.LogInformation("Removing {Count:00} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", searchList.Count, episode.Name, episodeId); - - var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; - foreach (var item in searchList) - LibraryManager.DeleteItem(item, deleteOptions); - - return Task.FromResult(ItemUpdateType.MetadataEdit); - } - - return Task.FromResult(ItemUpdateType.None); - } - - #endregion -} diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index cdf080e6..e463a793 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; @@ -19,16 +20,24 @@ namespace Shokofin.Providers; public class SeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo> { public string Name => Plugin.MetadataProviderName; + private readonly IHttpClientFactory HttpClientFactory; + private readonly ILogger<SeasonProvider> Logger; private readonly ShokoAPIManager ApiManager; - public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvider> logger, ShokoAPIManager apiManager) + private readonly IIdLookup Lookup; + + private readonly ILibraryManager LibraryManager; + + public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager) { HttpClientFactory = httpClientFactory; Logger = logger; ApiManager = apiManager; + Lookup = lookup; + LibraryManager = libraryManager; } public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) @@ -176,5 +185,135 @@ public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchI public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); + + public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + // We're not interested in the dummy season. + if (!season.IndexNumber.HasValue) + return ItemUpdateType.None; + + // Abort if we're unable to get the shoko series id + var series = season.Series; + if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + return ItemUpdateType.None; + + var seasonNumber = season.IndexNumber!.Value; + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for season. (Series={SeriesId})", seriesId); + return ItemUpdateType.None; + } + + var itemUpdated = ItemUpdateType.None; + if (Plugin.Instance.Configuration.AddMissingMetadata) { + // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. + var existingEpisodes = new HashSet<string>(); + foreach (var episode in season.Children.OfType<Episode>()) { + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + foreach (var episodeId in episodeIds) + existingEpisodes.Add(episodeId); + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) + existingEpisodes.Add(episodeId); + } + + // Special handling of specials (pun intended). + if (seasonNumber == 0) { + foreach (var sI in showInfo.SeasonList) { + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(sI.Id)) + existingEpisodes.Add(episodeId); + + foreach (var episodeInfo in sI.SpecialsList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + if (AddVirtualEpisode(showInfo, sI, episodeInfo, season)) + itemUpdated |= ItemUpdateType.MetadataImport; + } + } + } + // Every other "season". + else { + var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); + if (seasonInfo == null) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); + return ItemUpdateType.None; + } + + var offset = seasonNumber - showInfo.SeasonNumberBaseDictionary[seasonInfo.Id]; + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) + existingEpisodes.Add(episodeId); + + foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList).Concat(seasonInfo.OthersList)) { + var episodeParentIndex = episodeInfo.IsSpecial ? 0 : Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); + if (episodeParentIndex != seasonNumber) + continue; + + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + if (AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season)) + itemUpdated |= ItemUpdateType.MetadataImport; + } + } + } + + // Remove the virtual season/episode that matches the newly updated item + var searchList = LibraryManager + .GetItemList( + new() { + ParentId = season.ParentId, + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, + ExcludeItemIds = new [] { season.Id }, + IndexNumber = seasonNumber, + DtoOptions = new(true), + }, + true + ) + .Where(item => !item.IndexNumber.HasValue) + .ToList(); + if (searchList.Count > 0) + { + Logger.LogInformation("Removing {Count:00} duplicate seasons from Series {SeriesName} (Series={SeriesId})", searchList.Count, series.Name, seriesId); + + var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; + foreach (var item in searchList) + LibraryManager.DeleteItem(item, deleteOptions); + + itemUpdated |= ItemUpdateType.MetadataEdit; + } + + + return itemUpdated; + } + + private bool EpisodeExists(string episodeId, string seriesId, string? groupId) + { + var searchList = LibraryManager.GetItemList(new() { + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Episode }, + HasAnyProviderId = new Dictionary<string, string> { [ShokoEpisodeId.Name] = episodeId }, + DtoOptions = new(true), + }, true); + + if (searchList.Count > 0) { + Logger.LogDebug("A virtual or physical episode entry already exists for Episode {EpisodeName}. Ignoring. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", searchList[0].Name, episodeId, seriesId, groupId); + return true; + } + return false; + } + + private bool AddVirtualEpisode(Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, Season season) + { + if (EpisodeExists(episodeInfo.Id, seasonInfo.Id, showInfo.GroupId)) + return false; + + var episodeId = LibraryManager.GetNewItemId(season.Series.Id + " Season " + seasonInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); + var episode = EpisodeProvider.CreateMetadata(showInfo, seasonInfo, episodeInfo, season, episodeId); + + Logger.LogInformation("Adding virtual Episode {EpisodeNumber:000} in Season {SeasonNumber:00} for Series {SeriesName}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episode.IndexNumber, season.Name, showInfo.Name, episodeInfo.Id, seasonInfo.Id, showInfo.GroupId); + + season.AddChild(episode); + + return true; + } } diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index a23b96d9..eb972f50 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -1,13 +1,15 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; -using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; @@ -15,9 +17,11 @@ using Shokofin.ExternalIds; using Shokofin.Utils; +using Info = Shokofin.API.Info; + namespace Shokofin.Providers; -public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> +public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>, ICustomMetadataProvider<Series> { public string Name => Plugin.MetadataProviderName; @@ -29,12 +33,21 @@ public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> private readonly IFileSystem FileSystem; - public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager, IFileSystem fileSystem) + private readonly IIdLookup Lookup; + + private readonly ILibraryManager LibraryManager; + + private readonly ILocalizationManager LocalizationManager; + + public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager, IFileSystem fileSystem, IIdLookup lookup, ILibraryManager libraryManager, ILocalizationManager localizationManager) { Logger = logger; HttpClientFactory = httpClientFactory; ApiManager = apiManager; FileSystem = fileSystem; + Lookup = lookup; + LibraryManager = libraryManager; + LocalizationManager = localizationManager; } public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) @@ -117,4 +130,134 @@ public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo info, C public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); + + public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + // Abort if we're unable to get the shoko series id + if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + return ItemUpdateType.None; + + // Provide metadata for a series using Shoko's Group feature + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for series. (Series={SeriesID})", seriesId); + return ItemUpdateType.None; + } + + // Get the existing seasons and episode ids + var itemUpdated = ItemUpdateType.None; + if (Plugin.Instance.Configuration.AddMissingMetadata) { + var hasSpecials = false; + var (seasons, _) = GetExistingSeasonsAndEpisodeIds(series); + foreach (var pair in showInfo.SeasonOrderDictionary) { + if (seasons.ContainsKey(pair.Key)) + continue; + if (pair.Value.SpecialsList.Count > 0) + hasSpecials = true; + var offset = pair.Key - showInfo.SeasonNumberBaseDictionary[pair.Value.Id]; + var season = AddVirtualSeason(pair.Value, offset, pair.Key, series); + if (season != null) + itemUpdated |= ItemUpdateType.MetadataImport; + } + + if (hasSpecials && !seasons.ContainsKey(0)) { + var season = AddVirtualSeason(0, series); + if (season != null) + itemUpdated |= ItemUpdateType.MetadataImport; + } + } + + return itemUpdated; + } + private (Dictionary<int, Season>, HashSet<string>) GetExistingSeasonsAndEpisodeIds(Series series) + { + var seasons = new Dictionary<int, Season>(); + var episodes = new HashSet<string>(); + foreach (var item in series.GetRecursiveChildren()) switch (item) { + case Season season: + if (season.IndexNumber.HasValue) + seasons.TryAdd(season.IndexNumber.Value, season); + // Add all known episode ids for the season. + if (Lookup.TryGetSeriesIdFor(season, out var seriesId)) + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesId)) + episodes.Add(episodeId); + break; + case Episode episode: + // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + foreach (var episodeId in episodeIds) + episodes.Add(episodeId); + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) + episodes.Add(episodeId); + break; + } + return (seasons, episodes); + } + + private bool SeasonExists(string seriesPresentationUniqueKey, string seriesName, int seasonNumber) + { + var searchList = LibraryManager.GetItemList(new() { + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, + IndexNumber = seasonNumber, + SeriesPresentationUniqueKey = seriesPresentationUniqueKey, + DtoOptions = new(true), + }, true); + + if (searchList.Count > 0) { + Logger.LogDebug("Season {SeasonName} for Series {SeriesName} was created in another concurrent thread, skipping.", searchList[0].Name, seriesName); + return true; + } + + return false; + } + + private Season? AddVirtualSeason(int seasonNumber, Series series) + { + if (SeasonExists(series.GetPresentationUniqueKey(), series.Name, seasonNumber)) + return null; + + string seasonName; + if (seasonNumber == 0) + seasonName = LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName; + else + seasonName = string.Format(LocalizationManager.GetLocalizedString("NameSeasonNumber"), seasonNumber.ToString(CultureInfo.InvariantCulture)); + + var season = new Season { + Name = seasonName, + IndexNumber = seasonNumber, + SortName = seasonName, + ForcedSortName = seasonName, + Id = LibraryManager.GetNewItemId( + series.Id + "Season " + seasonNumber.ToString(CultureInfo.InvariantCulture), + typeof(Season)), + IsVirtualItem = true, + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), + DateModified = DateTime.UtcNow, + DateLastSaved = DateTime.UtcNow, + }; + + Logger.LogInformation("Adding virtual Season {SeasonNumber:00} to Series {SeriesName}.", seasonNumber, series.Name); + + series.AddChild(season); + + return season; + } + + private Season? AddVirtualSeason(Info.SeasonInfo seasonInfo, int offset, int seasonNumber, Series series) + { + if (SeasonExists(series.GetPresentationUniqueKey(), series.Name, seasonNumber)) + return null; + + var seasonId = LibraryManager.GetNewItemId(series.Id + "Season " + seasonNumber.ToString(CultureInfo.InvariantCulture), typeof(Season)); + var season = SeasonProvider.CreateMetadata(seasonInfo, seasonNumber, offset, series, seasonId); + + Logger.LogInformation("Adding virtual Season {SeasonNumber:00} to Series {SeriesName}. (Series={SeriesId})", seasonNumber, series.Name, seasonInfo.Id); + + series.AddChild(season); + + return season; + } + } From db6656dfea91e23f9c4ffa6a4566284977593d03 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 9 Apr 2024 20:59:59 +0000 Subject: [PATCH 0768/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 1ab57ee8..487df1e8 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.99", + "changelog": "refactor: split-up extra metadata provider", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.99/shoko_3.0.1.99.zip", + "checksum": "0631035367d742f60ccdb0ff7fe95b38", + "timestamp": "2024-04-09T20:59:58Z" + }, { "version": "3.0.1.98", "changelog": "refactor: simplify extra metadata provider\n\nmisc: remove unneeded stored offset\n\nmisc: more logging for link generation stage", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.95/shoko_3.0.1.95.zip", "checksum": "769d3cc409b02dc48e8d1aa1c77923e9", "timestamp": "2024-04-09T10:30:16Z" - }, - { - "version": "3.0.1.94", - "changelog": "fix: change to more permissive folder/file names", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.94/shoko_3.0.1.94.zip", - "checksum": "bf3d9ffa87b6c1b1bab1cb37025711c6", - "timestamp": "2024-04-09T08:30:39Z" } ] } From 7134cdde93ee7b57899e6a65c63bd3a065ef2ddf Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 10 Apr 2024 16:01:03 +0200 Subject: [PATCH 0769/1103] refactor: remove others but keep alt. ver. --- Shokofin/API/Info/SeasonInfo.cs | 12 ------------ Shokofin/API/Info/ShowInfo.cs | 4 ---- Shokofin/Providers/SeasonProvider.cs | 10 ++-------- Shokofin/Utils/Ordering.cs | 20 ++++---------------- 4 files changed, 6 insertions(+), 40 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 4398c315..f8fa0c40 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -48,13 +48,6 @@ public class SeasonInfo /// </summary> public List<EpisodeInfo> AlternateEpisodesList; - /// <summary> - /// A pre-filtered list of "other" episodes that belong to this series. - /// - /// Ordered by AniDb air-date. - /// </summary> - public List<EpisodeInfo> OthersList; - /// <summary> /// A pre-filtered list of "extra" videos that belong to this series. /// @@ -104,7 +97,6 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li var episodesList = new List<EpisodeInfo>(); var extrasList = new List<EpisodeInfo>(); var altEpisodesList = new List<EpisodeInfo>(); - var othersList = new List<EpisodeInfo>(); // Iterate over the episodes once and store some values for later use. int index = 0; @@ -116,9 +108,6 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li lastNormalEpisode = index; break; case EpisodeType.Other: - othersList.Add(episode); - break; - case EpisodeType.Unknown: altEpisodesList.Add(episode); break; default: @@ -154,7 +143,6 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li RawEpisodeList = episodes; EpisodeList = episodesList; AlternateEpisodesList = altEpisodesList; - OthersList = othersList; ExtrasList = extrasList; SpecialsAnchors = specialsAnchorDictionary; SpecialsList = specialsList; diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index 317f8520..834f81c8 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -126,8 +126,6 @@ public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) var seasonNumberOffset = 1; if (seasonInfo.AlternateEpisodesList.Count > 0) seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); - if (seasonInfo.OthersList.Count > 0) - seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); Id = seasonInfo.Id; GroupId = seasonInfo.Shoko.IDs.ParentGroup.ToString(); @@ -189,8 +187,6 @@ public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool u seasonOrderDictionary.Add(seasonNumberOffset, seasonInfo); if (seasonInfo.AlternateEpisodesList.Count > 0) seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); - if (seasonInfo.OthersList.Count > 0) - seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); } Id = defaultSeason.Id; diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index e463a793..df8362d5 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -116,13 +116,7 @@ public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber default: break; case 1: - if (seasonInfo.AlternateEpisodesList.Count > 0) - type = "Alternate Stories"; - else - type = "Other Episodes"; - break; - case 2: - type = "Other Episodes"; + type = "Alternate Version"; break; } if (!string.IsNullOrEmpty(type)) { @@ -243,7 +237,7 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) existingEpisodes.Add(episodeId); - foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList).Concat(seasonInfo.OthersList)) { + foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList)) { var episodeParentIndex = episodeInfo.IsSpecial ? 0 : Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); if (episodeParentIndex != seasonNumber) continue; diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index 853820dc..bb1fe96a 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -113,7 +113,6 @@ public static int GetEpisodeNumber(ShowInfo group, SeasonInfo series, EpisodeInf var sizes = series.Shoko.Sizes.Total; switch (episode.AniDB.Type) { case EpisodeType.Other: - case EpisodeType.Unknown: case EpisodeType.Normal: // offset += 0; // it's not needed, so it's just here as a comment instead. break; @@ -218,21 +217,10 @@ public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo if (!group.SeasonNumberBaseDictionary.TryGetValue(series.Id, out var seasonNumber)) throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id})"); - var offset = 0; - switch (episode.AniDB.Type) { - default: - break; - case EpisodeType.Unknown: { - offset = 1; - break; - } - case EpisodeType.Other: { - offset = series.AlternateEpisodesList.Count > 0 ? 2 : 1; - break; - } - } - - return seasonNumber + offset; + return episode.AniDB.Type switch { + EpisodeType.Other => seasonNumber + 1, + _ => seasonNumber, + }; } /// <summary> From 514a908ed953368cad653ff084fd62dafc206d33 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 10 Apr 2024 16:13:54 +0200 Subject: [PATCH 0770/1103] fix: make owarimonogarari great again! - Convert movie series into web series if all the normal episodes are hidden and we have "other" type episodes locally. --- Shokofin/API/Info/SeasonInfo.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index f8fa0c40..e06496ce 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -126,6 +126,14 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li index++; } + // Switch the type from movie to web if we've hidden the main movie, and we have some of the parts. + var type = series.AniDBEntity.Type; + if (type == SeriesType.Movie && episodesList.Count == 0 && altEpisodesList.Any(ep => ep.Shoko.Size > 0)) { + type = SeriesType.Web; + episodesList = altEpisodesList; + altEpisodesList = new(); + } + // While the filtered specials list is ordered by episode number specialsList = specialsList .OrderBy(e => e.AniDB.EpisodeNumber) @@ -135,7 +143,7 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li Shoko = series; AniDB = series.AniDBEntity; TvDB = series.TvDBEntityList.FirstOrDefault(); - Type = series.AniDBEntity.Type; + Type = type; Tags = tags; Genres = genres; Studios = studios; From fe9a1be891b14edda82984bb8dc8c34cddc295b3 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 10 Apr 2024 14:21:21 +0000 Subject: [PATCH 0771/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 487df1e8..d19b79d7 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.100", + "changelog": "fix: make owarimonogarari great again!\n\n- Convert movie series into web series if all the normal episodes are\n hidden and we have \"other\" type episodes locally.\n\nrefactor: remove others but keep alt. ver.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.100/shoko_3.0.1.100.zip", + "checksum": "5b9aeb9577ec28258ca4baca252d893d", + "timestamp": "2024-04-10T14:21:18Z" + }, { "version": "3.0.1.99", "changelog": "refactor: split-up extra metadata provider", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.96/shoko_3.0.1.96.zip", "checksum": "f0624ecbdd03c022423df5ed60323db6", "timestamp": "2024-04-09T14:00:16Z" - }, - { - "version": "3.0.1.95", - "changelog": "fix: update user password field type", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.95/shoko_3.0.1.95.zip", - "checksum": "769d3cc409b02dc48e8d1aa1c77923e9", - "timestamp": "2024-04-09T10:30:16Z" } ] } From 50a3f32fa9c79ea8ffbae13007ad17b1901a433a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 10 Apr 2024 20:45:47 +0200 Subject: [PATCH 0772/1103] refactor: allow specials only season info and always map the alternate episodes as main episodes if main episodes are filtered out. --- Shokofin/API/Info/EpisodeInfo.cs | 13 ---- Shokofin/API/Info/SeasonInfo.cs | 56 ++++++++++------ Shokofin/API/Info/ShowInfo.cs | 80 ++++++++++++++++------- Shokofin/Providers/SeasonProvider.cs | 9 ++- Shokofin/Providers/SeriesProvider.cs | 20 +++--- Shokofin/Resolvers/ShokoResolveManager.cs | 2 +- Shokofin/Utils/Ordering.cs | 79 +++++++++++----------- 7 files changed, 146 insertions(+), 113 deletions(-) diff --git a/Shokofin/API/Info/EpisodeInfo.cs b/Shokofin/API/Info/EpisodeInfo.cs index cc6a2f40..e7c43f34 100644 --- a/Shokofin/API/Info/EpisodeInfo.cs +++ b/Shokofin/API/Info/EpisodeInfo.cs @@ -2,8 +2,6 @@ using Shokofin.API.Models; using Shokofin.Utils; -using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; - namespace Shokofin.API.Info; public class EpisodeInfo @@ -18,17 +16,6 @@ public class EpisodeInfo public Episode.TvDB? TvDB; - public bool IsSpecial - { - get - { - if (ExtraType != null) return false; - var order = Plugin.Instance.Configuration.SpecialsPlacement; - var allowOtherData = order == SpecialOrderType.InBetweenSeasonByOtherData || order == SpecialOrderType.InBetweenSeasonMixed; - return allowOtherData ? (TvDB?.SeasonNumber == 0 || AniDB.Type == EpisodeType.Special) : AniDB.Type == EpisodeType.Special; - } - } - public EpisodeInfo(Episode episode) { Id = episode.IDs.Shoko.ToString(); diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index e06496ce..0a0186b8 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -9,56 +9,56 @@ namespace Shokofin.API.Info; public class SeasonInfo { - public string Id; + public readonly string Id; - public Series Shoko; + public readonly Series Shoko; - public Series.AniDBWithDate AniDB; + public readonly Series.AniDBWithDate AniDB; - public Series.TvDB? TvDB; + public readonly Series.TvDB? TvDB; - public SeriesType Type; + public readonly SeriesType Type; - public string[] Tags; + public readonly IReadOnlyList<string> Tags; - public string[] Genres; + public readonly IReadOnlyList<string> Genres; - public string[] Studios; + public readonly IReadOnlyList<string> Studios; - public PersonInfo[] Staff; + public readonly IReadOnlyList<PersonInfo> Staff; /// <summary> /// All episodes (of all type) that belong to this series. /// /// Unordered. /// </summary> - public List<EpisodeInfo> RawEpisodeList; + public readonly IReadOnlyList<EpisodeInfo> RawEpisodeList; /// <summary> /// A pre-filtered list of normal episodes that belong to this series. /// /// Ordered by AniDb air-date. /// </summary> - public List<EpisodeInfo> EpisodeList; + public readonly List<EpisodeInfo> EpisodeList; /// <summary> /// A pre-filtered list of "unknown" episodes that belong to this series. /// /// Ordered by AniDb air-date. /// </summary> - public List<EpisodeInfo> AlternateEpisodesList; + public readonly List<EpisodeInfo> AlternateEpisodesList; /// <summary> /// A pre-filtered list of "extra" videos that belong to this series. /// /// Ordered by AniDb air-date. /// </summary> - public List<EpisodeInfo> ExtrasList; + public readonly List<EpisodeInfo> ExtrasList; /// <summary> /// A dictionary holding mappings for the previous normal episode for every special episode in a series. /// </summary> - public Dictionary<EpisodeInfo, EpisodeInfo> SpecialsAnchors; + public readonly IReadOnlyDictionary<EpisodeInfo, EpisodeInfo> SpecialsAnchors; /// <summary> /// A pre-filtered list of special episodes without an ExtraType @@ -66,17 +66,17 @@ public class SeasonInfo /// /// Ordered by AniDb episode number. /// </summary> - public List<EpisodeInfo> SpecialsList; + public readonly List<EpisodeInfo> SpecialsList; /// <summary> /// Related series data available in Shoko. /// </summary> - public List<Relation> Relations; + public readonly IReadOnlyList<Relation> Relations; /// <summary> /// Map of related series with type. /// </summary> - public Dictionary<string, RelationType> RelationMap; + public readonly IReadOnlyDictionary<string, RelationType> RelationMap; public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, List<Relation> relations, string[] genres, string[] tags) { @@ -126,13 +126,29 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li index++; } - // Switch the type from movie to web if we've hidden the main movie, and we have some of the parts. + // Replace the normal episodes if we've hidden all the normal episodes and we have at least one + // alternate episode locally. var type = series.AniDBEntity.Type; - if (type == SeriesType.Movie && episodesList.Count == 0 && altEpisodesList.Any(ep => ep.Shoko.Size > 0)) { - type = SeriesType.Web; + if (episodesList.Count == 0 && altEpisodesList.Any(ep => ep.Shoko.Size > 0)) { + // Switch the type from movie to web if we've hidden the main movie, and we have some of the parts. + if (type == SeriesType.Movie) + type = SeriesType.Web; + episodesList = altEpisodesList; altEpisodesList = new(); } + // Treat all 'tv special' episodes as specials. + else if (type == SeriesType.TVSpecial) { + if (episodesList.Count > 0) { + specialsList.InsertRange(0, episodesList); + episodesList = new(); + } + if (altEpisodesList.Count > 0) { + specialsList.InsertRange(0, altEpisodesList); + altEpisodesList = new(); + } + specialsAnchorDictionary = new(); + } // While the filtered specials list is ordered by episode number specialsList = specialsList diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index 834f81c8..56e17c62 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -14,22 +14,22 @@ public class ShowInfo /// <summary> /// Main Shoko Series Id. /// </summary> - public string Id; + public readonly string Id; /// <summary> /// Main Shoko Group Id. /// </summary> - public string? GroupId; + public readonly string? GroupId; /// <summary> /// Shoko Group Id used for Collection Support. /// </summary> - public string? CollectionId; + public readonly string? CollectionId; /// <summary> /// The main name of the show. /// </summary> - public string Name; + public readonly string Name; /// <summary> /// Indicates this is a standalone show without a group attached to it. @@ -40,7 +40,7 @@ public class ShowInfo /// <summary> /// The Shoko Group, if this is not a standalone show entry. /// </summary> - public Group? Shoko; + public readonly Group? Shoko; /// <summary> /// First premiere date of the show. @@ -82,51 +82,66 @@ public class ShowInfo /// <summary> /// All tags from across all seasons. /// </summary> - public string[] Tags; + public readonly IReadOnlyList<string> Tags; /// <summary> /// All genres from across all seasons. /// </summary> - public string[] Genres; + public readonly IReadOnlyList<string> Genres; /// <summary> /// All studios from across all seasons. /// </summary> - public string[] Studios; + public readonly IReadOnlyList<string> Studios; /// <summary> /// All staff from across all seasons. /// </summary> - public PersonInfo[] Staff; + public readonly IReadOnlyList<PersonInfo> Staff; /// <summary> /// All seasons. /// </summary> - public List<SeasonInfo> SeasonList; + public readonly List<SeasonInfo> SeasonList; /// <summary> /// The season order dictionary. /// </summary> - public Dictionary<int, SeasonInfo> SeasonOrderDictionary; + public readonly IReadOnlyDictionary<int, SeasonInfo> SeasonOrderDictionary; /// <summary> /// The season number base-number dictionary. /// </summary> - public Dictionary<string, int> SeasonNumberBaseDictionary; + private readonly IReadOnlyDictionary<string, int> SeasonNumberBaseDictionary; + + /// <summary> + /// A pre-filtered set of special episode ids without an ExtraType + /// attached. + /// </summary> + private readonly IReadOnlySet<string> SpecialsSet; + + /// <summary> + /// Indicates that the show has specials. + /// </summary> + public bool HasSpecials => + SpecialsSet.Count > 0; /// <summary> /// The default season for the show. /// </summary> - public SeasonInfo DefaultSeason; + public readonly SeasonInfo DefaultSeason; public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) { - var seasonNumberBaseDictionary = new Dictionary<string, int>() { { seasonInfo.Id, 1 } }; - var seasonOrderDictionary = new Dictionary<int, SeasonInfo>() { { 1, seasonInfo } }; - var seasonNumberOffset = 1; + var seasonNumberBaseDictionary = new Dictionary<string, int>(); + var seasonOrderDictionary = new Dictionary<int, SeasonInfo>(); + var seasonNumberOffset = 0; + if (seasonInfo.EpisodeList.Count > 0 || seasonInfo.AlternateEpisodesList.Count > 0) + seasonNumberBaseDictionary.Add(seasonInfo.Id, seasonNumberOffset); + if (seasonInfo.EpisodeList.Count > 0) + seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); if (seasonInfo.AlternateEpisodesList.Count > 0) seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); - Id = seasonInfo.Id; GroupId = seasonInfo.Shoko.IDs.ParentGroup.ToString(); CollectionId = collectionId ?? seasonInfo.Shoko.IDs.ParentGroup.ToString(); @@ -135,9 +150,10 @@ public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) Genres = seasonInfo.Genres; Studios = seasonInfo.Studios; Staff = seasonInfo.Staff; - SeasonList = new() { seasonInfo }; + SeasonList = new List<SeasonInfo>() { seasonInfo }; SeasonNumberBaseDictionary = seasonNumberBaseDictionary; SeasonOrderDictionary = seasonOrderDictionary; + SpecialsSet = seasonInfo.SpecialsList.Select(episodeInfo => episodeInfo.Id).ToHashSet(); DefaultSeason = seasonInfo; } @@ -179,14 +195,19 @@ public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool u } var defaultSeason = seasonList[foundIndex]; + var specialsSet = new HashSet<string>(); var seasonOrderDictionary = new Dictionary<int, SeasonInfo>(); var seasonNumberBaseDictionary = new Dictionary<string, int>(); - var seasonNumberOffset = 0; - foreach (var (seasonInfo, index) in seasonList.Select((s, i) => (s, i))) { - seasonNumberBaseDictionary.Add(seasonInfo.Id, ++seasonNumberOffset); - seasonOrderDictionary.Add(seasonNumberOffset, seasonInfo); + var seasonNumberOffset = 1; + foreach (var seasonInfo in seasonList) { + if (seasonInfo.EpisodeList.Count > 0 || seasonInfo.AlternateEpisodesList.Count > 0) + seasonNumberBaseDictionary.Add(seasonInfo.Id, seasonNumberOffset); + if (seasonInfo.EpisodeList.Count > 0) + seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo); if (seasonInfo.AlternateEpisodesList.Count > 0) - seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); + seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo); + foreach (var episodeInfo in seasonInfo.SpecialsList) + specialsSet.Add(episodeInfo.Id); } Id = defaultSeason.Id; @@ -201,10 +222,21 @@ public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool u SeasonList = seasonList; SeasonNumberBaseDictionary = seasonNumberBaseDictionary; SeasonOrderDictionary = seasonOrderDictionary; + SpecialsSet = specialsSet; DefaultSeason = defaultSeason; } - public SeasonInfo? GetSeriesInfoBySeasonNumber(int seasonNumber) { + public bool IsSpecial(EpisodeInfo episodeInfo) + => SpecialsSet.Contains(episodeInfo.Id); + + public bool TryGetBaseSeasonNumberForSeasonInfo(SeasonInfo season, out int baseSeasonNumber) + => SeasonNumberBaseDictionary.TryGetValue(season.Id, out baseSeasonNumber); + + public int GetBaseSeasonNumberForSeasonInfo(SeasonInfo season) + => SeasonNumberBaseDictionary.TryGetValue(season.Id, out var baseSeasonNumber) ? baseSeasonNumber : 0; + + public SeasonInfo? GetSeasonInfoBySeasonNumber(int seasonNumber) + { if (seasonNumber == 0 || !(SeasonOrderDictionary.TryGetValue(seasonNumber, out var seasonInfo) && seasonInfo != null)) return null; diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index df8362d5..d76f5406 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -75,8 +75,8 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat return result; } - var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); - if (seasonInfo == null || !showInfo.SeasonNumberBaseDictionary.TryGetValue(seasonInfo.Id, out var baseSeasonNumber)) { + var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber); + if (seasonInfo == null || !showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var baseSeasonNumber)) { Logger.LogWarning("Unable to find series info for Season {SeasonNumber}. (Series={SeriesId},Group={GroupId})", seasonNumber, seriesId, showInfo.GroupId); return result; } @@ -227,18 +227,17 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio } // Every other "season". else { - var seasonInfo = showInfo.GetSeriesInfoBySeasonNumber(seasonNumber); + var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber); if (seasonInfo == null) { Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); return ItemUpdateType.None; } - var offset = seasonNumber - showInfo.SeasonNumberBaseDictionary[seasonInfo.Id]; foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) existingEpisodes.Add(episodeId); foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList)) { - var episodeParentIndex = episodeInfo.IsSpecial ? 0 : Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); + var episodeParentIndex = Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); if (episodeParentIndex != seasonNumber) continue; diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index eb972f50..551dcb28 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -85,9 +86,9 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat ProductionYear = premiereDate?.Year, EndDate = endDate, Status = !endDate.HasValue || endDate.Value > DateTime.UtcNow ? SeriesStatus.Continuing : SeriesStatus.Ended, - Tags = show.Tags, - Genres = show.Genres, - Studios = show.Studios, + Tags = show.Tags.ToArray(), + Genres = show.Genres.ToArray(), + Studios = show.Studios.ToArray(), OfficialRating = show.OfficialRating, CustomRating = show.CustomRating, CommunityRating = show.CommunityRating, @@ -147,20 +148,17 @@ public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptio // Get the existing seasons and episode ids var itemUpdated = ItemUpdateType.None; if (Plugin.Instance.Configuration.AddMissingMetadata) { - var hasSpecials = false; var (seasons, _) = GetExistingSeasonsAndEpisodeIds(series); - foreach (var pair in showInfo.SeasonOrderDictionary) { - if (seasons.ContainsKey(pair.Key)) + foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { + if (seasons.ContainsKey(seasonNumber)) continue; - if (pair.Value.SpecialsList.Count > 0) - hasSpecials = true; - var offset = pair.Key - showInfo.SeasonNumberBaseDictionary[pair.Value.Id]; - var season = AddVirtualSeason(pair.Value, offset, pair.Key, series); + var offset = seasonNumber - showInfo.GetBaseSeasonNumberForSeasonInfo(seasonInfo); + var season = AddVirtualSeason(seasonInfo, offset, seasonNumber, series); if (season != null) itemUpdated |= ItemUpdateType.MetadataImport; } - if (hasSpecials && !seasons.ContainsKey(0)) { + if (showInfo.HasSpecials && !seasons.ContainsKey(0)) { var season = AddVirtualSeason(0, series); if (season != null) itemUpdated |= ItemUpdateType.MetadataImport; diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 43a5c7d5..efa08455 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -539,7 +539,7 @@ await Task.WhenAll(files.Select(async (tuple) => { } } else { - var isSpecial = episode.IsSpecial; + var isSpecial = show.IsSpecial(episode); var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); var seasonName = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}"; if (!string.IsNullOrEmpty(extrasFolder)) { diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index bb1fe96a..4962fd84 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -87,31 +87,33 @@ public enum SpecialOrderType { /// Get index number for an episode in a series. /// </summary> /// <returns>Absolute index.</returns> - public static int GetEpisodeNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) + public static int GetEpisodeNumber(ShowInfo showInfo, SeasonInfo seasonInfo, EpisodeInfo episodeInfo) { int offset = 0; - if (episode.ExtraType != null) { - var seasonIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); + if (episodeInfo.ExtraType != null) { + var seasonIndex = showInfo.SeasonList.FindIndex(s => string.Equals(s.Id, seasonInfo.Id)); if (seasonIndex == -1) - throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); - var index = series.ExtrasList.FindIndex(e => string.Equals(e.Id, episode.Id)); + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={showInfo.GroupId},Series={seasonInfo.Id},Episode={episodeInfo.Id})"); + var index = seasonInfo.ExtrasList.FindIndex(e => string.Equals(e.Id, episodeInfo.Id)); if (index == -1) - throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); - offset = group.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.ExtrasList.Count); + throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={showInfo.GroupId},Series={seasonInfo.Id},Episode={episodeInfo.Id})"); + offset = showInfo.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.ExtrasList.Count); return offset + index + 1; } - if (episode.AniDB.Type == EpisodeType.Special) { - var seasonIndex = group.SeasonList.FindIndex(s => string.Equals(s.Id, series.Id)); + + if (showInfo.IsSpecial(episodeInfo)) { + var seasonIndex = showInfo.SeasonList.FindIndex(s => string.Equals(s.Id, seasonInfo.Id)); if (seasonIndex == -1) - throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); - var index = series.SpecialsList.FindIndex(e => string.Equals(e.Id, episode.Id)); + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={showInfo.GroupId},Series={seasonInfo.Id},Episode={episodeInfo.Id})"); + var index = seasonInfo.SpecialsList.FindIndex(e => string.Equals(e.Id, episodeInfo.Id)); if (index == -1) - throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={group.Id},Series={series.Id},Episode={episode.Id})"); - offset = group.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); + throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={showInfo.GroupId},Series={seasonInfo.Id},Episode={episodeInfo.Id})"); + offset = showInfo.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); return offset + index + 1; } - var sizes = series.Shoko.Sizes.Total; - switch (episode.AniDB.Type) { + + var sizes = seasonInfo.Shoko.Sizes.Total; + switch (episodeInfo.AniDB.Type) { case EpisodeType.Other: case EpisodeType.Normal: // offset += 0; // it's not needed, so it's just here as a comment instead. @@ -130,25 +132,25 @@ public static int GetEpisodeNumber(ShowInfo group, SeasonInfo series, EpisodeInf offset += sizes?.Trailers ?? 0; goto case EpisodeType.Trailer; } - return offset + episode.AniDB.EpisodeNumber; + return offset + episodeInfo.AniDB.EpisodeNumber; } - public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo group, SeasonInfo series, EpisodeInfo episode) + public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo showInfo, SeasonInfo seasonInfo, EpisodeInfo episodeInfo) { var order = Plugin.Instance.Configuration.SpecialsPlacement; // Return early if we want to exclude them from the normal seasons. if (order == SpecialOrderType.Excluded) { // Check if this should go in the specials season. - return (null, null, null, episode.IsSpecial); + return (null, null, null, showInfo.IsSpecial(episodeInfo)); } // Abort if episode is not a TvDB special or AniDB special - if (!episode.IsSpecial) + if (!showInfo.IsSpecial(episodeInfo)) return (null, null, null, false); int? episodeNumber = null; - int seasonNumber = GetSeasonNumber(group, series, episode); + int seasonNumber = GetSeasonNumber(showInfo, seasonInfo, episodeInfo); int? airsBeforeEpisodeNumber = null; int? airsBeforeSeasonNumber = null; int? airsAfterSeasonNumber = null; @@ -160,10 +162,10 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo group, Seaso byAirdate: // Reset the order if we come from `SpecialOrderType.InBetweenSeasonMixed`. episodeNumber = null; - if (series.SpecialsAnchors.TryGetValue(episode, out var previousEpisode)) - episodeNumber = GetEpisodeNumber(group, series, previousEpisode); + if (seasonInfo.SpecialsAnchors.TryGetValue(episodeInfo, out var previousEpisode)) + episodeNumber = GetEpisodeNumber(showInfo, seasonInfo, previousEpisode); - if (episodeNumber.HasValue && episodeNumber.Value < series.EpisodeList.Count) { + if (episodeNumber.HasValue && episodeNumber.Value < seasonInfo.EpisodeList.Count) { airsBeforeEpisodeNumber = episodeNumber.Value + 1; airsBeforeSeasonNumber = seasonNumber; } @@ -174,14 +176,14 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo group, Seaso case SpecialOrderType.InBetweenSeasonMixed: case SpecialOrderType.InBetweenSeasonByOtherData: // We need to have TvDB/TMDB data in the first place to do this method. - if (episode.TvDB == null) { + if (episodeInfo.TvDB == null) { if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirdate; break; } - episodeNumber = episode.TvDB.AirsBeforeEpisode; + episodeNumber = episodeInfo.TvDB.AirsBeforeEpisode; if (!episodeNumber.HasValue) { - if (episode.TvDB.AirsBeforeSeason.HasValue) { + if (episodeInfo.TvDB.AirsBeforeSeason.HasValue) { airsBeforeSeasonNumber = seasonNumber; break; } @@ -191,9 +193,9 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo group, Seaso break; } - var nextEpisode = series.EpisodeList.FirstOrDefault(e => e.TvDB != null && e.TvDB.SeasonNumber == seasonNumber && e.TvDB.EpisodeNumber == episodeNumber); + var nextEpisode = seasonInfo.EpisodeList.FirstOrDefault(e => e.TvDB != null && e.TvDB.SeasonNumber == seasonNumber && e.TvDB.EpisodeNumber == episodeNumber); if (nextEpisode != null) { - airsBeforeEpisodeNumber = GetEpisodeNumber(group, series, nextEpisode); + airsBeforeEpisodeNumber = GetEpisodeNumber(showInfo, seasonInfo, nextEpisode); airsBeforeSeasonNumber = seasonNumber; break; } @@ -208,19 +210,19 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo group, Seaso /// <summary> /// Get season number for an episode in a series. /// </summary> - /// <param name="group"></param> - /// <param name="series"></param> - /// <param name="episode"></param> + /// <param name="showInfo"></param> + /// <param name="seasonInfo"></param> + /// <param name="episodeInfo"></param> /// <returns></returns> - public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo episode) + public static int GetSeasonNumber(ShowInfo showInfo, SeasonInfo seasonInfo, EpisodeInfo episodeInfo) { - if (!group.SeasonNumberBaseDictionary.TryGetValue(series.Id, out var seasonNumber)) - throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={group.Id},Series={series.Id})"); + if (!showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var seasonNumber)) + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={showInfo.GroupId},Series={seasonInfo.Id})"); + + if (seasonInfo.AlternateEpisodesList.Any(ep => ep.Id == episodeInfo.Id)) + return seasonNumber + 1; - return episode.AniDB.Type switch { - EpisodeType.Other => seasonNumber + 1, - _ => seasonNumber, - }; + return seasonNumber; } /// <summary> @@ -234,7 +236,6 @@ public static int GetSeasonNumber(ShowInfo group, SeasonInfo series, EpisodeInfo { case EpisodeType.Normal: case EpisodeType.Other: - case EpisodeType.Unknown: return null; case EpisodeType.ThemeSong: case EpisodeType.OpeningSong: From 57bbbf472151914e41d1a8c12f3ad1cb83c77844 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 10 Apr 2024 18:55:26 +0000 Subject: [PATCH 0773/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index d19b79d7..920eb6f0 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.101", + "changelog": "refactor: allow specials only season info\nand always map the alternate episodes as main episodes\nif main episodes are filtered out.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.101/shoko_3.0.1.101.zip", + "checksum": "13f71ef620334d9d726383976c34624a", + "timestamp": "2024-04-10T18:55:25Z" + }, { "version": "3.0.1.100", "changelog": "fix: make owarimonogarari great again!\n\n- Convert movie series into web series if all the normal episodes are\n hidden and we have \"other\" type episodes locally.\n\nrefactor: remove others but keep alt. ver.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.97/shoko_3.0.1.97.zip", "checksum": "f5d22c7b6e98afc33b004d956605c36c", "timestamp": "2024-04-09T18:46:12Z" - }, - { - "version": "3.0.1.96", - "changelog": "fix: force ascii for vfs paths", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.96/shoko_3.0.1.96.zip", - "checksum": "f0624ecbdd03c022423df5ed60323db6", - "timestamp": "2024-04-09T14:00:16Z" } ] } From 34e0e66ed33405ddf7594ab86b4071138115446b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 10 Apr 2024 21:36:30 +0200 Subject: [PATCH 0774/1103] fix: fix season number assignement and usage --- Shokofin/API/Info/ShowInfo.cs | 2 +- Shokofin/Utils/Ordering.cs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index 56e17c62..3e9e9779 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -135,7 +135,7 @@ public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) { var seasonNumberBaseDictionary = new Dictionary<string, int>(); var seasonOrderDictionary = new Dictionary<int, SeasonInfo>(); - var seasonNumberOffset = 0; + var seasonNumberOffset = 1; if (seasonInfo.EpisodeList.Count > 0 || seasonInfo.AlternateEpisodesList.Count > 0) seasonNumberBaseDictionary.Add(seasonInfo.Id, seasonNumberOffset); if (seasonInfo.EpisodeList.Count > 0) diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index 4962fd84..cab7af5c 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -216,8 +216,11 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo showInfo, Se /// <returns></returns> public static int GetSeasonNumber(ShowInfo showInfo, SeasonInfo seasonInfo, EpisodeInfo episodeInfo) { - if (!showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var seasonNumber)) + if (!showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var seasonNumber)) { + if (showInfo.IsSpecial(episodeInfo)) + return 0; throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={showInfo.GroupId},Series={seasonInfo.Id})"); + } if (seasonInfo.AlternateEpisodesList.Any(ep => ep.Id == episodeInfo.Id)) return seasonNumber + 1; From f61a1a95de8364445ef7000a54171280ddb974bb Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 10 Apr 2024 19:37:19 +0000 Subject: [PATCH 0775/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 920eb6f0..c462a835 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.102", + "changelog": "fix: fix season number assignement and usage", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.102/shoko_3.0.1.102.zip", + "checksum": "fbb7a00d35223a71fd26c4ee0c96273b", + "timestamp": "2024-04-10T19:37:18Z" + }, { "version": "3.0.1.101", "changelog": "refactor: allow specials only season info\nand always map the alternate episodes as main episodes\nif main episodes are filtered out.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.98/shoko_3.0.1.98.zip", "checksum": "383409f47b08c2e92e60d391d16e061a", "timestamp": "2024-04-09T20:52:02Z" - }, - { - "version": "3.0.1.97", - "changelog": "fix: use older endpoints for stable", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.97/shoko_3.0.1.97.zip", - "checksum": "f5d22c7b6e98afc33b004d956605c36c", - "timestamp": "2024-04-09T18:46:12Z" } ] } From 327fe7dd854c267a273f613eadd497b5e8751204 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 10 Apr 2024 21:40:33 +0200 Subject: [PATCH 0776/1103] fix: fix season number usage --- Shokofin/Resolvers/ShokoResolveManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index efa08455..be9b0032 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -540,7 +540,7 @@ await Task.WhenAll(files.Select(async (tuple) => { } else { var isSpecial = show.IsSpecial(episode); - var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); + var seasonNumber = isSpecial ? 0 : Ordering.GetSeasonNumber(show, season, episode); var seasonName = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}"; if (!string.IsNullOrEmpty(extrasFolder)) { folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", extrasFolder)); From 2f27ed66eeeb44402b377fe07540fc09ed493e2f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 10 Apr 2024 22:05:30 +0200 Subject: [PATCH 0777/1103] fix: only create links for series in the local collection - Only create links for any local series linked to multi-series files. This should prevent a file linked to multiple series from creating a new mostly empty series if the user don't have any other files for said series. --- Shokofin/Resolvers/ShokoResolveManager.cs | 38 ++++++++++++++++++----- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index be9b0032..eb62047b 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -271,7 +271,9 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold for (var page = 2; page <= totalPages; page++) pages.Add(GetImportFolderFilesPage(importFolderId, importFolderSubPath, page, semaphore)); - var totalFiles = 0; + var singleSeriesIds = new HashSet<int>(); + var multiSeriesFiles = new List<(API.Models.File, string)>(); + var totalSingleSeriesFiles = 0; do { var task = Task.WhenAny(pages).ConfigureAwait(false).GetAwaiter().GetResult(); pages.Remove(task); @@ -296,22 +298,44 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold if (!fileSet.Contains(sourceLocation)) continue; - totalFiles++; - foreach (var xref in file.CrossReferences) - yield return (sourceLocation, fileId: file.Id.ToString(), seriesId: xref.Series.Shoko.ToString()); + // Yield all single-series files now, and offset the processing of all multi-series files for later. + var seriesIds = file.CrossReferences.Select(x => x.Series.Shoko).ToHashSet(); + if (seriesIds.Count == 1) { + totalSingleSeriesFiles++; + singleSeriesIds.Add(seriesIds.First()); + foreach (var xref in file.CrossReferences) + yield return (sourceLocation, fileId: file.Id.ToString(), seriesId: xref.Series.Shoko.ToString()); + } + else if (seriesIds.Count > 1) { + multiSeriesFiles.Add((file, sourceLocation)); + } } } while (pages.Count > 0); + // Check which series of the multiple series we have, and only yield + // the paths for the series we have. This will fail if an OVA episode is + // linked to both the OVA and e.g. a specials for the TV Series. + var totalMultiSeriesFiles = 0; + foreach (var (file, sourceLocation) in multiSeriesFiles) { + var crossReferences = file.CrossReferences + .Where(xref => singleSeriesIds.Contains(xref.Series.Shoko)) + .ToList(); + foreach (var xref in crossReferences) + yield return (sourceLocation, fileId: file.Id.ToString(), seriesId: xref.Series.Shoko.ToString()); + totalMultiSeriesFiles += crossReferences.Count; + } + var timeSpent = DateTime.UtcNow - start; Logger.LogDebug( - "Iterated {FileCount} files to potentially use within media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath})", - totalFiles, + "Iterated {FileCount} ({MultiFileCount}→{MultiFileCount}) files to potentially use within media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath})", + totalSingleSeriesFiles, + multiSeriesFiles.Count, + totalMultiSeriesFiles, mediaFolderPath, timeSpent, importFolderId, importFolderSubPath ); - } private async Task<ListResult<API.Models.File>> GetImportFolderFilesPage(int importFolderId, string importFolderSubPath, int page, SemaphoreSlim semaphore) From 03c3906486bb452e3d7f6b8d0fab2bc13e06c539 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 10 Apr 2024 20:06:26 +0000 Subject: [PATCH 0778/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index c462a835..3911ee5b 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.103", + "changelog": "fix: only create links for series in the local collection\n\n- Only create links for any local series linked to\n multi-series files. This should prevent a file linked to\n multiple series from creating a new mostly empty series\n if the user don't have any other files for said series.\n\nfix: fix season number usage", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.103/shoko_3.0.1.103.zip", + "checksum": "862502227360cff63832846b7f170b4c", + "timestamp": "2024-04-10T20:06:24Z" + }, { "version": "3.0.1.102", "changelog": "fix: fix season number assignement and usage", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.99/shoko_3.0.1.99.zip", "checksum": "0631035367d742f60ccdb0ff7fe95b38", "timestamp": "2024-04-09T20:59:58Z" - }, - { - "version": "3.0.1.98", - "changelog": "refactor: simplify extra metadata provider\n\nmisc: remove unneeded stored offset\n\nmisc: more logging for link generation stage", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.98/shoko_3.0.1.98.zip", - "checksum": "383409f47b08c2e92e60d391d16e061a", - "timestamp": "2024-04-09T20:52:02Z" } ] } From f895e44f2f0a0da6fc23adbdfc492ec8dc8bd851 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 10 Apr 2024 22:29:10 +0200 Subject: [PATCH 0779/1103] misc: add 'add missing metadata' setting --- Shokofin/Configuration/configController.js | 11 +++++------ Shokofin/Configuration/configPage.html | 21 ++++++++++++++------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 7f7474fd..4a6406e6 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -162,6 +162,7 @@ async function defaultSubmit(form) { config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; config.SeparateMovies = form.querySelector("#SeparateMovies").checked; config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; + config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; // Media Folder settings const mediaFolderId = form.querySelector("#MediaFolderSelector").value; @@ -319,6 +320,7 @@ async function syncSettings(form) { config.TitleAlternateType = form.querySelector("#TitleAlternateType").value; config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; + config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; config.DescriptionSource = form.querySelector("#DescriptionSource").value; config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; @@ -330,14 +332,12 @@ async function syncSettings(form) { config.AddTMDBId = form.querySelector("#AddTMDBId").checked; // Library settings - config.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; - config.LibraryFiltering = fitleringMode === "true" ? true : fitleringMode === "false" ? false : null; config.UseGroupsForShows = form.querySelector("#UseGroupsForShows").checked; config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; - config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; config.SeparateMovies = form.querySelector("#SeparateMovies").checked; + config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; - config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; + config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; // Tag settings config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; @@ -577,12 +577,11 @@ export default function (page) { form.querySelector("#CollectionGrouping").value = config.CollectionGrouping || "Default"; form.querySelector("#SeparateMovies").checked = config.SeparateMovies != null ? config.SeparateMovies : true; form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" ? "AfterSeason" : config.SpecialsPlacement; + form.querySelector("#AddMissingMetadata").checked = config.AddMissingMetadata || false; // Media Folder settings form.querySelector("#VirtualFileSystem").checked = config.VirtualFileSystem != null ? config.VirtualFileSystem : true; form.querySelector("#LibraryFiltering").value = `${config.LibraryFiltering != null ? config.LibraryFiltering : null}`; - - // Media Folder settings mediaFolderSelector.innerHTML += config.MediaFolders.map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.MediaFolderPath}</option>`).join(""); // User settings diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index ba698f18..4891ba6d 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -65,6 +65,13 @@ <h3>Metadata Settings</h3> </select> <div class="fieldDescription selectFieldDescription">How to select the alternate title for each item. The plugin will fallback to the default title if no title can be found in the target language.</div> </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> + <span>Mark specials</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add a number to the title of each specials episode</div> + </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="DescriptionSource">Description source:</label> <select is="emby-select" id="DescriptionSource" name="DescriptionSource" class="emby-select-withcolor emby-select"> @@ -90,13 +97,6 @@ <h3>Metadata Settings</h3> </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="MarkSpecialsWhenGrouped" /> - <span>Mark specials</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Add a number to the title of each specials episode</div> - </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="CleanupAniDBDescriptions" /> @@ -185,6 +185,13 @@ <h3>Library Settings</h3> </select> <div class="fieldDescription">Determines how to group entities into collections.</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> From 1e781b8b598a50f601bd58160bb7d2fb70646a5a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 11 Apr 2024 11:54:03 +0200 Subject: [PATCH 0780/1103] misc: add SignalR settings + cleanup - Add the SignalR settings - Cleaned up some code in the settings controller. --- Shokofin/Configuration/configController.js | 207 +++++++++++++++++++-- Shokofin/Configuration/configPage.html | 93 +++++++++ 2 files changed, 281 insertions(+), 19 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 4a6406e6..94ce8bdf 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -20,21 +20,34 @@ const Messages = { // Split the values at every comma. .split(",") // Sanitize inputs. - .map(str => { - // Trim the start and end and convert to lower-case. - str = str.trim().toLowerCase(); - return str; - }), + .map(str => str.trim().toLowerCase()) + .filter(str => str), ); - // Filter out empty values. - if (filteredSet.has("")) - filteredSet.delete(""); - // 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); +} + async function loadUserConfig(form, userId, config) { if (!userId) { form.querySelector("#UserSettingsContainer").setAttribute("hidden", ""); @@ -112,6 +125,46 @@ async function loadMediaFolderConfig(form, mediaFolderId, config) { Dashboard.hideLoadingMsg(); } +async function loadSignalrMediaFolderConfig(form, mediaFolderId, config) { + if (!mediaFolderId) { + 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); + if (!mediaFolderConfig) { + form.querySelector("#SignalRMediaFolderDefaultSettingsContainer").removeAttribute("hidden"); + form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); + 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"); + + 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", @@ -129,6 +182,34 @@ function getApiKey(username, password, userKey = false) { }); } +/** + * + * @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); @@ -165,8 +246,8 @@ async function defaultSubmit(form) { config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; // Media Folder settings - const mediaFolderId = form.querySelector("#MediaFolderSelector").value; - const mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; + let mediaFolderId = form.querySelector("#MediaFolderSelector").value; + let mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; if (mediaFolderConfig) { const filteringMode = form.querySelector("#MediaFolderLibraryFiltering").value; mediaFolderConfig.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; @@ -178,6 +259,22 @@ async function defaultSubmit(form) { config.LibraryFiltering = filteringMode === "true" ? true : filteringMode === "false" ? false : null; } + // 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(", "); + mediaFolderId = form.querySelector("#SignalRMediaFolderSelector").value; + mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; + if (mediaFolderConfig) { + mediaFolderConfig.IsFileEventsEnabled = form.querySelector("#SignalRFileEvents").checked; + mediaFolderConfig.IsRefreshEventsEnabled = form.querySelector("#SignalRRefreshEvents").checked; + } + else { + config.SignalR_FileEvents = form.querySelector("#SignalRDefaultFileEvents").checked; + config.SignalR_RefreshEnabled = form.querySelector("#SignalRDefaultRefreshEvents").checked; + } + // Tag settings config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; config.HideArtStyleTags = form.querySelector("#HideArtStyleTags").checked; @@ -313,7 +410,6 @@ async function syncSettings(form) { form.querySelector("#PublicUrl").value = publicUrl; } const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); - const fitleringMode = form.querySelector("#LibraryFiltering").value; // Metadata settings config.TitleMainType = form.querySelector("#TitleMainType").value; @@ -396,7 +492,32 @@ async function syncMediaFolderSettings(form) { config.LibraryFiltering = filteringMode === "true" ? true : filteringMode === "false" ? false : null; } - const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config) + 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 = form.querySelector("#SignalRMediaFolderSelector").value; + const reconnectIntervals = filterReconnectIntervals(form.querySelector("#SignalRAutoReconnectIntervals").value); + + config.SignalR_AutoConnectEnabled = form.querySelector("#SignalRAutoConnect").checked; + config.SignalR_AutoReconnectInSeconds = reconnectIntervals; + form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); + + const mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; + if (mediaFolderConfig) { + mediaFolderConfig.IsFileEventsEnabled = form.querySelector("#SignalRFileEvents").checked; + mediaFolderConfig.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; @@ -450,6 +571,7 @@ export default function (page) { const form = page.querySelector("#ShokoConfigForm"); 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) => { @@ -478,6 +600,8 @@ export default function (page) { form.querySelector("#ProviderSection").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("#TagSection").removeAttribute("hidden"); form.querySelector("#AdvancedSection").removeAttribute("hidden"); @@ -493,17 +617,39 @@ export default function (page) { form.querySelector("#ProviderSection").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("#TagSection").setAttribute("hidden", ""); form.querySelector("#AdvancedSection").setAttribute("hidden", ""); form.querySelector("#ExperimentalSection").setAttribute("hidden", ""); } - const userId = form.querySelector("#UserSelector").value; - loadUserConfig(form, userId, config); + loadUserConfig(form, form.querySelector("#UserSelector").value, config); + loadMediaFolderConfig(form, form.querySelector("#MediaFolderSelector").value, config); + loadSignalrMediaFolderConfig(form, form.querySelector("#SignalRMediaFolderSelector").value, config); + }; - const mediaFolderId = form.querySelector("#MediaFolderSelector").value; - loadMediaFolderConfig(form, mediaFolderId, config); + /** + * + * @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) => { @@ -518,7 +664,11 @@ export default function (page) { 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; @@ -543,6 +693,7 @@ export default function (page) { Dashboard.showLoadingMsg(); try { const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + const signalrStatus = await getSignalrStatus(); const users = await ApiClient.getUsers(); // Connection settings @@ -584,6 +735,13 @@ export default function (page) { form.querySelector("#LibraryFiltering").value = `${config.LibraryFiltering != null ? config.LibraryFiltering : null}`; mediaFolderSelector.innerHTML += config.MediaFolders.map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.MediaFolderPath}</option>`).join(""); + // SignalR settings + form.querySelector("#SignalRAutoConnect").checked = config.SignalR_AutoConnectEnabled; + form.querySelector("#SignalRAutoReconnectIntervals").value = config.SignalR_AutoReconnectInSeconds.join(", "); + signalrMediaFolderSelector.innerHTML += config.MediaFolders.map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${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(""); @@ -611,6 +769,7 @@ export default function (page) { } refreshSettings(config); + refreshSignalr(signalrStatus); } catch (err) { Dashboard.alert(Messages.UnableToRender); @@ -641,7 +800,17 @@ export default function (page) { break; case "media-folder-settings": Dashboard.showLoadingMsg(); - syncMediaFolderSettings(form).then(refreshSettings).case(onError); + 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); diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 4891ba6d..73040651 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -298,6 +298,99 @@ <h3>Media Folder Settings</h3> <span>Save</span> </button> </fieldset> + <fieldset id="SignalRSection1" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>SignalR Connection</h3> + </legend> + <div class="inputContainer inputContainer-withDescription"> + <label class="inputLabel inputLabelUnfocused" for="SignalRStatus">Status:</label><input is="emby-input" type="text" id="SignalRStatus" label="Status:" disabled readonly class="emby-input" value="Inactive"> + <div class="fieldDescription">SignalR connection status.</div> + </div> + <div id="SignalRConnectContainer" hidden> + <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> + <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> + </fieldset> + <fieldset id="SignalRSection2" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>SignalR Settings</h3> + </legend> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SignalRAutoConnect" /> + <span>Auto Connect On Start</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + Automatically establish a SignalR connection to Shoko Server when Jellyfin starts. + </div> + </div> + <div class="inputContainer inputContainer-withDescription"> + <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 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> + </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>Enable the SignalR file 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 Update Events</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>Enable the 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" /> + <span>File Events</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>Enable the SignalR file 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> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>Enable the SignalR metadata update events for the media folder.</div> + </div> + </div> + </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> <legend> <h3>User Settings</h3> From af5ee59118b047eaf18f4791e2411636de0da5a6 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 11 Apr 2024 12:59:55 +0200 Subject: [PATCH 0781/1103] misc: always ignore items outside the VFS - Always ignore items outside the VFS if the VFS is enabled. --- Shokofin/Resolvers/ShokoResolveManager.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index eb62047b..ab6cfb61 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -671,10 +671,12 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file return false; } - // Abort now if the VFS is enabled, since it will take care of moving - // from the physical library to the "virtual" library. - if (parent.ParentId == root.Id && mediaFolderConfig.IsVirtualFileSystemEnabled) - return false; + // Filter out anything in the media folder if the VFS is enabled, + // 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) + return true; var shouldIgnore = mediaFolderConfig.IsLibraryFilteringEnabled ?? mediaFolderConfig.IsVirtualFileSystemEnabled || isSoleProvider; var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); @@ -786,7 +788,7 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b if (!Lookup.IsEnabledForItem(parent)) return null; - // We're already within the VFS, so let jellyfin take it from here. + // Skip anything outside the VFS. var fullPath = fileInfo.FullName; if (!fullPath.StartsWith(Plugin.Instance.VirtualRoot)) return null; From fbdd331d24bf05294f426ece87e1e9c72abad72a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 11 Apr 2024 13:01:12 +0200 Subject: [PATCH 0782/1103] misc: swap season conversion logic - Swapped the TV Specials conversion logic and the episode list conversion logic so the tv specials take precedence. --- Shokofin/API/Info/SeasonInfo.cs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 0a0186b8..c881be86 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -126,19 +126,9 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li index++; } - // Replace the normal episodes if we've hidden all the normal episodes and we have at least one - // alternate episode locally. - var type = series.AniDBEntity.Type; - if (episodesList.Count == 0 && altEpisodesList.Any(ep => ep.Shoko.Size > 0)) { - // Switch the type from movie to web if we've hidden the main movie, and we have some of the parts. - if (type == SeriesType.Movie) - type = SeriesType.Web; - - episodesList = altEpisodesList; - altEpisodesList = new(); - } // Treat all 'tv special' episodes as specials. - else if (type == SeriesType.TVSpecial) { + var type = series.AniDBEntity.Type; + if (type == SeriesType.TVSpecial) { if (episodesList.Count > 0) { specialsList.InsertRange(0, episodesList); episodesList = new(); @@ -149,6 +139,16 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li } specialsAnchorDictionary = new(); } + // Replace the normal episodes if we've hidden all the normal episodes and we have at least one + // alternate episode locally. + else if (episodesList.Count == 0 && altEpisodesList.Any(ep => ep.Shoko.Size > 0)) { + // Switch the type from movie to web if we've hidden the main movie, and we have some of the parts. + if (type == SeriesType.Movie) + type = SeriesType.Web; + + episodesList = altEpisodesList; + altEpisodesList = new(); + } // While the filtered specials list is ordered by episode number specialsList = specialsList From 2859de9b01a4a78482f021e5bacb30171b01223a Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 11 Apr 2024 11:01:58 +0000 Subject: [PATCH 0783/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 3911ee5b..8742863c 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.104", + "changelog": "misc: swap season conversion logic\n\n- Swapped the TV Specials conversion logic and\n the episode list conversion logic so the tv specials take precedence.\n\nmisc: always ignore items outside the VFS\n\n- Always ignore items outside the VFS if the VFS is enabled.\n\nmisc: add SignalR settings + cleanup\n\n- Add the SignalR settings\n\n- Cleaned up some code in the settings controller.\n\nmisc: add 'add missing metadata' setting", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.104/shoko_3.0.1.104.zip", + "checksum": "d27fe1c9f63620cb5443a860c1609b20", + "timestamp": "2024-04-11T11:01:56Z" + }, { "version": "3.0.1.103", "changelog": "fix: only create links for series in the local collection\n\n- Only create links for any local series linked to\n multi-series files. This should prevent a file linked to\n multiple series from creating a new mostly empty series\n if the user don't have any other files for said series.\n\nfix: fix season number usage", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.100/shoko_3.0.1.100.zip", "checksum": "5b9aeb9577ec28258ca4baca252d893d", "timestamp": "2024-04-10T14:21:18Z" - }, - { - "version": "3.0.1.99", - "changelog": "refactor: split-up extra metadata provider", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.99/shoko_3.0.1.99.zip", - "checksum": "0631035367d742f60ccdb0ff7fe95b38", - "timestamp": "2024-04-09T20:59:58Z" } ] } From fc07a1b2ec7f3fb8afe78c1178236058d2651873 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 11 Apr 2024 14:02:55 +0200 Subject: [PATCH 0784/1103] fix: use relative based episode numbering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Because Owarimonogarari (2017) is a clusterfudge to support, and implementing this won't break existing stuff… in theory. --- Shokofin/API/Info/SeasonInfo.cs | 28 ++++++++++++++++++---------- Shokofin/Utils/Ordering.cs | 33 +++++++++------------------------ 2 files changed, 27 insertions(+), 34 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index c881be86..501fc943 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -29,28 +29,28 @@ public class SeasonInfo /// <summary> /// All episodes (of all type) that belong to this series. - /// + /// /// Unordered. /// </summary> public readonly IReadOnlyList<EpisodeInfo> RawEpisodeList; /// <summary> /// A pre-filtered list of normal episodes that belong to this series. - /// + /// /// Ordered by AniDb air-date. /// </summary> public readonly List<EpisodeInfo> EpisodeList; /// <summary> /// A pre-filtered list of "unknown" episodes that belong to this series. - /// + /// /// Ordered by AniDb air-date. /// </summary> public readonly List<EpisodeInfo> AlternateEpisodesList; /// <summary> /// A pre-filtered list of "extra" videos that belong to this series. - /// + /// /// Ordered by AniDb air-date. /// </summary> public readonly List<EpisodeInfo> ExtrasList; @@ -126,6 +126,19 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li index++; } + // 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. + episodesList = episodesList + .OrderBy(e => e.AniDB.EpisodeNumber) + .ToList(); + specialsList = specialsList + .OrderBy(e => e.AniDB.EpisodeNumber) + .ToList(); + altEpisodesList = altEpisodesList + .OrderBy(e => e.AniDB.EpisodeNumber) + .ToList(); + // Treat all 'tv special' episodes as specials. var type = series.AniDBEntity.Type; if (type == SeriesType.TVSpecial) { @@ -141,7 +154,7 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li } // Replace the normal episodes if we've hidden all the normal episodes and we have at least one // alternate episode locally. - else if (episodesList.Count == 0 && altEpisodesList.Any(ep => ep.Shoko.Size > 0)) { + else if (episodesList.Count == 0 && altEpisodesList.Count > 0) { // Switch the type from movie to web if we've hidden the main movie, and we have some of the parts. if (type == SeriesType.Movie) type = SeriesType.Web; @@ -150,11 +163,6 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li altEpisodesList = new(); } - // While the filtered specials list is ordered by episode number - specialsList = specialsList - .OrderBy(e => e.AniDB.EpisodeNumber) - .ToList(); - Id = seriesId; Shoko = series; AniDB = series.AniDBEntity; diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index cab7af5c..cb398943 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -89,12 +89,13 @@ public enum SpecialOrderType { /// <returns>Absolute index.</returns> public static int GetEpisodeNumber(ShowInfo showInfo, SeasonInfo seasonInfo, EpisodeInfo episodeInfo) { - int offset = 0; + var index = 0; + var offset = 0; if (episodeInfo.ExtraType != null) { var seasonIndex = showInfo.SeasonList.FindIndex(s => string.Equals(s.Id, seasonInfo.Id)); if (seasonIndex == -1) throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={showInfo.GroupId},Series={seasonInfo.Id},Episode={episodeInfo.Id})"); - var index = seasonInfo.ExtrasList.FindIndex(e => string.Equals(e.Id, episodeInfo.Id)); + index = seasonInfo.ExtrasList.FindIndex(e => string.Equals(e.Id, episodeInfo.Id)); if (index == -1) throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={showInfo.GroupId},Series={seasonInfo.Id},Episode={episodeInfo.Id})"); offset = showInfo.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.ExtrasList.Count); @@ -105,34 +106,18 @@ public static int GetEpisodeNumber(ShowInfo showInfo, SeasonInfo seasonInfo, Epi var seasonIndex = showInfo.SeasonList.FindIndex(s => string.Equals(s.Id, seasonInfo.Id)); if (seasonIndex == -1) throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={showInfo.GroupId},Series={seasonInfo.Id},Episode={episodeInfo.Id})"); - var index = seasonInfo.SpecialsList.FindIndex(e => string.Equals(e.Id, episodeInfo.Id)); + index = seasonInfo.SpecialsList.FindIndex(e => string.Equals(e.Id, episodeInfo.Id)); if (index == -1) throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={showInfo.GroupId},Series={seasonInfo.Id},Episode={episodeInfo.Id})"); offset = showInfo.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); return offset + index + 1; } - var sizes = seasonInfo.Shoko.Sizes.Total; - switch (episodeInfo.AniDB.Type) { - case EpisodeType.Other: - case EpisodeType.Normal: - // offset += 0; // it's not needed, so it's just here as a comment instead. - break; - // Add them to the bottom of the list if we didn't filter them out properly. - case EpisodeType.Parody: - offset += sizes?.Episodes ?? 0; - goto case EpisodeType.Normal; - case EpisodeType.OpeningSong: - offset += sizes?.Parodies ?? 0; - goto case EpisodeType.Parody; - case EpisodeType.Trailer: - offset += sizes?.Credits ?? 0; - goto case EpisodeType.OpeningSong; - default: - offset += sizes?.Trailers ?? 0; - goto case EpisodeType.Trailer; - } - return offset + episodeInfo.AniDB.EpisodeNumber; + index = seasonInfo.EpisodeList.FindIndex(ep => ep.Id == episodeInfo.Id); + if (index == -1) + index = seasonInfo.AlternateEpisodesList.FindIndex(ep => ep.Id == episodeInfo.Id); + + return index + 1; } public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo showInfo, SeasonInfo seasonInfo, EpisodeInfo episodeInfo) From c760ba85f5041d6ba8c8463c11cae66f9cf88497 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 11 Apr 2024 12:10:55 +0000 Subject: [PATCH 0785/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 8742863c..08559564 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.105", + "changelog": "fix: use relative based episode numbering\n\n- Because Owarimonogarari (2017) is a clusterfudge to support, and\n implementing this won't break existing stuff\u2026 in theory.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.105/shoko_3.0.1.105.zip", + "checksum": "77681e93c483f163495b17c69d478ebf", + "timestamp": "2024-04-11T12:10:53Z" + }, { "version": "3.0.1.104", "changelog": "misc: swap season conversion logic\n\n- Swapped the TV Specials conversion logic and\n the episode list conversion logic so the tv specials take precedence.\n\nmisc: always ignore items outside the VFS\n\n- Always ignore items outside the VFS if the VFS is enabled.\n\nmisc: add SignalR settings + cleanup\n\n- Add the SignalR settings\n\n- Cleaned up some code in the settings controller.\n\nmisc: add 'add missing metadata' setting", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.101/shoko_3.0.1.101.zip", "checksum": "13f71ef620334d9d726383976c34624a", "timestamp": "2024-04-10T18:55:25Z" - }, - { - "version": "3.0.1.100", - "changelog": "fix: make owarimonogarari great again!\n\n- Convert movie series into web series if all the normal episodes are\n hidden and we have \"other\" type episodes locally.\n\nrefactor: remove others but keep alt. ver.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.100/shoko_3.0.1.100.zip", - "checksum": "5b9aeb9577ec28258ca4baca252d893d", - "timestamp": "2024-04-10T14:21:18Z" } ] } From b0e6e6a1cc6fe586aa139536058196a41a958a7e Mon Sep 17 00:00:00 2001 From: Orski174 <72353018+Orski174@users.noreply.github.com> Date: Thu, 11 Apr 2024 17:11:11 +0300 Subject: [PATCH 0786/1103] Add issue template --- .github/ISSUES_TEMPLATE/issue.yml | 90 +++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 .github/ISSUES_TEMPLATE/issue.yml diff --git a/.github/ISSUES_TEMPLATE/issue.yml b/.github/ISSUES_TEMPLATE/issue.yml new file mode 100644 index 00000000..d37a7717 --- /dev/null +++ b/.github/ISSUES_TEMPLATE/issue.yml @@ -0,0 +1,90 @@ +name: Shokofin Issue Report 101 +description: Report your issues here! +labels: [] +projects: [] +assignees: + - revam +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: input + id: jelly + attributes: + label: Jellyfin version. + placeholder: "E.g. `10.8.12`" + validations: + required: true + - type: input + id: shokofin + attributes: + label: Shokofin version. + placeholder: "E.g. `3.0.1.0`" + validations: + required: true + - type: input + id: Shokoserver + attributes: + label: Shoko Server version, release channel, and commit hash. + placeholder: "E.g. `1.0.0 Stable` or `1.0.0 Dev (efefefe)`" + validations: + required: true + - type: textarea + id: fileStructure + attributes: + label: File structure of your _Media Library Folder in Jellyfin_/_Import Folder in Shoko Server_. + placeholder: "E.g. ../Anime A/Episode 1.avi or ../Anime A/Season 1/Episode 1.avi" + validations: + required: true + - type: textarea + id: screenshot + attributes: + label: Screenshot of the "library settings" section of the plugin settings. + validations: + required: true + - type: markdown + attributes: + value: | + Library type and metadata/image providers enabled for the library/libaries in Jellyfin. + - type: checkboxes + id: library + attributes: + label: "Library Type:" + options: + - label: Shows + - label: Movies + - label: Movies & Shows + validations: + required: true + - type: input + id: showmeta + attributes: + label: "Show providers:" + description: Metadata providers that you are using for Show metadata + placeholder: "`Shoko`" + validations: + required: true + - type: input + id: seasonmeta + attributes: + label: "Season providers:" + description: Metadata providers that you are using for Season metadata + placeholder: "`Shoko`" + validations: + required: true + - type: input + id: episodemeta + attributes: + label: "Episode providers:" + description: Metadata providers that you are using for Episode metadata + placeholder: "`Shoko`" + validations: + required: true + - type: textarea + id: issue + attributes: + label: Your Issue + description: Try to explain your issue in simple terms. We'll ask for details if it's needed. + validations: + required: true From 3f70fa165a86debd72af27639230119a1eff8aa6 Mon Sep 17 00:00:00 2001 From: Orski174 <72353018+Orski174@users.noreply.github.com> Date: Thu, 11 Apr 2024 17:45:12 +0300 Subject: [PATCH 0787/1103] Path fix --- .github/{ISSUES_TEMPLATE => ISSUE_TEMPLATE}/issue.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{ISSUES_TEMPLATE => ISSUE_TEMPLATE}/issue.yml (100%) diff --git a/.github/ISSUES_TEMPLATE/issue.yml b/.github/ISSUE_TEMPLATE/issue.yml similarity index 100% rename from .github/ISSUES_TEMPLATE/issue.yml rename to .github/ISSUE_TEMPLATE/issue.yml From 50f4112364570b7cfae49a4be287b71c8934af02 Mon Sep 17 00:00:00 2001 From: Orski174 <72353018+Orski174@users.noreply.github.com> Date: Thu, 11 Apr 2024 20:21:10 +0300 Subject: [PATCH 0788/1103] Fixing up the template --- .github/ISSUE_TEMPLATE/issue.yml | 37 +++++++++++++------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/issue.yml b/.github/ISSUE_TEMPLATE/issue.yml index d37a7717..e1ffb46a 100644 --- a/.github/ISSUE_TEMPLATE/issue.yml +++ b/.github/ISSUE_TEMPLATE/issue.yml @@ -8,7 +8,10 @@ body: - type: markdown attributes: value: | - Thanks for taking the time to fill out this bug report! + ## Shokofin Issue Report + Please fill out the following information to help us understand your issue better, we respond faster on [Discord](https://discord.gg/shokoanime) <br /> + If you are facing setup issues use discord, we'll be happy to help you. <br /> + **Note:** This template is for bug reports only, if you require another form of assistance please use the Discord server. - type: input id: jelly attributes: @@ -57,28 +60,13 @@ body: - label: Movies & Shows validations: required: true - - type: input - id: showmeta - attributes: - label: "Show providers:" - description: Metadata providers that you are using for Show metadata - placeholder: "`Shoko`" - validations: - required: true - - type: input - id: seasonmeta - attributes: - label: "Season providers:" - description: Metadata providers that you are using for Season metadata - placeholder: "`Shoko`" - validations: - required: true - - type: input - id: episodemeta + - type: checkboxes + id: metadataCheck attributes: - label: "Episode providers:" - description: Metadata providers that you are using for Episode metadata - placeholder: "`Shoko`" + label: "Have you tried creating a library with only Shoko as metadata provider?" + options: + - label: "Yes" + required: true validations: required: true - type: textarea @@ -88,3 +76,8 @@ body: description: Try to explain your issue in simple terms. We'll ask for details if it's needed. validations: required: true + - type: textarea + id: stackTrace + attributes: + label: Stack Trace + description: If relevant, paste here. \ No newline at end of file From 5956c849f29a29d66cba7d225d14481556978b40 Mon Sep 17 00:00:00 2001 From: Orski174 <72353018+Orski174@users.noreply.github.com> Date: Fri, 12 Apr 2024 00:04:51 +0300 Subject: [PATCH 0789/1103] Added request features template --- .github/ISSUE_TEMPLATE/features.yml | 34 +++++++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/issue.yml | 5 +++-- 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/features.yml diff --git a/.github/ISSUE_TEMPLATE/features.yml b/.github/ISSUE_TEMPLATE/features.yml new file mode 100644 index 00000000..912a9657 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/features.yml @@ -0,0 +1,34 @@ +name: Shokofin Feature Request 101 +description: Request your features here! +labels: [] +projects: [] +assignees: + - revam +body: + - type: markdown + attributes: + value: | + **Feature Request** + Suggest a request or idea that will help the project! + - type: textarea + id: description + attributes: + label: Description + description: Please describe the feature you would like to request. + placeholder: Describe your feature here. + validations: + required: true + - type: textarea + id: solution + attributes: + label: Suggested Solution + description: How would you like the feature to be implemented? + placeholder: Describe your solution here. + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Information + description: Any additional information you would like to provide? + placeholder: Provide any additional information here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/issue.yml b/.github/ISSUE_TEMPLATE/issue.yml index e1ffb46a..f1c77d40 100644 --- a/.github/ISSUE_TEMPLATE/issue.yml +++ b/.github/ISSUE_TEMPLATE/issue.yml @@ -9,8 +9,9 @@ body: attributes: value: | ## Shokofin Issue Report - Please fill out the following information to help us understand your issue better, we respond faster on [Discord](https://discord.gg/shokoanime) <br /> - If you are facing setup issues use discord, we'll be happy to help you. <br /> + Please fill out the following information to help us understand your issue better, we respond faster on [Discord](https://discord.gg/shokoanime). + If you are facing setup issues use discord, we'll be happy to help you. + **Note:** This template is for bug reports only, if you require another form of assistance please use the Discord server. - type: input id: jelly From 87c9d29a0a347d0ca3747d26157b729d995a9f56 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 12 Apr 2024 00:14:24 +0200 Subject: [PATCH 0790/1103] fix: remove the extra logic for tv series, since it was flawed. --- Shokofin/API/Info/SeasonInfo.cs | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 501fc943..d29b7b93 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -139,22 +139,10 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li .OrderBy(e => e.AniDB.EpisodeNumber) .ToList(); - // Treat all 'tv special' episodes as specials. - var type = series.AniDBEntity.Type; - if (type == SeriesType.TVSpecial) { - if (episodesList.Count > 0) { - specialsList.InsertRange(0, episodesList); - episodesList = new(); - } - if (altEpisodesList.Count > 0) { - specialsList.InsertRange(0, altEpisodesList); - altEpisodesList = new(); - } - specialsAnchorDictionary = new(); - } // Replace the normal episodes if we've hidden all the normal episodes and we have at least one // alternate episode locally. - else if (episodesList.Count == 0 && altEpisodesList.Count > 0) { + var type = series.AniDBEntity.Type; + if (episodesList.Count == 0 && altEpisodesList.Count > 0) { // Switch the type from movie to web if we've hidden the main movie, and we have some of the parts. if (type == SeriesType.Movie) type = SeriesType.Web; From 47eff1d00268d4d5fb3941cbd5f88019ea900e80 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 12 Apr 2024 00:15:23 +0200 Subject: [PATCH 0791/1103] fix: we can have season info without a base number within a show info. --- Shokofin/Utils/Ordering.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index cb398943..46474cb3 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -201,11 +201,8 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo showInfo, Se /// <returns></returns> public static int GetSeasonNumber(ShowInfo showInfo, SeasonInfo seasonInfo, EpisodeInfo episodeInfo) { - if (!showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var seasonNumber)) { - if (showInfo.IsSpecial(episodeInfo)) - return 0; - throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={showInfo.GroupId},Series={seasonInfo.Id})"); - } + if (!showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var seasonNumber)) + return 0; if (seasonInfo.AlternateEpisodesList.Any(ep => ep.Id == episodeInfo.Id)) return seasonNumber + 1; From 54a7d88915d4c238a65b07305c2d106c96194fbc Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 12 Apr 2024 00:26:53 +0200 Subject: [PATCH 0792/1103] fix: add missing episode padding - Add the missing padding to the episode numbers in the file names for the links inside the VFS. So this change requires a refresh of the library to "fix" the VFS, in case it wasn't clear enough already. --- Shokofin/API/Info/ShowInfo.cs | 7 +++++++ Shokofin/Resolvers/ShokoResolveManager.cs | 9 ++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index 3e9e9779..697117f7 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -131,6 +131,11 @@ public class ShowInfo /// </summary> public readonly SeasonInfo DefaultSeason; + /// <summary> + /// Episode number padding for file name generation. + /// </summary> + public readonly int EpisodePadding; + public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) { var seasonNumberBaseDictionary = new Dictionary<string, int>(); @@ -155,6 +160,7 @@ public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) SeasonOrderDictionary = seasonOrderDictionary; SpecialsSet = seasonInfo.SpecialsList.Select(episodeInfo => episodeInfo.Id).ToHashSet(); DefaultSeason = seasonInfo; + EpisodePadding = Math.Max(2, (new int[] { seasonInfo.EpisodeList.Count, seasonInfo.AlternateEpisodesList.Count, seasonInfo.SpecialsList.Count }).Max().ToString().Length); } public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool useGroupIdForCollection) @@ -224,6 +230,7 @@ public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool u SeasonOrderDictionary = seasonOrderDictionary; SpecialsSet = specialsSet; DefaultSeason = defaultSeason; + EpisodePadding = Math.Max(2, seasonList.SelectMany(s => new int[] { s.EpisodeList.Count, s.AlternateEpisodesList.Count }).Append(specialsSet.Count).Max().ToString().Length); } public bool IsSpecial(EpisodeInfo episodeInfo) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index ab6cfb61..c0177af5 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -564,15 +564,18 @@ await Task.WhenAll(files.Select(async (tuple) => { } else { var isSpecial = show.IsSpecial(episode); - var seasonNumber = isSpecial ? 0 : Ordering.GetSeasonNumber(show, season, episode); + var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); var seasonName = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}"; if (!string.IsNullOrEmpty(extrasFolder)) { folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", extrasFolder)); - folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", seasonName, extrasFolder)); + + // Only place the extra within the season if we have a season number assigned to the episode. + if (seasonNumber != 0) + folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", seasonName, extrasFolder)); } else { folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", seasonName)); - episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber}"; + episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber.ToString().PadLeft(show.EpisodePadding, '0')}"; } } From eed1aa72289d664c18c269392732e4a82cb169d4 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 11 Apr 2024 22:42:32 +0000 Subject: [PATCH 0793/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 08559564..79dcedff 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.106", + "changelog": "fix: add missing episode padding\n\n- Add the missing padding to the episode numbers in the file names\n for the links inside the VFS. So this change requires a refresh of the\n library to \"fix\" the VFS, in case it wasn't clear enough already.\n\nfix: we can have season info without a base number\nwithin a show info.\n\nfix: remove the extra logic for tv series,\nsince it was flawed.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.106/shoko_3.0.1.106.zip", + "checksum": "1e9054b146efe2f9ea40f580d84cc9ff", + "timestamp": "2024-04-11T22:42:30Z" + }, { "version": "3.0.1.105", "changelog": "fix: use relative based episode numbering\n\n- Because Owarimonogarari (2017) is a clusterfudge to support, and\n implementing this won't break existing stuff\u2026 in theory.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.102/shoko_3.0.1.102.zip", "checksum": "fbb7a00d35223a71fd26c4ee0c96273b", "timestamp": "2024-04-10T19:37:18Z" - }, - { - "version": "3.0.1.101", - "changelog": "refactor: allow specials only season info\nand always map the alternate episodes as main episodes\nif main episodes are filtered out.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.101/shoko_3.0.1.101.zip", - "checksum": "13f71ef620334d9d726383976c34624a", - "timestamp": "2024-04-10T18:55:25Z" } ] } From d3daa823ce67e8c3c9a9382dcff48177e3725dae Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 12 Apr 2024 01:26:39 +0200 Subject: [PATCH 0794/1103] fix: increment season number _after_ saving it - Increment the season number when grouping is not used after saving it, not before saving it. --- Shokofin/API/Info/ShowInfo.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index 697117f7..2b852941 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -144,9 +144,9 @@ public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) if (seasonInfo.EpisodeList.Count > 0 || seasonInfo.AlternateEpisodesList.Count > 0) seasonNumberBaseDictionary.Add(seasonInfo.Id, seasonNumberOffset); if (seasonInfo.EpisodeList.Count > 0) - seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); + seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo); if (seasonInfo.AlternateEpisodesList.Count > 0) - seasonOrderDictionary.Add(++seasonNumberOffset, seasonInfo); + seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo); Id = seasonInfo.Id; GroupId = seasonInfo.Shoko.IDs.ParentGroup.ToString(); CollectionId = collectionId ?? seasonInfo.Shoko.IDs.ParentGroup.ToString(); From 6b0a8309bfdd72d934fcdeaac421cb86eed79c8d Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 11 Apr 2024 23:29:06 +0000 Subject: [PATCH 0795/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 79dcedff..713d97dc 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.107", + "changelog": "fix: increment season number _after_ saving it\n\n- Increment the season number when grouping is not used\n after saving it, not before saving it.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.107/shoko_3.0.1.107.zip", + "checksum": "ae397b52e895b1abbef07c84a1ae88bc", + "timestamp": "2024-04-11T23:29:04Z" + }, { "version": "3.0.1.106", "changelog": "fix: add missing episode padding\n\n- Add the missing padding to the episode numbers in the file names\n for the links inside the VFS. So this change requires a refresh of the\n library to \"fix\" the VFS, in case it wasn't clear enough already.\n\nfix: we can have season info without a base number\nwithin a show info.\n\nfix: remove the extra logic for tv series,\nsince it was flawed.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.103/shoko_3.0.1.103.zip", "checksum": "862502227360cff63832846b7f170b4c", "timestamp": "2024-04-10T20:06:24Z" - }, - { - "version": "3.0.1.102", - "changelog": "fix: fix season number assignement and usage", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.102/shoko_3.0.1.102.zip", - "checksum": "fbb7a00d35223a71fd26c4ee0c96273b", - "timestamp": "2024-04-10T19:37:18Z" } ] } From 21d8184061b9317197e39cf3ff10e2dceeba436c Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Tue, 28 Nov 2023 22:42:54 +0000 Subject: [PATCH 0796/1103] Feat: Add sortable checkboxes for description source (Client side only, no settings saved) --- Shokofin/Configuration/configController.js | 117 ++++++++++++++++++++- Shokofin/Configuration/configPage.html | 30 ++++-- 2 files changed, 134 insertions(+), 13 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 94ce8bdf..484830ad 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -46,6 +46,55 @@ const Messages = { // Convert it back into an array. return Array.from(filteredSet).sort((a, b) => a - b); + } + + function adjustSortableListElement(element) { + const btnSortable = element.querySelector(".btnSortable"); + const inner = btnSortable.querySelector(".material-icons"); + + if (element.previousElementSibling) { + btnSortable.title = "Up"; + btnSortable.classList.add("btnSortableMoveUp"); + inner.classList.add("keyboard_arrow_up"); + + btnSortable.classList.remove("btnSortableMoveDown"); + inner.classList.remove("keyboard_arrow_down"); + } else { + btnSortable.title = "Down"; + btnSortable.classList.add("btnSortableMoveDown"); + inner.classList.add("keyboard_arrow_down"); + + btnSortable.classList.remove("btnSortableMoveUp"); + inner.classList.remove("keyboard_arrow_up"); + } +} + +function onSortableContainerClick(element) { + const parentWithClass = (element, className) => { + return (element.parentElement.classList.contains(className)) ? element.parentElement : null; + } + const btnSortable = parentWithClass(element.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); + } + } + + for (const option of list.querySelectorAll(".sortableOption")) { + adjustSortableListElement(option) + }; + } } async function loadUserConfig(form, userId, config) { @@ -227,7 +276,7 @@ async function defaultSubmit(form) { config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; - config.DescriptionSource = form.querySelector("#DescriptionSource").value; + setDescriptionSourcesIntoConfig(form, config); config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMiscLines = form.querySelector("#MinimalAniDBDescriptions").checked; @@ -417,7 +466,7 @@ async function syncSettings(form) { config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; - config.DescriptionSource = form.querySelector("#DescriptionSource").value; + setDescriptionSourcesIntoConfig(form, config); config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMiscLines = form.querySelector("#MinimalAniDBDescriptions").checked; @@ -689,6 +738,8 @@ export default function (page) { } }); + form.querySelector("#descriptionSource").addEventListener("click", onSortableContainerClick); + page.addEventListener("viewshow", async function () { Dashboard.showLoadingMsg(); try { @@ -707,7 +758,7 @@ export default function (page) { form.querySelector("#TitleAllowAny").checked = config.TitleAllowAny; form.querySelector("#TitleAddForMultipleEpisodes").checked = config.TitleAddForMultipleEpisodes != null ? config.TitleAddForMultipleEpisodes : true; form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; - form.querySelector("#DescriptionSource").value = config.DescriptionSource; + await setDescriptionSourcesFromConfig(form); form.querySelector("#CleanupAniDBDescriptions").checked = config.SynopsisCleanMultiEmptyLines || config.SynopsisCleanLinks; form.querySelector("#MinimalAniDBDescriptions").checked = config.SynopsisRemoveSummary || config.SynopsisCleanMiscLines; @@ -819,3 +870,63 @@ export default function (page) { return false; }); } + +function setDescriptionSourcesIntoConfig(form, config) { + config.DescriptionSource = Array.prototype.map.call( + Array.prototype.filter.call( + form.querySelectorAll("#descriptionSource .chkDescriptionSource"), + (el) => el.checked + ), + (el) => el.dataset.descriptionsource + ); + + config.DescriptionSourceOrder = Array.prototype.map.call( + form.querySelectorAll("#descriptionSource .chkDescriptionSource"), + (el) => el.dataset.descriptionsource + ); +} + +async function setDescriptionSourcesFromConfig(form) { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + + // #region Defaults + config.DescriptionSourceOrder ??= ["AniDB", "TMDB", "TVDB"] + if (typeof config.DescriptionSource === "string") { + // This should only trigger when migrating the string setting to an array setting + let checked = config.DescriptionSourceOrder; + let order = config.DescriptionSourceOrder; + switch (config.DescriptionSource) { + case "OnlyAniDb": + checked = ["AniDB"]; + break; + case "OnlyOther": + checked = ["TMDB", "TVDB"]; + order = ["TMDB", "TVDB", "AniDB"] + break; + case "PreferOther": + order = ["TMDB", "TVDB", "AniDB"]; + break; + } + config.DescriptionSource = checked; + config.DescriptionSourceOrder = order; + } + // #endregion Defaults + + const list = form.querySelector("#descriptionSource .checkboxList"); + const listItems = list.querySelectorAll('.listItem'); + + for (const item of listItems) { + const source = item.dataset.descriptionsource; + if (config.DescriptionSource.includes(source)) { + item.querySelector(".chkDescriptionSource").checked = true; + } + if (config.DescriptionSourceOrder.includes(source)) { + list.removeChild(item); // This is safe to be removed as we can re-add it in the next loop + } + } + + for (const source of config.DescriptionSourceOrder) { + const targetElement = Array.prototype.find.call(listItems, (el) => el.dataset.descriptionsource === source); + list.append(targetElement); + } +} \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 73040651..28622aa9 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -72,16 +72,26 @@ <h3>Metadata Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Add a number to the title of each specials episode</div> </div> - <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="DescriptionSource">Description source:</label> - <select is="emby-select" id="DescriptionSource" name="DescriptionSource" class="emby-select-withcolor emby-select"> - <option value="Default">Use default source for selected Series/Season grouping</option> - <option value="OnlyAniDb">Only use AniDB</option> - <option value="PreferAniDb">Prefer AniDB if available, otherwise use TvDB/TMDB</option> - <option value="OnlyOther">Only use TvDB/TMDB</option> - <option value="PreferOther">Prefer TvDB/TMDB if available, otherwise use AniDB</option> - </select> - <div class="fieldDescription selectFieldDescription">How to select the description to use for each item.</div> + <div id="descriptionSource" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Description source:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem descriptionSourceItem sortableOption" data-descriptionsource="AniDB"> + <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-descriptionsource="AniDB"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">AniDB</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 descriptionSourceItem sortableOption" data-descriptionsource="TVDB"> + <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-descriptionsource="TVDB"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">TVDB</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 descriptionSourceItem sortableOption" data-descriptionsource="TMDB"> + <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-descriptionsource="TMDB"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">TMDB</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> + <div class="fieldDescription">How to select the description to use for each item.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> From e4a3e90998657d538cfa3b326d5cc5029d2e8f7a Mon Sep 17 00:00:00 2001 From: Mikal S <7761729+revam@users.noreply.github.com> Date: Fri, 12 Apr 2024 02:21:07 +0200 Subject: [PATCH 0797/1103] Update and rename issue.yml to bug.yml Final touches. --- .github/ISSUE_TEMPLATE/{issue.yml => bug.yml} | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) rename .github/ISSUE_TEMPLATE/{issue.yml => bug.yml} (59%) diff --git a/.github/ISSUE_TEMPLATE/issue.yml b/.github/ISSUE_TEMPLATE/bug.yml similarity index 59% rename from .github/ISSUE_TEMPLATE/issue.yml rename to .github/ISSUE_TEMPLATE/bug.yml index f1c77d40..e79a9391 100644 --- a/.github/ISSUE_TEMPLATE/issue.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -1,5 +1,5 @@ -name: Shokofin Issue Report 101 -description: Report your issues here! +name: Shokofin Bug Report 101 +description: Report any bugs here! labels: [] projects: [] assignees: @@ -8,11 +8,12 @@ body: - type: markdown attributes: value: | - ## Shokofin Issue Report - Please fill out the following information to help us understand your issue better, we respond faster on [Discord](https://discord.gg/shokoanime). - If you are facing setup issues use discord, we'll be happy to help you. - - **Note:** This template is for bug reports only, if you require another form of assistance please use the Discord server. + ## Shokofin Bug Report + **Important:** This form is exclusively for reporting bugs. If your issue is not due to a bug but you requires assistance (e.g. with setup) or if you just have a question or inquiry, then please seek help on our [Discord](https://discord.gg/shokoanime) server instead. Our Discord community is eager to assist, and we often respond faster and can provide more immediate support on Discord. + + To help us understand and resolve your bug report more efficiently, please fill out the following information. + + And remember, for quicker assistance on any inquiries, Discord is the way to go! - type: input id: jelly attributes: @@ -51,29 +52,30 @@ body: attributes: value: | Library type and metadata/image providers enabled for the library/libaries in Jellyfin. - - type: checkboxes + - type: dropdown id: library attributes: - label: "Library Type:" + label: Library Type(s). + multiple: true options: - - label: Shows - - label: Movies - - label: Movies & Shows + - Shows + - Movies + - Movies & Shows validations: required: true - type: checkboxes id: metadataCheck attributes: - label: "Have you tried creating a library with only Shoko as metadata provider?" + label: "Do the issue persists after creating a library with Shoko set as the only metadata provider? (Now is your time to check if you haven't already.)" options: - - label: "Yes" + - label: "Yes, I hereby confirm that the issue persists after creating a library with Shoko set as the only metadata provider." required: true validations: required: true - type: textarea id: issue attributes: - label: Your Issue + label: Issue description: Try to explain your issue in simple terms. We'll ask for details if it's needed. validations: required: true @@ -81,4 +83,4 @@ body: id: stackTrace attributes: label: Stack Trace - description: If relevant, paste here. \ No newline at end of file + description: If relevant, paste here. From 0336b023a2a6329277f6099a68eef4a990e0a316 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 12 Apr 2024 03:12:12 +0200 Subject: [PATCH 0798/1103] fix: add workaround for mixed library support - Implement a workaround for using the mixed library with no movies yet, by adding empty 'tvshow.nfo' and 'season.nfo' files within the show/season directories (but only in mixed libraries) so the jellyfin internal logic will assign the directories as a show/season/episode structure. ||This is untested code btw. I'll test tomorrow. Now good night.|| --- README.md | 4 +- Shokofin/Resolvers/ShokoResolveManager.cs | 69 ++++++++++++++++++----- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 657439cb..8876a28e 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,7 @@ Learn more about Shoko at https://shokoanime.com/. - [X] Movie library - - [X] Mixed show/movie library¹. - - ¹ _You need at least one movie in your library for this to currently work as expected. This is an issue with Jellyfin 10.8._ + - [X] Mixed show/movie library. - [X] Supports adding local trailers diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index c0177af5..6fdd80d9 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -352,8 +352,10 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string var subtitles = 0; var fixedSubtitles = 0; var skippedSubtitles = 0; + var skippedNfo = 0; var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); + var allNfoFiles = new HashSet<string>(); var allPathsForVFS = new ConcurrentBag<(string sourceLocation, string symbolicLink)>(); var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); await Task.WhenAll(files.Select(async (tuple) => { @@ -361,7 +363,7 @@ await Task.WhenAll(files.Select(async (tuple) => { try { // Skip any source files we weren't meant to have in the library. - var (sourceLocation, symbolicLinks) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); + var (sourceLocation, symbolicLinks, nfoFiles) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); if (string.IsNullOrEmpty(sourceLocation)) return; @@ -440,6 +442,25 @@ await Task.WhenAll(files.Select(async (tuple) => { } } } + + foreach (var nfoFile in nfoFiles) + { + if (allNfoFiles.Contains(nfoFile)) + continue; + allNfoFiles.Add(nfoFile); + + var nfoDirectory = Path.GetDirectoryName(nfoFile)!; + if (!Directory.Exists(nfoDirectory)) + Directory.CreateDirectory(nfoDirectory); + + if (!File.Exists(nfoFile)) { + File.WriteAllText(nfoFile, ""); + } + else { + skippedNfo++; + } + + } } finally { semaphore.Release(); @@ -449,10 +470,12 @@ await Task.WhenAll(files.Select(async (tuple) => { var removedLinks = 0; var removedSubtitles = 0; + var removedNfo = 0; var toBeRemoved = FileSystem.GetFilePaths(vfsPath, true) .Select(path => (path, extName: Path.GetExtension(path))) - .Where(tuple => _namingOptions.VideoFileExtensions.Contains(tuple.extName) || _namingOptions.SubtitleFileExtensions.Contains(tuple.extName)) + .Where(tuple => _namingOptions.VideoFileExtensions.Contains(tuple.extName) || _namingOptions.SubtitleFileExtensions.Contains(tuple.extName) || tuple.extName == ".nfo") .ExceptBy(allPathsForVFS.Select(tuple => tuple.symbolicLink).ToHashSet(), tuple => tuple.path) + .ExceptBy(allNfoFiles, tuple => tuple.path) .ToList(); foreach (var (symbolicLink, extName) in toBeRemoved) { // Continue in case we already removed the (subtitle) file. @@ -471,6 +494,9 @@ await Task.WhenAll(files.Select(async (tuple) => { File.Delete(symbolicLink); } } + else if (extName == ".nfo") { + removedNfo++; + } else { removedSubtitles++; } @@ -480,15 +506,18 @@ await Task.WhenAll(files.Select(async (tuple) => { var timeSpent = DateTime.UtcNow - start; Logger.LogInformation( - "Created {CreatedMedia} ({CreatedSubtitles}), fixed {FixedMedia} ({FixedSubtitles}), skipped {SkippedMedia} ({SkippedSubtitles}), and removed {RemovedMedia} ({RemovedSubtitles}) symbolic links in media folder at {Path} in {TimeSpan}", + "Created {CreatedMedia} ({CreatedSubtitles},{CreatedNFO}), fixed {FixedMedia} ({FixedSubtitles}), skipped {SkippedMedia} ({SkippedSubtitles},{SkippedNFO}), and removed {RemovedMedia} ({RemovedSubtitles},{RemovedNFO}) symbolic links in media folder at {Path} in {TimeSpan}", allPathsForVFS.Count - skippedLinks - fixedLinks - subtitles, subtitles - fixedSubtitles - skippedSubtitles, + allNfoFiles.Count - skippedNfo, fixedLinks, fixedSubtitles, skippedLinks, skippedSubtitles, - toBeRemoved.Count, + skippedNfo, + removedLinks, removedSubtitles, + removedNfo, mediaFolder.Path, timeSpent ); @@ -497,11 +526,11 @@ await Task.WhenAll(files.Select(async (tuple) => { // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 chacters. private const int NameCutOff = 64; - private async Task<(string sourceLocation, string[] symbolicLinks)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId) + private async Task<(string sourceLocation, string[] symbolicLinks, string[] nfoFiles)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId) { var season = await ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); if (season == null) - return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); + return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>()); var isMovieSeason = season.Type == SeriesType.Movie; var shouldAbort = collectionType switch { @@ -510,19 +539,19 @@ await Task.WhenAll(files.Select(async (tuple) => { _ => false, }; if (shouldAbort) - return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); + return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>()); var show = await ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false); if (show == null) - return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); + return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>()); var file = await ApiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false); var episode = file?.EpisodeList.FirstOrDefault(); if (file == null || episode == null) - return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); + return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>()); if (season == null || episode == null) - return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>()); + return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>()); var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters() ?? $"Shoko Series {show.Id}"; var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); @@ -534,6 +563,7 @@ await Task.WhenAll(files.Select(async (tuple) => { if (episodeName.Length >= NameCutOff) episodeName = episodeName[..NameCutOff].Split(' ').SkipLast(1).Join(' ') + "…"; + var nfoFiles = new List<string>(); var folders = new List<string>(); var extrasFolder = file.ExtraType switch { null => null, @@ -565,18 +595,27 @@ await Task.WhenAll(files.Select(async (tuple) => { else { var isSpecial = show.IsSpecial(episode); var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); - var seasonName = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}"; + var seasonFolder = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}"; + var showFolder = $"{showName} [{ShokoSeriesId.Name}={show.Id}]"; if (!string.IsNullOrEmpty(extrasFolder)) { - folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", extrasFolder)); + folders.Add(Path.Combine(vfsPath, showFolder, extrasFolder)); // Only place the extra within the season if we have a season number assigned to the episode. if (seasonNumber != 0) - folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", seasonName, extrasFolder)); + folders.Add(Path.Combine(vfsPath, showFolder, seasonFolder, extrasFolder)); } else { - folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}]", seasonName)); + folders.Add(Path.Combine(vfsPath, showFolder, seasonFolder)); episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber.ToString().PadLeft(show.EpisodePadding, '0')}"; } + + // Add NFO files for the show and season if we're in a mixed library, + // to allow the built-in movie resolver to detect the directories + // properly as tv shows. + if (collectionType == null) { + nfoFiles.Add(Path.Combine(vfsPath, showFolder, "tvshow.nfo")); + nfoFiles.Add(Path.Combine(vfsPath, showFolder, seasonFolder, "season.nfo")); + } } var fileName = $"{episodeName} [{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{fileNameSuffix}{Path.GetExtension(sourceLocation)}"; @@ -586,7 +625,7 @@ await Task.WhenAll(files.Select(async (tuple) => { foreach (var symbolicLink in symbolicLinks) ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, file.EpisodeList.Select(episode => episode.Id)); - return (sourceLocation, symbolicLinks); + return (sourceLocation, symbolicLinks, nfoFiles: nfoFiles.ToArray()); } private static void CleanupDirectoryStructure(string? path) From 1928d82c0a9a337612f5d1b3120c3a6874a76762 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 12 Apr 2024 01:13:17 +0000 Subject: [PATCH 0799/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 713d97dc..2626ce29 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.108", + "changelog": "fix: add workaround for mixed library support\n\n- Implement a workaround for using the mixed library with no movies yet,\n by adding empty 'tvshow.nfo' and 'season.nfo' files within the\n show/season directories (but only in mixed libraries) so the jellyfin\n internal logic will assign the directories as a show/season/episode\n structure. ||This is untested code btw. I'll test tomorrow. Now good night.||\n\nmisc: add issue template (#49) [skip ci]\n\n\nUpdate and rename issue.yml to bug.yml\n\nFinal touches.\n\nAdded request features template\nFixing up the template\nPath fix\nAdd issue templat", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.108/shoko_3.0.1.108.zip", + "checksum": "2a156f5c4bdc3a1c6e832a2fd871466b", + "timestamp": "2024-04-12T01:13:15Z" + }, { "version": "3.0.1.107", "changelog": "fix: increment season number _after_ saving it\n\n- Increment the season number when grouping is not used\n after saving it, not before saving it.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.104/shoko_3.0.1.104.zip", "checksum": "d27fe1c9f63620cb5443a860c1609b20", "timestamp": "2024-04-11T11:01:56Z" - }, - { - "version": "3.0.1.103", - "changelog": "fix: only create links for series in the local collection\n\n- Only create links for any local series linked to\n multi-series files. This should prevent a file linked to\n multiple series from creating a new mostly empty series\n if the user don't have any other files for said series.\n\nfix: fix season number usage", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.103/shoko_3.0.1.103.zip", - "checksum": "862502227360cff63832846b7f170b4c", - "timestamp": "2024-04-10T20:06:24Z" } ] } From 95a3bcd0984a22f7b6d1efe2bda6a4d7b684318d Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Fri, 12 Apr 2024 08:59:30 +0100 Subject: [PATCH 0800/1103] Misc: minor refactoring --- Shokofin/Configuration/configController.js | 25 ++++++++++------------ 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 484830ad..5e1e6734 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -69,11 +69,12 @@ const Messages = { } } -function onSortableContainerClick(element) { +/** @param {PointerEvent} event */ +function onSortableContainerClick(event) { const parentWithClass = (element, className) => { return (element.parentElement.classList.contains(className)) ? element.parentElement : null; } - const btnSortable = parentWithClass(element.target, "btnSortable"); + const btnSortable = parentWithClass(event.target, "btnSortable"); if (btnSortable) { const listItem = parentWithClass(btnSortable, "sortableOption"); const list = parentWithClass(listItem, "paperList"); @@ -872,18 +873,14 @@ export default function (page) { } function setDescriptionSourcesIntoConfig(form, config) { - config.DescriptionSource = Array.prototype.map.call( - Array.prototype.filter.call( - form.querySelectorAll("#descriptionSource .chkDescriptionSource"), - (el) => el.checked - ), - (el) => el.dataset.descriptionsource - ); - - config.DescriptionSourceOrder = Array.prototype.map.call( - form.querySelectorAll("#descriptionSource .chkDescriptionSource"), - (el) => el.dataset.descriptionsource - ); + const descriptionElements = form.querySelectorAll(`#descriptionSource .chkDescriptionSource`); + config.DescriptionSource = Array.prototype.filter.call(descriptionElements, + (el) => el.checked) + .map((el) => el.dataset.descriptionsource); + + config.DescriptionSourceOrder = Array.prototype.map.call(descriptionElements, + (el) => el.dataset.descriptionsource + ); } async function setDescriptionSourcesFromConfig(form) { From a3330baed8c2243dd4543e8da803cc95ee81a852 Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Fri, 12 Apr 2024 10:29:57 +0100 Subject: [PATCH 0801/1103] Feat: Implements C# changes for new settings --- Shokofin/Configuration/PluginConfiguration.cs | 10 ++- Shokofin/Utils/Text.cs | 90 ++++++++++--------- 2 files changed, 55 insertions(+), 45 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index c25a416c..827704eb 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -98,7 +98,12 @@ public virtual string PrettyUrl /// <summary> /// The description source. This will be replaced in the future. /// </summary> - public TextSourceType DescriptionSource { get; set; } + public TextSourceType[] DescriptionSource { get; set; } + + /// <summary> + /// The prioritisation order of source providers for description sources. + /// </summary> + public TextSourceType[] DescriptionSourceOrder { get; set; } /// <summary> /// Clean up links within the AniDB description for entries. @@ -286,7 +291,8 @@ public PluginConfiguration() TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; TitleAllowAny = false; - DescriptionSource = TextSourceType.Default; + DescriptionSource = new[] { TextSourceType.AniDb }; + DescriptionSourceOrder = new[] { TextSourceType.AniDb, TextSourceType.TvDb, TextSourceType.TMDB }; VirtualFileSystem = true; VirtualFileSystemThreads = 10; UseGroupsForShows = false; diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index b9be8f77..eabf6727 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -71,30 +71,19 @@ public static class Text /// </summary> public enum TextSourceType { /// <summary> - /// Use the default source for the current series grouping. + /// Use data from AniDB. /// </summary> - Default = 1, - - /// <summary> - /// Only use AniDb, or null if no data is available. - /// </summary> - OnlyAniDb = 2, + AniDb = 0, /// <summary> - /// Prefer the AniDb data, but use the other provider if there is no - /// AniDb data available. + /// Use data from TvDB. /// </summary> - PreferAniDb = 3, + TvDb = 1, /// <summary> - /// Prefer the other provider (e.g. TvDB/TMDB) + /// Use data from TMDB /// </summary> - PreferOther = 4, - - /// <summary> - /// Only use the other provider, or null if no data is available. - /// </summary> - OnlyOther = 5, + TMDB = 2 } /// <summary> @@ -149,40 +138,55 @@ public enum DisplayTitleType { FullTitle = 3, } - public static string? GetDescription(ShowInfo show) + public static string GetDescription(ShowInfo show) => GetDescription(show.DefaultSeason); - public static string? GetDescription(SeasonInfo season) - => GetDescription(season.AniDB.Description, season.TvDB?.Description); + public static string GetDescription(SeasonInfo season) + { + Dictionary<TextSourceType, string> descriptions = new() { + {TextSourceType.AniDb, season.AniDB.Description ?? string.Empty}, + {TextSourceType.TvDb, season.TvDB?.Description ?? string.Empty}, + }; + return GetDescription(descriptions); + } - public static string? GetDescription(EpisodeInfo episode) - => GetDescription(episode.AniDB.Description, episode.TvDB?.Description); + public static string GetDescription(EpisodeInfo episode) + { + Dictionary<TextSourceType, string> descriptions = new() { + {TextSourceType.AniDb, episode.AniDB.Description ?? string.Empty}, + {TextSourceType.TvDb, episode.TvDB?.Description ?? string.Empty}, + }; + return GetDescription(descriptions); + } - public static string? GetDescription(IEnumerable<EpisodeInfo> episodeList) + public static string GetDescription(IEnumerable<EpisodeInfo> episodeList) => JoinText(episodeList.Select(episode => GetDescription(episode))); - private static string GetDescription(string aniDbDescription, string? otherDescription) + private static string GetDescription(Dictionary<TextSourceType, string> descriptions) { - string overview; - switch (Plugin.Instance.Configuration.DescriptionSource) { - default: - case TextSourceType.PreferAniDb: - overview = SanitizeTextSummary(aniDbDescription); - if (string.IsNullOrEmpty(overview)) - goto case TextSourceType.OnlyOther; - break; - case TextSourceType.PreferOther: - overview = otherDescription ?? string.Empty; - if (string.IsNullOrEmpty(overview)) - goto case TextSourceType.OnlyAniDb; - break; - case TextSourceType.OnlyAniDb: - overview = SanitizeTextSummary(aniDbDescription); - break; - case TextSourceType.OnlyOther: - overview = otherDescription ?? string.Empty; - break; + string overview = string.Empty; + + var providerOrder = Plugin.Instance.Configuration.DescriptionSourceOrder; + var providers = Plugin.Instance.Configuration.DescriptionSource; + + if (providers.Length == 0) { + return overview; // This is what they want if everything is unticked... } + + foreach (var provider in providerOrder.Where(provider => providers.Contains(provider))) + { + if (!string.IsNullOrEmpty(overview)) { + return overview; + } + + overview = provider switch + { + TextSourceType.AniDb => descriptions.TryGetValue(TextSourceType.AniDb, out var desc) ? SanitizeTextSummary(desc) : string.Empty, + TextSourceType.TvDb => descriptions.TryGetValue(TextSourceType.TvDb, out var desc) ? desc : string.Empty, + _ => string.Empty + }; + } + return overview; } From 4423bd2af2a84390c9cd9a9e8f0fc02808e5426d Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Fri, 12 Apr 2024 11:11:55 +0100 Subject: [PATCH 0802/1103] Fix: Change HTML description values to match Enum counterpart --- Shokofin/Configuration/configPage.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 28622aa9..83fb3a08 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -75,13 +75,13 @@ <h3>Metadata Settings</h3> <div id="descriptionSource" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Description source:</h3> <div class="checkboxList paperList checkboxList-paperList"> - <div class="listItem descriptionSourceItem sortableOption" data-descriptionsource="AniDB"> - <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-descriptionsource="AniDB"><span></span></label> + <div class="listItem descriptionSourceItem sortableOption" data-descriptionsource="AniDb"> + <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-descriptionsource="AniDb"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">AniDB</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 descriptionSourceItem sortableOption" data-descriptionsource="TVDB"> - <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-descriptionsource="TVDB"><span></span></label> + <div class="listItem descriptionSourceItem sortableOption" data-descriptionsource="TvDb"> + <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-descriptionsource="TvDb"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">TVDB</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> From 1091d6b28001fc38324b6569745cb87e2cb8107a Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Fri, 12 Apr 2024 19:31:51 +0100 Subject: [PATCH 0803/1103] Refactor: Allows for a partially working settings migration --- Shokofin/Configuration/PluginConfiguration.cs | 13 +++- Shokofin/Configuration/configController.js | 61 ++++++++++--------- Shokofin/Configuration/configPage.html | 5 +- Shokofin/Utils/Text.cs | 2 +- 4 files changed, 48 insertions(+), 33 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 827704eb..3018433d 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -9,6 +9,7 @@ using CollectionCreationType = Shokofin.Utils.Ordering.CollectionCreationType; using OrderType = Shokofin.Utils.Ordering.OrderType; using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; +using System; namespace Shokofin.Configuration; @@ -96,9 +97,14 @@ public virtual string PrettyUrl public bool MarkSpecialsWhenGrouped { get; set; } /// <summary> - /// The description source. This will be replaced in the future. + /// This setting is now deprecated. Use `DescriptionSourceList` instead. /// </summary> - public TextSourceType[] DescriptionSource { get; set; } + public string? DescriptionSource { get; set; } + + /// <summary> + /// The collection of providers for descriptions + /// </summary> + public TextSourceType[] DescriptionSourceList { get; set; } /// <summary> /// The prioritisation order of source providers for description sources. @@ -291,7 +297,8 @@ public PluginConfiguration() TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; TitleAllowAny = false; - DescriptionSource = new[] { TextSourceType.AniDb }; + DescriptionSource = null; + DescriptionSourceList = new[] { TextSourceType.AniDb, TextSourceType.TvDb, TextSourceType.TMDB }; DescriptionSourceOrder = new[] { TextSourceType.AniDb, TextSourceType.TvDb, TextSourceType.TMDB }; VirtualFileSystem = true; VirtualFileSystemThreads = 10; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 5e1e6734..c446ee6f 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -739,7 +739,7 @@ export default function (page) { } }); - form.querySelector("#descriptionSource").addEventListener("click", onSortableContainerClick); + form.querySelector("#descriptionSourceList").addEventListener("click", onSortableContainerClick); page.addEventListener("viewshow", async function () { Dashboard.showLoadingMsg(); @@ -873,8 +873,9 @@ export default function (page) { } function setDescriptionSourcesIntoConfig(form, config) { - const descriptionElements = form.querySelectorAll(`#descriptionSource .chkDescriptionSource`); - config.DescriptionSource = Array.prototype.filter.call(descriptionElements, + const descriptionElements = form.querySelectorAll(`#descriptionSourceList .chkDescriptionSource`); + config.DescriptionSource = ""; + config.DescriptionSourceList = Array.prototype.filter.call(descriptionElements, (el) => el.checked) .map((el) => el.dataset.descriptionsource); @@ -886,35 +887,14 @@ function setDescriptionSourcesIntoConfig(form, config) { async function setDescriptionSourcesFromConfig(form) { const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - // #region Defaults - config.DescriptionSourceOrder ??= ["AniDB", "TMDB", "TVDB"] - if (typeof config.DescriptionSource === "string") { - // This should only trigger when migrating the string setting to an array setting - let checked = config.DescriptionSourceOrder; - let order = config.DescriptionSourceOrder; - switch (config.DescriptionSource) { - case "OnlyAniDb": - checked = ["AniDB"]; - break; - case "OnlyOther": - checked = ["TMDB", "TVDB"]; - order = ["TMDB", "TVDB", "AniDB"] - break; - case "PreferOther": - order = ["TMDB", "TVDB", "AniDB"]; - break; - } - config.DescriptionSource = checked; - config.DescriptionSourceOrder = order; - } - // #endregion Defaults + migrateDescriptionSource(config); - const list = form.querySelector("#descriptionSource .checkboxList"); + const list = form.querySelector("#descriptionSourceList .checkboxList"); const listItems = list.querySelectorAll('.listItem'); for (const item of listItems) { const source = item.dataset.descriptionsource; - if (config.DescriptionSource.includes(source)) { + if (config.DescriptionSourceList.includes(source)) { item.querySelector(".chkDescriptionSource").checked = true; } if (config.DescriptionSourceOrder.includes(source)) { @@ -924,6 +904,31 @@ async function setDescriptionSourcesFromConfig(form) { for (const source of config.DescriptionSourceOrder) { const targetElement = Array.prototype.find.call(listItems, (el) => el.dataset.descriptionsource === source); - list.append(targetElement); + if (targetElement) { + list.append(targetElement); + } } +} + +function migrateDescriptionSource(config) { + if (config.DescriptionSource !== null) { + let checked = config.DescriptionSourceList; + let order = config.DescriptionSourceOrder; + switch (config.DescriptionSource) { + case "OnlyAniDb": + checked = ["AniDb"]; + break; + case "OnlyOther": + checked = ["TMDB", "TvDb"]; + order = ["TMDB", "TvDb", "AniDb"] + break; + case "PreferOther": + order = ["TMDB", "TvDb", "AniDb"]; + break; + } + + config.DescriptionSource = ""; + config.DescriptionSourceList = checked; + config.DescriptionSourceOrder = order; + } } \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 83fb3a08..5d2e81ec 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -72,7 +72,10 @@ <h3>Metadata Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Add a number to the title of each specials episode</div> </div> - <div id="descriptionSource" style="margin-bottom: 2em;"> + <!-- (Below) Kept to allow setting migration! --> + <input is="emby-input" type="text" id="descriptionSource" style="display: none;" value="" /> + <!-- (Above) Kept to allow setting migration! --> + <div id="descriptionSourceList" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Description source:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem descriptionSourceItem sortableOption" data-descriptionsource="AniDb"> diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index eabf6727..cd4f2986 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -167,7 +167,7 @@ private static string GetDescription(Dictionary<TextSourceType, string> descript string overview = string.Empty; var providerOrder = Plugin.Instance.Configuration.DescriptionSourceOrder; - var providers = Plugin.Instance.Configuration.DescriptionSource; + var providers = Plugin.Instance.Configuration.DescriptionSourceList; if (providers.Length == 0) { return overview; // This is what they want if everything is unticked... From 58748e33c0c222aa4393c4d2d875680701dd4d1b Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Fri, 12 Apr 2024 20:05:48 +0100 Subject: [PATCH 0804/1103] Fix: Avoid requesting the config twice after load... --- Shokofin/Configuration/configController.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index c446ee6f..e3793e89 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -759,7 +759,7 @@ export default function (page) { form.querySelector("#TitleAllowAny").checked = config.TitleAllowAny; form.querySelector("#TitleAddForMultipleEpisodes").checked = config.TitleAddForMultipleEpisodes != null ? config.TitleAddForMultipleEpisodes : true; form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; - await setDescriptionSourcesFromConfig(form); + await setDescriptionSourcesFromConfig(form, config); form.querySelector("#CleanupAniDBDescriptions").checked = config.SynopsisCleanMultiEmptyLines || config.SynopsisCleanLinks; form.querySelector("#MinimalAniDBDescriptions").checked = config.SynopsisRemoveSummary || config.SynopsisCleanMiscLines; @@ -884,9 +884,7 @@ function setDescriptionSourcesIntoConfig(form, config) { ); } -async function setDescriptionSourcesFromConfig(form) { - const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - +async function setDescriptionSourcesFromConfig(form, config) { migrateDescriptionSource(config); const list = form.querySelector("#descriptionSourceList .checkboxList"); From 82796e2903ed4948e42ebe779ad59046b9d383d5 Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Fri, 12 Apr 2024 20:10:23 +0100 Subject: [PATCH 0805/1103] Chore: Remove uneccersary using that snuck in somehow --- Shokofin/Configuration/PluginConfiguration.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 3018433d..bc30486c 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -9,7 +9,6 @@ using CollectionCreationType = Shokofin.Utils.Ordering.CollectionCreationType; using OrderType = Shokofin.Utils.Ordering.OrderType; using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; -using System; namespace Shokofin.Configuration; From e9c35d763b180dc776079c181f1c0a65b962941a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 12 Apr 2024 23:08:01 +0200 Subject: [PATCH 0806/1103] refactor: move VFS location - Moved the location of the VFS outside the plugins directory, so it won't be scanned on startup by Jellyfin when it tries to discover the plugins to load. Also, in case it wasn't obvious enough. THIS IS A BREAKIGN CHANGE. Okay? Okay. --- Shokofin/Plugin.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 31366ed6..4f9cfe1f 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -24,13 +24,14 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages /// <summary> /// "Virtual" File System Root Directory. /// </summary> - public string VirtualRoot => Path.Combine(DataFolderPath, "VFS"); + public readonly string VirtualRoot; public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger) : base(applicationPaths, xmlSerializer) { Instance = this; ConfigurationChanged += OnConfigChanged; IgnoredFolders = Configuration.IgnoredFolders.ToHashSet(); + VirtualRoot = Path.Combine(applicationPaths.ProgramDataPath, "Shokofin", "VFS"); Logger = logger; Logger.LogInformation("Virtual File System Location; {Path}", VirtualRoot); } From d2358615e25179d1c15e8bff670ebb15c13c8d31 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 12 Apr 2024 23:08:23 +0200 Subject: [PATCH 0807/1103] misc: add missing TODO and remove trailing spaces --- Shokofin/Resolvers/ShokoResolveManager.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 6fdd80d9..08c659dc 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -196,7 +196,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold Logger.LogWarning( "Failed to find a match for media folder at {Path} after {Amount} attempts in {TimeSpan}.", mediaFolder.Path, - attempts, + attempts, DateTime.UtcNow - start ); } @@ -443,6 +443,7 @@ await Task.WhenAll(files.Select(async (tuple) => { } } + // TODO: Remove these two hacks once we have proper support for adding multiple series at once. foreach (var nfoFile in nfoFiles) { if (allNfoFiles.Contains(nfoFile)) @@ -636,7 +637,7 @@ private static void CleanupDirectoryStructure(string? path) path = Path.GetDirectoryName(path); } } - + private IReadOnlyList<string> FindSubtitlesForPath(string sourcePath) { var externalPaths = new List<string>(); From 838d627e2002b0c7dbaaba8f0c3f8b67e5299636 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 12 Apr 2024 21:10:56 +0000 Subject: [PATCH 0808/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 2626ce29..1b40ff93 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.109", + "changelog": "misc: add missing TODO and remove trailing spaces\n\nrefactor: move VFS location\n\n- Moved the location of the VFS outside the plugins directory, so it\n won't be scanned on startup by Jellyfin when it tries to discover\n the plugins to load. Also, in case it wasn't obvious enough.\n THIS IS A BREAKIGN CHANGE. Okay? Okay.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.109/shoko_3.0.1.109.zip", + "checksum": "5c47ba81f04a92a71c5095f3a1239ea0", + "timestamp": "2024-04-12T21:10:54Z" + }, { "version": "3.0.1.108", "changelog": "fix: add workaround for mixed library support\n\n- Implement a workaround for using the mixed library with no movies yet,\n by adding empty 'tvshow.nfo' and 'season.nfo' files within the\n show/season directories (but only in mixed libraries) so the jellyfin\n internal logic will assign the directories as a show/season/episode\n structure. ||This is untested code btw. I'll test tomorrow. Now good night.||\n\nmisc: add issue template (#49) [skip ci]\n\n\nUpdate and rename issue.yml to bug.yml\n\nFinal touches.\n\nAdded request features template\nFixing up the template\nPath fix\nAdd issue templat", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.105/shoko_3.0.1.105.zip", "checksum": "77681e93c483f163495b17c69d478ebf", "timestamp": "2024-04-11T12:10:53Z" - }, - { - "version": "3.0.1.104", - "changelog": "misc: swap season conversion logic\n\n- Swapped the TV Specials conversion logic and\n the episode list conversion logic so the tv specials take precedence.\n\nmisc: always ignore items outside the VFS\n\n- Always ignore items outside the VFS if the VFS is enabled.\n\nmisc: add SignalR settings + cleanup\n\n- Add the SignalR settings\n\n- Cleaned up some code in the settings controller.\n\nmisc: add 'add missing metadata' setting", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.104/shoko_3.0.1.104.zip", - "checksum": "d27fe1c9f63620cb5443a860c1609b20", - "timestamp": "2024-04-11T11:01:56Z" } ] } From fcd40172258ae51d2d3e89005d5b4dfcc84e5521 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 13 Apr 2024 00:44:25 +0200 Subject: [PATCH 0809/1103] fix: fix off-by-one error in GetAttributeValue --- Shokofin/StringExtensions.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Shokofin/StringExtensions.cs b/Shokofin/StringExtensions.cs index 26eaa253..29f4fffb 100644 --- a/Shokofin/StringExtensions.cs +++ b/Shokofin/StringExtensions.cs @@ -101,9 +101,10 @@ public static string ReplaceInvalidPathCharacters(this string path) if (attribute.Length == 0) throw new ArgumentException("String can't be empty.", nameof(attribute)); - // Must be at least 3 characters after the attribute =, ], any character. + // Must be at least 3 characters after the attribute =, ], any character, + // then we offset it by 1, because we want the index and not length. var attributeIndex = text.IndexOf(attribute, StringComparison.OrdinalIgnoreCase); - var maxIndex = text.Length - attribute.Length - 3; + var maxIndex = text.Length - attribute.Length - 2; while (attributeIndex > -1 && attributeIndex < maxIndex) { var attributeEnd = attributeIndex + attribute.Length; From 9de4aa6f5983622fbeb9952b011a71939d8e882e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 13 Apr 2024 00:44:53 +0200 Subject: [PATCH 0810/1103] misc: order file list by series id. --- Shokofin/Resolvers/ShokoResolveManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 08c659dc..423045b1 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -938,9 +938,9 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b // return new() { Items = items, ExtraFiles = new() }; // TODO: Remove these two hacks once we have proper support for adding multiple series at once. - if (items.Where(i => i is Movie).ToList().Count == 0 && items.Count > 0) { + if (!items.Any(i => i is Movie) && items.Count > 0) { fileInfoList.Clear(); - fileInfoList.AddRange(items.Select(s => FileSystem.GetFileSystemInfo(s.Path))); + fileInfoList.AddRange(items.OrderBy(s => int.Parse(s.Path.GetAttributeValue(ShokoSeriesId.Name)!)).Select(s => FileSystem.GetFileSystemInfo(s.Path))); } return new() { Items = items.Where(i => i is Movie).ToList(), ExtraFiles = items.OfType<TvSeries>().Select(s => FileSystem.GetFileSystemInfo(s.Path)).ToList() }; From 985d858a12830dcc27aafa8dcb4a573933210edb Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 12 Apr 2024 22:45:42 +0000 Subject: [PATCH 0811/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 1b40ff93..05c07e43 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.110", + "changelog": "misc: order file list by series id.\n\nfix: fix off-by-one error in GetAttributeValue", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.110/shoko_3.0.1.110.zip", + "checksum": "fb5abc01ccafc5b83e96340e057f56c5", + "timestamp": "2024-04-12T22:45:41Z" + }, { "version": "3.0.1.109", "changelog": "misc: add missing TODO and remove trailing spaces\n\nrefactor: move VFS location\n\n- Moved the location of the VFS outside the plugins directory, so it\n won't be scanned on startup by Jellyfin when it tries to discover\n the plugins to load. Also, in case it wasn't obvious enough.\n THIS IS A BREAKIGN CHANGE. Okay? Okay.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.106/shoko_3.0.1.106.zip", "checksum": "1e9054b146efe2f9ea40f580d84cc9ff", "timestamp": "2024-04-11T22:42:30Z" - }, - { - "version": "3.0.1.105", - "changelog": "fix: use relative based episode numbering\n\n- Because Owarimonogarari (2017) is a clusterfudge to support, and\n implementing this won't break existing stuff\u2026 in theory.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.105/shoko_3.0.1.105.zip", - "checksum": "77681e93c483f163495b17c69d478ebf", - "timestamp": "2024-04-11T12:10:53Z" } ] } From afc68baf6211e67d7490c6e82c332ed2b7a9f291 Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Sat, 13 Apr 2024 11:21:57 +0100 Subject: [PATCH 0812/1103] Misc: Migrations begone... Simpler for everyone this way! --- Shokofin/Configuration/PluginConfiguration.cs | 8 +----- Shokofin/Configuration/configController.js | 26 ------------------- Shokofin/Configuration/configPage.html | 3 --- Shokofin/Utils/Text.cs | 6 ++--- 4 files changed, 4 insertions(+), 39 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index bc30486c..7358a7d0 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -96,12 +96,7 @@ public virtual string PrettyUrl public bool MarkSpecialsWhenGrouped { get; set; } /// <summary> - /// This setting is now deprecated. Use `DescriptionSourceList` instead. - /// </summary> - public string? DescriptionSource { get; set; } - - /// <summary> - /// The collection of providers for descriptions + /// The collection of providers for descriptions. Replaces the former `DescriptionSource`. /// </summary> public TextSourceType[] DescriptionSourceList { get; set; } @@ -296,7 +291,6 @@ public PluginConfiguration() TitleMainType = DisplayLanguageType.Default; TitleAlternateType = DisplayLanguageType.Origin; TitleAllowAny = false; - DescriptionSource = null; DescriptionSourceList = new[] { TextSourceType.AniDb, TextSourceType.TvDb, TextSourceType.TMDB }; DescriptionSourceOrder = new[] { TextSourceType.AniDb, TextSourceType.TvDb, TextSourceType.TMDB }; VirtualFileSystem = true; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index e3793e89..ea573f58 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -874,7 +874,6 @@ export default function (page) { function setDescriptionSourcesIntoConfig(form, config) { const descriptionElements = form.querySelectorAll(`#descriptionSourceList .chkDescriptionSource`); - config.DescriptionSource = ""; config.DescriptionSourceList = Array.prototype.filter.call(descriptionElements, (el) => el.checked) .map((el) => el.dataset.descriptionsource); @@ -885,8 +884,6 @@ function setDescriptionSourcesIntoConfig(form, config) { } async function setDescriptionSourcesFromConfig(form, config) { - migrateDescriptionSource(config); - const list = form.querySelector("#descriptionSourceList .checkboxList"); const listItems = list.querySelectorAll('.listItem'); @@ -907,26 +904,3 @@ async function setDescriptionSourcesFromConfig(form, config) { } } } - -function migrateDescriptionSource(config) { - if (config.DescriptionSource !== null) { - let checked = config.DescriptionSourceList; - let order = config.DescriptionSourceOrder; - switch (config.DescriptionSource) { - case "OnlyAniDb": - checked = ["AniDb"]; - break; - case "OnlyOther": - checked = ["TMDB", "TvDb"]; - order = ["TMDB", "TvDb", "AniDb"] - break; - case "PreferOther": - order = ["TMDB", "TvDb", "AniDb"]; - break; - } - - config.DescriptionSource = ""; - config.DescriptionSourceList = checked; - config.DescriptionSourceOrder = order; - } -} \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 5d2e81ec..42813b2e 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -72,9 +72,6 @@ <h3>Metadata Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Add a number to the title of each specials episode</div> </div> - <!-- (Below) Kept to allow setting migration! --> - <input is="emby-input" type="text" id="descriptionSource" style="display: none;" value="" /> - <!-- (Above) Kept to allow setting migration! --> <div id="descriptionSourceList" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Description source:</h3> <div class="checkboxList paperList checkboxList-paperList"> diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index cd4f2986..bf750f0b 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -221,7 +221,7 @@ public static (string?, string?) GetEpisodeTitles(IEnumerable<Title> seriesTitle => GetTitles(seriesTitles, episodeTitles, null, episodeTitle, DisplayTitleType.SubTitle, metadataLanguage); public static (string?, string?) GetSeriesTitles(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) - => GetTitles(seriesTitles, null, seriesTitle, null, DisplayTitleType.MainTitle, metadataLanguage); + => GetTitles(seriesTitles, null, seriesTitle, null, DisplayTitleType.MainTitle, metadataLanguage); public static (string?, string?) GetMovieTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) => GetTitles(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplayTitleType.FullTitle, metadataLanguage); @@ -237,7 +237,7 @@ public static (string?, string?) GetTitles(IEnumerable<Title>? seriesTitles, IEn ); } - public static string? JoinText(IEnumerable<string?> textList) + public static string JoinText(IEnumerable<string?> textList) { var filteredList = textList .Where(title => !string.IsNullOrWhiteSpace(title)) @@ -247,7 +247,7 @@ public static (string?, string?) GetTitles(IEnumerable<Title>? seriesTitles, IEn .ToList(); if (filteredList.Count == 0) - return null; + return string.Empty; var index = 1; var outputText = filteredList[0]; From 33ba6b250a0eee10d9a5ce3835a31dbb78223467 Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Sat, 13 Apr 2024 11:28:46 +0100 Subject: [PATCH 0813/1103] Fix: Settings description tweak --- 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 42813b2e..30507c0e 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -91,7 +91,7 @@ <h3 class="checkboxListLabel">Description source:</h3> <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">How to select the description to use for each item.</div> + <div class="fieldDescription">The metadata providers to use as the source of episode/series/season descriptions.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> @@ -119,7 +119,7 @@ <h3 class="checkboxListLabel">Description source:</h3> <input is="emby-checkbox" type="checkbox" id="MinimalAniDBDescriptions" /> <span>Minimalistic AniDB descriptions</span> </label> - <div class="fieldDescription checkboxFieldDescription">Trim any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summery'</div> + <div class="fieldDescription checkboxFieldDescription">Trim any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summary'</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> From 74158319ef0bf9b81377cf9427552f47b0db833f Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Sat, 13 Apr 2024 11:53:12 +0100 Subject: [PATCH 0814/1103] Fix: Ensure description sources sorting direction renders as expected --- Shokofin/Configuration/configController.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index ea573f58..df841d99 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -903,4 +903,7 @@ async function setDescriptionSourcesFromConfig(form, config) { list.append(targetElement); } } + for (const option of list.querySelectorAll(".sortableOption")) { + adjustSortableListElement(option) + }; } From e167ba88c139c7884401cbdefd291d4408898fec Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Sat, 13 Apr 2024 22:18:24 +0100 Subject: [PATCH 0815/1103] Misc: Implement requested style changes from #42 --- Shokofin/Utils/Text.cs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index bf750f0b..44a12c89 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -142,29 +142,23 @@ public static string GetDescription(ShowInfo show) => GetDescription(show.DefaultSeason); public static string GetDescription(SeasonInfo season) - { - Dictionary<TextSourceType, string> descriptions = new() { + => GetDescription(new Dictionary<TextSourceType, string>() { {TextSourceType.AniDb, season.AniDB.Description ?? string.Empty}, {TextSourceType.TvDb, season.TvDB?.Description ?? string.Empty}, - }; - return GetDescription(descriptions); - } + }); public static string GetDescription(EpisodeInfo episode) - { - Dictionary<TextSourceType, string> descriptions = new() { + => GetDescription(new Dictionary<TextSourceType, string>() { {TextSourceType.AniDb, episode.AniDB.Description ?? string.Empty}, {TextSourceType.TvDb, episode.TvDB?.Description ?? string.Empty}, - }; - return GetDescription(descriptions); - } + }); public static string GetDescription(IEnumerable<EpisodeInfo> episodeList) - => JoinText(episodeList.Select(episode => GetDescription(episode))); + => JoinText(episodeList.Select(episode => GetDescription(episode))) ?? string.Empty; private static string GetDescription(Dictionary<TextSourceType, string> descriptions) { - string overview = string.Empty; + var overview = string.Empty; var providerOrder = Plugin.Instance.Configuration.DescriptionSourceOrder; var providers = Plugin.Instance.Configuration.DescriptionSourceList; @@ -237,7 +231,7 @@ public static (string?, string?) GetTitles(IEnumerable<Title>? seriesTitles, IEn ); } - public static string JoinText(IEnumerable<string?> textList) + public static string? JoinText(IEnumerable<string?> textList) { var filteredList = textList .Where(title => !string.IsNullOrWhiteSpace(title)) @@ -247,7 +241,7 @@ public static string JoinText(IEnumerable<string?> textList) .ToList(); if (filteredList.Count == 0) - return string.Empty; + return null; var index = 1; var outputText = filteredList[0]; From e30cba4da0e23735cdac4e32c5ecf0c9739babbd Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 13 Apr 2024 21:26:45 +0000 Subject: [PATCH 0816/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 05c07e43..b5e18715 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.111", + "changelog": "Merge pull request #42 from fearnlj01/config-tweak\n\nAdd sortable checkboxes for description source\nMisc: Implement requested style changes from #42\n\nFix: Ensure description sources sorting direction renders as expected\n\nFix: Settings description tweak\n\nMisc: Migrations begone... Simpler for everyone this way!\n\n\nChore: Remove uneccersary using that snuck in somehow\n\nFix: Avoid requesting the config twice after load...\n\nRefactor: Allows for a partially working settings migration\n\nFix: Change HTML description values to match Enum counterpart\n\nFeat: Implements C# changes for new settings\n\nMisc: minor refactoring\n\nFeat: Add sortable checkboxes for description source\n(Client side only, no settings saved", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.111/shoko_3.0.1.111.zip", + "checksum": "4435d62750c4efaf0a74a830fd1f9131", + "timestamp": "2024-04-13T21:26:44Z" + }, { "version": "3.0.1.110", "changelog": "misc: order file list by series id.\n\nfix: fix off-by-one error in GetAttributeValue", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.107/shoko_3.0.1.107.zip", "checksum": "ae397b52e895b1abbef07c84a1ae88bc", "timestamp": "2024-04-11T23:29:04Z" - }, - { - "version": "3.0.1.106", - "changelog": "fix: add missing episode padding\n\n- Add the missing padding to the episode numbers in the file names\n for the links inside the VFS. So this change requires a refresh of the\n library to \"fix\" the VFS, in case it wasn't clear enough already.\n\nfix: we can have season info without a base number\nwithin a show info.\n\nfix: remove the extra logic for tv series,\nsince it was flawed.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.106/shoko_3.0.1.106.zip", - "checksum": "1e9054b146efe2f9ea40f580d84cc9ff", - "timestamp": "2024-04-11T22:42:30Z" } ] } From c0e715df29591fa734686ee8c1a59bcc622ab261 Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Sat, 13 Apr 2024 22:39:28 +0100 Subject: [PATCH 0817/1103] fix: prevent error being logged in the console on plugin settings page --- 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 30507c0e..c2eca30f 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -313,7 +313,7 @@ <h3>Media Folder Settings</h3> <h3>SignalR Connection</h3> </legend> <div class="inputContainer inputContainer-withDescription"> - <label class="inputLabel inputLabelUnfocused" for="SignalRStatus">Status:</label><input is="emby-input" type="text" id="SignalRStatus" label="Status:" disabled readonly class="emby-input" value="Inactive"> + <input is="emby-input" type="text" id="SignalRStatus" label="Status:" disabled readonly value="Inactive"> <div class="fieldDescription">SignalR connection status.</div> </div> <div id="SignalRConnectContainer" hidden> From d7f4394c972d8fe6f3ce0b663bf5595013611059 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 13 Apr 2024 21:40:15 +0000 Subject: [PATCH 0818/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index b5e18715..6012d3a8 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.112", + "changelog": "fix: prevent error being logged in the console on plugin settings page", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.112/shoko_3.0.1.112.zip", + "checksum": "41712fea39e3dea10c3004de0ae10cce", + "timestamp": "2024-04-13T21:40:13Z" + }, { "version": "3.0.1.111", "changelog": "Merge pull request #42 from fearnlj01/config-tweak\n\nAdd sortable checkboxes for description source\nMisc: Implement requested style changes from #42\n\nFix: Ensure description sources sorting direction renders as expected\n\nFix: Settings description tweak\n\nMisc: Migrations begone... Simpler for everyone this way!\n\n\nChore: Remove uneccersary using that snuck in somehow\n\nFix: Avoid requesting the config twice after load...\n\nRefactor: Allows for a partially working settings migration\n\nFix: Change HTML description values to match Enum counterpart\n\nFeat: Implements C# changes for new settings\n\nMisc: minor refactoring\n\nFeat: Add sortable checkboxes for description source\n(Client side only, no settings saved", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.108/shoko_3.0.1.108.zip", "checksum": "2a156f5c4bdc3a1c6e832a2fd871466b", "timestamp": "2024-04-12T01:13:15Z" - }, - { - "version": "3.0.1.107", - "changelog": "fix: increment season number _after_ saving it\n\n- Increment the season number when grouping is not used\n after saving it, not before saving it.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.107/shoko_3.0.1.107.zip", - "checksum": "ae397b52e895b1abbef07c84a1ae88bc", - "timestamp": "2024-04-11T23:29:04Z" } ] } From 2942660290126032e3d39ca7073e9146c5873d51 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 13 Apr 2024 23:58:57 +0200 Subject: [PATCH 0819/1103] refactor: update signalr events - Updated the SignalR Connection Manager to support both the new and old signalr events. --- .../Interfaces/IFileMatchedEventArgs.cs | 49 ++++++++++++++++ .../Models/EpisodeInfoUpdatedEventArgs.cs | 37 +++++++++--- Shokofin/SignalR/Models/FileEventArgs.cs | 9 ++- .../SignalR/Models/FileMatchedEventArgs.cs | 43 +++++++------- Shokofin/SignalR/Models/FileMovedEventArgs.cs | 17 +++++- .../SignalR/Models/FileRenamedEventArgs.cs | 44 +++++++++++++- .../Models/SeriesInfoUpdatedEventArgs.cs | 28 +++++++-- Shokofin/SignalR/Models/UpdateReason.cs | 13 +++++ Shokofin/SignalR/SignalRConnectionManager.cs | 58 ++++++++++++------- 9 files changed, 239 insertions(+), 59 deletions(-) create mode 100644 Shokofin/SignalR/Interfaces/IFileMatchedEventArgs.cs create mode 100644 Shokofin/SignalR/Models/UpdateReason.cs diff --git a/Shokofin/SignalR/Interfaces/IFileMatchedEventArgs.cs b/Shokofin/SignalR/Interfaces/IFileMatchedEventArgs.cs new file mode 100644 index 00000000..d008802e --- /dev/null +++ b/Shokofin/SignalR/Interfaces/IFileMatchedEventArgs.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Shokofin.SignalR.Interfaces; + +public interface IFileEventArgs +{ + /// <summary> + /// Shoko file id. + /// </summary> + int FileId { get; } + + /// <summary> + /// The ID of the new import folder the event was detected in. + /// </summary> + /// <value></value> + int ImportFolderId { get; } + + /// <summary> + /// The relative path of the new file from the import folder base location. + /// </summary> + string RelativePath { get; } + + /// <summary> + /// Cross references of episodes linked to this file. + /// </summary> + List<FileCrossReference> CrossReferences { get; } + + public class FileCrossReference + { + /// <summary> + /// Shoko episode id. + /// </summary> + [JsonPropertyName("EpisodeID")] + public int EpisodeId { get; set; } + + /// <summary> + /// Shoko series id. + /// </summary> + [JsonPropertyName("SeriesID")] + public int SeriesId { get; set; } + + /// <summary> + /// Shoko group id. + /// </summary> + [JsonPropertyName("GroupID")] + public int GroupId { get; set; } + } +} \ No newline at end of file diff --git a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs index 43fccb8b..fb13d620 100644 --- a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Text.Json.Serialization; namespace Shokofin.SignalR.Models; @@ -5,20 +6,42 @@ namespace Shokofin.SignalR.Models; public class EpisodeInfoUpdatedEventArgs { /// <summary> - /// Shoko episode id. + /// The update reason. + /// </summary> + public UpdateReason Reason { get; set; } + /// <summary> + /// The provider metadata source. + /// </summary> + [JsonPropertyName("Source")] + public string ProviderName { get; set; } = string.Empty; + + /// <summary> + /// The provided metadata episode id. /// </summary> [JsonPropertyName("EpisodeID")] - public int EpisodeId { get; set; } + public int ProviderId { get; set; } /// <summary> - /// Shoko series id. + /// The provided metadata series id. /// </summary> [JsonPropertyName("SeriesID")] - public int SeriesId { get; set; } + public int ProviderSeriesId { get; set; } + + /// <summary> + /// Shoko episode ids affected by this update. + /// </summary> + [JsonPropertyName("ShokoEpisodeIDs")] + public List<int> EpisodeIds { get; set; } = new(); + + /// <summary> + /// Shoko series ids affected by this update. + /// </summary> + [JsonPropertyName("ShokoSeriesIDs")] + public List<int> SeriesIds { get; set; } = new(); /// <summary> - /// Shoko group id. + /// Shoko group ids affected by this update. /// </summary> - [JsonPropertyName("GroupID")] - public int GroupId { get; set; } + [JsonPropertyName("ShokoGroupIDs")] + public List<int> GroupIds { get; set; } = new(); } \ No newline at end of file diff --git a/Shokofin/SignalR/Models/FileEventArgs.cs b/Shokofin/SignalR/Models/FileEventArgs.cs index 5a4e69a1..1968d4e4 100644 --- a/Shokofin/SignalR/Models/FileEventArgs.cs +++ b/Shokofin/SignalR/Models/FileEventArgs.cs @@ -1,8 +1,10 @@ +using System.Collections.Generic; using System.Text.Json.Serialization; +using Shokofin.SignalR.Interfaces; namespace Shokofin.SignalR.Models; -public class FileEventArgs +public class FileEventArgs : IFileEventArgs { /// <summary> /// Shoko file id. @@ -22,4 +24,9 @@ public class FileEventArgs /// </summary> [JsonPropertyName("RelativePath")] public string RelativePath { get; set; } = string.Empty; + + /// <summary> + /// Cross references of episodes linked to this file. + /// </summary> + public List<IFileEventArgs.FileCrossReference> CrossReferences { get; set; } = new(); } diff --git a/Shokofin/SignalR/Models/FileMatchedEventArgs.cs b/Shokofin/SignalR/Models/FileMatchedEventArgs.cs index f1067c66..efe4ade0 100644 --- a/Shokofin/SignalR/Models/FileMatchedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileMatchedEventArgs.cs @@ -1,34 +1,33 @@ using System.Collections.Generic; using System.Text.Json.Serialization; +using Shokofin.SignalR.Interfaces; namespace Shokofin.SignalR.Models; -public class FileMatchedEventArgs : FileEventArgs +public class FileMatchedEventArgsV1 : IFileEventArgs { /// <summary> - /// Cross references of episodes linked to this file. + /// Shoko file id. /// </summary> - [JsonPropertyName("CrossRefs")] - public List<FileCrossReference> CrossReferences { get; set; } = new(); + [JsonPropertyName("FileID")] + public int FileId { get; set; } - public class FileCrossReference - { - /// <summary> - /// Shoko episode id. - /// </summary> - [JsonPropertyName("EpisodeID")] - public int EpisodeId { get; set; } + /// <summary> + /// The ID of the import folder the event was detected in. + /// </summary> + /// <value></value> + [JsonPropertyName("ImportFolderID")] + public int ImportFolderId { get; set; } - /// <summary> - /// Shoko series id. - /// </summary> - [JsonPropertyName("SeriesID")] - public int SeriesId { get; set; } + /// <summary> + /// The relative path of the file from the import folder base location. + /// </summary> + [JsonPropertyName("RelativePath")] + public string RelativePath { get; set; } = string.Empty; - /// <summary> - /// Shoko group id. - /// </summary> - [JsonPropertyName("GroupID")] - public int GroupId { get; set; } - } + /// <summary> + /// Cross references of episodes linked to this file. + /// </summary> + [JsonPropertyName("CrossRefs")] + public List<IFileEventArgs.FileCrossReference> CrossReferences { get; set; } = new(); } \ No newline at end of file diff --git a/Shokofin/SignalR/Models/FileMovedEventArgs.cs b/Shokofin/SignalR/Models/FileMovedEventArgs.cs index b1247725..c54ee78a 100644 --- a/Shokofin/SignalR/Models/FileMovedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileMovedEventArgs.cs @@ -3,7 +3,7 @@ namespace Shokofin.SignalR.Models; -public class FileMovedEventArgs : IFileRelocationEventArgs +public class FileMovedEventArgsV1 : IFileRelocationEventArgs { /// <summary> /// Shoko file id. @@ -37,3 +37,18 @@ public class FileMovedEventArgs : IFileRelocationEventArgs [JsonPropertyName("OldRelativePath")] public string PreviousRelativePath { get; set; } = string.Empty; } + +public class FileMovedEventArgs: FileEventArgs, IFileRelocationEventArgs +{ + /// <summary> + /// The ID of the old import folder the event was detected in. + /// </summary> + /// <value></value> + [JsonPropertyName("PreviousImportFolderID")] + public int PreviousImportFolderId { get; set; } + + /// <summary> + /// The relative path of the old file from the import folder base location. + /// </summary> + public string PreviousRelativePath { get; set; } = string.Empty; +} diff --git a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs index 9db248f6..997a08d2 100644 --- a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs @@ -3,8 +3,27 @@ namespace Shokofin.SignalR.Models; -public class FileRenamedEventArgs : FileEventArgs, IFileRelocationEventArgs +public class FileRenamedEventArgsV1 : IFileRelocationEventArgs { + /// <summary> + /// Shoko file id. + /// </summary> + [JsonPropertyName("FileID")] + public int FileId { get; set; } + + /// <summary> + /// The ID of the import folder the event was detected in. + /// </summary> + /// <value></value> + [JsonPropertyName("ImportFolderID")] + public int ImportFolderId { get; set; } + + /// <summary> + /// The relative path of the file from the import folder base location. + /// </summary> + [JsonPropertyName("RelativePath")] + public string RelativePath { get; set; } = string.Empty; + /// <summary> /// The new File name. /// </summary> @@ -17,10 +36,29 @@ public class FileRenamedEventArgs : FileEventArgs, IFileRelocationEventArgs [JsonPropertyName("OldFileName")] public string PreviousFileName { get; set; } = string.Empty; - public int PreviousImportFolderId { get; set; } + + /// <inheritdoc/> + public int PreviousImportFolderId => ImportFolderId; + + /// <inheritdoc/> + public string PreviousRelativePath => RelativePath[^FileName.Length] + PreviousFileName; +} + +public class FileRenamedEventArgs : FileEventArgs, IFileRelocationEventArgs +{ + /// <summary> + /// The new File name. + /// </summary> + public string FileName { get; set; } = string.Empty; /// <summary> - /// The relative path of the old file from the import folder base location. + /// The old file name. /// </summary> + public string PreviousFileName { get; set; } = string.Empty; + + /// <inheritdoc/> + public int PreviousImportFolderId => ImportFolderId; + + /// <inheritdoc/> public string PreviousRelativePath => RelativePath[^FileName.Length] + PreviousFileName; } \ No newline at end of file diff --git a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs index 7bab9b68..824d2bc1 100644 --- a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Text.Json.Serialization; namespace Shokofin.SignalR.Models; @@ -5,14 +6,31 @@ namespace Shokofin.SignalR.Models; public class SeriesInfoUpdatedEventArgs { /// <summary> - /// Shoko series id. + /// The update reason. + /// </summary> + public UpdateReason Reason { get; set; } + + /// <summary> + /// The provider metadata source. + /// </summary> + [JsonPropertyName("Source")] + public string ProviderName { get; set; } = string.Empty; + + /// <summary> + /// The provided metadata series id. /// </summary> [JsonPropertyName("SeriesID")] - public int SeriesId { get; set; } + public int ProviderId { get; set; } + + /// <summary> + /// Shoko series ids affected by this update. + /// </summary> + [JsonPropertyName("ShokoSeriesIDs")] + public List<int> SeriesIds { get; set; } = new(); /// <summary> - /// Shoko group id. + /// Shoko group ids affected by this update. /// </summary> - [JsonPropertyName("GroupID")] - public int GroupId { get; set; } + [JsonPropertyName("ShokoGroupIDs")] + public List<int> GroupIds { get; set; } = new(); } \ No newline at end of file diff --git a/Shokofin/SignalR/Models/UpdateReason.cs b/Shokofin/SignalR/Models/UpdateReason.cs new file mode 100644 index 00000000..a3f003e8 --- /dev/null +++ b/Shokofin/SignalR/Models/UpdateReason.cs @@ -0,0 +1,13 @@ + +using System.Text.Json.Serialization; + +namespace Shokofin.SignalR.Models; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum UpdateReason +{ + None = 0, + Added = 1, + Updated = 2, + Removed = 3, +} \ No newline at end of file diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 954db286..5676b376 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Shokofin.API.Models; using Shokofin.Configuration; using Shokofin.Resolvers; using Shokofin.SignalR.Interfaces; @@ -15,6 +16,14 @@ namespace Shokofin.SignalR; public class SignalRConnectionManager : IDisposable { + private static ComponentVersion? ServerVersion => + Plugin.Instance.Configuration.ServerVersion; + + 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)); + private const string HubUrl = "/signalr/aggregate?feeds=shoko"; private readonly ILogger<SignalRConnectionManager> Logger; @@ -76,10 +85,18 @@ private async Task ConnectAsync(PluginConfiguration config) connection.On<SeriesInfoUpdatedEventArgs>("ShokoEvent:SeriesUpdated", OnSeriesInfoUpdated); // Attach file events. - connection.On<FileMatchedEventArgs>("ShokoEvent:FileMatched", OnFileMatched); - connection.On<FileMovedEventArgs>("ShokoEvent:FileMoved", OnFileMoved); - connection.On<FileRenamedEventArgs>("ShokoEvent:FileRenamed", OnFileRenamed); - connection.On<FileEventArgs>("ShokoEvent:FileDeleted", OnFileDeleted); + if (UseOlderEvents) { + connection.On<FileMatchedEventArgsV1>("ShokoEvent:FileMatched", OnFileMatched); + connection.On<FileMovedEventArgsV1>("ShokoEvent:FileMoved", OnFileRelocated); + connection.On<FileRenamedEventArgsV1>("ShokoEvent:FileRenamed", OnFileRelocated); + connection.On<FileEventArgs>("ShokoEvent:FileDeleted", OnFileDeleted); + } + else { + connection.On<FileEventArgs>("ShokoEvent:FileMatched", OnFileMatched); + connection.On<FileMovedEventArgs>("ShokoEvent:FileMoved", OnFileRelocated); + connection.On<FileRenamedEventArgs>("ShokoEvent:FileRenamed", OnFileRelocated); + connection.On<FileEventArgs>("ShokoEvent:FileDeleted", OnFileDeleted); + } try { await connection.StartAsync().ConfigureAwait(false); @@ -181,10 +198,10 @@ private static string ConstructKey(PluginConfiguration config) #region File Events - private void OnFileMatched(FileMatchedEventArgs eventArgs) + private void OnFileMatched(IFileEventArgs eventArgs) { Logger.LogDebug( - "File matched; {ImportFolderIdB} {PathB} (File={FileId})", + "File matched; {ImportFolderId} {Path} (File={FileId})", eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs.FileId @@ -222,13 +239,7 @@ private void OnFileRelocated(IFileRelocationEventArgs eventArgs) // the links broke, and if the newly generated links is not in the list provided by the base items, then remove the broken link. } - private void OnFileMoved(FileMovedEventArgs eventArgs) - => OnFileRelocated(eventArgs); - - private void OnFileRenamed(FileRenamedEventArgs eventArgs) - => OnFileRelocated(eventArgs); - - private void OnFileDeleted(FileEventArgs eventArgs) + private void OnFileDeleted(IFileEventArgs eventArgs) { Logger.LogDebug( "File deleted; {ImportFolderIdB} {PathB} (File={FileId})", @@ -253,10 +264,14 @@ private void OnFileDeleted(FileEventArgs eventArgs) private void OnEpisodeInfoUpdated(EpisodeInfoUpdatedEventArgs eventArgs) { Logger.LogDebug( - "Episode updated. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", - eventArgs.EpisodeId, - eventArgs.SeriesId, - eventArgs.GroupId + "{ProviderName} episode {ProviderId} ({ProviderSeriesId}) dispatched event {UpdateReason}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", + eventArgs.ProviderName, + eventArgs.ProviderId, + eventArgs.ProviderSeriesId, + eventArgs.Reason, + eventArgs.EpisodeIds, + eventArgs.SeriesIds, + eventArgs.GroupIds ); // Refresh all epoisodes and movies linked to the episode. @@ -265,9 +280,12 @@ private void OnEpisodeInfoUpdated(EpisodeInfoUpdatedEventArgs eventArgs) private void OnSeriesInfoUpdated(SeriesInfoUpdatedEventArgs eventArgs) { Logger.LogDebug( - "Series updated. (Series={SeriesId},Group={GroupId})", - eventArgs.SeriesId, - eventArgs.GroupId + "{ProviderName} series {ProviderId} dispatched event {UpdateReason}. (Series={SeriesId},Group={GroupId})", + eventArgs.ProviderName, + eventArgs.ProviderId, + eventArgs.Reason, + eventArgs.SeriesIds, + eventArgs.GroupIds ); // look up the series/season/movie, then check the media folder they're From 68f2a02b373420fecad62a2489ba8a49f2d853f4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 14 Apr 2024 00:00:06 +0200 Subject: [PATCH 0820/1103] fix: better handling of index number end - Only add index number end for episodes if it's not the same as the episode number. --- Shokofin/Providers/EpisodeProvider.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index e57ed61f..ffcbfb7c 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -227,9 +227,9 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie }; } - if (file != null) { + if (file != null && file.EpisodeList.Count > 1) { var episodeNumberEnd = episodeNumber + file.EpisodeList.Count - 1; - if (episode.AniDB.EpisodeNumber != episodeNumberEnd) + if (episodeNumberEnd != episodeNumber && episode.AniDB.EpisodeNumber != episodeNumberEnd) result.IndexNumberEnd = episodeNumberEnd; } From 20121a4bfb576779815be20f31e180879b58be29 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 13 Apr 2024 22:01:00 +0000 Subject: [PATCH 0821/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 6012d3a8..1e06e855 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.113", + "changelog": "fix: better handling of index number end\n\n- Only add index number end for episodes if it's not the\n same as the episode number.\n\nrefactor: update signalr events\n\n- Updated the SignalR Connection Manager\n to support both the new and old signalr events.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.113/shoko_3.0.1.113.zip", + "checksum": "6f5479613dfec25fd10d91e40fe57964", + "timestamp": "2024-04-13T22:00:58Z" + }, { "version": "3.0.1.112", "changelog": "fix: prevent error being logged in the console on plugin settings page", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.109/shoko_3.0.1.109.zip", "checksum": "5c47ba81f04a92a71c5095f3a1239ea0", "timestamp": "2024-04-12T21:10:54Z" - }, - { - "version": "3.0.1.108", - "changelog": "fix: add workaround for mixed library support\n\n- Implement a workaround for using the mixed library with no movies yet,\n by adding empty 'tvshow.nfo' and 'season.nfo' files within the\n show/season directories (but only in mixed libraries) so the jellyfin\n internal logic will assign the directories as a show/season/episode\n structure. ||This is untested code btw. I'll test tomorrow. Now good night.||\n\nmisc: add issue template (#49) [skip ci]\n\n\nUpdate and rename issue.yml to bug.yml\n\nFinal touches.\n\nAdded request features template\nFixing up the template\nPath fix\nAdd issue templat", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.108/shoko_3.0.1.108.zip", - "checksum": "2a156f5c4bdc3a1c6e832a2fd871466b", - "timestamp": "2024-04-12T01:13:15Z" } ] } From 6dfefeeec3ea9f8466a22cb8f8ee0eba718d4860 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 14 Apr 2024 00:45:23 +0200 Subject: [PATCH 0822/1103] misc: add warning for windows users - Add a warning for windows users that they need to enable dev mode to be allowed to create sym links, along with a link for how to enable it. Also, this warning is only shown if we fail the test to create a sym link at start up. - Disable the VFS by default if we cannot create sym links. --- Shokofin/Configuration/PluginConfiguration.cs | 9 ++++++- Shokofin/Configuration/configController.js | 4 +++ Shokofin/Configuration/configPage.html | 2 ++ Shokofin/Plugin.cs | 26 ++++++++++++++++++- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 7358a7d0..f0c05343 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -16,6 +16,13 @@ public class PluginConfiguration : BasePluginConfiguration { #region Connection + /// <summary> + /// Helper for the web ui to show the windows only warning, and to disable + /// the VFS by default if we cannot create symbolic links. + /// </summary> + [XmlIgnore, JsonInclude] + public bool CanCreateSymbolicLinks => Plugin.Instance.CanCreateSymbolicLinks; + /// <summary> /// The URL for where to connect to shoko internally. /// And externally if no <seealso cref="PublicUrl"/> is set. @@ -293,7 +300,7 @@ public PluginConfiguration() TitleAllowAny = false; DescriptionSourceList = new[] { TextSourceType.AniDb, TextSourceType.TvDb, TextSourceType.TMDB }; DescriptionSourceOrder = new[] { TextSourceType.AniDb, TextSourceType.TvDb, TextSourceType.TMDB }; - VirtualFileSystem = true; + VirtualFileSystem = CanCreateSymbolicLinks; VirtualFileSystemThreads = 10; UseGroupsForShows = false; SeparateMovies = false; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index df841d99..92cf6a9d 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -639,6 +639,10 @@ export default function (page) { else { form.querySelector("#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("#Username").setAttribute("disabled", ""); diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index c2eca30f..2f64d89b 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -228,6 +228,7 @@ <h3>Media Folder Settings</h3> </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 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> 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. @@ -272,6 +273,7 @@ <h3>Media Folder Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription"> <div>Enables the use of the Virtual File System™ 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> 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. diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 4f9cfe1f..472b0cf1 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Plugins; @@ -19,6 +20,8 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages public override Guid Id => Guid.Parse("5216ccbf-d24a-4eb3-8a7e-7da4230b7052"); + public readonly bool CanCreateSymbolicLinks; + private readonly ILogger<Plugin> Logger; /// <summary> @@ -33,7 +36,28 @@ public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, IgnoredFolders = Configuration.IgnoredFolders.ToHashSet(); VirtualRoot = Path.Combine(applicationPaths.ProgramDataPath, "Shokofin", "VFS"); Logger = logger; - Logger.LogInformation("Virtual File System Location; {Path}", VirtualRoot); + CanCreateSymbolicLinks = true; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + var target = Path.Combine(Path.GetDirectoryName(VirtualRoot)!, "TestTarget.txt"); + var link = Path.Combine(Path.GetDirectoryName(VirtualRoot)!, "TestLink.txt"); + try { + if (!Directory.Exists(Path.GetDirectoryName(VirtualRoot)!)) + Directory.CreateDirectory(Path.GetDirectoryName(VirtualRoot)!); + File.Create(target); + File.CreateSymbolicLink(link, target); + } + catch { + CanCreateSymbolicLinks = false; + } + finally { + if (File.Exists(link)) + File.Delete(link); + if (File.Exists(target)) + File.Delete(target); + } + } + Logger.LogDebug("Virtual File System Location; {Path}", VirtualRoot); + Logger.LogDebug("Can create symbolic links; {Value}", CanCreateSymbolicLinks); } public void OnConfigChanged(object? sender, BasePluginConfiguration e) From c837b8ef8ea1721eb6001c023c8c6215db4346ce Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 13 Apr 2024 22:46:13 +0000 Subject: [PATCH 0823/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 1e06e855..08dfc472 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.114", + "changelog": "misc: add warning for windows users\n\n- Add a warning for windows users that they need to enable dev mode to\n be allowed to create sym links, along with a link for how to enable\n it. Also, this warning is only shown if we fail the test to create a\n sym link at start up.\n\n- Disable the VFS by default if we cannot create sym links.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.114/shoko_3.0.1.114.zip", + "checksum": "647df758c777a758c810b5e8d54d2fc9", + "timestamp": "2024-04-13T22:46:11Z" + }, { "version": "3.0.1.113", "changelog": "fix: better handling of index number end\n\n- Only add index number end for episodes if it's not the\n same as the episode number.\n\nrefactor: update signalr events\n\n- Updated the SignalR Connection Manager\n to support both the new and old signalr events.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.110/shoko_3.0.1.110.zip", "checksum": "fb5abc01ccafc5b83e96340e057f56c5", "timestamp": "2024-04-12T22:45:41Z" - }, - { - "version": "3.0.1.109", - "changelog": "misc: add missing TODO and remove trailing spaces\n\nrefactor: move VFS location\n\n- Moved the location of the VFS outside the plugins directory, so it\n won't be scanned on startup by Jellyfin when it tries to discover\n the plugins to load. Also, in case it wasn't obvious enough.\n THIS IS A BREAKIGN CHANGE. Okay? Okay.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.109/shoko_3.0.1.109.zip", - "checksum": "5c47ba81f04a92a71c5095f3a1239ea0", - "timestamp": "2024-04-12T21:10:54Z" } ] } From ef8e981e371ac72c7099e843e1b7dbbdd487062f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 14 Apr 2024 01:13:33 +0200 Subject: [PATCH 0824/1103] fix: fix sym link test --- Shokofin/Plugin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 472b0cf1..77794b3a 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -43,7 +43,7 @@ public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, try { if (!Directory.Exists(Path.GetDirectoryName(VirtualRoot)!)) Directory.CreateDirectory(Path.GetDirectoryName(VirtualRoot)!); - File.Create(target); + File.WriteAllText(target, ""); File.CreateSymbolicLink(link, target); } catch { From 12d86c517c2efa9ff2d3e13bac8e17dd6eeb2d3d Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 13 Apr 2024 23:14:21 +0000 Subject: [PATCH 0825/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 08dfc472..a495346e 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.115", + "changelog": "fix: fix sym link test", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.115/shoko_3.0.1.115.zip", + "checksum": "0d701d7641ef938197b631b3ceaa2101", + "timestamp": "2024-04-13T23:14:19Z" + }, { "version": "3.0.1.114", "changelog": "misc: add warning for windows users\n\n- Add a warning for windows users that they need to enable dev mode to\n be allowed to create sym links, along with a link for how to enable\n it. Also, this warning is only shown if we fail the test to create a\n sym link at start up.\n\n- Disable the VFS by default if we cannot create sym links.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.111/shoko_3.0.1.111.zip", "checksum": "4435d62750c4efaf0a74a830fd1f9131", "timestamp": "2024-04-13T21:26:44Z" - }, - { - "version": "3.0.1.110", - "changelog": "misc: order file list by series id.\n\nfix: fix off-by-one error in GetAttributeValue", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.110/shoko_3.0.1.110.zip", - "checksum": "fb5abc01ccafc5b83e96340e057f56c5", - "timestamp": "2024-04-12T22:45:41Z" } ] } From 47c4677face5889d55fa1572bd699e1953e1ea7c Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Sun, 14 Apr 2024 11:11:38 +0100 Subject: [PATCH 0826/1103] chore: cleanup js in settings page (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now matches revam™ formatting standards --- Shokofin/Configuration/configController.js | 126 +++++++++++---------- 1 file changed, 64 insertions(+), 62 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 92cf6a9d..5a4d5cc6 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -13,7 +13,7 @@ const Messages = { * @param {string} value - Stringified list of values to filter. * @returns {string[]} An array of sanitized and filtered values. */ - function filterIgnoredFolders(value) { +function filterIgnoredFolders(value) { // We convert to a set to filter out duplicate values. const filteredSet = new Set( value @@ -33,7 +33,7 @@ const Messages = { * @param {string} value - Stringified list of values to filter. * @returns {number[]} An array of sanitized and filtered values. */ - function filterReconnectIntervals(value) { +function filterReconnectIntervals(value) { // We convert to a set to filter out duplicate values. const filteredSet = new Set( value @@ -48,54 +48,56 @@ const Messages = { return Array.from(filteredSet).sort((a, b) => a - b); } - function adjustSortableListElement(element) { - const btnSortable = element.querySelector(".btnSortable"); - const inner = btnSortable.querySelector(".material-icons"); - - if (element.previousElementSibling) { - btnSortable.title = "Up"; - btnSortable.classList.add("btnSortableMoveUp"); - inner.classList.add("keyboard_arrow_up"); - - btnSortable.classList.remove("btnSortableMoveDown"); - inner.classList.remove("keyboard_arrow_down"); - } else { - btnSortable.title = "Down"; - btnSortable.classList.add("btnSortableMoveDown"); - inner.classList.add("keyboard_arrow_down"); - - btnSortable.classList.remove("btnSortableMoveUp"); - inner.classList.remove("keyboard_arrow_up"); - } +function adjustSortableListElement(element) { + const btnSortable = element.querySelector(".btnSortable"); + const inner = btnSortable.querySelector(".material-icons"); + + if (element.previousElementSibling) { + btnSortable.title = "Up"; + btnSortable.classList.add("btnSortableMoveUp"); + inner.classList.add("keyboard_arrow_up"); + + btnSortable.classList.remove("btnSortableMoveDown"); + inner.classList.remove("keyboard_arrow_down"); + } + else { + btnSortable.title = "Down"; + btnSortable.classList.add("btnSortableMoveDown"); + inner.classList.add("keyboard_arrow_down"); + + btnSortable.classList.remove("btnSortableMoveUp"); + inner.classList.remove("keyboard_arrow_up"); + } } /** @param {PointerEvent} event */ function onSortableContainerClick(event) { - const parentWithClass = (element, className) => { - return (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); - } + const parentWithClass = (element, className) => { + return (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); + } + } - for (const option of list.querySelectorAll(".sortableOption")) { - adjustSortableListElement(option) - }; - } + for (const option of list.querySelectorAll(".sortableOption")) { + adjustSortableListElement(option) + }; + } } async function loadUserConfig(form, userId, config) { @@ -888,26 +890,26 @@ function setDescriptionSourcesIntoConfig(form, config) { } async function setDescriptionSourcesFromConfig(form, config) { - const list = form.querySelector("#descriptionSourceList .checkboxList"); - const listItems = list.querySelectorAll('.listItem'); + const list = form.querySelector("#descriptionSourceList .checkboxList"); + const listItems = list.querySelectorAll('.listItem'); - for (const item of listItems) { - const source = item.dataset.descriptionsource; - if (config.DescriptionSourceList.includes(source)) { - item.querySelector(".chkDescriptionSource").checked = true; - } - if (config.DescriptionSourceOrder.includes(source)) { - list.removeChild(item); // This is safe to be removed as we can re-add it in the next loop + for (const item of listItems) { + const source = item.dataset.descriptionsource; + if (config.DescriptionSourceList.includes(source)) { + item.querySelector(".chkDescriptionSource").checked = true; + } + if (config.DescriptionSourceOrder.includes(source)) { + list.removeChild(item); // This is safe to be removed as we can re-add it in the next loop + } } - } - for (const source of config.DescriptionSourceOrder) { - const targetElement = Array.prototype.find.call(listItems, (el) => el.dataset.descriptionsource === source); - if (targetElement) { - list.append(targetElement); + for (const source of config.DescriptionSourceOrder) { + const targetElement = Array.prototype.find.call(listItems, (el) => el.dataset.descriptionsource === source); + if (targetElement) { + list.append(targetElement); + } } - } - for (const option of list.querySelectorAll(".sortableOption")) { - adjustSortableListElement(option) - }; + for (const option of list.querySelectorAll(".sortableOption")) { + adjustSortableListElement(option) + }; } From 9f5874ebeef95120e8a34d0705d165fe8a9b501d Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 14 Apr 2024 10:12:23 +0000 Subject: [PATCH 0827/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index a495346e..c6397c4d 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.116", + "changelog": "chore: cleanup js in settings page (#51)\n\nNow matches revam\u2122 formatting standard", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.116/shoko_3.0.1.116.zip", + "checksum": "946de3d379dee74db88deef64d7531f8", + "timestamp": "2024-04-14T10:12:21Z" + }, { "version": "3.0.1.115", "changelog": "fix: fix sym link test", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.112/shoko_3.0.1.112.zip", "checksum": "41712fea39e3dea10c3004de0ae10cce", "timestamp": "2024-04-13T21:40:13Z" - }, - { - "version": "3.0.1.111", - "changelog": "Merge pull request #42 from fearnlj01/config-tweak\n\nAdd sortable checkboxes for description source\nMisc: Implement requested style changes from #42\n\nFix: Ensure description sources sorting direction renders as expected\n\nFix: Settings description tweak\n\nMisc: Migrations begone... Simpler for everyone this way!\n\n\nChore: Remove uneccersary using that snuck in somehow\n\nFix: Avoid requesting the config twice after load...\n\nRefactor: Allows for a partially working settings migration\n\nFix: Change HTML description values to match Enum counterpart\n\nFeat: Implements C# changes for new settings\n\nMisc: minor refactoring\n\nFeat: Add sortable checkboxes for description source\n(Client side only, no settings saved", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.111/shoko_3.0.1.111.zip", - "checksum": "4435d62750c4efaf0a74a830fd1f9131", - "timestamp": "2024-04-13T21:26:44Z" } ] } From 2345881847b770e965a62a9583eea3b982177069 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 14 Apr 2024 12:38:14 +0200 Subject: [PATCH 0828/1103] fix: throw early if we cannot create symlinks - Throw as early as possible if we have determined we cannot create symbolic links. - Update the warning in the settings to also say the users need to restart their Jellyfin server after enabling dev mode (to let the plugin re-check), for the VFS to work. --- Shokofin/Configuration/configPage.html | 4 ++-- Shokofin/Plugin.cs | 5 ++++- Shokofin/Resolvers/ShokoResolveManager.cs | 5 ++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 2f64d89b..048ef7e3 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -228,7 +228,7 @@ <h3>Media Folder Settings</h3> </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 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> to be able to create symbolic links, a feature <strong>required</strong> to use the VFS.</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. @@ -273,7 +273,7 @@ <h3>Media Folder Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription"> <div>Enables the use of the Virtual File System™ 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> to be able to create symbolic links, a feature <strong>required</strong> to use the VFS.</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> 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. diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 77794b3a..4d981e9d 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -20,6 +20,9 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages public override Guid Id => Guid.Parse("5216ccbf-d24a-4eb3-8a7e-7da4230b7052"); + /// <summary> + /// Indicates that we can create symbolic links. + /// </summary> public readonly bool CanCreateSymbolicLinks; private readonly ILogger<Plugin> Logger; @@ -43,7 +46,7 @@ public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, try { if (!Directory.Exists(Path.GetDirectoryName(VirtualRoot)!)) Directory.CreateDirectory(Path.GetDirectoryName(VirtualRoot)!); - File.WriteAllText(target, ""); + File.WriteAllText(target, string.Empty); File.CreateSymbolicLink(link, target); } catch { diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 423045b1..9aa0f698 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -227,6 +227,9 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold if (!mediaConfig.IsVirtualFileSystemEnabled) return null; + 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."); + // Check if we should introduce the VFS for the media folder. var start = DateTime.UtcNow; var allPaths = FileSystem.GetFilePaths(mediaFolder.Path, true) @@ -455,7 +458,7 @@ await Task.WhenAll(files.Select(async (tuple) => { Directory.CreateDirectory(nfoDirectory); if (!File.Exists(nfoFile)) { - File.WriteAllText(nfoFile, ""); + File.WriteAllText(nfoFile, string.Empty); } else { skippedNfo++; From cb06fbda061d797486f4ac5016b8f9a639fbc1c8 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 14 Apr 2024 10:39:06 +0000 Subject: [PATCH 0829/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index c6397c4d..be450f61 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.117", + "changelog": "fix: throw early if we cannot create symlinks\n\n- Throw as early as possible if we have determined we cannot\n create symbolic links.\n\n- Update the warning in the settings to also say the users need to\n restart their Jellyfin server after enabling dev mode (to let the\n plugin re-check), for the VFS to work.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.117/shoko_3.0.1.117.zip", + "checksum": "b16b26ac53196d172cc5615e43270caa", + "timestamp": "2024-04-14T10:39:04Z" + }, { "version": "3.0.1.116", "changelog": "chore: cleanup js in settings page (#51)\n\nNow matches revam\u2122 formatting standard", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.113/shoko_3.0.1.113.zip", "checksum": "6f5479613dfec25fd10d91e40fe57964", "timestamp": "2024-04-13T22:00:58Z" - }, - { - "version": "3.0.1.112", - "changelog": "fix: prevent error being logged in the console on plugin settings page", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.112/shoko_3.0.1.112.zip", - "checksum": "41712fea39e3dea10c3004de0ae10cce", - "timestamp": "2024-04-13T21:40:13Z" } ] } From 2764cdbfec58c57783d7859915f9e17eb0dd7c17 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 14 Apr 2024 13:37:56 +0200 Subject: [PATCH 0830/1103] refactor: more changes to signalr models - Another overhaul of the SingalR models, this time removing an (now) unneeded event model, and also (hopefully) correcting the relative paths to use the correct directory separators for the local os environment and not the environment Shoko Server is running in. --- ...eMatchedEventArgs.cs => IFileEventArgs.cs} | 4 +- .../Interfaces/IFileRelocationEventArgs.cs | 21 +--- .../Models/EpisodeInfoUpdatedEventArgs.cs | 14 ++- Shokofin/SignalR/Models/FileEventArgs.cs | 43 +++++-- .../SignalR/Models/FileMatchedEventArgs.cs | 33 ----- Shokofin/SignalR/Models/FileMovedEventArgs.cs | 113 ++++++++++++------ .../SignalR/Models/FileRenamedEventArgs.cs | 97 +++++++++------ .../Models/SeriesInfoUpdatedEventArgs.cs | 9 +- Shokofin/SignalR/SignalRConnectionManager.cs | 10 +- 9 files changed, 189 insertions(+), 155 deletions(-) rename Shokofin/SignalR/Interfaces/{IFileMatchedEventArgs.cs => IFileEventArgs.cs} (84%) delete mode 100644 Shokofin/SignalR/Models/FileMatchedEventArgs.cs diff --git a/Shokofin/SignalR/Interfaces/IFileMatchedEventArgs.cs b/Shokofin/SignalR/Interfaces/IFileEventArgs.cs similarity index 84% rename from Shokofin/SignalR/Interfaces/IFileMatchedEventArgs.cs rename to Shokofin/SignalR/Interfaces/IFileEventArgs.cs index d008802e..63e5d997 100644 --- a/Shokofin/SignalR/Interfaces/IFileMatchedEventArgs.cs +++ b/Shokofin/SignalR/Interfaces/IFileEventArgs.cs @@ -17,7 +17,9 @@ public interface IFileEventArgs int ImportFolderId { get; } /// <summary> - /// The relative path of the new file from the import folder base location. + /// The relative path from the base of the <see cref="ImportFolder"/> to + /// where the <see cref="File"/> lies, with a leading slash applied at + /// the start and normalised for the local system. /// </summary> string RelativePath { get; } diff --git a/Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs b/Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs index 7d3f84c1..e6918334 100644 --- a/Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs +++ b/Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs @@ -1,18 +1,8 @@ namespace Shokofin.SignalR.Interfaces; -public interface IFileRelocationEventArgs +public interface IFileRelocationEventArgs : IFileEventArgs { - /// <summary> - /// Shoko file id. - /// </summary> - int FileId { get; } - - /// <summary> - /// The ID of the new import folder the event was detected in. - /// </summary> - /// <value></value> - int ImportFolderId { get; } /// <summary> /// The ID of the old import folder the event was detected in. @@ -21,12 +11,9 @@ public interface IFileRelocationEventArgs int PreviousImportFolderId { get; } /// <summary> - /// The relative path of the new file from the import folder base location. - /// </summary> - string RelativePath { get; } - - /// <summary> - /// The relative path of the old file from the import folder base location. + /// The relative path from the previous base of the + /// <see cref="ImportFolder"/> to where the <see cref="File"/> previously + /// lied, with a leading slash applied at the start. /// </summary> string PreviousRelativePath { get; } } \ No newline at end of file diff --git a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs index fb13d620..235cb6f0 100644 --- a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs @@ -8,40 +8,42 @@ public class EpisodeInfoUpdatedEventArgs /// <summary> /// The update reason. /// </summary> + [JsonInclude, JsonPropertyName("Reason")] public UpdateReason Reason { get; set; } + /// <summary> /// The provider metadata source. /// </summary> - [JsonPropertyName("Source")] + [JsonInclude, JsonPropertyName("Source")] public string ProviderName { get; set; } = string.Empty; /// <summary> /// The provided metadata episode id. /// </summary> - [JsonPropertyName("EpisodeID")] + [JsonInclude, JsonPropertyName("EpisodeID")] public int ProviderId { get; set; } /// <summary> /// The provided metadata series id. /// </summary> - [JsonPropertyName("SeriesID")] + [JsonInclude, JsonPropertyName("SeriesID")] public int ProviderSeriesId { get; set; } /// <summary> /// Shoko episode ids affected by this update. /// </summary> - [JsonPropertyName("ShokoEpisodeIDs")] + [JsonInclude, JsonPropertyName("ShokoEpisodeIDs")] public List<int> EpisodeIds { get; set; } = new(); /// <summary> /// Shoko series ids affected by this update. /// </summary> - [JsonPropertyName("ShokoSeriesIDs")] + [JsonInclude, JsonPropertyName("ShokoSeriesIDs")] public List<int> SeriesIds { get; set; } = new(); /// <summary> /// Shoko group ids affected by this update. /// </summary> - [JsonPropertyName("ShokoGroupIDs")] + [JsonInclude, JsonPropertyName("ShokoGroupIDs")] public List<int> GroupIds { get; set; } = new(); } \ No newline at end of file diff --git a/Shokofin/SignalR/Models/FileEventArgs.cs b/Shokofin/SignalR/Models/FileEventArgs.cs index 1968d4e4..2c3bb05b 100644 --- a/Shokofin/SignalR/Models/FileEventArgs.cs +++ b/Shokofin/SignalR/Models/FileEventArgs.cs @@ -6,27 +6,44 @@ namespace Shokofin.SignalR.Models; public class FileEventArgs : IFileEventArgs { - /// <summary> - /// Shoko file id. - /// </summary> - [JsonPropertyName("FileID")] + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("FileID")] public int FileId { get; set; } - /// <summary> - /// The ID of the import folder the event was detected in. - /// </summary> - /// <value></value> - [JsonPropertyName("ImportFolderID")] + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("ImportFolderID")] public int ImportFolderId { get; set; } /// <summary> - /// The relative path of the file from the import folder base location. + /// The relative path with no leading slash and directory seperators used on + /// the Shoko side. /// </summary> - [JsonPropertyName("RelativePath")] - public string RelativePath { get; set; } = string.Empty; + [JsonInclude, JsonPropertyName("RelativePath")] + private string InternalPath { get; set; } = string.Empty; /// <summary> - /// Cross references of episodes linked to this file. + /// Cached path for later re-use. /// </summary> + [JsonIgnore] + private string? CachedPath { get; set; } + + /// <inheritdoc/> + [JsonIgnore] + public string RelativePath => + CachedPath ??= System.IO.Path.DirectorySeparatorChar + InternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("CrossReferences")] public List<IFileEventArgs.FileCrossReference> CrossReferences { get; set; } = new(); + +#pragma warning disable IDE0051 + /// <summary> + /// Legacy cross-references of episodes lined to this file. Only present + /// for setting the cross-references when deserializing JSON. + /// </summary> + [JsonInclude, JsonPropertyName("CrossRefs")] + private List<IFileEventArgs.FileCrossReference> LegacyCrossReferences { set { CrossReferences = value; } } +#pragma warning restore IDE0051 } diff --git a/Shokofin/SignalR/Models/FileMatchedEventArgs.cs b/Shokofin/SignalR/Models/FileMatchedEventArgs.cs deleted file mode 100644 index efe4ade0..00000000 --- a/Shokofin/SignalR/Models/FileMatchedEventArgs.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; -using Shokofin.SignalR.Interfaces; - -namespace Shokofin.SignalR.Models; - -public class FileMatchedEventArgsV1 : IFileEventArgs -{ - /// <summary> - /// Shoko file id. - /// </summary> - [JsonPropertyName("FileID")] - public int FileId { get; set; } - - /// <summary> - /// The ID of the import folder the event was detected in. - /// </summary> - /// <value></value> - [JsonPropertyName("ImportFolderID")] - public int ImportFolderId { get; set; } - - /// <summary> - /// The relative path of the file from the import folder base location. - /// </summary> - [JsonPropertyName("RelativePath")] - public string RelativePath { get; set; } = string.Empty; - - /// <summary> - /// Cross references of episodes linked to this file. - /// </summary> - [JsonPropertyName("CrossRefs")] - public List<IFileEventArgs.FileCrossReference> CrossReferences { get; set; } = new(); -} \ No newline at end of file diff --git a/Shokofin/SignalR/Models/FileMovedEventArgs.cs b/Shokofin/SignalR/Models/FileMovedEventArgs.cs index c54ee78a..2a861e67 100644 --- a/Shokofin/SignalR/Models/FileMovedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileMovedEventArgs.cs @@ -1,54 +1,93 @@ +using System.Collections.Generic; using System.Text.Json.Serialization; using Shokofin.SignalR.Interfaces; namespace Shokofin.SignalR.Models; -public class FileMovedEventArgsV1 : IFileRelocationEventArgs + +public class FileMovedEventArgs: FileEventArgs, IFileRelocationEventArgs { - /// <summary> - /// Shoko file id. - /// </summary> - [JsonPropertyName("FileID")] - public int FileId { get; set; } + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("PreviousImportFolderID")] + public int PreviousImportFolderId { get; set; } /// <summary> - /// The ID of the new import folder the event was detected in. + /// The previous relative path with no leading slash and directory + /// seperators used on the Shoko side. /// </summary> - /// <value></value> - [JsonPropertyName("NewImportFolderID")] - public int ImportFolderId { get; set; } + [JsonInclude, JsonPropertyName("PreviousRelativePath")] + private string PreviousInternalPath { get; set; } = string.Empty; /// <summary> - /// The ID of the old import folder the event was detected in. + /// Cached path for later re-use. /// </summary> - /// <value></value> - [JsonPropertyName("OldImportFolderID")] - public int PreviousImportFolderId { get; set; } + [JsonIgnore] + private string? PreviousCachedPath { get; set; } - /// <summary> - /// The relative path of the new file from the import folder base location. - /// </summary> - [JsonPropertyName("NewRelativePath")] - public string RelativePath { get; set; } = string.Empty; + /// <inheritdoc/> + [JsonIgnore] + public string PreviousRelativePath => + PreviousCachedPath ??= System.IO.Path.DirectorySeparatorChar + PreviousInternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); - /// <summary> - /// The relative path of the old file from the import folder base location. - /// </summary> - [JsonPropertyName("OldRelativePath")] - public string PreviousRelativePath { get; set; } = string.Empty; -} + public class V0 : IFileRelocationEventArgs + { + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("FileID")] + public int FileId { get; set; } -public class FileMovedEventArgs: FileEventArgs, IFileRelocationEventArgs -{ - /// <summary> - /// The ID of the old import folder the event was detected in. - /// </summary> - /// <value></value> - [JsonPropertyName("PreviousImportFolderID")] - public int PreviousImportFolderId { get; set; } + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("NewImportFolderID")] + public int ImportFolderId { get; set; } - /// <summary> - /// The relative path of the old file from the import folder base location. - /// </summary> - public string PreviousRelativePath { get; set; } = string.Empty; + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("OldImportFolderID")] + public int PreviousImportFolderId { get; set; } + + /// <summary> + /// The relative path with no leading slash and directory seperators used on + /// the Shoko side. + /// </summary> + [JsonInclude, JsonPropertyName("RelativePath")] + private string InternalPath { get; set; } = string.Empty; + + /// <summary> + /// Cached path for later re-use. + /// </summary> + [JsonIgnore] + private string? CachedPath { get; set; } + + /// <inheritdoc/> + [JsonIgnore] + public string RelativePath => + CachedPath ??= System.IO.Path.DirectorySeparatorChar + InternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + + + /// <summary> + /// The previous relative path with no leading slash and directory + /// seperators used on the Shoko side. + /// </summary> + [JsonInclude, JsonPropertyName("OldRelativePath")] + private string PreviousInternalPath { get; set; } = string.Empty; + + /// <summary> + /// Cached path for later re-use. + /// </summary> + [JsonIgnore] + private string? PreviousCachedPath { get; set; } + + /// <inheritdoc/> + [JsonIgnore] + public string PreviousRelativePath => + PreviousCachedPath ??= System.IO.Path.DirectorySeparatorChar + PreviousInternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + + /// <inheritdoc/> + [JsonIgnore] + public List<IFileEventArgs.FileCrossReference> CrossReferences => new(); + } } diff --git a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs index 997a08d2..f6430e3a 100644 --- a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs @@ -1,64 +1,85 @@ +using System.Collections.Generic; using System.Text.Json.Serialization; using Shokofin.SignalR.Interfaces; namespace Shokofin.SignalR.Models; -public class FileRenamedEventArgsV1 : IFileRelocationEventArgs -{ - /// <summary> - /// Shoko file id. - /// </summary> - [JsonPropertyName("FileID")] - public int FileId { get; set; } - - /// <summary> - /// The ID of the import folder the event was detected in. - /// </summary> - /// <value></value> - [JsonPropertyName("ImportFolderID")] - public int ImportFolderId { get; set; } - - /// <summary> - /// The relative path of the file from the import folder base location. - /// </summary> - [JsonPropertyName("RelativePath")] - public string RelativePath { get; set; } = string.Empty; +public class FileRenamedEventArgs : FileEventArgs, IFileRelocationEventArgs +{ /// <summary> /// The new File name. /// </summary> - [JsonPropertyName("NewFileName")] + [JsonInclude, JsonPropertyName("FileName")] public string FileName { get; set; } = string.Empty; /// <summary> /// The old file name. /// </summary> - [JsonPropertyName("OldFileName")] + [JsonInclude, JsonPropertyName("PreviousFileName")] public string PreviousFileName { get; set; } = string.Empty; - /// <inheritdoc/> + [JsonIgnore] public int PreviousImportFolderId => ImportFolderId; /// <inheritdoc/> + [JsonIgnore] public string PreviousRelativePath => RelativePath[^FileName.Length] + PreviousFileName; -} -public class FileRenamedEventArgs : FileEventArgs, IFileRelocationEventArgs -{ - /// <summary> - /// The new File name. - /// </summary> - public string FileName { get; set; } = string.Empty; + public class V0 : IFileRelocationEventArgs + { + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("FileID")] + public int FileId { get; set; } - /// <summary> - /// The old file name. - /// </summary> - public string PreviousFileName { get; set; } = string.Empty; + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("ImportFolderID")] + public int ImportFolderId { get; set; } - /// <inheritdoc/> - public int PreviousImportFolderId => ImportFolderId; + /// <summary> + /// The relative path with no leading slash and directory seperators used on + /// the Shoko side. + /// </summary> + [JsonInclude, JsonPropertyName("RelativePath")] + private string InternalPath { get; set; } = string.Empty; - /// <inheritdoc/> - public string PreviousRelativePath => RelativePath[^FileName.Length] + PreviousFileName; + /// <summary> + /// Cached path for later re-use. + /// </summary> + [JsonIgnore] + private string? CachedPath { get; set; } + + /// <inheritdoc/> + [JsonIgnore] + public string RelativePath => + CachedPath ??= System.IO.Path.DirectorySeparatorChar + InternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + + /// <summary> + /// The new File name. + /// </summary> + [JsonInclude, JsonPropertyName("NewFileName")] + public string FileName { get; set; } = string.Empty; + + /// <summary> + /// The old file name. + /// </summary> + [JsonInclude, JsonPropertyName("OldFileName")] + public string PreviousFileName { get; set; } = string.Empty; + + + /// <inheritdoc/> + [JsonIgnore] + public int PreviousImportFolderId => ImportFolderId; + + /// <inheritdoc/> + [JsonIgnore] + public string PreviousRelativePath => RelativePath[^FileName.Length] + PreviousFileName; + + /// <inheritdoc/> + [JsonIgnore] + public List<IFileEventArgs.FileCrossReference> CrossReferences => new(); + } } \ No newline at end of file diff --git a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs index 824d2bc1..b4b1a89b 100644 --- a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs @@ -8,29 +8,30 @@ public class SeriesInfoUpdatedEventArgs /// <summary> /// The update reason. /// </summary> + [JsonInclude, JsonPropertyName("Reason")] public UpdateReason Reason { get; set; } /// <summary> /// The provider metadata source. /// </summary> - [JsonPropertyName("Source")] + [JsonInclude, JsonPropertyName("Source")] public string ProviderName { get; set; } = string.Empty; /// <summary> /// The provided metadata series id. /// </summary> - [JsonPropertyName("SeriesID")] + [JsonInclude, JsonPropertyName("SeriesID")] public int ProviderId { get; set; } /// <summary> /// Shoko series ids affected by this update. /// </summary> - [JsonPropertyName("ShokoSeriesIDs")] + [JsonInclude, JsonPropertyName("ShokoSeriesIDs")] public List<int> SeriesIds { get; set; } = new(); /// <summary> /// Shoko group ids affected by this update. /// </summary> - [JsonPropertyName("ShokoGroupIDs")] + [JsonInclude, JsonPropertyName("ShokoGroupIDs")] public List<int> GroupIds { get; set; } = new(); } \ No newline at end of file diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 5676b376..1fe5aefc 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -85,17 +85,15 @@ private async Task ConnectAsync(PluginConfiguration config) connection.On<SeriesInfoUpdatedEventArgs>("ShokoEvent:SeriesUpdated", OnSeriesInfoUpdated); // Attach file events. + connection.On<FileEventArgs>("ShokoEvent:FileMatched", OnFileMatched); + connection.On<FileEventArgs>("ShokoEvent:FileDeleted", OnFileDeleted); if (UseOlderEvents) { - connection.On<FileMatchedEventArgsV1>("ShokoEvent:FileMatched", OnFileMatched); - connection.On<FileMovedEventArgsV1>("ShokoEvent:FileMoved", OnFileRelocated); - connection.On<FileRenamedEventArgsV1>("ShokoEvent:FileRenamed", OnFileRelocated); - connection.On<FileEventArgs>("ShokoEvent:FileDeleted", OnFileDeleted); + connection.On<FileMovedEventArgs.V0>("ShokoEvent:FileMoved", OnFileRelocated); + connection.On<FileRenamedEventArgs.V0>("ShokoEvent:FileRenamed", OnFileRelocated); } else { - connection.On<FileEventArgs>("ShokoEvent:FileMatched", OnFileMatched); connection.On<FileMovedEventArgs>("ShokoEvent:FileMoved", OnFileRelocated); connection.On<FileRenamedEventArgs>("ShokoEvent:FileRenamed", OnFileRelocated); - connection.On<FileEventArgs>("ShokoEvent:FileDeleted", OnFileDeleted); } try { From ffcc51849ea395273d6473ce127b0bd73c8c985b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 14 Apr 2024 11:38:42 +0000 Subject: [PATCH 0831/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index be450f61..2d5a8a1f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.118", + "changelog": "refactor: more changes to signalr models\n\n- Another overhaul of the SingalR models, this time removing\n an (now) unneeded event model, and also (hopefully) correcting the\n relative paths to use the correct directory separators for the local\n os environment and not the environment Shoko Server is running in.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.118/shoko_3.0.1.118.zip", + "checksum": "61dcdd30ab92ceac809a21462452d4d8", + "timestamp": "2024-04-14T11:38:40Z" + }, { "version": "3.0.1.117", "changelog": "fix: throw early if we cannot create symlinks\n\n- Throw as early as possible if we have determined we cannot\n create symbolic links.\n\n- Update the warning in the settings to also say the users need to\n restart their Jellyfin server after enabling dev mode (to let the\n plugin re-check), for the VFS to work.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.114/shoko_3.0.1.114.zip", "checksum": "647df758c777a758c810b5e8d54d2fc9", "timestamp": "2024-04-13T22:46:11Z" - }, - { - "version": "3.0.1.113", - "changelog": "fix: better handling of index number end\n\n- Only add index number end for episodes if it's not the\n same as the episode number.\n\nrefactor: update signalr events\n\n- Updated the SignalR Connection Manager\n to support both the new and old signalr events.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.113/shoko_3.0.1.113.zip", - "checksum": "6f5479613dfec25fd10d91e40fe57964", - "timestamp": "2024-04-13T22:00:58Z" } ] } From f1b56db1da55a94a2ceaa06fbcf27701b354ecde Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 14 Apr 2024 20:24:47 +0200 Subject: [PATCH 0832/1103] refactor: split up resolve methods [skip ci] - Split up the resolver manager methods so the individual parts can be used by the signalr connection manager in the future. --- Shokofin/Resolvers/LinkGenerationResult.cs | 102 +++++++ Shokofin/Resolvers/ShokoResolveManager.cs | 329 +++++++++++---------- 2 files changed, 272 insertions(+), 159 deletions(-) create mode 100644 Shokofin/Resolvers/LinkGenerationResult.cs diff --git a/Shokofin/Resolvers/LinkGenerationResult.cs b/Shokofin/Resolvers/LinkGenerationResult.cs new file mode 100644 index 00000000..7c7c2291 --- /dev/null +++ b/Shokofin/Resolvers/LinkGenerationResult.cs @@ -0,0 +1,102 @@ + +using System; +using MediaBrowser.Controller.Entities; +using Microsoft.Extensions.Logging; + +namespace Shokofin.Resolvers; + +public class LinkGenerationResult +{ + private DateTime CreatedAt { get; init; } = DateTime.Now; + + public int Total => + TotalVideos + TotalSubtitles + TotalNfos; + + public int Created => + CreatedVideos + CreatedSubtitles + CreatedNfos; + + public int Fixed => + FixedVideos + FixedSubtitles; + + public int Skipped => + SkippedVideos + SkippedSubtitles + SkippedNfos; + + public int Removed => + RemovedVideos + RemovedSubtitles + RemovedNfos; + + public int TotalVideos => + CreatedVideos + FixedVideos + SkippedVideos; + + public int CreatedVideos { get; set; } + + public int FixedVideos { get; set; } + + public int SkippedVideos { get; set; } + + public int RemovedVideos { get; set; } + + public int TotalSubtitles => + CreatedSubtitles + FixedSubtitles + SkippedSubtitles; + + public int CreatedSubtitles { get; set; } + + public int FixedSubtitles { get; set; } + + public int SkippedSubtitles { get; set; } + + public int RemovedSubtitles { get; set; } + + public int TotalNfos => + CreatedNfos + SkippedNfos; + + public int CreatedNfos { get; set; } + + public int SkippedNfos { get; set; } + + public int RemovedNfos { get; set; } + + public void Print(Folder mediaFolder, ILogger logger) + { + var timeSpent = DateTime.Now - CreatedAt; + logger.LogInformation( + "Created {CreatedTotal} ({CreatedMedia},{CreatedSubtitles},{CreatedNFO}), fixed {FixedTotal} ({FixedMedia},{FixedSubtitles}), skipped {SkippedTotal} ({SkippedMedia},{SkippedSubtitles},{SkippedNFO}), and removed {RemovedTotal} ({RemovedMedia},{RemovedSubtitles},{RemovedNFO}) entries in media folder at {Path} in {TimeSpan} (Total={Total})", + Created, + CreatedVideos, + CreatedSubtitles, + CreatedNfos, + Fixed, + FixedVideos, + FixedSubtitles, + Skipped, + SkippedVideos, + SkippedSubtitles, + SkippedNfos, + Removed, + RemovedVideos, + RemovedSubtitles, + RemovedNfos, + mediaFolder.Path, + timeSpent, + Total + ); + } + + public static LinkGenerationResult operator +(LinkGenerationResult a, LinkGenerationResult b) + { + return new() + { + CreatedAt = a.CreatedAt, + CreatedVideos = a.CreatedVideos + b.CreatedVideos, + FixedVideos = a.FixedVideos + b.FixedVideos, + SkippedVideos = a.SkippedVideos + b.SkippedVideos, + RemovedVideos = a.RemovedVideos + b.RemovedVideos, + CreatedSubtitles = a.CreatedSubtitles + b.CreatedSubtitles, + FixedSubtitles = a.FixedSubtitles + b.FixedSubtitles, + SkippedSubtitles = a.SkippedSubtitles + b.SkippedSubtitles, + RemovedSubtitles = a.RemovedSubtitles + b.RemovedSubtitles, + CreatedNfos = a.CreatedNfos + b.CreatedNfos, + SkippedNfos = a.SkippedNfos + b.SkippedNfos, + RemovedNfos = a.RemovedNfos + b.RemovedNfos, + }; + } +} \ No newline at end of file diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 9aa0f698..3d061bfc 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -208,6 +208,13 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold #region Generate Structure + public IReadOnlyList<(MediaFolderConfiguration config, Folder mediaFolder)> GetAvailableMediaFolders() + => Plugin.Instance.Configuration.MediaFolders + .Where(mediaFolder => mediaFolder.IsMapped && mediaFolder.IsFileEventsEnabled) + .Select(config => (config, mediaFolder: LibraryManager.GetItemById(config.MediaFolderId) as Folder)) + .OfType<(MediaFolderConfiguration config, Folder mediaFolder)>() + .ToList(); + /// <summary> /// Generates the VFS structure if the VFS is enabled globally or on the /// <paramref name="mediaFolder"/>. @@ -349,17 +356,10 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string sourceLocation, string fileId, string seriesId)> files) { - var start = DateTime.UtcNow; - var skippedLinks = 0; - var fixedLinks = 0; - var subtitles = 0; - var fixedSubtitles = 0; - var skippedSubtitles = 0; - var skippedNfo = 0; + var result = new LinkGenerationResult(); var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); - var allNfoFiles = new HashSet<string>(); - var allPathsForVFS = new ConcurrentBag<(string sourceLocation, string symbolicLink)>(); + var allPathsForVFS = new ConcurrentBag<string>(); var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); await Task.WhenAll(files.Select(async (tuple) => { await semaphore.WaitAsync().ConfigureAwait(false); @@ -367,103 +367,9 @@ await Task.WhenAll(files.Select(async (tuple) => { try { // Skip any source files we weren't meant to have in the library. var (sourceLocation, symbolicLinks, nfoFiles) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); - if (string.IsNullOrEmpty(sourceLocation)) - return; - - var sourcePrefix = Path.GetFileNameWithoutExtension(sourceLocation); - var sourcePrefixLength = sourceLocation.Length - Path.GetExtension(sourceLocation).Length; - var subtitleLinks = FindSubtitlesForPath(sourceLocation); - foreach (var symbolicLink in symbolicLinks) { - var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; - if (!Directory.Exists(symbolicDirectory)) - Directory.CreateDirectory(symbolicDirectory); - - allPathsForVFS.Add((sourceLocation, symbolicLink)); - if (!File.Exists(symbolicLink)) { - Logger.LogDebug("Linking {Link} → {LinkTarget}", symbolicLink, sourceLocation); - File.CreateSymbolicLink(symbolicLink, sourceLocation); - } - else { - var shouldFix = false; - try { - 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); - } - } - catch (Exception ex) { - Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link}", symbolicLink); - shouldFix = true; - } - if (shouldFix) { - File.Delete(symbolicLink); - File.CreateSymbolicLink(symbolicLink, sourceLocation); - fixedLinks++; - } - else { - skippedLinks++; - } - } - - if (subtitleLinks.Count > 0) { - var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); - foreach (var subtitleSource in subtitleLinks) { - var extName = subtitleSource[sourcePrefixLength..]; - var subtitleLink = Path.Combine(symbolicDirectory, symbolicName + extName); - - subtitles++; - allPathsForVFS.Add((subtitleSource, subtitleLink)); - if (!File.Exists(subtitleLink)) { - Logger.LogDebug("Linking {Link} → {LinkTarget}", subtitleLink, subtitleSource); - File.CreateSymbolicLink(subtitleLink, subtitleSource); - } - else { - var shouldFix = false; - try { - 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); - } - } - catch (Exception ex) { - Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link} for {LinkTarget}", subtitleLink, subtitleSource); - shouldFix = true; - } - if (shouldFix) { - File.Delete(subtitleLink); - File.CreateSymbolicLink(subtitleLink, subtitleSource); - fixedSubtitles++; - } - else { - skippedSubtitles++; - } - } - } - } - } - - // TODO: Remove these two hacks once we have proper support for adding multiple series at once. - foreach (var nfoFile in nfoFiles) - { - if (allNfoFiles.Contains(nfoFile)) - continue; - allNfoFiles.Add(nfoFile); - - var nfoDirectory = Path.GetDirectoryName(nfoFile)!; - if (!Directory.Exists(nfoDirectory)) - Directory.CreateDirectory(nfoDirectory); - - if (!File.Exists(nfoFile)) { - File.WriteAllText(nfoFile, string.Empty); - } - else { - skippedNfo++; - } - + var subResult = GenerateSymbolicLink(sourceLocation, symbolicLinks, nfoFiles, allPathsForVFS); + lock (semaphore) { + result += subResult; } } finally { @@ -472,64 +378,20 @@ await Task.WhenAll(files.Select(async (tuple) => { })) .ConfigureAwait(false); - var removedLinks = 0; - var removedSubtitles = 0; - var removedNfo = 0; - var toBeRemoved = FileSystem.GetFilePaths(vfsPath, true) - .Select(path => (path, extName: Path.GetExtension(path))) - .Where(tuple => _namingOptions.VideoFileExtensions.Contains(tuple.extName) || _namingOptions.SubtitleFileExtensions.Contains(tuple.extName) || tuple.extName == ".nfo") - .ExceptBy(allPathsForVFS.Select(tuple => tuple.symbolicLink).ToHashSet(), tuple => tuple.path) - .ExceptBy(allNfoFiles, tuple => tuple.path) - .ToList(); - foreach (var (symbolicLink, extName) in toBeRemoved) { - // Continue in case we already removed the (subtitle) file. - if (!File.Exists(symbolicLink)) - continue; - - File.Delete(symbolicLink); - - // Stats tracking. - if (_namingOptions.VideoFileExtensions.Contains(extName)) { - var subtitleLinks = _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(symbolicLink)) ? FindSubtitlesForPath(symbolicLink) : Array.Empty<string>(); - - removedLinks++; - foreach (var subtitleLink in subtitleLinks) { - removedSubtitles++; - File.Delete(symbolicLink); - } - } - else if (extName == ".nfo") { - removedNfo++; - } - else { - removedSubtitles++; - } - - CleanupDirectoryStructure(symbolicLink); - } - - var timeSpent = DateTime.UtcNow - start; - Logger.LogInformation( - "Created {CreatedMedia} ({CreatedSubtitles},{CreatedNFO}), fixed {FixedMedia} ({FixedSubtitles}), skipped {SkippedMedia} ({SkippedSubtitles},{SkippedNFO}), and removed {RemovedMedia} ({RemovedSubtitles},{RemovedNFO}) symbolic links in media folder at {Path} in {TimeSpan}", - allPathsForVFS.Count - skippedLinks - fixedLinks - subtitles, - subtitles - fixedSubtitles - skippedSubtitles, - allNfoFiles.Count - skippedNfo, - fixedLinks, - fixedSubtitles, - skippedLinks, - skippedSubtitles, - skippedNfo, - removedLinks, - removedSubtitles, - removedNfo, - mediaFolder.Path, - timeSpent - ); + result += CleanupStructure(vfsPath, allPathsForVFS); + result.Print(mediaFolder, Logger); } // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 chacters. private const int NameCutOff = 64; + public async Task<(string sourceLocation, string[] symbolicLinks, string[] nfoFiles)> GenerateLocationsForFile(Folder mediaFolder, string sourceLocation, string fileId, string seriesId) + { + var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); + return await GenerateLocationsForFile(vfsPath, collectionType, sourceLocation, fileId, seriesId); + } + private async Task<(string sourceLocation, string[] symbolicLinks, string[] nfoFiles)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId) { var season = await ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); @@ -632,6 +494,155 @@ await Task.WhenAll(files.Select(async (tuple) => { return (sourceLocation, symbolicLinks, nfoFiles: nfoFiles.ToArray()); } + + public LinkGenerationResult GenerateSymbolicLinks(string? sourceLocation, string[] symbolicLinks, string[] nfoFiles) + => GenerateSymbolicLink(sourceLocation, symbolicLinks, nfoFiles, new()); + + private LinkGenerationResult GenerateSymbolicLink(string? sourceLocation, string[] symbolicLinks, string[] nfoFiles, ConcurrentBag<string> allPathsForVFS) + { + var result = new LinkGenerationResult(); + if (string.IsNullOrEmpty(sourceLocation)) + return result; + + var sourcePrefixLength = sourceLocation.Length - Path.GetExtension(sourceLocation).Length; + var subtitleLinks = FindSubtitlesForPath(sourceLocation); + foreach (var symbolicLink in symbolicLinks) { + var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; + if (!Directory.Exists(symbolicDirectory)) + Directory.CreateDirectory(symbolicDirectory); + + allPathsForVFS.Add(symbolicLink); + if (!File.Exists(symbolicLink)) { + result.CreatedVideos++; + Logger.LogDebug("Linking {Link} → {LinkTarget}", symbolicLink, sourceLocation); + File.CreateSymbolicLink(symbolicLink, sourceLocation); + } + else { + var shouldFix = false; + try { + 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); + } + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link}", symbolicLink); + shouldFix = true; + } + if (shouldFix) { + File.Delete(symbolicLink); + File.CreateSymbolicLink(symbolicLink, sourceLocation); + result.FixedVideos++; + } + else { + result.SkippedVideos++; + } + } + + if (subtitleLinks.Count > 0) { + var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); + foreach (var subtitleSource in subtitleLinks) { + var extName = subtitleSource[sourcePrefixLength..]; + var subtitleLink = Path.Combine(symbolicDirectory, symbolicName + extName); + + allPathsForVFS.Add(subtitleLink); + if (!File.Exists(subtitleLink)) { + result.CreatedSubtitles++; + Logger.LogDebug("Linking {Link} → {LinkTarget}", subtitleLink, subtitleSource); + File.CreateSymbolicLink(subtitleLink, subtitleSource); + } + else { + var shouldFix = false; + try { + 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); + } + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link} for {LinkTarget}", subtitleLink, subtitleSource); + shouldFix = true; + } + if (shouldFix) { + File.Delete(subtitleLink); + File.CreateSymbolicLink(subtitleLink, subtitleSource); + result.FixedSubtitles++; + } + else { + result.SkippedSubtitles++; + } + } + } + } + } + + // TODO: Remove these two hacks once we have proper support for adding multiple series at once. + foreach (var nfoFile in nfoFiles) + { + if (allPathsForVFS.Contains(nfoFile)) + continue; + allPathsForVFS.Add(nfoFile); + + var nfoDirectory = Path.GetDirectoryName(nfoFile)!; + if (!Directory.Exists(nfoDirectory)) + Directory.CreateDirectory(nfoDirectory); + + if (!File.Exists(nfoFile)) { + result.CreatedNfos++; + Logger.LogDebug("Adding stub show/season NFO file {Target} ", nfoFile); + File.WriteAllText(nfoFile, string.Empty); + } + else { + result.SkippedNfos++; + } + } + + return result; + } + + private LinkGenerationResult CleanupStructure(string vfsPath, ConcurrentBag<string> allPathsForVFS) + { + var result = new LinkGenerationResult(); + var searchFiles = _namingOptions.VideoFileExtensions.Concat(_namingOptions.SubtitleFileExtensions).Append(".nfo").ToHashSet(); + var toBeRemoved = FileSystem.GetFilePaths(vfsPath, true) + .Select(path => (path, extName: Path.GetExtension(path))) + .Where(tuple => searchFiles.Contains(tuple.extName)) + .ExceptBy(allPathsForVFS.ToHashSet(), tuple => tuple.path) + .ToList(); + foreach (var (location, extName) in toBeRemoved) { + // Continue in case we already removed the (subtitle) file. + if (!File.Exists(location)) + continue; + + File.Delete(location); + + // Stats tracking. + if (_namingOptions.VideoFileExtensions.Contains(extName)) { + result.RemovedVideos++; + + var subtitleLinks = FindSubtitlesForPath(location); + foreach (var subtitleLink in subtitleLinks) { + result.RemovedSubtitles++; + File.Delete(location); + } + } + else if (extName == ".nfo") { + result.RemovedNfos++; + } + else { + result.RemovedSubtitles++; + } + + CleanupDirectoryStructure(location); + } + + return result; + } + private static void CleanupDirectoryStructure(string? path) { path = Path.GetDirectoryName(path); From a9edc2b3afaa3bbc53cb7a1b1077d6c250a2195b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 15 Apr 2024 00:10:50 +0200 Subject: [PATCH 0833/1103] fix: fix renamed event args previous relation path --- Shokofin/SignalR/Models/FileRenamedEventArgs.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs index f6430e3a..85667b10 100644 --- a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs @@ -25,7 +25,7 @@ public class FileRenamedEventArgs : FileEventArgs, IFileRelocationEventArgs /// <inheritdoc/> [JsonIgnore] - public string PreviousRelativePath => RelativePath[^FileName.Length] + PreviousFileName; + public string PreviousRelativePath => RelativePath[..^FileName.Length] + PreviousFileName; public class V0 : IFileRelocationEventArgs { From 1b3232c1fe1e259681db3bc3be46bf5fb0f5a07a Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 14 Apr 2024 22:11:45 +0000 Subject: [PATCH 0834/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 2d5a8a1f..7e99f956 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.119", + "changelog": "fix: fix renamed event args previous relation path\n\nrefactor: split up resolve methods [skip ci]\n\n- Split up the resolver manager methods so the individual parts\n can be used by the signalr connection manager in the future.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.119/shoko_3.0.1.119.zip", + "checksum": "ce399e5cba039b8be006c6104002d009", + "timestamp": "2024-04-14T22:11:43Z" + }, { "version": "3.0.1.118", "changelog": "refactor: more changes to signalr models\n\n- Another overhaul of the SingalR models, this time removing\n an (now) unneeded event model, and also (hopefully) correcting the\n relative paths to use the correct directory separators for the local\n os environment and not the environment Shoko Server is running in.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.115/shoko_3.0.1.115.zip", "checksum": "0d701d7641ef938197b631b3ceaa2101", "timestamp": "2024-04-13T23:14:19Z" - }, - { - "version": "3.0.1.114", - "changelog": "misc: add warning for windows users\n\n- Add a warning for windows users that they need to enable dev mode to\n be allowed to create sym links, along with a link for how to enable\n it. Also, this warning is only shown if we fail the test to create a\n sym link at start up.\n\n- Disable the VFS by default if we cannot create sym links.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.114/shoko_3.0.1.114.zip", - "checksum": "647df758c777a758c810b5e8d54d2fc9", - "timestamp": "2024-04-13T22:46:11Z" } ] } From 91c6f62bf98acc047161965bfb53796383027144 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 15 Apr 2024 03:55:17 +0200 Subject: [PATCH 0835/1103] refactor: overhaul link generation (again) - Overhauled the link generation to cache per file/series/location, and to allow cleanup/generation on a media folder, series, season, movie, and/or episode basis. --- Shokofin/Resolvers/LinkGenerationResult.cs | 10 + Shokofin/Resolvers/ShokoResolveManager.cs | 438 +++++++++++++++++---- 2 files changed, 381 insertions(+), 67 deletions(-) diff --git a/Shokofin/Resolvers/LinkGenerationResult.cs b/Shokofin/Resolvers/LinkGenerationResult.cs index 7c7c2291..0ba41894 100644 --- a/Shokofin/Resolvers/LinkGenerationResult.cs +++ b/Shokofin/Resolvers/LinkGenerationResult.cs @@ -81,6 +81,16 @@ public void Print(Folder mediaFolder, ILogger logger) ); } + public void MarkSkipped() + { + SkippedSubtitles += FixedSubtitles + CreatedSubtitles; + FixedSubtitles = CreatedSubtitles = RemovedSubtitles = 0; + SkippedVideos += FixedVideos + CreatedVideos; + FixedVideos = CreatedVideos = RemovedVideos = 0; + SkippedNfos += CreatedNfos; + CreatedNfos = RemovedNfos = 0; + } + public static LinkGenerationResult operator +(LinkGenerationResult a, LinkGenerationResult b) { return new() diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 3d061bfc..25ef6182 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -125,6 +125,13 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) #region Media Folder Mapping + public IReadOnlyList<(MediaFolderConfiguration config, Folder mediaFolder)> GetAvailableMediaFolders() + => Plugin.Instance.Configuration.MediaFolders + .Where(mediaFolder => mediaFolder.IsMapped && mediaFolder.IsFileEventsEnabled) + .Select(config => (config, mediaFolder: LibraryManager.GetItemById(config.MediaFolderId) as Folder)) + .OfType<(MediaFolderConfiguration config, Folder mediaFolder)>() + .ToList(); + public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFolder(Folder mediaFolder) { var config = Plugin.Instance.Configuration; @@ -208,13 +215,6 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold #region Generate Structure - public IReadOnlyList<(MediaFolderConfiguration config, Folder mediaFolder)> GetAvailableMediaFolders() - => Plugin.Instance.Configuration.MediaFolders - .Where(mediaFolder => mediaFolder.IsMapped && mediaFolder.IsFileEventsEnabled) - .Select(config => (config, mediaFolder: LibraryManager.GetItemById(config.MediaFolderId) as Folder)) - .OfType<(MediaFolderConfiguration config, Folder mediaFolder)>() - .ToList(); - /// <summary> /// Generates the VFS structure if the VFS is enabled globally or on the /// <paramref name="mediaFolder"/>. @@ -222,40 +222,309 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold /// <param name="mediaFolder">The media folder to generate a structure for.</param> /// <param name="folderPath">The folder within the media folder to generate a structure for.</param> /// <returns>The VFS path, if it succeeded.</returns> - private Task<string?> GenerateStructureForFolderInVFS(Folder mediaFolder) - => DataCache.GetOrCreateAsync( - mediaFolder.Path, - async (_) => { - var mediaConfig = await GetOrCreateConfigurationForMediaFolder(mediaFolder); - if (!mediaConfig.IsMapped) - return null; + private async Task<string?> GenerateStructureInVFS(Folder mediaFolder, string folderPath) + { + var mediaConfig = await GetOrCreateConfigurationForMediaFolder(mediaFolder); + if (!mediaConfig.IsMapped) + return null; - // Return early if we're not going to generate them. - if (!mediaConfig.IsVirtualFileSystemEnabled) - return null; + // Return early if we're not going to generate them. + if (!mediaConfig.IsVirtualFileSystemEnabled) + return null; + + 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."); + + // Iterate the files already in the VFS. + var cleanLevel = -1; + IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; + var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + if (folderPath.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { + var start = DateTime.UtcNow; + var allPaths = FileSystem.GetFilePaths(mediaFolder.Path, true) + .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + .ToHashSet(); + Logger.LogDebug("Found {FileCount} files in media folder at {Path} in {TimeSpan}.", allPaths.Count, folderPath, DateTime.UtcNow - start); + + var pathSegments = folderPath[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar); + switch (pathSegments.Length) { + // show/movie-folder level + case 1: { + var seriesName = pathSegments[0]; + if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; + + // movie-folder + if (seriesName.TryGetAttributeValue(ShokoEpisodeId.Name, out var episodeId) ) { + if (!int.TryParse(episodeId, out _)) + break; + + cleanLevel = 1; + GetFilesForMovie(episodeId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + break; + } + + // show + cleanLevel = 1; + allFiles = GetFilesForShow(seriesId, null, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + break; + } + + // season/movie level + case 2: { + var (seriesName, seasonOrMovieName) = pathSegments; + if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; + + // movie + if (seriesName.TryGetAttributeValue(ShokoEpisodeId.Name, out var episodeId)) { + if (!seasonOrMovieName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) + break; + + if (!seasonOrMovieName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) + break; + + cleanLevel = -1; + allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path); + break; + } + + // "season" or extras + if (!seasonOrMovieName.StartsWith("Season ") || !int.TryParse(seasonOrMovieName.Split(' ').Last(), out var seasonNumber)) + break; + + cleanLevel = 2; + allFiles = GetFilesForShow(seriesId, seasonNumber, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + break; + } - 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."); + // episodes level + case 3: { + var (seriesName, seasonName, episodeName) = pathSegments; + if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; - // Check if we should introduce the VFS for the media folder. - var start = DateTime.UtcNow; - var allPaths = FileSystem.GetFilePaths(mediaFolder.Path, true) - .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) - .ToHashSet(); - Logger.LogDebug("Found {FileCount} files in media folder at {Path} in {TimeSpan}.", allPaths.Count, mediaFolder.Path, DateTime.UtcNow - start); + if (!seasonName.StartsWith("Season ") || !int.TryParse(seasonName.Split(' ').Last(), out var seasonNumber)) + break; - var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); - var allFiles = GetImportFolderFiles(mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); - await GenerateSymbolicLinks(mediaFolder, allFiles).ConfigureAwait(false); + if (!episodeName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) + break; - return vfsPath; - }, - new() { - AbsoluteExpirationRelativeToNow = DefaultTTL, + if (!episodeName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) + break; + + cleanLevel = -1; + allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path); + break; + } } + } + // Iterate files in the "real" media folder. + else if (folderPath.StartsWith(mediaFolder.Path)) { + var start = DateTime.UtcNow; + var allPaths = FileSystem.GetFilePaths(mediaFolder.Path, true) + .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + .ToHashSet(); + Logger.LogDebug("Found {FileCount} files in media folder at {Path} in {TimeSpan}.", allPaths.Count, mediaFolder.Path, DateTime.UtcNow - start); + + cleanLevel = 0; + allFiles = GetFilesForImportFolder(mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + } + + if (allFiles == null) + return null; + + await GenerateSymbolicLinks(mediaFolder, allFiles, cleanLevel).ConfigureAwait(false); + + return vfsPath; + } + + public IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForEpisode(string fileId, string seriesId, int importFolderId, string importFolderSubPath, string mediaFolderPath) + { + var start = DateTime.UtcNow; + var file = ApiClient.GetFile(fileId).ConfigureAwait(false).GetAwaiter().GetResult(); + if (file == null || !file.CrossReferences.Any(xref => xref.Series.ToString() == seriesId)) + yield break; + Logger.LogDebug( + "Iterating 1 file to potentially use within media folder at {Path} (File={FileId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", + mediaFolderPath, + fileId, + seriesId, + importFolderId, + importFolderSubPath + ); + + var location = file.Locations + .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length == 0 || location.Path.StartsWith(importFolderSubPath))) + .FirstOrDefault(); + if (location == null || file.CrossReferences.Count == 0) + yield break; + + var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); + if (!File.Exists(sourceLocation)) + yield break; + + yield return (sourceLocation, fileId, seriesId); + + var timeSpent = DateTime.UtcNow - start; + Logger.LogDebug( + "Iterated 1 file to potentially use within media folder at {Path} in {TimeSpan} (File={FileId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", + mediaFolderPath, + timeSpent, + fileId, + seriesId, + importFolderId, + importFolderSubPath ); + } - private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetImportFolderFiles(int importFolderId, string importFolderSubPath, string mediaFolderPath, ISet<string> fileSet) + public IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForMovie(string episodeId, string seriesId, int importFolderId, string importFolderSubPath, string mediaFolderPath, ISet<string> fileSet) + { + var start = DateTime.UtcNow; + var totalFiles = 0; + var seasonInfo = ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult(); + if (seasonInfo == null) + yield break; + Logger.LogDebug( + "Iterating files to potentially use within media folder at {Path} (Episode={EpisodeId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", + mediaFolderPath, + episodeId, + seriesId, + importFolderId, + importFolderSubPath + ); + + var episodeIds = seasonInfo.ExtrasList.Select(episode => episode.Id).Append(episodeId).ToHashSet(); + var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); + var fileLocations = files + .Where(files => files.CrossReferences.Any(xref => xref.Episodes.Any(xrefEp => episodeIds.Contains(xrefEp.Shoko.ToString())))) + .SelectMany(file => file.Locations.Select(location => (file, location))) + .ToList(); + foreach (var (file, location) in fileLocations) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.Path.StartsWith(importFolderSubPath)) + continue; + + var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, fileId: file.Id.ToString(), seriesId); + } + var timeSpent = DateTime.UtcNow - start; + Logger.LogDebug( + "Iterated {FileCount} file to potentially use within media folder at {Path} in {TimeSpan} (Episode={EpisodeId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", + totalFiles, + mediaFolderPath, + timeSpent, + episodeId, + seriesId, + importFolderId, + importFolderSubPath + ); + } + + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForShow(string seriesId, int? seasonNumber, int importFolderId, string importFolderSubPath, string mediaFolderPath, ISet<string> fileSet) + { + var start = DateTime.UtcNow; + var showInfo = ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult(); + if (showInfo == null) + yield break; + Logger.LogDebug( + "Iterating files to potentially use within media folder at {Path} (Series={SeriesId},Season={SeasonNumber},ImportFolder={FolderId},RelativePath={RelativePath})", + mediaFolderPath, + seriesId, + seasonNumber, + importFolderId, + importFolderSubPath + ); + + // Only return the files for the given season. + var totalFiles = 0; + if (seasonNumber.HasValue) { + // Special handling of specials (pun intended) + if (seasonNumber.Value == 0) { + foreach (var seasonInfo in showInfo.SeasonList) { + var episodeIds = seasonInfo.SpecialsList.Select(episode => episode.Id).ToHashSet(); + var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); + var fileLocations = files + .Where(files => files.CrossReferences.Any(xref => xref.Episodes.Any(xrefEp => episodeIds.Contains(xrefEp.Shoko.ToString())))) + .SelectMany(file => file.Locations.Select(location => (file, location))) + .ToList(); + foreach (var (file, location) in fileLocations) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.Path.StartsWith(importFolderSubPath)) + continue; + + var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, fileId: file.Id.ToString(), seriesId); + } + } + } + // All other seasons. + else { + var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber.Value); + if (seasonInfo != null) { + var baseNumber = showInfo.GetBaseSeasonNumberForSeasonInfo(seasonInfo); + var offset = seasonNumber.Value - baseNumber; + var episodeIds = (offset == 0 ? seasonInfo.EpisodeList.Concat(seasonInfo.ExtrasList) : seasonInfo.AlternateEpisodesList).Select(episode => episode.Id).ToHashSet(); + var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); + var fileLocations = files + .Where(files => files.CrossReferences.Any(xref => xref.Episodes.Any(xrefEp => episodeIds.Contains(xrefEp.Shoko.ToString())))) + .SelectMany(file => file.Locations.Select(location => (file, location))) + .ToList(); + foreach (var (file, location) in fileLocations) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.Path.StartsWith(importFolderSubPath)) + continue; + + var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, fileId: file.Id.ToString(), seriesId); + } + } + } + } + // Return all files for the show. + else { + foreach (var seasonInfo in showInfo.SeasonList) { + var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); + var fileLocations = files + .SelectMany(file => file.Locations.Select(location => (file, location))) + .ToList(); + foreach (var (file, location) in fileLocations) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.Path.StartsWith(importFolderSubPath)) + continue; + + var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, fileId: file.Id.ToString(), seriesId); + } + } + } + + var timeSpent = DateTime.UtcNow - start; + Logger.LogDebug( + "Iterated {FileCount} files to potentially use within media folder at {Path} in {TimeSpan} (Series={SeriesId},Season={SeasonNumber},ImportFolder={FolderId},RelativePath={RelativePath})", + totalFiles, + mediaFolderPath, + timeSpent, + seriesId, + seasonNumber, + importFolderId, + importFolderSubPath + ); + } + + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForImportFolder(int importFolderId, string importFolderSubPath, string mediaFolderPath, ISet<string> fileSet) { var start = DateTime.UtcNow; var firstPage = ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath); @@ -354,7 +623,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold return await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath, page).ConfigureAwait(false); } - private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string sourceLocation, string fileId, string seriesId)> files) + private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string sourceLocation, string fileId, string seriesId)> files, int cleanLevel) { var result = new LinkGenerationResult(); var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); @@ -362,23 +631,39 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string var allPathsForVFS = new ConcurrentBag<string>(); var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); await Task.WhenAll(files.Select(async (tuple) => { - await semaphore.WaitAsync().ConfigureAwait(false); - - try { - // Skip any source files we weren't meant to have in the library. - var (sourceLocation, symbolicLinks, nfoFiles) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); - var subResult = GenerateSymbolicLink(sourceLocation, symbolicLinks, nfoFiles, allPathsForVFS); - lock (semaphore) { - result += subResult; + var subResult = await DataCache.GetOrCreateAsync( + $"file={tuple.fileId},series={tuple.seriesId},location={tuple.sourceLocation}", + (_) => Logger.LogTrace("Re-used previous links for path {SourceLocation} (File={FileId},Series={SeriesId})", tuple.sourceLocation, tuple.fileId, tuple.seriesId), + async (_) => { + await semaphore.WaitAsync().ConfigureAwait(false); + + Logger.LogTrace("Generating links for path {SourceLocation} (File={FileId},Series={SeriesId})", tuple.sourceLocation, tuple.fileId, tuple.seriesId); + try { + // Skip any source files we weren't meant to have in the library. + var (sourceLocation, symbolicLinks, nfoFiles) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); + return GenerateSymbolicLinks(sourceLocation, symbolicLinks, nfoFiles, allPathsForVFS); + } + finally { + semaphore.Release(); + } + }, + new() { + AbsoluteExpirationRelativeToNow = DefaultTTL, } - } - finally { - semaphore.Release(); + ); + + // Combine the current results with the overall results and mark the entitis as skipped + // for the next iterations. + lock (semaphore) { + result += subResult; + subResult.MarkSkipped(); } })) .ConfigureAwait(false); - result += CleanupStructure(vfsPath, allPathsForVFS); + // Cleanup the structure in the VFS. + result += CleanupStructure(vfsPath, allPathsForVFS, cleanLevel); + result.Print(mediaFolder, Logger); } @@ -494,11 +779,10 @@ await Task.WhenAll(files.Select(async (tuple) => { return (sourceLocation, symbolicLinks, nfoFiles: nfoFiles.ToArray()); } - public LinkGenerationResult GenerateSymbolicLinks(string? sourceLocation, string[] symbolicLinks, string[] nfoFiles) - => GenerateSymbolicLink(sourceLocation, symbolicLinks, nfoFiles, new()); + => GenerateSymbolicLinks(sourceLocation, symbolicLinks, nfoFiles, new()); - private LinkGenerationResult GenerateSymbolicLink(string? sourceLocation, string[] symbolicLinks, string[] nfoFiles, ConcurrentBag<string> allPathsForVFS) + private LinkGenerationResult GenerateSymbolicLinks(string? sourceLocation, string[] symbolicLinks, string[] nfoFiles, ConcurrentBag<string> allPathsForVFS) { var result = new LinkGenerationResult(); if (string.IsNullOrEmpty(sourceLocation)) @@ -604,11 +888,35 @@ private LinkGenerationResult GenerateSymbolicLink(string? sourceLocation, string return result; } - private LinkGenerationResult CleanupStructure(string vfsPath, ConcurrentBag<string> allPathsForVFS) + private LinkGenerationResult CleanupStructure(string vfsPath, ConcurrentBag<string> allPathsForVFS, int cleanLevel) { + IEnumerable<string>? pathsToSearch = null; + switch (cleanLevel) { + // Media-folder level + case 0: + pathsToSearch = FileSystem.GetFilePaths(vfsPath, true); + break; + // Series/box-set level + case 1: + // Season level + case 2: + pathsToSearch = allPathsForVFS + .Select(path => path[(vfsPath.Length + 1)..]) + .Where(relativePath => relativePath.Split(Path.DirectorySeparatorChar).Length > cleanLevel) + .Select(relativePath => Path.Combine(vfsPath, relativePath.Split(Path.DirectorySeparatorChar).Take(cleanLevel).Join(Path.DirectorySeparatorChar))) + .Distinct() + .SelectMany(path => FileSystem.GetFilePaths(path, true)); + break; + } + + // Return now if we're not going to search for files to remove. var result = new LinkGenerationResult(); + if (pathsToSearch == null) + return result; + + // Search the selected paths for files to remove. var searchFiles = _namingOptions.VideoFileExtensions.Concat(_namingOptions.SubtitleFileExtensions).Append(".nfo").ToHashSet(); - var toBeRemoved = FileSystem.GetFilePaths(vfsPath, true) + var toBeRemoved = pathsToSearch .Select(path => (path, extName: Path.GetExtension(path))) .Where(tuple => searchFiles.Contains(tuple.extName)) .ExceptBy(allPathsForVFS.ToHashSet(), tuple => tuple.path) @@ -627,7 +935,7 @@ private LinkGenerationResult CleanupStructure(string vfsPath, ConcurrentBag<stri var subtitleLinks = FindSubtitlesForPath(location); foreach (var subtitleLink in subtitleLinks) { result.RemovedSubtitles++; - File.Delete(location); + File.Delete(subtitleLink); } } else if (extName == ".nfo") { @@ -639,7 +947,6 @@ private LinkGenerationResult CleanupStructure(string vfsPath, ConcurrentBag<stri CleanupDirectoryStructure(location); } - return result; } @@ -846,15 +1153,13 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b return null; // Skip anything outside the VFS. - var fullPath = fileInfo.FullName; - if (!fullPath.StartsWith(Plugin.Instance.VirtualRoot)) + if (!fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) return null; - var (mediaFolder, _) = ApiManager.FindMediaFolder(fullPath, parent, root); - if (mediaFolder == root) + if (parent.GetTopParent() is not Folder mediaFolder) return null; - var vfsPath = await GenerateStructureForFolderInVFS(mediaFolder).ConfigureAwait(false); + var vfsPath = await GenerateStructureInVFS(mediaFolder, fileInfo.FullName).ConfigureAwait(false); if (string.IsNullOrEmpty(vfsPath)) return null; @@ -867,8 +1172,6 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b }; } - // TODO: Redirect to the base item in the VFS if needed. - return null; } catch (Exception ex) { @@ -890,12 +1193,15 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b if (!Lookup.IsEnabledForItem(parent)) return null; - // Redirect children of a VFS managed media folder to the VFS. - if (parent.ParentId == root.Id) { - var vfsPath = await GenerateStructureForFolderInVFS(parent).ConfigureAwait(false); - if (string.IsNullOrEmpty(vfsPath)) - return null; + if (parent.GetTopParent() is not Folder mediaFolder) + return null; + var vfsPath = await GenerateStructureInVFS(mediaFolder, parent.Path).ConfigureAwait(false); + if (string.IsNullOrEmpty(vfsPath)) + return null; + + // Redirect children of a VFS managed media folder to the VFS. + if (parent.IsTopParent) { var createMovies = collectionType == CollectionType.Movies || (collectionType == null && Plugin.Instance.Configuration.SeparateMovies); var items = FileSystem.GetDirectories(vfsPath) .AsParallel() @@ -960,8 +1266,6 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b return new() { Items = items.Where(i => i is Movie).ToList(), ExtraFiles = items.OfType<TvSeries>().Select(s => FileSystem.GetFileSystemInfo(s.Path)).ToList() }; } - // TODO: Redirect to the base item in the VFS if needed. - return null; } catch (Exception ex) { From d4f331e12d494167caea8c7adf90b956fbe7ae33 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 15 Apr 2024 01:56:03 +0000 Subject: [PATCH 0836/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 7e99f956..4c7712b9 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.120", + "changelog": "refactor: overhaul link generation (again)\n\n- Overhauled the link generation to cache per file/series/location, and\n to allow cleanup/generation on a media folder, series, season, movie,\n and/or episode basis.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.120/shoko_3.0.1.120.zip", + "checksum": "3e7df7e8b4de82bb9116056dc5e3b67e", + "timestamp": "2024-04-15T01:56:01Z" + }, { "version": "3.0.1.119", "changelog": "fix: fix renamed event args previous relation path\n\nrefactor: split up resolve methods [skip ci]\n\n- Split up the resolver manager methods so the individual parts\n can be used by the signalr connection manager in the future.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.116/shoko_3.0.1.116.zip", "checksum": "946de3d379dee74db88deef64d7531f8", "timestamp": "2024-04-14T10:12:21Z" - }, - { - "version": "3.0.1.115", - "changelog": "fix: fix sym link test", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.115/shoko_3.0.1.115.zip", - "checksum": "0d701d7641ef938197b631b3ceaa2101", - "timestamp": "2024-04-13T23:14:19Z" } ] } From ed20a8e4666f55d164a1c9f67f6b2e017d198513 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 15 Apr 2024 04:05:13 +0200 Subject: [PATCH 0837/1103] fix: fix signalr models --- Shokofin/SignalR/Models/FileEventArgs.cs | 2 +- Shokofin/SignalR/Models/FileMovedEventArgs.cs | 4 ++-- Shokofin/SignalR/Models/FileRenamedEventArgs.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/SignalR/Models/FileEventArgs.cs b/Shokofin/SignalR/Models/FileEventArgs.cs index 2c3bb05b..df408c45 100644 --- a/Shokofin/SignalR/Models/FileEventArgs.cs +++ b/Shokofin/SignalR/Models/FileEventArgs.cs @@ -19,7 +19,7 @@ public class FileEventArgs : IFileEventArgs /// the Shoko side. /// </summary> [JsonInclude, JsonPropertyName("RelativePath")] - private string InternalPath { get; set; } = string.Empty; + public string InternalPath { get; set; } = string.Empty; /// <summary> /// Cached path for later re-use. diff --git a/Shokofin/SignalR/Models/FileMovedEventArgs.cs b/Shokofin/SignalR/Models/FileMovedEventArgs.cs index 2a861e67..00ed367a 100644 --- a/Shokofin/SignalR/Models/FileMovedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileMovedEventArgs.cs @@ -50,7 +50,7 @@ public class V0 : IFileRelocationEventArgs /// the Shoko side. /// </summary> [JsonInclude, JsonPropertyName("RelativePath")] - private string InternalPath { get; set; } = string.Empty; + public string InternalPath { get; set; } = string.Empty; /// <summary> /// Cached path for later re-use. @@ -71,7 +71,7 @@ public class V0 : IFileRelocationEventArgs /// seperators used on the Shoko side. /// </summary> [JsonInclude, JsonPropertyName("OldRelativePath")] - private string PreviousInternalPath { get; set; } = string.Empty; + public string PreviousInternalPath { get; set; } = string.Empty; /// <summary> /// Cached path for later re-use. diff --git a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs index 85667b10..40c8966b 100644 --- a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs @@ -42,7 +42,7 @@ public class V0 : IFileRelocationEventArgs /// the Shoko side. /// </summary> [JsonInclude, JsonPropertyName("RelativePath")] - private string InternalPath { get; set; } = string.Empty; + public string InternalPath { get; set; } = string.Empty; /// <summary> /// Cached path for later re-use. From b897dbd519aeaea33375ac7e4ef865cff87ccf29 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 15 Apr 2024 02:06:03 +0000 Subject: [PATCH 0838/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 4c7712b9..7e942ed0 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.121", + "changelog": "fix: fix signalr models", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.121/shoko_3.0.1.121.zip", + "checksum": "4782541b6439f4bc26eb815a9c4495e4", + "timestamp": "2024-04-15T02:06:01Z" + }, { "version": "3.0.1.120", "changelog": "refactor: overhaul link generation (again)\n\n- Overhauled the link generation to cache per file/series/location, and\n to allow cleanup/generation on a media folder, series, season, movie,\n and/or episode basis.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.117/shoko_3.0.1.117.zip", "checksum": "b16b26ac53196d172cc5615e43270caa", "timestamp": "2024-04-14T10:39:04Z" - }, - { - "version": "3.0.1.116", - "changelog": "chore: cleanup js in settings page (#51)\n\nNow matches revam\u2122 formatting standard", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.116/shoko_3.0.1.116.zip", - "checksum": "946de3d379dee74db88deef64d7531f8", - "timestamp": "2024-04-14T10:12:21Z" } ] } From 28acbbecd530608ec09c8bb78ba9677e6f454212 Mon Sep 17 00:00:00 2001 From: Mikal S <7761729+revam@users.noreply.github.com> Date: Mon, 15 Apr 2024 13:31:00 +0200 Subject: [PATCH 0839/1103] refactor: remove music video extra type mappjng --- Shokofin/Utils/Ordering.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index 46474cb3..d2feb977 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -239,9 +239,6 @@ public static int GetSeasonNumber(ShowInfo showInfo, SeasonInfo seasonInfo, Epis if (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) && (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase))) return ExtraType.Clip; - // Music videos - if (title.Contains("music video", System.StringComparison.OrdinalIgnoreCase)) - return ExtraType.ThemeVideo; // Behind the Scenes if (title.Contains("making of", System.StringComparison.CurrentCultureIgnoreCase)) return ExtraType.BehindTheScenes; From cac68627b5efabd1da65a100958893bf66d5ce70 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 15 Apr 2024 11:31:42 +0000 Subject: [PATCH 0840/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 7e942ed0..771ff30a 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.122", + "changelog": "refactor: remove music video extra type mappjn", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.122/shoko_3.0.1.122.zip", + "checksum": "5bc5164dfbca03f792badcfce06407de", + "timestamp": "2024-04-15T11:31:40Z" + }, { "version": "3.0.1.121", "changelog": "fix: fix signalr models", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.118/shoko_3.0.1.118.zip", "checksum": "61dcdd30ab92ceac809a21462452d4d8", "timestamp": "2024-04-14T11:38:40Z" - }, - { - "version": "3.0.1.117", - "changelog": "fix: throw early if we cannot create symlinks\n\n- Throw as early as possible if we have determined we cannot\n create symbolic links.\n\n- Update the warning in the settings to also say the users need to\n restart their Jellyfin server after enabling dev mode (to let the\n plugin re-check), for the VFS to work.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.117/shoko_3.0.1.117.zip", - "checksum": "b16b26ac53196d172cc5615e43270caa", - "timestamp": "2024-04-14T10:39:04Z" } ] } From 6ab13d58339eac0d65729dbdb4614d573044589f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 15 Apr 2024 13:51:32 +0200 Subject: [PATCH 0841/1103] refactor: simplify cleanup code --- Shokofin/Resolvers/ShokoResolveManager.cs | 45 ++++++----------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 25ef6182..59ce6d3b 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -236,7 +236,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold 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."); // Iterate the files already in the VFS. - var cleanLevel = -1; + string? pathToClean = null; IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); if (folderPath.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { @@ -259,13 +259,13 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold if (!int.TryParse(episodeId, out _)) break; - cleanLevel = 1; - GetFilesForMovie(episodeId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + pathToClean = folderPath; + allFiles = GetFilesForMovie(episodeId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); break; } // show - cleanLevel = 1; + pathToClean = folderPath; allFiles = GetFilesForShow(seriesId, null, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); break; } @@ -284,7 +284,6 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold if (!seasonOrMovieName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) break; - cleanLevel = -1; allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path); break; } @@ -293,7 +292,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold if (!seasonOrMovieName.StartsWith("Season ") || !int.TryParse(seasonOrMovieName.Split(' ').Last(), out var seasonNumber)) break; - cleanLevel = 2; + pathToClean = folderPath; allFiles = GetFilesForShow(seriesId, seasonNumber, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); break; } @@ -313,7 +312,6 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold if (!episodeName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) break; - cleanLevel = -1; allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path); break; } @@ -327,14 +325,14 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold .ToHashSet(); Logger.LogDebug("Found {FileCount} files in media folder at {Path} in {TimeSpan}.", allPaths.Count, mediaFolder.Path, DateTime.UtcNow - start); - cleanLevel = 0; + pathToClean = vfsPath; allFiles = GetFilesForImportFolder(mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); } if (allFiles == null) return null; - await GenerateSymbolicLinks(mediaFolder, allFiles, cleanLevel).ConfigureAwait(false); + await GenerateSymbolicLinks(mediaFolder, allFiles, pathToClean).ConfigureAwait(false); return vfsPath; } @@ -623,7 +621,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold return await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath, page).ConfigureAwait(false); } - private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string sourceLocation, string fileId, string seriesId)> files, int cleanLevel) + private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string sourceLocation, string fileId, string seriesId)> files, string? pathToClean) { var result = new LinkGenerationResult(); var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); @@ -662,7 +660,7 @@ await Task.WhenAll(files.Select(async (tuple) => { .ConfigureAwait(false); // Cleanup the structure in the VFS. - result += CleanupStructure(vfsPath, allPathsForVFS, cleanLevel); + result += CleanupStructure(vfsPath, allPathsForVFS, pathToClean); result.Print(mediaFolder, Logger); } @@ -888,35 +886,16 @@ private LinkGenerationResult GenerateSymbolicLinks(string? sourceLocation, strin return result; } - private LinkGenerationResult CleanupStructure(string vfsPath, ConcurrentBag<string> allPathsForVFS, int cleanLevel) + private LinkGenerationResult CleanupStructure(string vfsPath, ConcurrentBag<string> allPathsForVFS, string? pathToClean) { - IEnumerable<string>? pathsToSearch = null; - switch (cleanLevel) { - // Media-folder level - case 0: - pathsToSearch = FileSystem.GetFilePaths(vfsPath, true); - break; - // Series/box-set level - case 1: - // Season level - case 2: - pathsToSearch = allPathsForVFS - .Select(path => path[(vfsPath.Length + 1)..]) - .Where(relativePath => relativePath.Split(Path.DirectorySeparatorChar).Length > cleanLevel) - .Select(relativePath => Path.Combine(vfsPath, relativePath.Split(Path.DirectorySeparatorChar).Take(cleanLevel).Join(Path.DirectorySeparatorChar))) - .Distinct() - .SelectMany(path => FileSystem.GetFilePaths(path, true)); - break; - } - // Return now if we're not going to search for files to remove. var result = new LinkGenerationResult(); - if (pathsToSearch == null) + if (pathToClean == null) return result; // Search the selected paths for files to remove. var searchFiles = _namingOptions.VideoFileExtensions.Concat(_namingOptions.SubtitleFileExtensions).Append(".nfo").ToHashSet(); - var toBeRemoved = pathsToSearch + var toBeRemoved = FileSystem.GetFilePaths(pathToClean, true) .Select(path => (path, extName: Path.GetExtension(path))) .Where(tuple => searchFiles.Contains(tuple.extName)) .ExceptBy(allPathsForVFS.ToHashSet(), tuple => tuple.path) From 47239ad39e7205a910edf7ccc515e79b91cd0c8c Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 15 Apr 2024 11:52:15 +0000 Subject: [PATCH 0842/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 771ff30a..6a845a09 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.123", + "changelog": "refactor: simplify cleanup code", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.123/shoko_3.0.1.123.zip", + "checksum": "b47786b60e2271c85e0536e294458c0a", + "timestamp": "2024-04-15T11:52:13Z" + }, { "version": "3.0.1.122", "changelog": "refactor: remove music video extra type mappjn", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.119/shoko_3.0.1.119.zip", "checksum": "ce399e5cba039b8be006c6104002d009", "timestamp": "2024-04-14T22:11:43Z" - }, - { - "version": "3.0.1.118", - "changelog": "refactor: more changes to signalr models\n\n- Another overhaul of the SingalR models, this time removing\n an (now) unneeded event model, and also (hopefully) correcting the\n relative paths to use the correct directory separators for the local\n os environment and not the environment Shoko Server is running in.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.118/shoko_3.0.1.118.zip", - "checksum": "61dcdd30ab92ceac809a21462452d4d8", - "timestamp": "2024-04-14T11:38:40Z" } ] } From eac85c263227b70fa59e071b82156433101ed654 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 15 Apr 2024 23:45:25 +0200 Subject: [PATCH 0843/1103] fix: fix the simplified cleanup code - add path tracking to the link generation results so we can reuse the paths from a cached result when cleaning. --- Shokofin/Resolvers/LinkGenerationResult.cs | 27 +++++++++++++++++----- Shokofin/Resolvers/ShokoResolveManager.cs | 20 +++++++++++----- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/Shokofin/Resolvers/LinkGenerationResult.cs b/Shokofin/Resolvers/LinkGenerationResult.cs index 0ba41894..94f98b05 100644 --- a/Shokofin/Resolvers/LinkGenerationResult.cs +++ b/Shokofin/Resolvers/LinkGenerationResult.cs @@ -1,5 +1,6 @@ using System; +using System.Collections.Concurrent; using MediaBrowser.Controller.Entities; using Microsoft.Extensions.Logging; @@ -9,6 +10,8 @@ public class LinkGenerationResult { private DateTime CreatedAt { get; init; } = DateTime.Now; + public ConcurrentBag<string> Paths { get; init; } = new(); + public int Total => TotalVideos + TotalSubtitles + TotalNfos; @@ -83,19 +86,31 @@ public void Print(Folder mediaFolder, ILogger logger) public void MarkSkipped() { - SkippedSubtitles += FixedSubtitles + CreatedSubtitles; - FixedSubtitles = CreatedSubtitles = RemovedSubtitles = 0; - SkippedVideos += FixedVideos + CreatedVideos; - FixedVideos = CreatedVideos = RemovedVideos = 0; - SkippedNfos += CreatedNfos; - CreatedNfos = RemovedNfos = 0; + if (FixedSubtitles > 0 || CreatedSubtitles > 0) { + SkippedSubtitles += FixedSubtitles + CreatedSubtitles; + FixedSubtitles = CreatedSubtitles = 0; + } + if (FixedVideos > 0 || CreatedVideos > 0) { + SkippedVideos += FixedVideos + CreatedVideos; + FixedVideos = CreatedVideos = 0; + } + if (CreatedNfos > 0) { + SkippedNfos += CreatedNfos; + CreatedNfos = 0; + } } public static LinkGenerationResult operator +(LinkGenerationResult a, LinkGenerationResult b) { + // Re-use the same instance so the parallel execution will share the same bag. + var paths = a.Paths; + foreach (var path in b.Paths) + a.Paths.Add(path); + return new() { CreatedAt = a.CreatedAt, + Paths = paths, CreatedVideos = a.CreatedVideos + b.CreatedVideos, FixedVideos = a.FixedVideos + b.FixedVideos, SkippedVideos = a.SkippedVideos + b.SkippedVideos, diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 59ce6d3b..42c560dd 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -626,7 +626,6 @@ private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string var result = new LinkGenerationResult(); var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); - var allPathsForVFS = new ConcurrentBag<string>(); var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); await Task.WhenAll(files.Select(async (tuple) => { var subResult = await DataCache.GetOrCreateAsync( @@ -639,7 +638,7 @@ await Task.WhenAll(files.Select(async (tuple) => { try { // Skip any source files we weren't meant to have in the library. var (sourceLocation, symbolicLinks, nfoFiles) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); - return GenerateSymbolicLinks(sourceLocation, symbolicLinks, nfoFiles, allPathsForVFS); + return GenerateSymbolicLinks(sourceLocation, symbolicLinks, nfoFiles, result.Paths); } finally { semaphore.Release(); @@ -660,7 +659,7 @@ await Task.WhenAll(files.Select(async (tuple) => { .ConfigureAwait(false); // Cleanup the structure in the VFS. - result += CleanupStructure(vfsPath, allPathsForVFS, pathToClean); + result += CleanupStructure(vfsPath, result.Paths, pathToClean); result.Print(mediaFolder, Logger); } @@ -793,7 +792,7 @@ private LinkGenerationResult GenerateSymbolicLinks(string? sourceLocation, strin if (!Directory.Exists(symbolicDirectory)) Directory.CreateDirectory(symbolicDirectory); - allPathsForVFS.Add(symbolicLink); + result.Paths.Add(symbolicLink); if (!File.Exists(symbolicLink)) { result.CreatedVideos++; Logger.LogDebug("Linking {Link} → {LinkTarget}", symbolicLink, sourceLocation); @@ -829,7 +828,7 @@ private LinkGenerationResult GenerateSymbolicLinks(string? sourceLocation, strin var extName = subtitleSource[sourcePrefixLength..]; var subtitleLink = Path.Combine(symbolicDirectory, symbolicName + extName); - allPathsForVFS.Add(subtitleLink); + result.Paths.Add(subtitleLink); if (!File.Exists(subtitleLink)) { result.CreatedSubtitles++; Logger.LogDebug("Linking {Link} → {LinkTarget}", subtitleLink, subtitleSource); @@ -865,9 +864,18 @@ private LinkGenerationResult GenerateSymbolicLinks(string? sourceLocation, strin // TODO: Remove these two hacks once we have proper support for adding multiple series at once. foreach (var nfoFile in nfoFiles) { - if (allPathsForVFS.Contains(nfoFile)) + if (allPathsForVFS.Contains(nfoFile)) { + if (!result.Paths.Contains(nfoFile)) + result.Paths.Add(nfoFile); continue; + } + if (result.Paths.Contains(nfoFile)) { + if (!allPathsForVFS.Contains(nfoFile)) + allPathsForVFS.Add(nfoFile); + continue; + } allPathsForVFS.Add(nfoFile); + result.Paths.Add(nfoFile); var nfoDirectory = Path.GetDirectoryName(nfoFile)!; if (!Directory.Exists(nfoDirectory)) From 09f04c9985bcf329df72d744ac2aa8e886387d08 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 15 Apr 2024 21:46:49 +0000 Subject: [PATCH 0844/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 6a845a09..64e15587 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.124", + "changelog": "fix: fix the simplified cleanup code\n\n- add path tracking to the link generation results so we can\n reuse the paths from a cached result when cleaning.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.124/shoko_3.0.1.124.zip", + "checksum": "06de5975cb4886afb6c688ff6097e547", + "timestamp": "2024-04-15T21:46:48Z" + }, { "version": "3.0.1.123", "changelog": "refactor: simplify cleanup code", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.120/shoko_3.0.1.120.zip", "checksum": "3e7df7e8b4de82bb9116056dc5e3b67e", "timestamp": "2024-04-15T01:56:01Z" - }, - { - "version": "3.0.1.119", - "changelog": "fix: fix renamed event args previous relation path\n\nrefactor: split up resolve methods [skip ci]\n\n- Split up the resolver manager methods so the individual parts\n can be used by the signalr connection manager in the future.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.119/shoko_3.0.1.119.zip", - "checksum": "ce399e5cba039b8be006c6104002d009", - "timestamp": "2024-04-14T22:11:43Z" } ] } From 8ef174ac1e990bbda3366fd384b106cf4358f85a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 16 Apr 2024 00:01:15 +0200 Subject: [PATCH 0845/1103] fix: correct yielded series id for files --- Shokofin/Resolvers/ShokoResolveManager.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 42c560dd..d35e2078 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -407,7 +407,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold continue; totalFiles++; - yield return (sourceLocation, fileId: file.Id.ToString(), seriesId); + yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); } var timeSpent = DateTime.UtcNow - start; Logger.LogDebug( @@ -458,7 +458,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold continue; totalFiles++; - yield return (sourceLocation, fileId: file.Id.ToString(), seriesId); + yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); } } } @@ -483,7 +483,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold continue; totalFiles++; - yield return (sourceLocation, fileId: file.Id.ToString(), seriesId); + yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); } } } @@ -504,7 +504,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold continue; totalFiles++; - yield return (sourceLocation, fileId: file.Id.ToString(), seriesId); + yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); } } } @@ -581,7 +581,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold totalSingleSeriesFiles++; singleSeriesIds.Add(seriesIds.First()); foreach (var xref in file.CrossReferences) - yield return (sourceLocation, fileId: file.Id.ToString(), seriesId: xref.Series.Shoko.ToString()); + yield return (sourceLocation, file.Id.ToString(), xref.Series.Shoko.ToString()); } else if (seriesIds.Count > 1) { multiSeriesFiles.Add((file, sourceLocation)); @@ -598,7 +598,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold .Where(xref => singleSeriesIds.Contains(xref.Series.Shoko)) .ToList(); foreach (var xref in crossReferences) - yield return (sourceLocation, fileId: file.Id.ToString(), seriesId: xref.Series.Shoko.ToString()); + yield return (sourceLocation, file.Id.ToString(), xref.Series.Shoko.ToString()); totalMultiSeriesFiles += crossReferences.Count; } From 170f95681943fce064ea230cf809ee9f54bd37cd Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 16 Apr 2024 00:02:38 +0200 Subject: [PATCH 0846/1103] misc: remove unneeded arg for cleanup func. --- Shokofin/Resolvers/ShokoResolveManager.cs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index d35e2078..19b7decd 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -659,7 +659,8 @@ await Task.WhenAll(files.Select(async (tuple) => { .ConfigureAwait(false); // Cleanup the structure in the VFS. - result += CleanupStructure(vfsPath, result.Paths, pathToClean); + if (!string.IsNullOrEmpty(pathToClean)) + result += CleanupStructure(pathToClean, result.Paths); result.Print(mediaFolder, Logger); } @@ -894,19 +895,15 @@ private LinkGenerationResult GenerateSymbolicLinks(string? sourceLocation, strin return result; } - private LinkGenerationResult CleanupStructure(string vfsPath, ConcurrentBag<string> allPathsForVFS, string? pathToClean) + private LinkGenerationResult CleanupStructure(string directoryToClean, ConcurrentBag<string> allKnownPaths) { - // Return now if we're not going to search for files to remove. - var result = new LinkGenerationResult(); - if (pathToClean == null) - return result; - // Search the selected paths for files to remove. + var result = new LinkGenerationResult(); var searchFiles = _namingOptions.VideoFileExtensions.Concat(_namingOptions.SubtitleFileExtensions).Append(".nfo").ToHashSet(); - var toBeRemoved = FileSystem.GetFilePaths(pathToClean, true) + var toBeRemoved = FileSystem.GetFilePaths(directoryToClean, true) .Select(path => (path, extName: Path.GetExtension(path))) .Where(tuple => searchFiles.Contains(tuple.extName)) - .ExceptBy(allPathsForVFS.ToHashSet(), tuple => tuple.path) + .ExceptBy(allKnownPaths.ToHashSet(), tuple => tuple.path) .ToList(); foreach (var (location, extName) in toBeRemoved) { // Continue in case we already removed the (subtitle) file. From 3e218920d4c2f534ecb536f38ca459da5fcf9225 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 16 Apr 2024 00:08:00 +0200 Subject: [PATCH 0847/1103] misc: tweak link generation logging --- Shokofin/Resolvers/LinkGenerationResult.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/LinkGenerationResult.cs b/Shokofin/Resolvers/LinkGenerationResult.cs index 94f98b05..e45700e8 100644 --- a/Shokofin/Resolvers/LinkGenerationResult.cs +++ b/Shokofin/Resolvers/LinkGenerationResult.cs @@ -61,7 +61,9 @@ public class LinkGenerationResult public void Print(Folder mediaFolder, ILogger logger) { var timeSpent = DateTime.Now - CreatedAt; - logger.LogInformation( + var logLevel = Removed == 0 && Skipped == Total ? LogLevel.Debug : LogLevel.Information; + logger.Log( + logLevel, "Created {CreatedTotal} ({CreatedMedia},{CreatedSubtitles},{CreatedNFO}), fixed {FixedTotal} ({FixedMedia},{FixedSubtitles}), skipped {SkippedTotal} ({SkippedMedia},{SkippedSubtitles},{SkippedNFO}), and removed {RemovedTotal} ({RemovedMedia},{RemovedSubtitles},{RemovedNFO}) entries in media folder at {Path} in {TimeSpan} (Total={Total})", Created, CreatedVideos, From 83f01bd67e04236e0a9eb25d46b2d3a500130e71 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 15 Apr 2024 22:08:47 +0000 Subject: [PATCH 0848/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 64e15587..bb2c1777 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.125", + "changelog": "misc: tweak link generation logging\n\nmisc: remove unneeded arg for cleanup func.\n\nfix: correct yielded series id for files", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.125/shoko_3.0.1.125.zip", + "checksum": "c8520e33727a185d0a3ad32e3651db91", + "timestamp": "2024-04-15T22:08:45Z" + }, { "version": "3.0.1.124", "changelog": "fix: fix the simplified cleanup code\n\n- add path tracking to the link generation results so we can\n reuse the paths from a cached result when cleaning.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.121/shoko_3.0.1.121.zip", "checksum": "4782541b6439f4bc26eb815a9c4495e4", "timestamp": "2024-04-15T02:06:01Z" - }, - { - "version": "3.0.1.120", - "changelog": "refactor: overhaul link generation (again)\n\n- Overhauled the link generation to cache per file/series/location, and\n to allow cleanup/generation on a media folder, series, season, movie,\n and/or episode basis.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.120/shoko_3.0.1.120.zip", - "checksum": "3e7df7e8b4de82bb9116056dc5e3b67e", - "timestamp": "2024-04-15T01:56:01Z" } ] } From 5438c813b887348fceb91ad11d1248db2c6aa41d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 16 Apr 2024 03:59:53 +0200 Subject: [PATCH 0849/1103] fix: fix signalr models (again) --- Shokofin/SignalR/Models/FileMovedEventArgs.cs | 2 +- Shokofin/SignalR/Models/FileRenamedEventArgs.cs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Shokofin/SignalR/Models/FileMovedEventArgs.cs b/Shokofin/SignalR/Models/FileMovedEventArgs.cs index 00ed367a..50d71e48 100644 --- a/Shokofin/SignalR/Models/FileMovedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileMovedEventArgs.cs @@ -49,7 +49,7 @@ public class V0 : IFileRelocationEventArgs /// The relative path with no leading slash and directory seperators used on /// the Shoko side. /// </summary> - [JsonInclude, JsonPropertyName("RelativePath")] + [JsonInclude, JsonPropertyName("NewRelativePath")] public string InternalPath { get; set; } = string.Empty; /// <summary> diff --git a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs index 40c8966b..5ab7f6c6 100644 --- a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs @@ -69,14 +69,13 @@ public class V0 : IFileRelocationEventArgs [JsonInclude, JsonPropertyName("OldFileName")] public string PreviousFileName { get; set; } = string.Empty; - /// <inheritdoc/> [JsonIgnore] public int PreviousImportFolderId => ImportFolderId; /// <inheritdoc/> [JsonIgnore] - public string PreviousRelativePath => RelativePath[^FileName.Length] + PreviousFileName; + public string PreviousRelativePath => RelativePath[..^FileName.Length] + PreviousFileName; /// <inheritdoc/> [JsonIgnore] From fb561ad7a07dd1733bdb6d939ee776c752577107 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 16 Apr 2024 02:00:52 +0000 Subject: [PATCH 0850/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index bb2c1777..02f68c8d 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.126", + "changelog": "fix: fix signalr models (again)", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.126/shoko_3.0.1.126.zip", + "checksum": "493279e9e86d69d8e1136ae480f175da", + "timestamp": "2024-04-16T02:00:50Z" + }, { "version": "3.0.1.125", "changelog": "misc: tweak link generation logging\n\nmisc: remove unneeded arg for cleanup func.\n\nfix: correct yielded series id for files", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.122/shoko_3.0.1.122.zip", "checksum": "5bc5164dfbca03f792badcfce06407de", "timestamp": "2024-04-15T11:31:40Z" - }, - { - "version": "3.0.1.121", - "changelog": "fix: fix signalr models", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.121/shoko_3.0.1.121.zip", - "checksum": "4782541b6439f4bc26eb815a9c4495e4", - "timestamp": "2024-04-15T02:06:01Z" } ] } From 000bbb24362b6fb67f5e2d8f518503dc409ea3c2 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 16 Apr 2024 04:19:42 +0200 Subject: [PATCH 0851/1103] fix: fix signalr models (again, but for real this time) --- Shokofin/SignalR/Models/FileEventArgs.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/SignalR/Models/FileEventArgs.cs b/Shokofin/SignalR/Models/FileEventArgs.cs index df408c45..84b6d0a4 100644 --- a/Shokofin/SignalR/Models/FileEventArgs.cs +++ b/Shokofin/SignalR/Models/FileEventArgs.cs @@ -44,6 +44,6 @@ public class FileEventArgs : IFileEventArgs /// for setting the cross-references when deserializing JSON. /// </summary> [JsonInclude, JsonPropertyName("CrossRefs")] - private List<IFileEventArgs.FileCrossReference> LegacyCrossReferences { set { CrossReferences = value; } } + public List<IFileEventArgs.FileCrossReference> LegacyCrossReferences { set { CrossReferences = value; } } #pragma warning restore IDE0051 } From 3b731544dba268a021c196fdbbcb3963a02591e2 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 16 Apr 2024 02:20:28 +0000 Subject: [PATCH 0852/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 02f68c8d..8f86394c 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.127", + "changelog": "fix: fix signalr models (again, but for real this time)", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.127/shoko_3.0.1.127.zip", + "checksum": "3de9d69ad6f5a0843d5108dfe7e09094", + "timestamp": "2024-04-16T02:20:26Z" + }, { "version": "3.0.1.126", "changelog": "fix: fix signalr models (again)", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.123/shoko_3.0.1.123.zip", "checksum": "b47786b60e2271c85e0536e294458c0a", "timestamp": "2024-04-15T11:52:13Z" - }, - { - "version": "3.0.1.122", - "changelog": "refactor: remove music video extra type mappjn", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.122/shoko_3.0.1.122.zip", - "checksum": "5bc5164dfbca03f792badcfce06407de", - "timestamp": "2024-04-15T11:31:40Z" } ] } From a38a1e2116ac737131950cd9c178f8cfef3756e9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 16 Apr 2024 04:20:51 +0200 Subject: [PATCH 0853/1103] misc: fix typo [skip ci] --- Shokofin/SignalR/Models/FileEventArgs.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/SignalR/Models/FileEventArgs.cs b/Shokofin/SignalR/Models/FileEventArgs.cs index 84b6d0a4..56bdd5b2 100644 --- a/Shokofin/SignalR/Models/FileEventArgs.cs +++ b/Shokofin/SignalR/Models/FileEventArgs.cs @@ -40,7 +40,7 @@ public class FileEventArgs : IFileEventArgs #pragma warning disable IDE0051 /// <summary> - /// Legacy cross-references of episodes lined to this file. Only present + /// Legacy cross-references of episodes linked to this file. Only present /// for setting the cross-references when deserializing JSON. /// </summary> [JsonInclude, JsonPropertyName("CrossRefs")] From 1c15e2aaf82f57729aca388bf525538907034b0a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 16 Apr 2024 04:57:08 +0200 Subject: [PATCH 0854/1103] misc: update file event args - Update file event args to expose the file location id, if available, and to have an indicator as to if we got any cross-references from the server (this is needed because we're supporting _both_ the stable server _and_ the daily server). - Update the debug log statments to include the new properties. --- Shokofin/SignalR/Interfaces/IFileEventArgs.cs | 11 +++++++++++ Shokofin/SignalR/Models/FileEventArgs.cs | 19 +++++++++++++++++-- Shokofin/SignalR/SignalRConnectionManager.cs | 18 ++++++++++++------ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/Shokofin/SignalR/Interfaces/IFileEventArgs.cs b/Shokofin/SignalR/Interfaces/IFileEventArgs.cs index 63e5d997..fad6ae5b 100644 --- a/Shokofin/SignalR/Interfaces/IFileEventArgs.cs +++ b/Shokofin/SignalR/Interfaces/IFileEventArgs.cs @@ -10,6 +10,11 @@ public interface IFileEventArgs /// </summary> int FileId { get; } + /// <summary> + /// Shoko file location id, if available. + /// </summary> + int? FileLocationId { get; } + /// <summary> /// The ID of the new import folder the event was detected in. /// </summary> @@ -23,6 +28,12 @@ public interface IFileEventArgs /// </summary> string RelativePath { get; } + /// <summary> + /// Indicates that the event has cross references provided. They may still + /// be empty, but now we don't need to fetch them seperately. + /// </summary> + bool HasCrossReferences { get; } + /// <summary> /// Cross references of episodes linked to this file. /// </summary> diff --git a/Shokofin/SignalR/Models/FileEventArgs.cs b/Shokofin/SignalR/Models/FileEventArgs.cs index 56bdd5b2..0e65b357 100644 --- a/Shokofin/SignalR/Models/FileEventArgs.cs +++ b/Shokofin/SignalR/Models/FileEventArgs.cs @@ -10,6 +10,10 @@ public class FileEventArgs : IFileEventArgs [JsonInclude, JsonPropertyName("FileID")] public int FileId { get; set; } + /// <inheritdoc/> + [JsonInclude, JsonPropertyName("FileLocationID")] + public int? FileLocationId { get; set; } + /// <inheritdoc/> [JsonInclude, JsonPropertyName("ImportFolderID")] public int ImportFolderId { get; set; } @@ -35,15 +39,26 @@ public class FileEventArgs : IFileEventArgs .Replace('\\', System.IO.Path.DirectorySeparatorChar); /// <inheritdoc/> - [JsonInclude, JsonPropertyName("CrossReferences")] + [JsonIgnore] + public bool HasCrossReferences { get; set; } + + /// <inheritdoc/> + [JsonIgnore] public List<IFileEventArgs.FileCrossReference> CrossReferences { get; set; } = new(); #pragma warning disable IDE0051 + /// <summary> + /// Legacy cross-references of episodes linked to this file. Only present + /// for setting the cross-references when deserializing JSON. + /// </summary> + [JsonInclude, JsonPropertyName("CrossReferences")] + public List<IFileEventArgs.FileCrossReference> CurrentCrossReferences { set { HasCrossReferences = true; CrossReferences = value; } } + /// <summary> /// Legacy cross-references of episodes linked to this file. Only present /// for setting the cross-references when deserializing JSON. /// </summary> [JsonInclude, JsonPropertyName("CrossRefs")] - public List<IFileEventArgs.FileCrossReference> LegacyCrossReferences { set { CrossReferences = value; } } + public List<IFileEventArgs.FileCrossReference> LegacyCrossReferences { set { HasCrossReferences = true; CrossReferences = value; } } #pragma warning restore IDE0051 } diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 1fe5aefc..1f0b42b9 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -199,10 +199,12 @@ private static string ConstructKey(PluginConfiguration config) private void OnFileMatched(IFileEventArgs eventArgs) { Logger.LogDebug( - "File matched; {ImportFolderId} {Path} (File={FileId})", + "File matched; {ImportFolderId} {Path} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", eventArgs.ImportFolderId, eventArgs.RelativePath, - eventArgs.FileId + eventArgs.FileId, + eventArgs.FileLocationId, + eventArgs.HasCrossReferences ); // also check if the locations we've found are mapped, and if they are @@ -217,12 +219,14 @@ private void OnFileMatched(IFileEventArgs eventArgs) private void OnFileRelocated(IFileRelocationEventArgs eventArgs) { Logger.LogDebug( - "File relocated; {ImportFolderIdA} {PathA} → {ImportFolderIdB} {PathB} (File={FileId})", + "File relocated; {ImportFolderIdA} {PathA} → {ImportFolderIdB} {PathB} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", eventArgs.PreviousImportFolderId, eventArgs.PreviousRelativePath, eventArgs.ImportFolderId, eventArgs.RelativePath, - eventArgs.FileId + eventArgs.FileId, + eventArgs.FileLocationId, + eventArgs.HasCrossReferences ); // check the previous and current locations, and report the changes. @@ -240,10 +244,12 @@ private void OnFileRelocated(IFileRelocationEventArgs eventArgs) private void OnFileDeleted(IFileEventArgs eventArgs) { Logger.LogDebug( - "File deleted; {ImportFolderIdB} {PathB} (File={FileId})", + "File deleted; {ImportFolderIdB} {PathB} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", eventArgs.ImportFolderId, eventArgs.RelativePath, - eventArgs.FileId + eventArgs.FileId, + eventArgs.FileLocationId, + eventArgs.HasCrossReferences ); // The location has been removed. From d0fdb86673454dc378bf3b238d65d4daf0637304 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 16 Apr 2024 05:05:23 +0200 Subject: [PATCH 0855/1103] misc: add missing props. to v0 models --- Shokofin/SignalR/Models/FileMovedEventArgs.cs | 8 ++++++++ Shokofin/SignalR/Models/FileRenamedEventArgs.cs | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/Shokofin/SignalR/Models/FileMovedEventArgs.cs b/Shokofin/SignalR/Models/FileMovedEventArgs.cs index 50d71e48..855cf7b1 100644 --- a/Shokofin/SignalR/Models/FileMovedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileMovedEventArgs.cs @@ -37,6 +37,10 @@ public class V0 : IFileRelocationEventArgs [JsonInclude, JsonPropertyName("FileID")] public int FileId { get; set; } + /// <inheritdoc/> + [JsonIgnore] + public int? FileLocationId => null; + /// <inheritdoc/> [JsonInclude, JsonPropertyName("NewImportFolderID")] public int ImportFolderId { get; set; } @@ -86,6 +90,10 @@ public class V0 : IFileRelocationEventArgs .Replace('/', System.IO.Path.DirectorySeparatorChar) .Replace('\\', System.IO.Path.DirectorySeparatorChar); + /// <inheritdoc/> + [JsonIgnore] + public bool HasCrossReferences => false; + /// <inheritdoc/> [JsonIgnore] public List<IFileEventArgs.FileCrossReference> CrossReferences => new(); diff --git a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs index 5ab7f6c6..9344f8be 100644 --- a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs @@ -33,6 +33,10 @@ public class V0 : IFileRelocationEventArgs [JsonInclude, JsonPropertyName("FileID")] public int FileId { get; set; } + /// <inheritdoc/> + [JsonIgnore] + public int? FileLocationId => null; + /// <inheritdoc/> [JsonInclude, JsonPropertyName("ImportFolderID")] public int ImportFolderId { get; set; } @@ -77,6 +81,10 @@ public class V0 : IFileRelocationEventArgs [JsonIgnore] public string PreviousRelativePath => RelativePath[..^FileName.Length] + PreviousFileName; + /// <inheritdoc/> + [JsonIgnore] + public bool HasCrossReferences => false; + /// <inheritdoc/> [JsonIgnore] public List<IFileEventArgs.FileCrossReference> CrossReferences => new(); From 848c52abb019e31cbbcf92cf569a9676b0b1cc65 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 16 Apr 2024 03:06:15 +0000 Subject: [PATCH 0856/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 8f86394c..3f1791ef 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.128", + "changelog": "misc: add missing props. to v0 models\n\nmisc: update file event args\n\n- Update file event args to expose the file location id, if available,\n and to have an indicator as to if we got any cross-references\n from the server (this is needed because we're supporting _both_\n the stable server _and_ the daily server).\n\n- Update the debug log statments to include the new properties.\n\nmisc: fix typo [skip ci]", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.128/shoko_3.0.1.128.zip", + "checksum": "a3f564d513b105784c27c0e90f6e73d7", + "timestamp": "2024-04-16T03:06:13Z" + }, { "version": "3.0.1.127", "changelog": "fix: fix signalr models (again, but for real this time)", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.124/shoko_3.0.1.124.zip", "checksum": "06de5975cb4886afb6c688ff6097e547", "timestamp": "2024-04-15T21:46:48Z" - }, - { - "version": "3.0.1.123", - "changelog": "refactor: simplify cleanup code", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.123/shoko_3.0.1.123.zip", - "checksum": "b47786b60e2271c85e0536e294458c0a", - "timestamp": "2024-04-15T11:52:13Z" } ] } From accba9b1bed6fe6eb630cfb1f4cb07f8fffc2b5e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 16 Apr 2024 05:22:33 +0200 Subject: [PATCH 0857/1103] fix: only iterate files in folder --- Shokofin/Resolvers/ShokoResolveManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 19b7decd..26bcea1f 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -241,7 +241,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); if (folderPath.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { var start = DateTime.UtcNow; - var allPaths = FileSystem.GetFilePaths(mediaFolder.Path, true) + var allPaths = FileSystem.GetFilePaths(folderPath, true) .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) .ToHashSet(); Logger.LogDebug("Found {FileCount} files in media folder at {Path} in {TimeSpan}.", allPaths.Count, folderPath, DateTime.UtcNow - start); From cc76f230bc03ea029f50707878ea2ed23d8d84d1 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 16 Apr 2024 03:23:14 +0000 Subject: [PATCH 0858/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 3f1791ef..6cd7044c 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.129", + "changelog": "fix: only iterate files in folder", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.129/shoko_3.0.1.129.zip", + "checksum": "9a20bf38f55b6ad7908c13b9761c87f1", + "timestamp": "2024-04-16T03:23:12Z" + }, { "version": "3.0.1.128", "changelog": "misc: add missing props. to v0 models\n\nmisc: update file event args\n\n- Update file event args to expose the file location id, if available,\n and to have an indicator as to if we got any cross-references\n from the server (this is needed because we're supporting _both_\n the stable server _and_ the daily server).\n\n- Update the debug log statments to include the new properties.\n\nmisc: fix typo [skip ci]", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.125/shoko_3.0.1.125.zip", "checksum": "c8520e33727a185d0a3ad32e3651db91", "timestamp": "2024-04-15T22:08:45Z" - }, - { - "version": "3.0.1.124", - "changelog": "fix: fix the simplified cleanup code\n\n- add path tracking to the link generation results so we can\n reuse the paths from a cached result when cleaning.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.124/shoko_3.0.1.124.zip", - "checksum": "06de5975cb4886afb6c688ff6097e547", - "timestamp": "2024-04-15T21:46:48Z" } ] } From 6e13799ac4ce976d8bdf3cd290e6c5a09d247a0a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 16 Apr 2024 05:30:23 +0200 Subject: [PATCH 0859/1103] revert: "fix: only iterate files in folder" This reverts commit accba9b1bed6fe6eb630cfb1f4cb07f8fffc2b5e. --- Shokofin/Resolvers/ShokoResolveManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 26bcea1f..19b7decd 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -241,7 +241,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); if (folderPath.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { var start = DateTime.UtcNow; - var allPaths = FileSystem.GetFilePaths(folderPath, true) + var allPaths = FileSystem.GetFilePaths(mediaFolder.Path, true) .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) .ToHashSet(); Logger.LogDebug("Found {FileCount} files in media folder at {Path} in {TimeSpan}.", allPaths.Count, folderPath, DateTime.UtcNow - start); From 96787f20e3ae6f84ffb6dc0e62623b8d51af9e47 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 16 Apr 2024 03:32:03 +0000 Subject: [PATCH 0860/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 6cd7044c..75d20a05 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.130", + "changelog": "revert: \"fix: only iterate files in folder\"\n\nThis reverts commit accba9b1bed6fe6eb630cfb1f4cb07f8fffc2b5e.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.130/shoko_3.0.1.130.zip", + "checksum": "c65ff8f6a7b67bfafd1df236c5493a2d", + "timestamp": "2024-04-16T03:32:02Z" + }, { "version": "3.0.1.129", "changelog": "fix: only iterate files in folder", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.126/shoko_3.0.1.126.zip", "checksum": "493279e9e86d69d8e1136ae480f175da", "timestamp": "2024-04-16T02:00:50Z" - }, - { - "version": "3.0.1.125", - "changelog": "misc: tweak link generation logging\n\nmisc: remove unneeded arg for cleanup func.\n\nfix: correct yielded series id for files", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.125/shoko_3.0.1.125.zip", - "checksum": "c8520e33727a185d0a3ad32e3651db91", - "timestamp": "2024-04-15T22:08:45Z" } ] } From d04c184e9fb18d944226d6eacd8bae6de3662055 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 16 Apr 2024 20:59:19 +0200 Subject: [PATCH 0861/1103] fix: only iterate files in media folder once --- Shokofin/Resolvers/ShokoResolveManager.cs | 39 +++++++++++++---------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 19b7decd..90f0489a 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -49,7 +49,7 @@ public class ShokoResolveManager ExpirationScanFrequency = ExpirationScanFrequency, }); - private static readonly TimeSpan ExpirationScanFrequency = new(0, 25, 0); + private static readonly TimeSpan ExpirationScanFrequency = TimeSpan.FromMinutes(25); private static readonly TimeSpan DefaultTTL = TimeSpan.FromMinutes(60); @@ -125,6 +125,23 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) #region Media Folder Mapping + private IReadOnlySet<string> GetPathsForMediaFolder(Folder mediaFolder) + => DataCache.GetOrCreate<IReadOnlySet<string>>( + $"paths-for-media-folder:{mediaFolder.Path}", + (paths) => Logger.LogTrace("Reusing {FileCount} files for folder at {Path}", paths.Count, mediaFolder.Path), + (_) => { + var start = DateTime.UtcNow; + var paths = FileSystem.GetFilePaths(mediaFolder.Path, true) + .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + .ToHashSet(); + Logger.LogDebug("Found {FileCount} files in folder at {Path} in {TimeSpan}.", paths.Count, mediaFolder.Path, DateTime.UtcNow - start); + return paths; + }, + new() { + SlidingExpiration = TimeSpan.FromMinutes(30), + } + ); + public IReadOnlyList<(MediaFolderConfiguration config, Folder mediaFolder)> GetAvailableMediaFolders() => Plugin.Instance.Configuration.MediaFolders .Where(mediaFolder => mediaFolder.IsMapped && mediaFolder.IsFileEventsEnabled) @@ -240,12 +257,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); if (folderPath.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { - var start = DateTime.UtcNow; - var allPaths = FileSystem.GetFilePaths(mediaFolder.Path, true) - .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) - .ToHashSet(); - Logger.LogDebug("Found {FileCount} files in media folder at {Path} in {TimeSpan}.", allPaths.Count, folderPath, DateTime.UtcNow - start); - + var allPaths = GetPathsForMediaFolder(mediaFolder); var pathSegments = folderPath[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar); switch (pathSegments.Length) { // show/movie-folder level @@ -319,12 +331,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold } // Iterate files in the "real" media folder. else if (folderPath.StartsWith(mediaFolder.Path)) { - var start = DateTime.UtcNow; - var allPaths = FileSystem.GetFilePaths(mediaFolder.Path, true) - .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) - .ToHashSet(); - Logger.LogDebug("Found {FileCount} files in media folder at {Path} in {TimeSpan}.", allPaths.Count, mediaFolder.Path, DateTime.UtcNow - start); - + var allPaths = GetPathsForMediaFolder(mediaFolder); pathToClean = vfsPath; allFiles = GetFilesForImportFolder(mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); } @@ -376,7 +383,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold ); } - public IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForMovie(string episodeId, string seriesId, int importFolderId, string importFolderSubPath, string mediaFolderPath, ISet<string> fileSet) + public IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForMovie(string episodeId, string seriesId, int importFolderId, string importFolderSubPath, string mediaFolderPath, IReadOnlySet<string> fileSet) { var start = DateTime.UtcNow; var totalFiles = 0; @@ -422,7 +429,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold ); } - private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForShow(string seriesId, int? seasonNumber, int importFolderId, string importFolderSubPath, string mediaFolderPath, ISet<string> fileSet) + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForShow(string seriesId, int? seasonNumber, int importFolderId, string importFolderSubPath, string mediaFolderPath, IReadOnlySet<string> fileSet) { var start = DateTime.UtcNow; var showInfo = ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -522,7 +529,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold ); } - private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForImportFolder(int importFolderId, string importFolderSubPath, string mediaFolderPath, ISet<string> fileSet) + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForImportFolder(int importFolderId, string importFolderSubPath, string mediaFolderPath, IReadOnlySet<string> fileSet) { var start = DateTime.UtcNow; var firstPage = ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath); From c81497e4fd9e12b059cab9bb812f5d73db8aa90d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 16 Apr 2024 20:59:56 +0200 Subject: [PATCH 0862/1103] fix: override date created on episodes/movies --- Shokofin/API/Info/FileInfo.cs | 4 ++-- Shokofin/API/Models/File.cs | 11 +++++++++-- Shokofin/Providers/EpisodeProvider.cs | 11 +++++++---- Shokofin/Providers/MovieProvider.cs | 1 + 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/Shokofin/API/Info/FileInfo.cs b/Shokofin/API/Info/FileInfo.cs index c306e6e7..8b1638a8 100644 --- a/Shokofin/API/Info/FileInfo.cs +++ b/Shokofin/API/Info/FileInfo.cs @@ -12,7 +12,7 @@ public class FileInfo public MediaBrowser.Model.Entities.ExtraType? ExtraType; - public File File; + public File Shoko; public List<EpisodeInfo> EpisodeList; @@ -25,7 +25,7 @@ public FileInfo(File file, List<List<EpisodeInfo>> groupedEpisodeLists, string s Id = file.Id.ToString(); SeriesId = seriesId; ExtraType = episodeList.FirstOrDefault(episode => episode.ExtraType != null)?.ExtraType; - File = file; + Shoko = file; EpisodeList = episodeList; AlternateEpisodeLists = alternateEpisodeLists; } diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index 559c312f..bc9e58bb 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -42,11 +42,18 @@ public class File /// </summary> public TimeSpan Duration { get; set; } + /// <summary> + /// The file creation date of this file. + /// </summary> [JsonPropertyName("Created")] public DateTime CreatedAt { get; set; } - [JsonPropertyName("Updated")] - public DateTime LastUpdatedAt { get; set; } + /// <summary> + /// When the file was last imported. Usually is a file only imported once, + /// but there may be exceptions. + /// </summary> + [JsonPropertyName("Imported")] + public DateTime? ImportedAt { get; set; } [JsonPropertyName("AniDB")] public AniDB? AniDBData { get; set; } diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index ffcbfb7c..18ed37db 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -227,10 +227,13 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie }; } - if (file != null && file.EpisodeList.Count > 1) { - var episodeNumberEnd = episodeNumber + file.EpisodeList.Count - 1; - if (episodeNumberEnd != episodeNumber && episode.AniDB.EpisodeNumber != episodeNumberEnd) - result.IndexNumberEnd = episodeNumberEnd; + if (file != null) { + result.DateCreated = file.Shoko.ImportedAt ?? file.Shoko.CreatedAt; + if (file.EpisodeList.Count > 1) { + var episodeNumberEnd = episodeNumber + file.EpisodeList.Count - 1; + if (episodeNumberEnd != episodeNumber && episode.AniDB.EpisodeNumber != episodeNumberEnd) + result.IndexNumberEnd = episodeNumberEnd; + } } AddProviderIds(result, episodeId: episode.Id, fileId: file?.Id, seriesId: file?.SeriesId, anidbId: episode.AniDB.Id.ToString()); diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index ec97efc8..eb40c6c5 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -64,6 +64,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio Genres = season.Genres.ToArray(), Studios = season.Studios.ToArray(), CommunityRating = rating, + DateCreated = file.Shoko.ImportedAt ?? file.Shoko.CreatedAt, }; result.Item.SetProviderId(ShokoFileId.Name, file.Id); result.Item.SetProviderId(ShokoEpisodeId.Name, episode.Id); From 2ded32aad530ad0dcf19f02dd9dcd5684cffe0b2 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 16 Apr 2024 19:01:05 +0000 Subject: [PATCH 0863/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 75d20a05..eafdac1c 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.131", + "changelog": "fix: override date created on episodes/movies\n\nfix: only iterate files in media folder once", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.131/shoko_3.0.1.131.zip", + "checksum": "5f5a6e877d92cf06ce782cd2921c7131", + "timestamp": "2024-04-16T19:01:04Z" + }, { "version": "3.0.1.130", "changelog": "revert: \"fix: only iterate files in folder\"\n\nThis reverts commit accba9b1bed6fe6eb630cfb1f4cb07f8fffc2b5e.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.127/shoko_3.0.1.127.zip", "checksum": "3de9d69ad6f5a0843d5108dfe7e09094", "timestamp": "2024-04-16T02:20:26Z" - }, - { - "version": "3.0.1.126", - "changelog": "fix: fix signalr models (again)", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.126/shoko_3.0.1.126.zip", - "checksum": "493279e9e86d69d8e1136ae480f175da", - "timestamp": "2024-04-16T02:00:50Z" } ] } From ab853bed4541e5d32cf9a60586450c8b764058df Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Thu, 18 Apr 2024 00:15:52 +0100 Subject: [PATCH 0864/1103] Misc: Change update webhook [skip ci] --- .github/images/jellyfin.png | Bin 0 -> 39555 bytes .github/workflows/release-daily.yml | 16 +++++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 .github/images/jellyfin.png diff --git a/.github/images/jellyfin.png b/.github/images/jellyfin.png new file mode 100644 index 0000000000000000000000000000000000000000..0541db468c93d9c42772320ff9ef6810188238ff GIT binary patch literal 39555 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̗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 T#-X^E0XXz^jI}FnJ4XH+jec3y literal 0 HcmV?d00001 diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index b465f978..3c25de6e 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -137,16 +137,18 @@ jobs: steps: - name: Notify Discord Users - uses: Ilshidur/action-discord@08d9328877d6954120eef2b07abbc79249bb6210 - env: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + uses: tsickert/discord-webhook@v6.0.0 with: - args: | - # <:jellyfin:1045360407814090953> Shokofin: New Unstable Build! + webhook-url: ${{ secrets.WEBHOOK_URL }} + embed-color: 9985983 + embed-timestamp: ${{ needs.current_info.outputs.date }} + embed-author-name: Shokofin | New Unstable Build + embed-author-icon-url: https://raw.githubusercontent.com/${{ github.repository }}/master/.github/images/jellyfin.png + embed-author-url: https://github.com/${{ github.repository }} + embed-description: | **Version**: `${{ needs.current_info.outputs.version }}` (`${{ needs.current_info.outputs.sha_short }}`) - Update your plugin using the [unstable manifest](<https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest-unstable.json>) or by downloading the release from [GitHub Releases](<https://github.com/ShokoAnime/Shokofin/releases/tag/${{ needs.current_info.outputs.version }}>) and installing it manually! + Update your plugin using the [unstable manifest](https://raw.githubusercontent.com/${{ github.repository }}/master/manifest-unstable.json) or by downloading the release from [GitHub Releases](https://github.com/${{ github.repository }}/releases/tag/${{ needs.current_info.outputs.version }}) and installing it manually! **Changes since last build**: - ${{ needs.current_info.outputs.changelog }} \ No newline at end of file From 8b839c8eb1dda817ab9adb59b60f9ef02c80aa7e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 17 Apr 2024 01:45:50 +0200 Subject: [PATCH 0865/1103] misc: smarter path set and episode ids generation --- Shokofin/API/ShokoAPIManager.cs | 39 +++++++++++++++++---------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 44b1ef87..5d1f7b19 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -293,26 +293,27 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) } // Set up both at the same time. - private async Task<(HashSet<string>, HashSet<string>)> GetPathSetAndLocalEpisodeIdsForSeries(string seriesId) - { - var key =$"series-path-set-and-episode-ids:${seriesId}"; - if (DataCache.TryGetValue<(HashSet<string>, HashSet<string>)>(key, out var cached)) - return cached; - - 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) - foreach (var fileLocation in file.Locations) - pathSet.Add((Path.GetDirectoryName(fileLocation.Path) ?? string.Empty) + Path.DirectorySeparatorChar); - var xref = file.CrossReferences.First(xref => xref.Series.Shoko.ToString() == seriesId); - foreach (var episodeXRef in xref.Episodes) - episodeIds.Add(episodeXRef.Shoko.ToString()); - } + private Task<(HashSet<string>, HashSet<string>)> GetPathSetAndLocalEpisodeIdsForSeries(string seriesId) + => DataCache.GetOrCreateAsync<(HashSet<string>, HashSet<string>)>( + $"series-path-set-and-episode-ids:${seriesId}", + async (_) => { + 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) + foreach (var fileLocation in file.Locations) + pathSet.Add((Path.GetDirectoryName(fileLocation.Path) ?? string.Empty) + Path.DirectorySeparatorChar); + var xref = file.CrossReferences.First(xref => xref.Series.Shoko.ToString() == seriesId); + foreach (var episodeXRef in xref.Episodes) + episodeIds.Add(episodeXRef.Shoko.ToString()); + if (file.ImportedAt.HasValue) { + + } + } - DataCache.Set(key, (pathSet, episodeIds), DefaultTimeSpan); - return (pathSet, episodeIds); - } + return (pathSet, episodeIds); + } + ); #endregion #region File Info From e7639fe3095d4cfa6f0b2d431107818257663e61 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 17 Apr 2024 02:45:09 +0200 Subject: [PATCH 0866/1103] refactor: add fast paths for link generation - Added back the fast path to skip link generation of all shows/movies if we've already created all possible links for the entire VFS for a media folder, until the cache expires or is cleared. - Added a new fast path to skip link generation for all children of already generated paths, until the cache expires or is cleared. - Removed the per-file-slash-series-slash-path caching, since we now cache per folder _and_ file location instead. - Fixed up link generation result logging so it will be consistent again. - Some misc. internal restructor of the resolver manager. --- Shokofin/Resolvers/LinkGenerationResult.cs | 26 +---- Shokofin/Resolvers/ShokoResolveManager.cs | 116 +++++++++++---------- 2 files changed, 66 insertions(+), 76 deletions(-) diff --git a/Shokofin/Resolvers/LinkGenerationResult.cs b/Shokofin/Resolvers/LinkGenerationResult.cs index e45700e8..3eef7075 100644 --- a/Shokofin/Resolvers/LinkGenerationResult.cs +++ b/Shokofin/Resolvers/LinkGenerationResult.cs @@ -58,13 +58,11 @@ public class LinkGenerationResult public int RemovedNfos { get; set; } - public void Print(Folder mediaFolder, ILogger logger) + public void Print(ILogger logger, string path) { var timeSpent = DateTime.Now - CreatedAt; - var logLevel = Removed == 0 && Skipped == Total ? LogLevel.Debug : LogLevel.Information; - logger.Log( - logLevel, - "Created {CreatedTotal} ({CreatedMedia},{CreatedSubtitles},{CreatedNFO}), fixed {FixedTotal} ({FixedMedia},{FixedSubtitles}), skipped {SkippedTotal} ({SkippedMedia},{SkippedSubtitles},{SkippedNFO}), and removed {RemovedTotal} ({RemovedMedia},{RemovedSubtitles},{RemovedNFO}) entries in media folder at {Path} in {TimeSpan} (Total={Total})", + logger.LogInformation( + "Created {CreatedTotal} ({CreatedMedia},{CreatedSubtitles},{CreatedNFO}), fixed {FixedTotal} ({FixedMedia},{FixedSubtitles}), skipped {SkippedTotal} ({SkippedMedia},{SkippedSubtitles},{SkippedNFO}), and removed {RemovedTotal} ({RemovedMedia},{RemovedSubtitles},{RemovedNFO}) entries in folder at {Path} in {TimeSpan} (Total={Total})", Created, CreatedVideos, CreatedSubtitles, @@ -80,28 +78,12 @@ public void Print(Folder mediaFolder, ILogger logger) RemovedVideos, RemovedSubtitles, RemovedNfos, - mediaFolder.Path, + path, timeSpent, Total ); } - public void MarkSkipped() - { - if (FixedSubtitles > 0 || CreatedSubtitles > 0) { - SkippedSubtitles += FixedSubtitles + CreatedSubtitles; - FixedSubtitles = CreatedSubtitles = 0; - } - if (FixedVideos > 0 || CreatedVideos > 0) { - SkippedVideos += FixedVideos + CreatedVideos; - FixedVideos = CreatedVideos = 0; - } - if (CreatedNfos > 0) { - SkippedNfos += CreatedNfos; - CreatedNfos = 0; - } - } - public static LinkGenerationResult operator +(LinkGenerationResult a, LinkGenerationResult b) { // Re-use the same instance so the parallel execution will share the same bag. diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 90f0489a..175e4b11 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -237,10 +237,26 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold /// <paramref name="mediaFolder"/>. /// </summary> /// <param name="mediaFolder">The media folder to generate a structure for.</param> - /// <param name="folderPath">The folder within 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> - private async Task<string?> GenerateStructureInVFS(Folder mediaFolder, string folderPath) + private async Task<string?> GenerateStructureInVFS(Folder mediaFolder, string path) { + // Skip link generation if we've already generated for the media folder. + if (DataCache.TryGetValue<string?>($"should-skip-media-folder:{mediaFolder.Path}", out var vfsPath)) + return vfsPath; + vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + + // Check full path and all parent directories if they have been indexed. + if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { + var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).Prepend(vfsPath).ToArray(); + while (pathSegments.Length > 1) { + var subPath = Path.Join(pathSegments); + if (DataCache.TryGetValue<bool>($"should-skip-vfs-path:{subPath}", out _)) + return vfsPath; + pathSegments = pathSegments.SkipLast(1).ToArray(); + } + } + var mediaConfig = await GetOrCreateConfigurationForMediaFolder(mediaFolder); if (!mediaConfig.IsMapped) return null; @@ -255,10 +271,10 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold // Iterate the files already in the VFS. string? pathToClean = null; IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; - var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); - if (folderPath.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { + vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { var allPaths = GetPathsForMediaFolder(mediaFolder); - var pathSegments = folderPath[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar); + var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar); switch (pathSegments.Length) { // show/movie-folder level case 1: { @@ -271,13 +287,13 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold if (!int.TryParse(episodeId, out _)) break; - pathToClean = folderPath; + pathToClean = path; allFiles = GetFilesForMovie(episodeId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); break; } // show - pathToClean = folderPath; + pathToClean = path; allFiles = GetFilesForShow(seriesId, null, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); break; } @@ -304,7 +320,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold if (!seasonOrMovieName.StartsWith("Season ") || !int.TryParse(seasonOrMovieName.Split(' ').Last(), out var seasonNumber)) break; - pathToClean = folderPath; + pathToClean = path; allFiles = GetFilesForShow(seriesId, seasonNumber, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); break; } @@ -330,7 +346,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold } } // Iterate files in the "real" media folder. - else if (folderPath.StartsWith(mediaFolder.Path)) { + else if (path.StartsWith(mediaFolder.Path)) { var allPaths = GetPathsForMediaFolder(mediaFolder); pathToClean = vfsPath; allFiles = GetFilesForImportFolder(mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); @@ -339,7 +355,43 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold if (allFiles == null) return null; - await GenerateSymbolicLinks(mediaFolder, allFiles, pathToClean).ConfigureAwait(false); + var result = new LinkGenerationResult(); + var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); + var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); + await Task.WhenAll(allFiles.Select(async (tuple) => { + await semaphore.WaitAsync().ConfigureAwait(false); + + try { + // Skip any source files we weren't meant to have in the library. + var (sourceLocation, symbolicLinks, nfoFiles) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); + var subResult = GenerateSymbolicLinks(sourceLocation, symbolicLinks, nfoFiles, result.Paths); + + // Combine the current results with the overall results and mark the entitis as skipped + // for the next iterations. + lock (semaphore) { + result += subResult; + } + } + finally { + semaphore.Release(); + } + })) + .ConfigureAwait(false); + + // Cleanup the structure in the VFS. + if (!string.IsNullOrEmpty(pathToClean)) + result += CleanupStructure(pathToClean, result.Paths); + + // Save which paths we've already generated so we can skip generation + // for them and their sub-paths later, and also print the result. + if (path.StartsWith(mediaFolder.Path)) { + DataCache.Set($"should-skip-media-folder:{mediaFolder.Path}", vfsPath, DefaultTTL); + result.Print(Logger, mediaFolder.Path); + } + else { + DataCache.Set($"should-skip-vfs-path:{path}", true); + result.Print(Logger, path); + } return vfsPath; } @@ -628,50 +680,6 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold return await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath, page).ConfigureAwait(false); } - private async Task GenerateSymbolicLinks(Folder mediaFolder, IEnumerable<(string sourceLocation, string fileId, string seriesId)> files, string? pathToClean) - { - var result = new LinkGenerationResult(); - var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); - var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); - var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); - await Task.WhenAll(files.Select(async (tuple) => { - var subResult = await DataCache.GetOrCreateAsync( - $"file={tuple.fileId},series={tuple.seriesId},location={tuple.sourceLocation}", - (_) => Logger.LogTrace("Re-used previous links for path {SourceLocation} (File={FileId},Series={SeriesId})", tuple.sourceLocation, tuple.fileId, tuple.seriesId), - async (_) => { - await semaphore.WaitAsync().ConfigureAwait(false); - - Logger.LogTrace("Generating links for path {SourceLocation} (File={FileId},Series={SeriesId})", tuple.sourceLocation, tuple.fileId, tuple.seriesId); - try { - // Skip any source files we weren't meant to have in the library. - var (sourceLocation, symbolicLinks, nfoFiles) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); - return GenerateSymbolicLinks(sourceLocation, symbolicLinks, nfoFiles, result.Paths); - } - finally { - semaphore.Release(); - } - }, - new() { - AbsoluteExpirationRelativeToNow = DefaultTTL, - } - ); - - // Combine the current results with the overall results and mark the entitis as skipped - // for the next iterations. - lock (semaphore) { - result += subResult; - subResult.MarkSkipped(); - } - })) - .ConfigureAwait(false); - - // Cleanup the structure in the VFS. - if (!string.IsNullOrEmpty(pathToClean)) - result += CleanupStructure(pathToClean, result.Paths); - - result.Print(mediaFolder, Logger); - } - // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 chacters. private const int NameCutOff = 64; From 0f0e991e6c2721a6c5e7528d02706ea995dbf230 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 18 Apr 2024 14:26:18 +0200 Subject: [PATCH 0867/1103] fix: fix adding missing metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed up the code for adding/removing "missing" metadata as needed. It will now work even better than it ever did, since we're now handling more edge cases. 👍 --- Shokofin/API/Info/ShowInfo.cs | 2 +- Shokofin/Providers/CustomEpisodeProvider.cs | 119 +++++++++ Shokofin/Providers/CustomSeasonProvider.cs | 253 ++++++++++++++++++++ Shokofin/Providers/CustomSeriesProvider.cs | 197 +++++++++++++++ Shokofin/Providers/EpisodeProvider.cs | 46 +--- Shokofin/Providers/SeasonProvider.cs | 140 +---------- Shokofin/Providers/SeriesProvider.cs | 145 +---------- 7 files changed, 577 insertions(+), 325 deletions(-) create mode 100644 Shokofin/Providers/CustomEpisodeProvider.cs create mode 100644 Shokofin/Providers/CustomSeasonProvider.cs create mode 100644 Shokofin/Providers/CustomSeriesProvider.cs diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index 2b852941..eced74a5 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -118,7 +118,7 @@ public class ShowInfo /// A pre-filtered set of special episode ids without an ExtraType /// attached. /// </summary> - private readonly IReadOnlySet<string> SpecialsSet; + public readonly IReadOnlySet<string> SpecialsSet; /// <summary> /// Indicates that the show has specials. diff --git a/Shokofin/Providers/CustomEpisodeProvider.cs b/Shokofin/Providers/CustomEpisodeProvider.cs new file mode 100644 index 00000000..2a1cf862 --- /dev/null +++ b/Shokofin/Providers/CustomEpisodeProvider.cs @@ -0,0 +1,119 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.ExternalIds; + +using Info = Shokofin.API.Info; + +namespace Shokofin.Providers; + +/// <summary> +/// The custom episode provider. Responsible for de-duplicating 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 CustomEpisodeProvider : ICustomMetadataProvider<Episode> +{ + public string Name => Plugin.MetadataProviderName; + + private readonly ILogger<CustomEpisodeProvider> Logger; + + private readonly IIdLookup Lookup; + + private readonly ILibraryManager LibraryManager; + + public CustomEpisodeProvider(ILogger<CustomEpisodeProvider> logger, IIdLookup lookup, ILibraryManager libraryManager) + { + Logger = logger; + Lookup = lookup; + LibraryManager = libraryManager; + } + + public Task<ItemUpdateType> FetchAsync(Episode episode, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + var series = episode.Series; + if (series is null) + return Task.FromResult(ItemUpdateType.None); + + // Abort if we're unable to get the shoko episode id + if (!Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) + return Task.FromResult(ItemUpdateType.None); + + if (RemoveDuplicates(LibraryManager, Logger, episodeId, episode, series.GetPresentationUniqueKey())) + return Task.FromResult(ItemUpdateType.MetadataEdit); + + return Task.FromResult(ItemUpdateType.None); + } + + public static bool RemoveDuplicates(ILibraryManager libraryManager, ILogger logger, string episodeId, Episode episode, string seriesPresentationUniqueKey) + { + // Remove any extra virtual episodes that matches the newly refreshed episode. + var searchList = libraryManager.GetItemList( + new() { + ExcludeItemIds = new[] { episode.Id }, + HasAnyProviderId = new() { { ShokoEpisodeId.Name, episodeId } }, + IncludeItemTypes = new[] { Jellyfin.Data.Enums.BaseItemKind.Episode }, + GroupByPresentationUniqueKey = false, + GroupBySeriesPresentationUniqueKey = true, + SeriesPresentationUniqueKey = seriesPresentationUniqueKey, + DtoOptions = new(true), + }, + true + ) + .Where(item => string.IsNullOrEmpty(item.Path)) + .ToList(); + if (searchList.Count > 0) { + logger.LogDebug("Removing {Count} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", searchList.Count, episode.Name, episodeId); + + var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; + foreach (var item in searchList) + libraryManager.DeleteItem(item, deleteOptions); + + return true; + } + + return false; + } + + private static bool EpisodeExists(ILibraryManager libraryManager, ILogger logger, string seriesPresentationUniqueKey, string episodeId, string seriesId, string? groupId) + { + var searchList = libraryManager.GetItemList( + new() { + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Episode }, + HasAnyProviderId = new() { { ShokoEpisodeId.Name, episodeId } }, + GroupByPresentationUniqueKey = false, + GroupBySeriesPresentationUniqueKey = true, + SeriesPresentationUniqueKey = seriesPresentationUniqueKey, + DtoOptions = new(true), + }, + true + ); + if (searchList.Count > 0) { + logger.LogTrace("A virtual or physical episode entry already exists for Episode {EpisodeName}. Ignoring. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", searchList[0].Name, episodeId, seriesId, groupId); + return true; + } + return false; + } + + public static bool AddVirtualEpisode(ILibraryManager libraryManager, ILogger logger, Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, Season season, Series series) + { + if (EpisodeExists(libraryManager, logger, series.GetPresentationUniqueKey(), episodeInfo.Id, seasonInfo.Id, showInfo.GroupId)) + return false; + + var episodeId = libraryManager.GetNewItemId(season.Series.Id + " Season " + seasonInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); + var episode = EpisodeProvider.CreateMetadata(showInfo, seasonInfo, episodeInfo, season, episodeId); + + logger.LogInformation("Adding virtual Episode {EpisodeNumber} in Season {SeasonNumber} for Series {SeriesName}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episode.IndexNumber, season.IndexNumber, showInfo.Name, episodeInfo.Id, seasonInfo.Id, showInfo.GroupId); + + season.AddChild(episode); + + return true; + } +} diff --git a/Shokofin/Providers/CustomSeasonProvider.cs b/Shokofin/Providers/CustomSeasonProvider.cs new file mode 100644 index 00000000..3e2ff3d6 --- /dev/null +++ b/Shokofin/Providers/CustomSeasonProvider.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; + +using Info = Shokofin.API.Info; + +namespace Shokofin.Providers; + +/// <summary> +/// The custom season provider. Responsible for de-duplicating seasons and +/// adding/removing "missing" 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 CustomSeasonProvider : ICustomMetadataProvider<Season> +{ + public string Name => Plugin.MetadataProviderName; + + private readonly ILogger<CustomSeasonProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + private readonly IIdLookup Lookup; + + private readonly ILibraryManager LibraryManager; + + public CustomSeasonProvider(ILogger<CustomSeasonProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager) + { + Logger = logger; + ApiManager = apiManager; + Lookup = lookup; + LibraryManager = libraryManager; + } + + public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + // We're not interested in the dummy season. + if (!season.IndexNumber.HasValue) + return ItemUpdateType.None; + + // Abort if we're unable to get the shoko series id + var series = season.Series; + if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + return ItemUpdateType.None; + + var seasonNumber = season.IndexNumber!.Value; + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for season. (Series={SeriesId})", seriesId); + return ItemUpdateType.None; + } + + var itemUpdated = ItemUpdateType.None; + if (Plugin.Instance.Configuration.AddMissingMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + // Special handling of specials (pun intended). + if (seasonNumber == 0) { + var goodKnownEpisodeIds = showInfo.SpecialsSet; + var toRemove = new List<Episode>(); + var existingEpisodes = new HashSet<string>(); + foreach (var episode in season.Children.OfType<Episode>()) { + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + if (episode.IsVirtualItem && !goodKnownEpisodeIds.Overlaps(episodeIds)) { + toRemove.Add(episode); + } + else { + foreach (var episodeId in episodeIds) + existingEpisodes.Add(episodeId); + } + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { + if (episode.IsVirtualItem && !goodKnownEpisodeIds.Contains(episodeId)) + toRemove.Add(episode); + else + existingEpisodes.Add(episodeId); + } + } + + foreach (var episode in toRemove) { + Logger.LogDebug("Removing unknown Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, 0, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } + + foreach (var sI in showInfo.SeasonList) { + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(sI.Id)) + existingEpisodes.Add(episodeId); + + foreach (var episodeInfo in sI.SpecialsList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, sI, episodeInfo, season, series)) + itemUpdated |= ItemUpdateType.MetadataImport; + } + } + } + // Every other "season". + else { + var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber); + if (seasonInfo == null || !showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var baseSeasonNumber)) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); + return ItemUpdateType.None; + } + var offset = Math.Abs(seasonNumber - baseSeasonNumber); + + var episodeList = offset == 0 ? seasonInfo.EpisodeList : seasonInfo.AlternateEpisodesList; + var goodKnownEpisodeIds = episodeList + .Select(episodeInfo => episodeInfo.Id) + .ToHashSet(); + var toRemove = new List<Episode>(); + var existingEpisodes = new HashSet<string>(); + foreach (var episode in season.Children.OfType<Episode>()) { + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + if (episode.IsVirtualItem && !goodKnownEpisodeIds.Overlaps(episodeIds)) { + toRemove.Add(episode); + } + else { + foreach (var episodeId in episodeIds) + existingEpisodes.Add(episodeId); + } + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { + if (episode.IsVirtualItem && !goodKnownEpisodeIds.Contains(episodeId)) + toRemove.Add(episode); + else + existingEpisodes.Add(episodeId); + } + } + + foreach (var episode in toRemove) { + Logger.LogDebug("Removing unknown Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, seasonNumber, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } + + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) + existingEpisodes.Add(episodeId); + + foreach (var episodeInfo in episodeList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, seasonInfo, episodeInfo, season, series)) + itemUpdated |= ItemUpdateType.MetadataImport; + } + } + } + + if (RemoveDuplicates(LibraryManager, Logger, seasonNumber, season, series, seriesId)) + itemUpdated |= ItemUpdateType.MetadataEdit; + + return itemUpdated; + } + private static bool RemoveDuplicates(ILibraryManager libraryManager, ILogger logger, int seasonNumber, Season season, Series series, string seriesId) + { + // Remove the virtual season/episode that matches the newly updated item + var searchList = libraryManager + .GetItemList( + new() { + ParentId = season.ParentId, + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, + ExcludeItemIds = new [] { season.Id }, + IndexNumber = seasonNumber, + DtoOptions = new(true), + }, + true + ) + .Where(item => !item.IndexNumber.HasValue) + .ToList(); + if (searchList.Count > 0) + { + logger.LogDebug("Removing {Count:00} duplicates of Season {SeasonNumber:00} from Series {SeriesName} (Series={SeriesId})", searchList.Count, seasonNumber, series.Name, seriesId); + + var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; + foreach (var item in searchList) + libraryManager.DeleteItem(item, deleteOptions); + + return true; + } + return false; + } + + private static bool SeasonExists(ILibraryManager libraryManager, ILogger logger, string seriesPresentationUniqueKey, string seriesName, int seasonNumber) + { + var searchList = libraryManager.GetItemList( + new() { + IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, + IndexNumber = seasonNumber, + GroupByPresentationUniqueKey = false, + GroupBySeriesPresentationUniqueKey = true, + SeriesPresentationUniqueKey = seriesPresentationUniqueKey, + DtoOptions = new(true), + }, + true + ); + + if (searchList.Count > 0) { + logger.LogTrace("Season {SeasonNumber} for Series {SeriesName} exists.", seasonNumber, seriesName); + return true; + } + + return false; + } + + public static Season? AddVirtualSeasonZero(ILibraryManager libraryManager, ILogger logger, Series series) + { + if (SeasonExists(libraryManager, logger, series.GetPresentationUniqueKey(), series.Name, 0)) + return null; + + var seasonName = libraryManager.GetLibraryOptions(series).SeasonZeroDisplayName; + var season = new Season { + Name = seasonName, + IndexNumber = 0, + SortName = seasonName, + ForcedSortName = seasonName, + Id = libraryManager.GetNewItemId(series.Id + "Season 0", typeof(Season)), + IsVirtualItem = true, + SeriesId = series.Id, + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), + DateCreated = series.DateCreated, + DateModified = series.DateModified, + DateLastSaved = series.DateLastSaved, + }; + + logger.LogInformation("Adding virtual Season {SeasonNumber} to Series {SeriesName}.", 0, series.Name); + + series.AddChild(season); + + return season; + } + + public static Season? AddVirtualSeason(ILibraryManager libraryManager, ILogger logger, Info.SeasonInfo seasonInfo, int offset, int seasonNumber, Series series) + { + if (SeasonExists(libraryManager, logger, series.GetPresentationUniqueKey(), series.Name, seasonNumber)) + return null; + + var seasonId = libraryManager.GetNewItemId(series.Id + "Season " + seasonNumber.ToString(System.Globalization.CultureInfo.InvariantCulture), typeof(Season)); + var season = SeasonProvider.CreateMetadata(seasonInfo, seasonNumber, offset, series, seasonId); + + logger.LogInformation("Adding virtual Season {SeasonNumber} to Series {SeriesName}. (Series={SeriesId})", seasonNumber, series.Name, seasonInfo.Id); + + series.AddChild(season); + + return season; + } +} \ No newline at end of file diff --git a/Shokofin/Providers/CustomSeriesProvider.cs b/Shokofin/Providers/CustomSeriesProvider.cs new file mode 100644 index 00000000..1802912e --- /dev/null +++ b/Shokofin/Providers/CustomSeriesProvider.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.Utils; + +using Info = Shokofin.API.Info; + +namespace Shokofin.Providers; + +public class CustomSeriesProvider : ICustomMetadataProvider<Series> +{ + public string Name => Plugin.MetadataProviderName; + + private readonly ILogger<CustomSeriesProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + private readonly IIdLookup Lookup; + + private readonly ILibraryManager LibraryManager; + + public CustomSeriesProvider(ILogger<CustomSeriesProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager) + { + Logger = logger; + ApiManager = apiManager; + Lookup = lookup; + LibraryManager = libraryManager; + } + + public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + // Abort if we're unable to get the shoko series id + if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + return ItemUpdateType.None; + + // Provide metadata for a series using Shoko's Group feature + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for series. (Series={SeriesID})", seriesId); + return ItemUpdateType.None; + } + + // Get the existing seasons and episode ids + var itemUpdated = ItemUpdateType.None; + if (Plugin.Instance.Configuration.AddMissingMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + // Get the existing seasons and episode ids + var seasons = series.Children + .OfType<Season>() + .Where(season => season.IndexNumber.HasValue) + .ToDictionary(season => season.IndexNumber!.Value); + + var knownSeasonIds = showInfo.SeasonOrderDictionary.Select(s => s.Key).ToHashSet(); + if (showInfo.HasSpecials) + knownSeasonIds.Add(0); + + var toRemove = seasons + .ExceptBy(knownSeasonIds, season => season.Key) + .Where(season => season.Value.IsVirtualItem) + .ToList(); + foreach (var (seasonNumber, season) in toRemove) { + Logger.LogDebug("Removing unknown Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Name, seriesId); + seasons.Remove(seasonNumber); + LibraryManager.DeleteItem(season, new() { DeleteFileLocation = false }); + } + + // Add missing seasons + foreach (var (seasonNumber, season) in CreateMissingSeasons(showInfo, series, seasons)) { + itemUpdated |= ItemUpdateType.MetadataImport; + seasons.TryAdd(seasonNumber, season); + } + + // Specials. + if (seasons.TryGetValue(0, out var zeroSeason)) { + var goodKnownEpisodeIds = showInfo.SpecialsSet; + var toRemoveEpisodes = new List<Episode>(); + var existingEpisodes = new HashSet<string>(); + foreach (var episode in zeroSeason.Children.OfType<Episode>()) { + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + if (episode.IsVirtualItem && !goodKnownEpisodeIds.Overlaps(episodeIds)) { + toRemoveEpisodes.Add(episode); + } + else { + foreach (var episodeId in episodeIds) + existingEpisodes.Add(episodeId); + } + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { + if (episode.IsVirtualItem && !goodKnownEpisodeIds.Contains(episodeId)) + toRemoveEpisodes.Add(episode); + else + existingEpisodes.Add(episodeId); + } + } + + foreach (var episode in toRemoveEpisodes) { + Logger.LogDebug("Removing unknown Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, 0, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } + + foreach (var seasonInfo in showInfo.SeasonList) { + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) + existingEpisodes.Add(episodeId); + + foreach (var episodeInfo in seasonInfo.SpecialsList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, seasonInfo, episodeInfo, zeroSeason, series)) + itemUpdated |= ItemUpdateType.MetadataImport; + } + } + } + + // All other seasons. + foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; + + if (seasonInfo == null || !showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var baseSeasonNumber)) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); + return ItemUpdateType.None; + } + var offset = Math.Abs(seasonNumber - baseSeasonNumber); + + var episodeList = offset == 0 ? seasonInfo.EpisodeList : seasonInfo.AlternateEpisodesList; + var goodKnownEpisodeIds = episodeList + .Select(episodeInfo => episodeInfo.Id) + .ToHashSet(); + var toRemoveEpisodes = new List<Episode>(); + var existingEpisodes = new HashSet<string>(); + foreach (var episode in season.Children.OfType<Episode>()) { + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + if (episode.IsVirtualItem && !goodKnownEpisodeIds.Overlaps(episodeIds)) { + toRemoveEpisodes.Add(episode); + } + else { + foreach (var episodeId in episodeIds) + existingEpisodes.Add(episodeId); + } + else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { + if (episode.IsVirtualItem && !goodKnownEpisodeIds.Contains(episodeId)) + toRemoveEpisodes.Add(episode); + else + existingEpisodes.Add(episodeId); + } + } + + foreach (var episode in toRemoveEpisodes) { + Logger.LogDebug("Removing unknown Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, seasonNumber, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } + + foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) + existingEpisodes.Add(episodeId); + + foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList)) { + var episodeParentIndex = Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); + if (episodeParentIndex != seasonNumber) + continue; + + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; + + if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, seasonInfo, episodeInfo, season, series)) + itemUpdated |= ItemUpdateType.MetadataImport; + } + } + } + + return itemUpdated; + } + + private IEnumerable<(int, Season)> CreateMissingSeasons(Info.ShowInfo showInfo, Series series, Dictionary<int, Season> seasons) + { + foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { + if (seasons.ContainsKey(seasonNumber)) + continue; + var offset = seasonNumber - showInfo.GetBaseSeasonNumberForSeasonInfo(seasonInfo); + var season = CustomSeasonProvider.AddVirtualSeason(LibraryManager, Logger, seasonInfo, offset, seasonNumber, series); + if (season == null) + continue; + yield return (seasonNumber, season); + } + + if (showInfo.HasSpecials && !seasons.ContainsKey(0)) { + var season = CustomSeasonProvider.AddVirtualSeasonZero(LibraryManager, Logger, series); + if (season != null) + yield return (0, season); + } + } +} \ No newline at end of file diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 18ed37db..6dc246c8 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -16,7 +16,6 @@ using Info = Shokofin.API.Info; using SeriesType = Shokofin.API.Models.SeriesType; using EpisodeType = Shokofin.API.Models.EpisodeType; -using MediaBrowser.Controller.Library; namespace Shokofin.Providers; @@ -30,17 +29,11 @@ public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo> private readonly ShokoAPIManager ApiManager; - private readonly IIdLookup Lookup; - - private readonly ILibraryManager LibraryManager; - - public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager) + public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProvider> logger, ShokoAPIManager apiManager) { HttpClientFactory = httpClientFactory; Logger = logger; ApiManager = apiManager; - Lookup = lookup; - LibraryManager = libraryManager; } public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) @@ -77,7 +70,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell } // if the episode info is null then the series info and conditionally the group info is also null. - if (fileInfo == null || episodeInfo == null || seasonInfo == null || showInfo == null) { + if (episodeInfo == null || seasonInfo == null || showInfo == null) { Logger.LogWarning("Unable to find episode info for path {Path}", info.Path); return result; } @@ -98,7 +91,7 @@ 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); - public static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Info.FileInfo file, string metadataLanguage) + 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); private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Info.FileInfo? file, string metadataLanguage, Season? season, Guid episodeId) @@ -260,37 +253,4 @@ public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo search public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); - - public Task<ItemUpdateType> FetchAsync(Episode episode, MetadataRefreshOptions options, CancellationToken cancellationToken) - { - // Abort if we're unable to get the shoko episode id - if (!Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) - return Task.FromResult(ItemUpdateType.None); - - // Remove any extra virtual episodes that matches the newly refreshed episode. - var searchList = LibraryManager - .GetItemList( - new() { - ParentId = episode.ParentId, - IsVirtualItem = true, - ExcludeItemIds = new[] { episode.Id }, - HasAnyProviderId = new() { { ShokoEpisodeId.Name, episodeId } }, - IncludeItemTypes = new[] { Jellyfin.Data.Enums.BaseItemKind.Episode }, - GroupByPresentationUniqueKey = false, - DtoOptions = new(true), - }, - true - ); - if (searchList.Count > 0) { - Logger.LogInformation("Removing {Count:00} duplicate episodes for episode {EpisodeName}. (Episode={EpisodeId})", searchList.Count, episode.Name, episodeId); - - var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; - foreach (var item in searchList) - LibraryManager.DeleteItem(item, deleteOptions); - - return Task.FromResult(ItemUpdateType.MetadataEdit); - } - - return Task.FromResult(ItemUpdateType.None); - } } diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index d76f5406..a2873ab7 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -5,7 +5,6 @@ using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; @@ -27,17 +26,11 @@ public class SeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo> private readonly ShokoAPIManager ApiManager; - private readonly IIdLookup Lookup; - - private readonly ILibraryManager LibraryManager; - - public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager) + public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvider> logger, ShokoAPIManager apiManager) { HttpClientFactory = httpClientFactory; Logger = logger; ApiManager = apiManager; - Lookup = lookup; - LibraryManager = libraryManager; } public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) @@ -49,7 +42,7 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat // Special handling of the "Specials" season (pun intended). if (info.IndexNumber.Value == 0) { - // We're forcing the sort names to start with "ZZ" to make it + // We're forcing the sort names to start with "ZZ" to make it // always appear last in the UI. var seasonName = info.Name; result.Item = new Season { @@ -179,134 +172,5 @@ public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchI public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); - - public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptions options, CancellationToken cancellationToken) - { - // We're not interested in the dummy season. - if (!season.IndexNumber.HasValue) - return ItemUpdateType.None; - - // Abort if we're unable to get the shoko series id - var series = season.Series; - if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) - return ItemUpdateType.None; - - var seasonNumber = season.IndexNumber!.Value; - var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); - if (showInfo == null || showInfo.SeasonList.Count == 0) { - Logger.LogWarning("Unable to find show info for season. (Series={SeriesId})", seriesId); - return ItemUpdateType.None; - } - - var itemUpdated = ItemUpdateType.None; - if (Plugin.Instance.Configuration.AddMissingMetadata) { - // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - var existingEpisodes = new HashSet<string>(); - foreach (var episode in season.Children.OfType<Episode>()) { - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - foreach (var episodeId in episodeIds) - existingEpisodes.Add(episodeId); - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) - existingEpisodes.Add(episodeId); - } - - // Special handling of specials (pun intended). - if (seasonNumber == 0) { - foreach (var sI in showInfo.SeasonList) { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(sI.Id)) - existingEpisodes.Add(episodeId); - - foreach (var episodeInfo in sI.SpecialsList) { - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; - - if (AddVirtualEpisode(showInfo, sI, episodeInfo, season)) - itemUpdated |= ItemUpdateType.MetadataImport; - } - } - } - // Every other "season". - else { - var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber); - if (seasonInfo == null) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber:00} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); - return ItemUpdateType.None; - } - - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) - existingEpisodes.Add(episodeId); - - foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList)) { - var episodeParentIndex = Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); - if (episodeParentIndex != seasonNumber) - continue; - - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; - - if (AddVirtualEpisode(showInfo, seasonInfo, episodeInfo, season)) - itemUpdated |= ItemUpdateType.MetadataImport; - } - } - } - - // Remove the virtual season/episode that matches the newly updated item - var searchList = LibraryManager - .GetItemList( - new() { - ParentId = season.ParentId, - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, - ExcludeItemIds = new [] { season.Id }, - IndexNumber = seasonNumber, - DtoOptions = new(true), - }, - true - ) - .Where(item => !item.IndexNumber.HasValue) - .ToList(); - if (searchList.Count > 0) - { - Logger.LogInformation("Removing {Count:00} duplicate seasons from Series {SeriesName} (Series={SeriesId})", searchList.Count, series.Name, seriesId); - - var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; - foreach (var item in searchList) - LibraryManager.DeleteItem(item, deleteOptions); - - itemUpdated |= ItemUpdateType.MetadataEdit; - } - - - return itemUpdated; - } - - private bool EpisodeExists(string episodeId, string seriesId, string? groupId) - { - var searchList = LibraryManager.GetItemList(new() { - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Episode }, - HasAnyProviderId = new Dictionary<string, string> { [ShokoEpisodeId.Name] = episodeId }, - DtoOptions = new(true), - }, true); - - if (searchList.Count > 0) { - Logger.LogDebug("A virtual or physical episode entry already exists for Episode {EpisodeName}. Ignoring. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", searchList[0].Name, episodeId, seriesId, groupId); - return true; - } - return false; - } - - private bool AddVirtualEpisode(Info.ShowInfo showInfo, Info.SeasonInfo seasonInfo, Info.EpisodeInfo episodeInfo, Season season) - { - if (EpisodeExists(episodeInfo.Id, seasonInfo.Id, showInfo.GroupId)) - return false; - - var episodeId = LibraryManager.GetNewItemId(season.Series.Id + " Season " + seasonInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); - var episode = EpisodeProvider.CreateMetadata(showInfo, seasonInfo, episodeInfo, season, episodeId); - - Logger.LogInformation("Adding virtual Episode {EpisodeNumber:000} in Season {SeasonNumber:00} for Series {SeriesName}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episode.IndexNumber, season.Name, showInfo.Name, episodeInfo.Id, seasonInfo.Id, showInfo.GroupId); - - season.AddChild(episode); - - return true; - } } diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 551dcb28..123fefc0 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -1,16 +1,13 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; @@ -18,11 +15,9 @@ using Shokofin.ExternalIds; using Shokofin.Utils; -using Info = Shokofin.API.Info; - namespace Shokofin.Providers; -public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>, ICustomMetadataProvider<Series> +public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> { public string Name => Plugin.MetadataProviderName; @@ -34,21 +29,12 @@ public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>, ICust private readonly IFileSystem FileSystem; - private readonly IIdLookup Lookup; - - private readonly ILibraryManager LibraryManager; - - private readonly ILocalizationManager LocalizationManager; - - public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager, IFileSystem fileSystem, IIdLookup lookup, ILibraryManager libraryManager, ILocalizationManager localizationManager) + public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvider> logger, ShokoAPIManager apiManager, IFileSystem fileSystem) { Logger = logger; HttpClientFactory = httpClientFactory; ApiManager = apiManager; FileSystem = fileSystem; - Lookup = lookup; - LibraryManager = libraryManager; - LocalizationManager = localizationManager; } public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) @@ -131,131 +117,4 @@ public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo info, C public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); - - public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptions options, CancellationToken cancellationToken) - { - // Abort if we're unable to get the shoko series id - if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) - return ItemUpdateType.None; - - // Provide metadata for a series using Shoko's Group feature - var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); - if (showInfo == null || showInfo.SeasonList.Count == 0) { - Logger.LogWarning("Unable to find show info for series. (Series={SeriesID})", seriesId); - return ItemUpdateType.None; - } - - // Get the existing seasons and episode ids - var itemUpdated = ItemUpdateType.None; - if (Plugin.Instance.Configuration.AddMissingMetadata) { - var (seasons, _) = GetExistingSeasonsAndEpisodeIds(series); - foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { - if (seasons.ContainsKey(seasonNumber)) - continue; - var offset = seasonNumber - showInfo.GetBaseSeasonNumberForSeasonInfo(seasonInfo); - var season = AddVirtualSeason(seasonInfo, offset, seasonNumber, series); - if (season != null) - itemUpdated |= ItemUpdateType.MetadataImport; - } - - if (showInfo.HasSpecials && !seasons.ContainsKey(0)) { - var season = AddVirtualSeason(0, series); - if (season != null) - itemUpdated |= ItemUpdateType.MetadataImport; - } - } - - return itemUpdated; - } - private (Dictionary<int, Season>, HashSet<string>) GetExistingSeasonsAndEpisodeIds(Series series) - { - var seasons = new Dictionary<int, Season>(); - var episodes = new HashSet<string>(); - foreach (var item in series.GetRecursiveChildren()) switch (item) { - case Season season: - if (season.IndexNumber.HasValue) - seasons.TryAdd(season.IndexNumber.Value, season); - // Add all known episode ids for the season. - if (Lookup.TryGetSeriesIdFor(season, out var seriesId)) - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seriesId)) - episodes.Add(episodeId); - break; - case Episode episode: - // Get a hash-set of existing episodes – both physical and virtual – to exclude when adding new virtual episodes. - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - foreach (var episodeId in episodeIds) - episodes.Add(episodeId); - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) - episodes.Add(episodeId); - break; - } - return (seasons, episodes); - } - - private bool SeasonExists(string seriesPresentationUniqueKey, string seriesName, int seasonNumber) - { - var searchList = LibraryManager.GetItemList(new() { - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, - IndexNumber = seasonNumber, - SeriesPresentationUniqueKey = seriesPresentationUniqueKey, - DtoOptions = new(true), - }, true); - - if (searchList.Count > 0) { - Logger.LogDebug("Season {SeasonName} for Series {SeriesName} was created in another concurrent thread, skipping.", searchList[0].Name, seriesName); - return true; - } - - return false; - } - - private Season? AddVirtualSeason(int seasonNumber, Series series) - { - if (SeasonExists(series.GetPresentationUniqueKey(), series.Name, seasonNumber)) - return null; - - string seasonName; - if (seasonNumber == 0) - seasonName = LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName; - else - seasonName = string.Format(LocalizationManager.GetLocalizedString("NameSeasonNumber"), seasonNumber.ToString(CultureInfo.InvariantCulture)); - - var season = new Season { - Name = seasonName, - IndexNumber = seasonNumber, - SortName = seasonName, - ForcedSortName = seasonName, - Id = LibraryManager.GetNewItemId( - series.Id + "Season " + seasonNumber.ToString(CultureInfo.InvariantCulture), - typeof(Season)), - IsVirtualItem = true, - SeriesId = series.Id, - SeriesName = series.Name, - SeriesPresentationUniqueKey = series.GetPresentationUniqueKey(), - DateModified = DateTime.UtcNow, - DateLastSaved = DateTime.UtcNow, - }; - - Logger.LogInformation("Adding virtual Season {SeasonNumber:00} to Series {SeriesName}.", seasonNumber, series.Name); - - series.AddChild(season); - - return season; - } - - private Season? AddVirtualSeason(Info.SeasonInfo seasonInfo, int offset, int seasonNumber, Series series) - { - if (SeasonExists(series.GetPresentationUniqueKey(), series.Name, seasonNumber)) - return null; - - var seasonId = LibraryManager.GetNewItemId(series.Id + "Season " + seasonNumber.ToString(CultureInfo.InvariantCulture), typeof(Season)); - var season = SeasonProvider.CreateMetadata(seasonInfo, seasonNumber, offset, series, seasonId); - - Logger.LogInformation("Adding virtual Season {SeasonNumber:00} to Series {SeriesName}. (Series={SeriesId})", seasonNumber, series.Name, seasonInfo.Id); - - series.AddChild(season); - - return season; - } - } From b0de3d0888ee9509496c4bf7de01db73749e5868 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 17 Apr 2024 01:48:23 +0200 Subject: [PATCH 0868/1103] fix: fix import order for items in the VFS - Fix the "recently added" section of the dashboard by monkey-patching the creation date for each item type. --- Shokofin/API/Info/SeasonInfo.cs | 17 ++++- Shokofin/API/Info/ShowInfo.cs | 16 ++++ Shokofin/API/ShokoAPIManager.cs | 22 +++++- Shokofin/Providers/EpisodeProvider.cs | 11 +-- Shokofin/Resolvers/ShokoResolveManager.cs | 93 +++++++++++++++++++++++ 5 files changed, 149 insertions(+), 10 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index d29b7b93..757dae0e 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Shokofin.API.Models; @@ -19,6 +20,18 @@ public class SeasonInfo public readonly SeriesType Type; + /// <summary> + /// The date of the earliest imported file, or when the series was created + /// in shoko if no files are imported yet. + /// </summary> + public readonly DateTime? EarliestImportedAt; + + /// <summary> + /// The date of the last imported file, or when the series was created + /// in shoko if no files are imported yet. + /// </summary> + public readonly DateTime? LastImportedAt; + public readonly IReadOnlyList<string> Tags; public readonly IReadOnlyList<string> Genres; @@ -78,7 +91,7 @@ public class SeasonInfo /// </summary> public readonly IReadOnlyDictionary<string, RelationType> RelationMap; - public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, List<Relation> relations, string[] genres, string[] tags) + public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImportedAt, List<EpisodeInfo> episodes, List<Role> cast, List<Relation> relations, string[] genres, string[] tags) { var seriesId = series.IDs.Shoko.ToString(); var studios = cast @@ -156,6 +169,8 @@ public SeasonInfo(Series series, List<EpisodeInfo> episodes, List<Role> cast, Li AniDB = series.AniDBEntity; TvDB = series.TvDBEntityList.FirstOrDefault(); Type = type; + EarliestImportedAt = earliestImportedAt; + LastImportedAt = lastImportedAt; Tags = tags; Genres = genres; Studios = studios; diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index eced74a5..b0fc71d8 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -79,6 +79,18 @@ public class ShowInfo public float CommunityRating => (float)(SeasonList.Aggregate(0f, (total, seasonInfo) => total + seasonInfo.AniDB.Rating.ToFloat(10)) / SeasonList.Count); + /// <summary> + /// The date of the earliest imported file, or when the series was created + /// in shoko if no files are imported yet. + /// </summary> + public readonly DateTime? EarliestImportedAt; + + /// <summary> + /// The date of the last imported file, or when the series was created + /// in shoko if no files are imported yet. + /// </summary> + public readonly DateTime? LastImportedAt; + /// <summary> /// All tags from across all seasons. /// </summary> @@ -151,6 +163,8 @@ public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) GroupId = seasonInfo.Shoko.IDs.ParentGroup.ToString(); CollectionId = collectionId ?? seasonInfo.Shoko.IDs.ParentGroup.ToString(); Name = seasonInfo.Shoko.Name; + EarliestImportedAt = seasonInfo.EarliestImportedAt; + LastImportedAt = seasonInfo.LastImportedAt; Tags = seasonInfo.Tags; Genres = seasonInfo.Genres; Studios = seasonInfo.Studios; @@ -221,6 +235,8 @@ public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool u Name = group.Name; Shoko = group; CollectionId = useGroupIdForCollection ? groupId : group.IDs.ParentGroup?.ToString(); + EarliestImportedAt = seasonList.Select(seasonInfo => seasonInfo.EarliestImportedAt).Min(); + LastImportedAt = seasonList.Select(seasonInfo => seasonInfo.LastImportedAt).Max(); Tags = seasonList.SelectMany(s => s.Tags).Distinct().ToArray(); Genres = seasonList.SelectMany(s => s.Genres).Distinct().ToArray(); Studios = seasonList.SelectMany(s => s.Studios).Distinct().ToArray(); diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 5d1f7b19..10d9264b 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -626,6 +626,7 @@ private Task<SeasonInfo> CreateSeasonInfo(Series series, string seriesId) async (cachedEntry) => { Logger.LogTrace("Creating info object for season {SeriesName}. (Series={SeriesId})", series.Name, seriesId); + var (earliestImportedAt, lastImportedAt)= await GetEarliestImportedAtForSeries(seriesId).ConfigureAwait(false); var episodes = (await APIClient.GetEpisodesFromSeries(seriesId).ConfigureAwait(false) ?? new()).List .Select(e => CreateEpisodeInfo(e, e.IDs.Shoko.ToString())) .Where(e => !e.Shoko.IsHidden) @@ -635,8 +636,7 @@ private Task<SeasonInfo> CreateSeasonInfo(Series series, string seriesId) var relations = await APIClient.GetSeriesRelations(seriesId).ConfigureAwait(false); var genres = await GetGenresForSeries(seriesId).ConfigureAwait(false); var tags = await GetTagsForSeries(seriesId).ConfigureAwait(false); - - var seasonInfo = new SeasonInfo(series, episodes, cast, relations, genres, tags); + var seasonInfo = new SeasonInfo(series, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags); foreach (var episode in episodes) EpisodeIdToSeriesIdDictionary.TryAdd(episode.Id, seriesId); @@ -647,6 +647,24 @@ private Task<SeasonInfo> CreateSeasonInfo(Series series, string seriesId) } ); + private Task<(DateTime?, DateTime?)> GetEarliestImportedAtForSeries(string seriesId) + => DataCache.GetOrCreateAsync<(DateTime?, DateTime?)>( + $"series-earliest-imported-at:${seriesId}", + async (_) => { + var files = await APIClient.GetFilesForSeries(seriesId).ConfigureAwait(false); + if (!files.Any(f => f.ImportedAt.HasValue)) + return (null, null); + return ( + files.Any(f => f.ImportedAt.HasValue) + ? files.Where(f => f.ImportedAt.HasValue).Select(f => f.ImportedAt!.Value).Min() + : files.Select(f => f.CreatedAt).Min(), + files.Any(f => f.ImportedAt.HasValue) + ? files.Where(f => f.ImportedAt.HasValue).Select(f => f.ImportedAt!.Value).Max() + : files.Select(f => f.CreatedAt).Max() + ); + } + ); + #endregion #region Series Helpers diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 6dc246c8..d46966f0 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -220,13 +220,10 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie }; } - if (file != null) { - result.DateCreated = file.Shoko.ImportedAt ?? file.Shoko.CreatedAt; - if (file.EpisodeList.Count > 1) { - var episodeNumberEnd = episodeNumber + file.EpisodeList.Count - 1; - if (episodeNumberEnd != episodeNumber && episode.AniDB.EpisodeNumber != episodeNumberEnd) - result.IndexNumberEnd = episodeNumberEnd; - } + if (file != null && file.EpisodeList.Count > 1) { + var episodeNumberEnd = episodeNumber + file.EpisodeList.Count - 1; + if (episodeNumberEnd != episodeNumber && episode.AniDB.EpisodeNumber != episodeNumberEnd) + result.IndexNumberEnd = episodeNumberEnd; } AddProviderIds(result, episodeId: episode.Id, fileId: file?.Id, seriesId: file?.SeriesId, anidbId: episode.AniDB.Id.ToString()); diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 175e4b11..efa84d69 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -24,6 +24,7 @@ using File = System.IO.File; using TvSeries = MediaBrowser.Controller.Entities.TV.Series; +using TvSeason = MediaBrowser.Controller.Entities.TV.Season; namespace Shokofin.Resolvers; @@ -72,11 +73,15 @@ NamingOptions namingOptions Logger = logger; _namingOptions = namingOptions; ExternalPathParser = new ExternalPathParser(namingOptions, localizationManager, MediaBrowser.Model.Dlna.DlnaProfileType.Subtitle); + LibraryManager.ItemAdded += OnLibraryManagerItemAddedOrUpdated; + LibraryManager.ItemUpdated -= OnLibraryManagerItemAddedOrUpdated; LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; } ~ShokoResolveManager() { + LibraryManager.ItemAdded -= OnLibraryManagerItemAddedOrUpdated; + LibraryManager.ItemUpdated -= OnLibraryManagerItemAddedOrUpdated; LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; Clear(false); } @@ -95,6 +100,94 @@ public void Clear(bool restore = true) #region Changes Tracking + /// <summary> + /// Responsible for fixing up the date created at timestamps. + /// </summary> + /// <param name="sender"></param> + /// <param name="eventArgs"></param> + private void OnLibraryManagerItemAddedOrUpdated(object? sender, ItemChangeEventArgs eventArgs) + { + if (eventArgs.Item == null || eventArgs.Item.IsVirtualItem || string.IsNullOrEmpty(eventArgs.Item.Path) || !eventArgs.Item.Path.StartsWith(Plugin.Instance.VirtualRoot)) + return; + + switch (eventArgs.Item) { + case TvSeries series: { + var seriesName = Path.GetFileName(series.Path); + if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; + + var showInfo = ApiManager.GetShowInfoForSeries(seriesId) + .ConfigureAwait(false).GetAwaiter().GetResult(); + if (showInfo == null || !showInfo.EarliestImportedAt.HasValue || series.DateCreated == showInfo.EarliestImportedAt.Value) + break; + var updated = false; + if (showInfo.EarliestImportedAt.HasValue && series.DateCreated != showInfo.EarliestImportedAt.Value) { + series.DateCreated = showInfo.EarliestImportedAt.Value; + updated = true; + } + if (showInfo.EarliestImportedAt.HasValue && series.DateLastMediaAdded != showInfo.EarliestImportedAt.Value) { + series.DateLastMediaAdded = showInfo.EarliestImportedAt.Value; + updated = true; + } + + if (updated) + LibraryManager.UpdateItemAsync(series, eventArgs.Parent, ItemUpdateType.None, CancellationToken.None) + .ConfigureAwait(false).GetAwaiter().GetResult(); + break; + } + + // TVSeason / Season + case TvSeason season: { + if (!season.IndexNumber.HasValue) + break; + + var series = season.Series; + if (series == null) + break; + + var seriesName = Path.GetFileName(series.Path); + if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; + + var showInfo = ApiManager.GetShowInfoForSeries(seriesId) + .ConfigureAwait(false).GetAwaiter().GetResult(); + if (showInfo == null) + break; + + var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(season.IndexNumber.Value); + if (seasonInfo == null || !seasonInfo.EarliestImportedAt.HasValue || season.DateCreated == seasonInfo.EarliestImportedAt.Value) + break; + + season.DateCreated = seasonInfo.EarliestImportedAt.Value; + LibraryManager.UpdateItemAsync(season, eventArgs.Parent, ItemUpdateType.None, CancellationToken.None) + .ConfigureAwait(false).GetAwaiter().GetResult(); + break; + } + + // TvEpisode / Episode, Movie, and all other Video types. + case Video video: { + var videoName = Path.GetFileName(video.Path); + if (!videoName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; + + if (!videoName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) + break; + + var file = ApiClient.GetFile(fileId) + .ConfigureAwait(false).GetAwaiter().GetResult(); + var dateCreated = file.ImportedAt ?? file.CreatedAt; + + if (video.DateCreated == dateCreated) + break; + + video.DateCreated = dateCreated; + LibraryManager.UpdateItemAsync(video, eventArgs.Parent, ItemUpdateType.None, CancellationToken.None) + .ConfigureAwait(false).GetAwaiter().GetResult(); + break; + } + } + } + private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) { // Remove the VFS directory for any media library folders when they're removed. From 98cda16d8c3b9a13a072136f52847b578baf8093 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 19 Apr 2024 10:56:47 +0000 Subject: [PATCH 0869/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index eafdac1c..2b0be455 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.132", + "changelog": "fix: fix import order for items in the VFS\n\n- Fix the \"recently added\" section of the dashboard by monkey-patching\n the creation date for each item type.\n\nfix: fix adding missing metadata\n\n- Fixed up the code for adding/removing \"missing\" metadata as needed. It\n will now work even better than it ever did, since we're now handling\n more edge cases. \ud83d\udc4d\n\nrefactor: add fast paths for link generation\n\n- Added back the fast path to skip link generation of all shows/movies\n if we've already created all possible links for the entire VFS for a\n media folder, until the cache expires or is cleared.\n\n- Added a new fast path to skip link generation for all children of\n already generated paths, until the cache expires or is cleared.\n\n- Removed the per-file-slash-series-slash-path caching, since we now\n cache per folder _and_ file location instead.\n\n- Fixed up link generation result logging so it will be consistent\n again.\n\n- Some misc. internal restructor of the resolver manager.\n\nmisc: smarter path set and episode ids generation\n\nMerge pull request #53 from fearnlj01/webhook-back-to-embed\n\nMisc: Change update webhook [skip ci]\nMisc: Change update webhook [skip ci]", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.132/shoko_3.0.1.132.zip", + "checksum": "d1762380a90fb7e54218f3e3f8ecf647", + "timestamp": "2024-04-19T10:56:45Z" + }, { "version": "3.0.1.131", "changelog": "fix: override date created on episodes/movies\n\nfix: only iterate files in media folder once", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.128/shoko_3.0.1.128.zip", "checksum": "a3f564d513b105784c27c0e90f6e73d7", "timestamp": "2024-04-16T03:06:13Z" - }, - { - "version": "3.0.1.127", - "changelog": "fix: fix signalr models (again, but for real this time)", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.127/shoko_3.0.1.127.zip", - "checksum": "3de9d69ad6f5a0843d5108dfe7e09094", - "timestamp": "2024-04-16T02:20:26Z" } ] } From 6445e7a80125b75106e0411e4964fbcab5a1a588 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Fri, 19 Apr 2024 16:31:41 +0530 Subject: [PATCH 0870/1103] Fix discord webhook url [no ci] --- .github/workflows/release-daily.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 3c25de6e..55dca01d 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -139,7 +139,7 @@ jobs: - name: Notify Discord Users uses: tsickert/discord-webhook@v6.0.0 with: - webhook-url: ${{ secrets.WEBHOOK_URL }} + webhook-url: ${{ secrets.DISCORD_WEBHOOK }} embed-color: 9985983 embed-timestamp: ${{ needs.current_info.outputs.date }} embed-author-name: Shokofin | New Unstable Build @@ -151,4 +151,4 @@ jobs: Update your plugin using the [unstable manifest](https://raw.githubusercontent.com/${{ github.repository }}/master/manifest-unstable.json) or by downloading the release from [GitHub Releases](https://github.com/${{ github.repository }}/releases/tag/${{ needs.current_info.outputs.version }}) and installing it manually! **Changes since last build**: - ${{ needs.current_info.outputs.changelog }} \ No newline at end of file + ${{ needs.current_info.outputs.changelog }} From 964773954f4bc32b132aa5127eb72364603aebd2 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 19 Apr 2024 13:22:38 +0200 Subject: [PATCH 0871/1103] misc: remove unused method & model [skip ci] --- Shokofin/API/Models/Series.cs | 36 ---------------------------------- Shokofin/API/ShokoAPIClient.cs | 10 ---------- 2 files changed, 46 deletions(-) diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index 1a838e57..c8e3da23 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -274,42 +274,6 @@ public class FileSourceCounts } } -/// <summary> -/// An Extended Series Model with Values for Search Results -/// </summary> -public class SeriesSearchResult : Series -{ - /// <summary> - /// Indicates whether the search result is an exact match to the query. - /// </summary> - public bool ExactMatch { get; set; } - - /// <summary> - /// Represents the position of the match within the sanitized string. - /// This property is only applicable when ExactMatch is set to true. - /// A lower value indicates a match that occurs earlier in the string. - /// </summary> - public int Index { get; set; } - - /// <summary> - /// Represents the similarity measure between the sanitized query and the sanitized matched result. - /// This may be the sorensen-dice distance or the tag weight when comparing tags for a series. - /// A lower value indicates a more similar match. - /// </summary> - public double Distance { get; set; } - - /// <summary> - /// Represents the absolute difference in length between the sanitized query and the sanitized matched result. - /// A lower value indicates a match with a more similar length to the query. - /// </summary> - public int LengthDifference { get; set; } - - /// <summary> - /// Contains the original matched substring from the original string. - /// </summary> - public string Match { get; set; } = string.Empty; -} - [JsonConverter(typeof(JsonStringEnumConverter))] public enum SeriesType { diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 762e1c30..82b56d69 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -379,16 +379,6 @@ public Task<List<Series>> GetSeriesPathEndsWith(string dirname) return Get<List<Series>>($"/api/v3/Series/PathEndsWith/{Uri.EscapeDataString(dirname)}"); } - public async Task<Series?> GetSeriesByName(string name) - { - if (string.IsNullOrEmpty(name)) - return null; - - // Return the first (and hopefully only) exact match on the full title. - var results = await Get<List<SeriesSearchResult>>($"/api/v3/Series/Search?query={Uri.EscapeDataString(name)}&limit=10&fuzzy=false").ConfigureAwait(false); - return results?.FirstOrDefault(series => series.ExactMatch && series.Index == 0 && series.LengthDifference == 0 && string.Equals(name, series.Match, StringComparison.Ordinal)); - } - public Task<List<Tag>> GetSeriesTags(string id, ulong filter = 0) { return Get<List<Tag>>($"/api/v3/Series/{id}/Tags?filter={filter}&excludeDescriptions=true"); From b051411ae239525cfa0b034ab98e58fb88fced93 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 20 Apr 2024 13:32:02 +0200 Subject: [PATCH 0872/1103] fix: make scrobbling great again! - Add the missing initial "play" event late if we've enabled playback events _and_ lazy sync. Previously it was skipping the initial "play" event in this config, causing trakt syncing to fail from shoko to trakt. - Fixed event scrobbling when live sync is not enabled. - Added more documentation about the session fields. --- Shokofin/Sync/UserDataSyncManager.cs | 129 ++++++++++++++++++--------- 1 file changed, 89 insertions(+), 40 deletions(-) diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index a134ed2a..f60daf26 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -74,13 +74,53 @@ private static bool TryGetUserConfiguration(Guid userId, out UserConfiguration? internal class SessionMetadata { private readonly ILogger Logger; + /// <summary> + /// The video Id. + /// </summary> public Guid ItemId; + + /// <summary> + /// The shoko file id for the current item, if any. + /// </summary> public string? FileId; + + /// <summary> + /// The jellyfin native watch session. + /// </summary> public SessionInfo Session; - public long Ticks; + + /// <summary> + /// Current playback ticks. + /// </summary> + public long PlaybackTicks; + + /// <summary> + /// Playback ticks at the start of playback. Needed for the "start" event. + /// </summary> + public long InitialPlaybackTicks; + + /// <summary> + /// How many scrobble events we have done. Used to track when to sync + /// live progress back to shoko. + /// </summary> public byte ScrobbleTicks; - public bool SentPaused; - public int SkipCount; + + /// <summary> + /// Indicates that we've reacted to the pause event of the video + /// already. This is to track when to send pause/resume events. + /// </summary> + public bool IsPaused; + + /// <summary> + /// Indicates we've alredy sent the start event. + /// </summary> + public bool SentStartEvent; + + /// <summary> + /// The amount of events we have to skip before before we start sending + /// the events. + /// </summary> + public int SkipEventCount; public SessionMetadata(ILogger logger, SessionInfo sessionInfo) { @@ -88,22 +128,23 @@ public SessionMetadata(ILogger logger, SessionInfo sessionInfo) ItemId = Guid.Empty; FileId = null; Session = sessionInfo; - Ticks = 0; + PlaybackTicks = 0; + InitialPlaybackTicks = 0; ScrobbleTicks = 0; - SentPaused = false; - SkipCount = 0; + IsPaused = false; + SkipEventCount = 0; } public bool ShouldSendEvent(bool isPauseOrResumeEvent = false) { - if (SkipCount == 0) + if (SkipEventCount == 0) return true; - if (!isPauseOrResumeEvent && SkipCount > 0) - SkipCount--; + if (!isPauseOrResumeEvent && SkipEventCount > 0) + SkipEventCount--; Logger.LogDebug("Scrobble event was skipped. (File={FileId})", FileId); - return SkipCount == 0; + return SkipEventCount == 0; } } @@ -157,7 +198,7 @@ public async void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) var config = Plugin.Instance.Configuration; bool? success = null; switch (e.SaveReason) { - // case UserDataSaveReason.PlaybackStart: // The progress event is sent at the same time, so this event is not needed. + case UserDataSaveReason.PlaybackStart: case UserDataSaveReason.PlaybackProgress: { // If a session can't be found or created then throw an error. if (!ActiveSessions.TryGetValue(e.UserId, out var sessionMetadata)) @@ -167,55 +208,61 @@ public async void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) if (sessionMetadata.ItemId != itemId) { sessionMetadata.ItemId = e.Item.Id; sessionMetadata.FileId = fileId; - sessionMetadata.Ticks = userData.PlaybackPositionTicks; + sessionMetadata.PlaybackTicks = userData.PlaybackPositionTicks; + sessionMetadata.InitialPlaybackTicks = userData.PlaybackPositionTicks; sessionMetadata.ScrobbleTicks = 0; - sessionMetadata.SentPaused = false; - sessionMetadata.SkipCount = userConfig.SyncUserDataInitialSkipEventCount; + sessionMetadata.IsPaused = false; + sessionMetadata.SentStartEvent = false; + sessionMetadata.SkipEventCount = userConfig.SyncUserDataInitialSkipEventCount; Logger.LogInformation("Playback has started. (File={FileId})", fileId); - if (sessionMetadata.ShouldSendEvent() && userConfig.SyncUserDataUnderPlayback) - success = await APIClient.ScrobbleFile(fileId, episodeId, "play", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); + if (sessionMetadata.ShouldSendEvent() && userConfig.SyncUserDataUnderPlayback) { + sessionMetadata.SentStartEvent = true; + success = await APIClient.ScrobbleFile(fileId, episodeId, "play", sessionMetadata.InitialPlaybackTicks, userConfig.Token).ConfigureAwait(false); + } } else { - long ticks = sessionMetadata.Session.PlayState.PositionTicks ?? userData.PlaybackPositionTicks; + var isPaused = sessionMetadata.Session.PlayState?.IsPaused ?? false; + var ticks = sessionMetadata.Session.PlayState?.PositionTicks ?? userData.PlaybackPositionTicks; // We received an event, but the position didn't change, so the playback is most likely paused. - if (sessionMetadata.Session.PlayState?.IsPaused ?? false) { - if (sessionMetadata.SentPaused) + if (isPaused) { + if (sessionMetadata.IsPaused) return; - sessionMetadata.SentPaused = true; + sessionMetadata.IsPaused = true; Logger.LogInformation("Playback was paused. (File={FileId})", fileId); - if (sessionMetadata.ShouldSendEvent(true) && userConfig.SyncUserDataUnderPlayback ) - success = await APIClient.ScrobbleFile(fileId, episodeId, "pause", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); + if (sessionMetadata.ShouldSendEvent(true) && userConfig.SyncUserDataUnderPlayback) + success = await APIClient.ScrobbleFile(fileId, episodeId, "pause", sessionMetadata.PlaybackTicks, userConfig.Token).ConfigureAwait(false); } // The playback was resumed. - else if (sessionMetadata.SentPaused) { - sessionMetadata.Ticks = ticks; + else if (sessionMetadata.IsPaused) { + sessionMetadata.PlaybackTicks = ticks; sessionMetadata.ScrobbleTicks = 0; - sessionMetadata.SentPaused = false; + sessionMetadata.IsPaused = false; Logger.LogInformation("Playback was resumed. (File={FileId})", fileId); - if (sessionMetadata.ShouldSendEvent(true) && userConfig.SyncUserDataUnderPlayback ) - success = await APIClient.ScrobbleFile(fileId, episodeId, "resume", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); - } - // Return early if we're not scrobbling. - else if (!userConfig.SyncUserDataUnderPlaybackLive) { - sessionMetadata.Ticks = ticks; - return; + if (sessionMetadata.ShouldSendEvent(true) && userConfig.SyncUserDataUnderPlayback) + success = await APIClient.ScrobbleFile(fileId, episodeId, "resume", sessionMetadata.PlaybackTicks, userConfig.Token).ConfigureAwait(false); } // Live scrobbling. else { - var deltaTicks = Math.Abs(ticks - sessionMetadata.Ticks); - sessionMetadata.Ticks = ticks; + var deltaTicks = Math.Abs(ticks - sessionMetadata.PlaybackTicks); + sessionMetadata.PlaybackTicks = ticks; if (deltaTicks == 0 || deltaTicks < userConfig.SyncUserDataUnderPlaybackLiveThreshold && ++sessionMetadata.ScrobbleTicks < userConfig.SyncUserDataUnderPlaybackAtEveryXTicks) return; - Logger.LogInformation("Playback is running. (File={FileId})", fileId); + var logLevel = userConfig.SyncUserDataUnderPlaybackLive ? LogLevel.Information : LogLevel.Debug; + Logger.Log(logLevel, "Playback is running. (File={FileId})", fileId); sessionMetadata.ScrobbleTicks = 0; - if (sessionMetadata.ShouldSendEvent() && userConfig.SyncUserDataUnderPlayback ) { - success = await APIClient.ScrobbleFile(fileId, episodeId, "scrobble", sessionMetadata.Ticks, userConfig.Token).ConfigureAwait(false); + if (sessionMetadata.ShouldSendEvent() && userConfig.SyncUserDataUnderPlayback) { + if (!sessionMetadata.SentStartEvent) { + sessionMetadata.SentStartEvent = true; + success = await APIClient.ScrobbleFile(fileId, episodeId, "play", sessionMetadata.InitialPlaybackTicks, userConfig.Token).ConfigureAwait(false); + } + if (userConfig.SyncUserDataUnderPlaybackLive) + success = await APIClient.ScrobbleFile(fileId, episodeId, "scrobble", sessionMetadata.PlaybackTicks, userConfig.Token).ConfigureAwait(false); } } } @@ -231,10 +278,12 @@ public async void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) sessionMetadata.ItemId = Guid.Empty; sessionMetadata.FileId = null; - sessionMetadata.Ticks = 0; + sessionMetadata.PlaybackTicks = 0; + sessionMetadata.InitialPlaybackTicks = 0; sessionMetadata.ScrobbleTicks = 0; - sessionMetadata.SentPaused = false; - sessionMetadata.SkipCount = -1; + sessionMetadata.IsPaused = false; + sessionMetadata.SentStartEvent = false; + sessionMetadata.SkipEventCount = -1; } Logger.LogInformation("Playback has ended. (File={FileId})", fileId); From f2657bd7c68019e2c4fced7041d74c133686f0f7 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 20 Apr 2024 13:43:19 +0200 Subject: [PATCH 0873/1103] misc: add more traces and comments --- Shokofin/Resolvers/ShokoResolveManager.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index efa84d69..d6fbbb0d 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -455,12 +455,12 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { await semaphore.WaitAsync().ConfigureAwait(false); try { - // Skip any source files we weren't meant to have in the library. + Logger.LogTrace("Generating links for {Path} (File={FileId},Series={SeriesId})", tuple.sourceLocation, tuple.fileId, tuple.seriesId); + var (sourceLocation, symbolicLinks, nfoFiles) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); var subResult = GenerateSymbolicLinks(sourceLocation, symbolicLinks, nfoFiles, result.Paths); - // Combine the current results with the overall results and mark the entitis as skipped - // for the next iterations. + // Combine the current results with the overall results. lock (semaphore) { result += subResult; } @@ -890,6 +890,7 @@ public LinkGenerationResult GenerateSymbolicLinks(string? sourceLocation, string private LinkGenerationResult GenerateSymbolicLinks(string? sourceLocation, string[] symbolicLinks, string[] nfoFiles, ConcurrentBag<string> allPathsForVFS) { + // Skip any source files we weren't meant to have in the library. var result = new LinkGenerationResult(); if (string.IsNullOrEmpty(sourceLocation)) return result; @@ -1013,6 +1014,9 @@ private LinkGenerationResult CleanupStructure(string directoryToClean, Concurren .Where(tuple => searchFiles.Contains(tuple.extName)) .ExceptBy(allKnownPaths.ToHashSet(), tuple => tuple.path) .ToList(); + + Logger.LogTrace("To remove {FileCount} files in {DirectoryToClean}.", toBeRemoved.Count, directoryToClean); + foreach (var (location, extName) in toBeRemoved) { // Continue in case we already removed the (subtitle) file. if (!File.Exists(location)) @@ -1039,6 +1043,7 @@ private LinkGenerationResult CleanupStructure(string directoryToClean, Concurren CleanupDirectoryStructure(location); } + return result; } @@ -1059,12 +1064,8 @@ private IReadOnlyList<string> FindSubtitlesForPath(string sourcePath) return externalPaths; var files = FileSystem.GetFilePaths(folderPath) + .Except(new[] { sourcePath }) .ToList(); - files.Remove(sourcePath); - - if (files.Count == 0) - return externalPaths; - var sourcePrefix = Path.GetFileNameWithoutExtension(sourcePath); foreach (var file in files) { var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file); From 41a5901db587c51e7f9f935b06f654aae46d65d6 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 20 Apr 2024 11:44:01 +0000 Subject: [PATCH 0874/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 2b0be455..b54b4a24 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.133", + "changelog": "misc: add more traces and comments\n\nfix: make scrobbling great again!\n\n- Add the missing initial \"play\" event late if we've enabled playback\n events _and_ lazy sync. Previously it was skipping the initial \"play\"\n event in this config, causing trakt syncing to fail from shoko to\n trakt.\n\n- Fixed event scrobbling when live sync is not enabled.\n\n- Added more documentation about the session fields.\n\nmisc: remove unused method & model [skip ci]\n\nFix discord webhook url [no ci", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.133/shoko_3.0.1.133.zip", + "checksum": "07b83cdf08b706f30f488424062892d7", + "timestamp": "2024-04-20T11:43:59Z" + }, { "version": "3.0.1.132", "changelog": "fix: fix import order for items in the VFS\n\n- Fix the \"recently added\" section of the dashboard by monkey-patching\n the creation date for each item type.\n\nfix: fix adding missing metadata\n\n- Fixed up the code for adding/removing \"missing\" metadata as needed. It\n will now work even better than it ever did, since we're now handling\n more edge cases. \ud83d\udc4d\n\nrefactor: add fast paths for link generation\n\n- Added back the fast path to skip link generation of all shows/movies\n if we've already created all possible links for the entire VFS for a\n media folder, until the cache expires or is cleared.\n\n- Added a new fast path to skip link generation for all children of\n already generated paths, until the cache expires or is cleared.\n\n- Removed the per-file-slash-series-slash-path caching, since we now\n cache per folder _and_ file location instead.\n\n- Fixed up link generation result logging so it will be consistent\n again.\n\n- Some misc. internal restructor of the resolver manager.\n\nmisc: smarter path set and episode ids generation\n\nMerge pull request #53 from fearnlj01/webhook-back-to-embed\n\nMisc: Change update webhook [skip ci]\nMisc: Change update webhook [skip ci]", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.129/shoko_3.0.1.129.zip", "checksum": "9a20bf38f55b6ad7908c13b9761c87f1", "timestamp": "2024-04-16T03:23:12Z" - }, - { - "version": "3.0.1.128", - "changelog": "misc: add missing props. to v0 models\n\nmisc: update file event args\n\n- Update file event args to expose the file location id, if available,\n and to have an indicator as to if we got any cross-references\n from the server (this is needed because we're supporting _both_\n the stable server _and_ the daily server).\n\n- Update the debug log statments to include the new properties.\n\nmisc: fix typo [skip ci]", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.128/shoko_3.0.1.128.zip", - "checksum": "a3f564d513b105784c27c0e90f6e73d7", - "timestamp": "2024-04-16T03:06:13Z" } ] } From b7c9cb0a82a98b0aed2519d96e375bbfcd481d69 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 20 Apr 2024 19:04:36 +0200 Subject: [PATCH 0875/1103] refactor: easier import order in VFS - Set the link creation date instead of monkey-patching the items afterwards. --- Shokofin/Resolvers/ShokoResolveManager.cs | 179 ++++++---------------- 1 file changed, 48 insertions(+), 131 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index d6fbbb0d..e5b51da7 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -73,15 +73,11 @@ NamingOptions namingOptions Logger = logger; _namingOptions = namingOptions; ExternalPathParser = new ExternalPathParser(namingOptions, localizationManager, MediaBrowser.Model.Dlna.DlnaProfileType.Subtitle); - LibraryManager.ItemAdded += OnLibraryManagerItemAddedOrUpdated; - LibraryManager.ItemUpdated -= OnLibraryManagerItemAddedOrUpdated; LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; } ~ShokoResolveManager() { - LibraryManager.ItemAdded -= OnLibraryManagerItemAddedOrUpdated; - LibraryManager.ItemUpdated -= OnLibraryManagerItemAddedOrUpdated; LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; Clear(false); } @@ -100,94 +96,6 @@ public void Clear(bool restore = true) #region Changes Tracking - /// <summary> - /// Responsible for fixing up the date created at timestamps. - /// </summary> - /// <param name="sender"></param> - /// <param name="eventArgs"></param> - private void OnLibraryManagerItemAddedOrUpdated(object? sender, ItemChangeEventArgs eventArgs) - { - if (eventArgs.Item == null || eventArgs.Item.IsVirtualItem || string.IsNullOrEmpty(eventArgs.Item.Path) || !eventArgs.Item.Path.StartsWith(Plugin.Instance.VirtualRoot)) - return; - - switch (eventArgs.Item) { - case TvSeries series: { - var seriesName = Path.GetFileName(series.Path); - if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) - break; - - var showInfo = ApiManager.GetShowInfoForSeries(seriesId) - .ConfigureAwait(false).GetAwaiter().GetResult(); - if (showInfo == null || !showInfo.EarliestImportedAt.HasValue || series.DateCreated == showInfo.EarliestImportedAt.Value) - break; - var updated = false; - if (showInfo.EarliestImportedAt.HasValue && series.DateCreated != showInfo.EarliestImportedAt.Value) { - series.DateCreated = showInfo.EarliestImportedAt.Value; - updated = true; - } - if (showInfo.EarliestImportedAt.HasValue && series.DateLastMediaAdded != showInfo.EarliestImportedAt.Value) { - series.DateLastMediaAdded = showInfo.EarliestImportedAt.Value; - updated = true; - } - - if (updated) - LibraryManager.UpdateItemAsync(series, eventArgs.Parent, ItemUpdateType.None, CancellationToken.None) - .ConfigureAwait(false).GetAwaiter().GetResult(); - break; - } - - // TVSeason / Season - case TvSeason season: { - if (!season.IndexNumber.HasValue) - break; - - var series = season.Series; - if (series == null) - break; - - var seriesName = Path.GetFileName(series.Path); - if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) - break; - - var showInfo = ApiManager.GetShowInfoForSeries(seriesId) - .ConfigureAwait(false).GetAwaiter().GetResult(); - if (showInfo == null) - break; - - var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(season.IndexNumber.Value); - if (seasonInfo == null || !seasonInfo.EarliestImportedAt.HasValue || season.DateCreated == seasonInfo.EarliestImportedAt.Value) - break; - - season.DateCreated = seasonInfo.EarliestImportedAt.Value; - LibraryManager.UpdateItemAsync(season, eventArgs.Parent, ItemUpdateType.None, CancellationToken.None) - .ConfigureAwait(false).GetAwaiter().GetResult(); - break; - } - - // TvEpisode / Episode, Movie, and all other Video types. - case Video video: { - var videoName = Path.GetFileName(video.Path); - if (!videoName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) - break; - - if (!videoName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) - break; - - var file = ApiClient.GetFile(fileId) - .ConfigureAwait(false).GetAwaiter().GetResult(); - var dateCreated = file.ImportedAt ?? file.CreatedAt; - - if (video.DateCreated == dateCreated) - break; - - video.DateCreated = dateCreated; - LibraryManager.UpdateItemAsync(video, eventArgs.Parent, ItemUpdateType.None, CancellationToken.None) - .ConfigureAwait(false).GetAwaiter().GetResult(); - break; - } - } - } - private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) { // Remove the VFS directory for any media library folders when they're removed. @@ -448,30 +356,8 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold if (allFiles == null) return null; - var result = new LinkGenerationResult(); - var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); - var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); - await Task.WhenAll(allFiles.Select(async (tuple) => { - await semaphore.WaitAsync().ConfigureAwait(false); - - try { - Logger.LogTrace("Generating links for {Path} (File={FileId},Series={SeriesId})", tuple.sourceLocation, tuple.fileId, tuple.seriesId); - - var (sourceLocation, symbolicLinks, nfoFiles) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); - var subResult = GenerateSymbolicLinks(sourceLocation, symbolicLinks, nfoFiles, result.Paths); - - // Combine the current results with the overall results. - lock (semaphore) { - result += subResult; - } - } - finally { - semaphore.Release(); - } - })) - .ConfigureAwait(false); - - // Cleanup the structure in the VFS. + // Generate and cleanup the structure in the VFS. + var result = await GenerateStructure(mediaFolder, vfsPath, allFiles); if (!string.IsNullOrEmpty(pathToClean)) result += CleanupStructure(pathToClean, result.Paths); @@ -773,21 +659,54 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { return await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath, page).ConfigureAwait(false); } + private async Task<LinkGenerationResult> GenerateStructure(Folder mediaFolder, string vfsPath, IEnumerable<(string sourceLocation, string fileId, string seriesId)> allFiles) + { + var result = new LinkGenerationResult(); + var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); + var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); + await Task.WhenAll(allFiles.Select(async (tuple) => { + await semaphore.WaitAsync().ConfigureAwait(false); + + try { + Logger.LogTrace("Generating links for {Path} (File={FileId},Series={SeriesId})", tuple.sourceLocation, tuple.fileId, tuple.seriesId); + + var (sourceLocation, symbolicLinks, nfoFiles, importedAt) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); + + // Skip any source files we weren't meant to have in the library. + if (string.IsNullOrEmpty(sourceLocation) || !importedAt.HasValue) + return; + + var subResult = GenerateSymbolicLinks(sourceLocation, symbolicLinks, nfoFiles, importedAt.Value, result.Paths); + + // Combine the current results with the overall results. + lock (semaphore) { + result += subResult; + } + } + finally { + semaphore.Release(); + } + })) + .ConfigureAwait(false); + + return result; + } + // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 chacters. private const int NameCutOff = 64; - public async Task<(string sourceLocation, string[] symbolicLinks, string[] nfoFiles)> GenerateLocationsForFile(Folder mediaFolder, string sourceLocation, string fileId, string seriesId) + public async Task<(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime? importedAt)> GenerateLocationsForFile(Folder mediaFolder, string sourceLocation, string fileId, string seriesId) { var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); return await GenerateLocationsForFile(vfsPath, collectionType, sourceLocation, fileId, seriesId); } - private async Task<(string sourceLocation, string[] symbolicLinks, string[] nfoFiles)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId) + private async Task<(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime? importedAt)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId) { var season = await ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); if (season == null) - return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>()); + return (string.Empty, Array.Empty<string>(), Array.Empty<string>(), null); var isMovieSeason = season.Type == SeriesType.Movie; var shouldAbort = collectionType switch { @@ -796,19 +715,19 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { _ => false, }; if (shouldAbort) - return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>()); + return (string.Empty, Array.Empty<string>(), Array.Empty<string>(), null); var show = await ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false); if (show == null) - return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>()); + return (string.Empty, Array.Empty<string>(), Array.Empty<string>(), null); var file = await ApiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false); var episode = file?.EpisodeList.FirstOrDefault(); if (file == null || episode == null) - return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>()); + return (string.Empty, Array.Empty<string>(), Array.Empty<string>(), null); if (season == null || episode == null) - return (sourceLocation: string.Empty, symbolicLinks: Array.Empty<string>(), nfoFiles: Array.Empty<string>()); + return (string.Empty, Array.Empty<string>(), Array.Empty<string>(), null); var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters() ?? $"Shoko Series {show.Id}"; var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); @@ -882,19 +801,15 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { foreach (var symbolicLink in symbolicLinks) ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, file.EpisodeList.Select(episode => episode.Id)); - return (sourceLocation, symbolicLinks, nfoFiles: nfoFiles.ToArray()); + return (sourceLocation, symbolicLinks, nfoFiles: nfoFiles.ToArray(), file.Shoko.ImportedAt); } - public LinkGenerationResult GenerateSymbolicLinks(string? sourceLocation, string[] symbolicLinks, string[] nfoFiles) - => GenerateSymbolicLinks(sourceLocation, symbolicLinks, nfoFiles, new()); + public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime importedAt) + => GenerateSymbolicLinks(sourceLocation, symbolicLinks, nfoFiles, importedAt, new()); - private LinkGenerationResult GenerateSymbolicLinks(string? sourceLocation, string[] symbolicLinks, string[] nfoFiles, ConcurrentBag<string> allPathsForVFS) + private LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime importedAt, ConcurrentBag<string> allPathsForVFS) { - // Skip any source files we weren't meant to have in the library. var result = new LinkGenerationResult(); - if (string.IsNullOrEmpty(sourceLocation)) - return result; - var sourcePrefixLength = sourceLocation.Length - Path.GetExtension(sourceLocation).Length; var subtitleLinks = FindSubtitlesForPath(sourceLocation); foreach (var symbolicLink in symbolicLinks) { @@ -907,6 +822,8 @@ private LinkGenerationResult GenerateSymbolicLinks(string? sourceLocation, strin 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); } else { var shouldFix = false; From 2bb3f341df209a0d64b6c0d3f4c8d79a65679c3f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 20 Apr 2024 19:04:55 +0200 Subject: [PATCH 0876/1103] misc: decrease default VFS link gen. threads --- 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 f0c05343..3f069697 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -301,7 +301,7 @@ public PluginConfiguration() DescriptionSourceList = new[] { TextSourceType.AniDb, TextSourceType.TvDb, TextSourceType.TMDB }; DescriptionSourceOrder = new[] { TextSourceType.AniDb, TextSourceType.TvDb, TextSourceType.TMDB }; VirtualFileSystem = CanCreateSymbolicLinks; - VirtualFileSystemThreads = 10; + VirtualFileSystemThreads = 4; UseGroupsForShows = false; SeparateMovies = false; SeasonOrdering = OrderType.Default; From 0d9e2bfd2001e72ffc09172b916b45fbed6d0df9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 20 Apr 2024 19:07:09 +0200 Subject: [PATCH 0877/1103] refactor: better cleanup logic - Refactor how we do the cleanup, adding more trace/debug logging, only get the file list once, and to only try to clean up a directory once. --- Shokofin/Resolvers/ShokoResolveManager.cs | 83 +++++++++++++++-------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index e5b51da7..fd8edf56 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -131,6 +131,7 @@ private IReadOnlySet<string> GetPathsForMediaFolder(Folder mediaFolder) $"paths-for-media-folder:{mediaFolder.Path}", (paths) => Logger.LogTrace("Reusing {FileCount} files for folder at {Path}", paths.Count, mediaFolder.Path), (_) => { + Logger.LogDebug("Looking for files in folder at {Path}", mediaFolder.Path); var start = DateTime.UtcNow; var paths = FileSystem.GetFilePaths(mediaFolder.Path, true) .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) @@ -924,53 +925,79 @@ private LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string private LinkGenerationResult CleanupStructure(string directoryToClean, ConcurrentBag<string> allKnownPaths) { // Search the selected paths for files to remove. + Logger.LogDebug("Looking for files to remove in folder at {Path}", directoryToClean); + var start = DateTime.Now; + var previousStep = start; var result = new LinkGenerationResult(); var searchFiles = _namingOptions.VideoFileExtensions.Concat(_namingOptions.SubtitleFileExtensions).Append(".nfo").ToHashSet(); var toBeRemoved = FileSystem.GetFilePaths(directoryToClean, true) .Select(path => (path, extName: Path.GetExtension(path))) - .Where(tuple => searchFiles.Contains(tuple.extName)) + .Where(tuple => !string.IsNullOrEmpty(tuple.extName) && searchFiles.Contains(tuple.extName)) .ExceptBy(allKnownPaths.ToHashSet(), tuple => tuple.path) .ToList(); - Logger.LogTrace("To remove {FileCount} files in {DirectoryToClean}.", toBeRemoved.Count, directoryToClean); + var nextStep = DateTime.Now; + Logger.LogDebug("Found {FileCount} files to remove in {DirectoryToClean} in {TimeSpent}", toBeRemoved.Count, directoryToClean, nextStep - previousStep); + previousStep = nextStep; foreach (var (location, extName) in toBeRemoved) { - // Continue in case we already removed the (subtitle) file. - if (!File.Exists(location)) + try { + Logger.LogTrace("Removing file at {Path}", location); + File.Delete(location); + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to remove {FilePath}", location); continue; - - File.Delete(location); + } // Stats tracking. - if (_namingOptions.VideoFileExtensions.Contains(extName)) { + if (_namingOptions.VideoFileExtensions.Contains(extName)) result.RemovedVideos++; - - var subtitleLinks = FindSubtitlesForPath(location); - foreach (var subtitleLink in subtitleLinks) { - result.RemovedSubtitles++; - File.Delete(subtitleLink); - } - } - else if (extName == ".nfo") { + else if (extName == ".nfo") result.RemovedNfos++; - } - else { + else result.RemovedSubtitles++; - } - - CleanupDirectoryStructure(location); } - 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; + + var cleaned = 0; + var directoriesToClean = toBeRemoved + .SelectMany(tuple => { + var path = Path.GetDirectoryName(tuple.path); + var paths = new List<(string path, int level)>(); + while (!string.IsNullOrEmpty(path)) { + var level = path == directoryToClean ? 0 : path[(directoryToClean.Length + 1)..].Split(Path.DirectorySeparatorChar).Length; + paths.Add((path, level)); + if (path == directoryToClean) + break; + path = Path.GetDirectoryName(path); + } + return paths; + }) + .DistinctBy(tuple => tuple.path) + .OrderBy(tuple => tuple.level) + .ThenBy(tuple => tuple.path) + .Select(tuple => tuple.path) + .ToList(); - private static void CleanupDirectoryStructure(string? path) - { - path = Path.GetDirectoryName(path); - while (!string.IsNullOrEmpty(path) && Directory.Exists(path) && !Directory.EnumerateFileSystemEntries(path).Any()) { - Directory.Delete(path); - path = Path.GetDirectoryName(path); + nextStep = DateTime.Now; + Logger.LogDebug("Found {DirectoryCount} directories to potentially clean in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", toBeRemoved.Count, directoryToClean, nextStep - previousStep, nextStep - start); + previousStep = nextStep; + + foreach (var directoryPath in directoriesToClean) { + if (Directory.Exists(directoryPath) && !Directory.EnumerateFileSystemEntries(directoryPath).Any()) { + Logger.LogTrace("Removing empty directory at {Path}", directoryPath); + Directory.Delete(directoryPath); + cleaned++; + } } + + Logger.LogTrace("Cleaned {CleanedCount} directories in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", cleaned, directoriesToClean, nextStep - previousStep, nextStep - start); + + return result; } private IReadOnlyList<string> FindSubtitlesForPath(string sourcePath) From 1bb1fb2749b6edc1cb183a3311deebedf0a8420f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 20 Apr 2024 19:07:39 +0200 Subject: [PATCH 0878/1103] fix: only emit path for the same file/series once --- Shokofin/Resolvers/ShokoResolveManager.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index fd8edf56..36ed47ed 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -604,10 +604,13 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold importFolderSubPath ); foreach (var file in pageData.List) { + if (file.CrossReferences.Count == 0) + continue; + var location = file.Locations .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length == 0 || location.Path.StartsWith(importFolderSubPath))) .FirstOrDefault(); - if (location == null || file.CrossReferences.Count == 0) + if (location == null) continue; var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); @@ -619,8 +622,8 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold if (seriesIds.Count == 1) { totalSingleSeriesFiles++; singleSeriesIds.Add(seriesIds.First()); - foreach (var xref in file.CrossReferences) - yield return (sourceLocation, file.Id.ToString(), xref.Series.Shoko.ToString()); + foreach (var seriesId in seriesIds) + yield return (sourceLocation, file.Id.ToString(), seriesId.ToString()); } else if (seriesIds.Count > 1) { multiSeriesFiles.Add((file, sourceLocation)); @@ -635,9 +638,10 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold foreach (var (file, sourceLocation) in multiSeriesFiles) { var crossReferences = file.CrossReferences .Where(xref => singleSeriesIds.Contains(xref.Series.Shoko)) - .ToList(); - foreach (var xref in crossReferences) - yield return (sourceLocation, file.Id.ToString(), xref.Series.Shoko.ToString()); + .Select(xref => xref.Series.Shoko.ToString()) + .ToHashSet(); + foreach (var seriesId in crossReferences) + yield return (sourceLocation, file.Id.ToString(), seriesId); totalMultiSeriesFiles += crossReferences.Count; } From 5873ea3979110cff8d16b1b2b319d63e88e77c4d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 20 Apr 2024 19:07:53 +0200 Subject: [PATCH 0879/1103] misc: remove unneeded assignment --- Shokofin/Resolvers/ShokoResolveManager.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 36ed47ed..70ce6a31 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -273,7 +273,6 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold // Iterate the files already in the VFS. string? pathToClean = null; IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; - vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { var allPaths = GetPathsForMediaFolder(mediaFolder); var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar); From fa6f2fb1edcf93827e2ee5f7ec6eefe1ff1a843f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 20 Apr 2024 19:57:25 +0200 Subject: [PATCH 0880/1103] fix: temp. disable import order for VFS --- Shokofin/Resolvers/ShokoResolveManager.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 70ce6a31..9624cf37 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -811,7 +811,10 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime importedAt) => GenerateSymbolicLinks(sourceLocation, symbolicLinks, nfoFiles, importedAt, new()); +// TODO: Remove this for 10.9 +#pragma warning disable IDE0060 private LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime importedAt, ConcurrentBag<string> allPathsForVFS) +#pragma warning restore IDE0060 { var result = new LinkGenerationResult(); var sourcePrefixLength = sourceLocation.Length - Path.GetExtension(sourceLocation).Length; @@ -826,8 +829,9 @@ private LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string 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); + // TODO: Uncomment this for 10.9 + // // Mock the creation date to fake the "date added" order in Jellyfin. + // File.SetCreationTime(symbolicLink, importedAt); } else { var shouldFix = false; @@ -838,6 +842,13 @@ private LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", symbolicLink, sourceLocation, nextTarget?.FullName); } + // TODO: Uncomment this for 10.9 + // var date = File.GetCreationTime(symbolicLink); + // if (date != importedAt) { + // shouldFix = true; + // + // 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); @@ -846,6 +857,9 @@ private LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string if (shouldFix) { File.Delete(symbolicLink); File.CreateSymbolicLink(symbolicLink, sourceLocation); + // TODO: Uncomment this for 10.9 + // // Mock the creation date to fake the "date added" order in Jellyfin. + // File.SetCreationTime(symbolicLink, importedAt); result.FixedVideos++; } else { From d3a210b4980136c89545c531f326734af7cdfd40 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 20 Apr 2024 22:27:29 +0000 Subject: [PATCH 0881/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index b54b4a24..706909f8 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.134", + "changelog": "fix: temp. disable import order for VFS\n\nmisc: remove unneeded assignment\n\nfix: only emit path for the same file/series once\n\nrefactor: better cleanup logic\n\n- Refactor how we do the cleanup, adding more trace/debug logging,\n only get the file list once, and to only try to clean up a directory\n once.\n\nmisc: decrease default VFS link gen. threads\n\nrefactor: easier import order in VFS\n\n- Set the link creation date instead of monkey-patching the items\n afterwards.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.134/shoko_3.0.1.134.zip", + "checksum": "d07968638597639c9dc804c1907c5423", + "timestamp": "2024-04-20T22:27:27Z" + }, { "version": "3.0.1.133", "changelog": "misc: add more traces and comments\n\nfix: make scrobbling great again!\n\n- Add the missing initial \"play\" event late if we've enabled playback\n events _and_ lazy sync. Previously it was skipping the initial \"play\"\n event in this config, causing trakt syncing to fail from shoko to\n trakt.\n\n- Fixed event scrobbling when live sync is not enabled.\n\n- Added more documentation about the session fields.\n\nmisc: remove unused method & model [skip ci]\n\nFix discord webhook url [no ci", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.130/shoko_3.0.1.130.zip", "checksum": "c65ff8f6a7b67bfafd1df236c5493a2d", "timestamp": "2024-04-16T03:32:02Z" - }, - { - "version": "3.0.1.129", - "changelog": "fix: only iterate files in folder", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.129/shoko_3.0.1.129.zip", - "checksum": "9a20bf38f55b6ad7908c13b9761c87f1", - "timestamp": "2024-04-16T03:23:12Z" } ] } From 407ff2a202f3f12f206404483c22b46b63193cda Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 21 Apr 2024 01:42:39 +0200 Subject: [PATCH 0882/1103] misc: fix logging of skipped event [skip ci] --- Shokofin/Sync/UserDataSyncManager.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index f60daf26..ea1167cf 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -143,8 +143,11 @@ public bool ShouldSendEvent(bool isPauseOrResumeEvent = false) if (!isPauseOrResumeEvent && SkipEventCount > 0) SkipEventCount--; - Logger.LogDebug("Scrobble event was skipped. (File={FileId})", FileId); - return SkipEventCount == 0; + var shouldSend = SkipEventCount == 0; + if (!shouldSend) + Logger.LogDebug("Scrobble event was skipped. (File={FileId})", FileId); + + return shouldSend; } } From 75f5351e6b379718cea096addfbc084bba11f746 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 21 Apr 2024 12:43:40 +0200 Subject: [PATCH 0883/1103] fix: compensate for missing imported at dates in shoko server - Compensate for the (since fixed) bug in Shoko Server (hopefully) that lead to some files not having an imported at date if they've been imported, then removed from shoko, then reimported. --- Shokofin/Resolvers/ShokoResolveManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 9624cf37..e8625ad3 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -805,7 +805,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { foreach (var symbolicLink in symbolicLinks) ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, file.EpisodeList.Select(episode => episode.Id)); - return (sourceLocation, symbolicLinks, nfoFiles: nfoFiles.ToArray(), file.Shoko.ImportedAt); + return (sourceLocation, symbolicLinks, nfoFiles: nfoFiles.ToArray(), file.Shoko.ImportedAt ?? file.Shoko.CreatedAt); } public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime importedAt) From 41bd4f04a93ea7ec79298704f777922ed3a47eaa Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 21 Apr 2024 10:44:26 +0000 Subject: [PATCH 0884/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 706909f8..83be4b85 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.135", + "changelog": "fix: compensate for missing imported at dates in shoko server\n\n- Compensate for the (since fixed) bug in Shoko Server (hopefully) that\n lead to some files not having an imported at date if they've been\n imported, then removed from shoko, then reimported.\n\nmisc: fix logging of skipped event [skip ci]", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.135/shoko_3.0.1.135.zip", + "checksum": "03d339cab0a73bc6baab62e55a9f7d3b", + "timestamp": "2024-04-21T10:44:24Z" + }, { "version": "3.0.1.134", "changelog": "fix: temp. disable import order for VFS\n\nmisc: remove unneeded assignment\n\nfix: only emit path for the same file/series once\n\nrefactor: better cleanup logic\n\n- Refactor how we do the cleanup, adding more trace/debug logging,\n only get the file list once, and to only try to clean up a directory\n once.\n\nmisc: decrease default VFS link gen. threads\n\nrefactor: easier import order in VFS\n\n- Set the link creation date instead of monkey-patching the items\n afterwards.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.131/shoko_3.0.1.131.zip", "checksum": "5f5a6e877d92cf06ce782cd2921c7131", "timestamp": "2024-04-16T19:01:04Z" - }, - { - "version": "3.0.1.130", - "changelog": "revert: \"fix: only iterate files in folder\"\n\nThis reverts commit accba9b1bed6fe6eb630cfb1f4cb07f8fffc2b5e.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.130/shoko_3.0.1.130.zip", - "checksum": "c65ff8f6a7b67bfafd1df236c5493a2d", - "timestamp": "2024-04-16T03:32:02Z" } ] } From 2c2b6b11f81086751a254fedb5dbf0c3f8e7d74a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 21 Apr 2024 16:43:49 +0200 Subject: [PATCH 0885/1103] refactor: track cache access - Track when we accessed the caches last, and clear them in a scheduled task when they become stall. --- Shokofin/API/ShokoAPIClient.cs | 31 ++----- Shokofin/API/ShokoAPIManager.cs | 55 +++-------- Shokofin/Resolvers/ShokoResolveManager.cs | 27 ++---- Shokofin/Tasks/AutoClearPluginCacheTask.cs | 80 ++++++++++++++++ Shokofin/Tasks/ClearPluginCacheTask.cs | 6 +- Shokofin/Utils/GuardedMemoryCache.cs | 101 +++++++++++++++------ 6 files changed, 187 insertions(+), 113 deletions(-) create mode 100644 Shokofin/Tasks/AutoClearPluginCacheTask.cs diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 82b56d69..abd532fd 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -1,14 +1,10 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; -using System.Linq; using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Shokofin.API.Models; using Shokofin.Utils; @@ -37,42 +33,32 @@ 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)); - private GuardedMemoryCache _cache = new(new MemoryCacheOptions() { - ExpirationScanFrequency = ExpirationScanFrequency, - }); + public bool IsCacheStalled => _cache.IsStalled; - private static readonly TimeSpan ExpirationScanFrequency = new(0, 25, 0); - - private static readonly TimeSpan DefaultTimeSpan = new(2, 30, 0); + private readonly GuardedMemoryCache _cache; public ShokoAPIClient(ILogger<ShokoAPIClient> logger) { - _httpClient = new HttpClient - { + _httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10), }; Logger = logger; + _cache = new(logger, TimeSpan.FromMinutes(30), new() { ExpirationScanFrequency = TimeSpan.FromMinutes(25) }, new() { SlidingExpiration = new(2, 30, 0) }); } #region Base Implementation - public void Clear(bool restore = true) + public void Clear() { Logger.LogDebug("Clearing data…"); - _cache.Dispose(); - if (restore) { - Logger.LogDebug("Initialising new cache…"); - _cache = new(new MemoryCacheOptions() { - ExpirationScanFrequency = ExpirationScanFrequency, - }); - } + _cache.Clear(); } public void Dispose() { GC.SuppressFinalize(this); _httpClient.Dispose(); - Clear(false); + _cache.Dispose(); } private Task<ReturnType> Get<ReturnType>(string url, string? apiKey = null, bool skipCache = false) @@ -104,9 +90,6 @@ private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, st var value = await JsonSerializer.DeserializeAsync<ReturnType>(responseStream).ConfigureAwait(false) ?? throw new ApiException(response.StatusCode, nameof(ShokoAPIClient), "Unexpected null return value."); return value; - }, - new() { - SlidingExpiration = DefaultTimeSpan, } ); } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 10d9264b..07ee11a7 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -1,6 +1,5 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using System; using System.Collections.Concurrent; @@ -53,15 +52,12 @@ public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient Logger = logger; APIClient = apiClient; LibraryManager = libraryManager; + DataCache = new(logger, TimeSpan.FromMinutes(15), new() { ExpirationScanFrequency = TimeSpan.FromMinutes(25) }, new() { AbsoluteExpirationRelativeToNow = new(2, 30, 0) }); } - private GuardedMemoryCache DataCache = new(new MemoryCacheOptions() { - ExpirationScanFrequency = ExpirationScanFrequency, - }); + public bool IsCacheStalled => DataCache.IsStalled; - private static readonly TimeSpan ExpirationScanFrequency = new(0, 25, 0); - - private static readonly TimeSpan DefaultTimeSpan = new(2, 30, 0); + private readonly GuardedMemoryCache DataCache; #region Ignore rule @@ -144,13 +140,12 @@ public string StripMediaFolder(string fullPath) public void Dispose() { GC.SuppressFinalize(this); - Clear(false); + Clear(); } - public void Clear(bool restore = true) + public void Clear() { Logger.LogDebug("Clearing data…"); - DataCache.Dispose(); EpisodeIdToEpisodePathDictionary.Clear(); EpisodeIdToSeriesIdDictionary.Clear(); FileAndSeriesIdToEpisodeIdDictionary.Clear(); @@ -164,12 +159,7 @@ public void Clear(bool restore = true) SeriesIdToDefaultSeriesIdDictionary.Clear(); SeriesIdToCollectionIdDictionary.Clear(); SeriesIdToPathDictionary.Clear(); - if (restore) { - Logger.LogDebug("Initialising new cache…"); - DataCache = new(new MemoryCacheOptions() { - ExpirationScanFrequency = ExpirationScanFrequency, - }); - } + DataCache.Clear(); Logger.LogDebug("Cleanup complete."); } @@ -294,7 +284,7 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) // Set up both at the same time. private Task<(HashSet<string>, HashSet<string>)> GetPathSetAndLocalEpisodeIdsForSeries(string seriesId) - => DataCache.GetOrCreateAsync<(HashSet<string>, HashSet<string>)>( + => DataCache.GetOrCreateAsync( $"series-path-set-and-episode-ids:${seriesId}", async (_) => { var pathSet = new HashSet<string>(); @@ -312,7 +302,8 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) } return (pathSet, episodeIds); - } + }, + new() ); #endregion @@ -479,9 +470,6 @@ private Task<FileInfo> CreateFileInfo(File file, string fileId, string seriesId) FileAndSeriesIdToEpisodeIdDictionary[$"{fileId}:{seriesId}"] = episodeList.Select(episode => episode.Id).ToList(); return fileInfo; - }, - new() { - AbsoluteExpirationRelativeToNow = DefaultTimeSpan, } ); @@ -519,9 +507,6 @@ private EpisodeInfo CreateEpisodeInfo(Episode episode, string episodeId) Logger.LogTrace("Creating info object for episode {EpisodeName}. (Episode={EpisodeId})", episode.Name, episodeId); return new EpisodeInfo(episode); - }, - new() { - AbsoluteExpirationRelativeToNow = DefaultTimeSpan, } ); @@ -583,7 +568,7 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) var key = $"season:{seriesId}"; if (DataCache.TryGetValue<SeasonInfo>(key, out var seasonInfo)) { - Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId); + Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo?.Shoko.Name, seriesId); return seasonInfo; } @@ -598,7 +583,7 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) var cachedKey = $"season:{seriesId}"; if (DataCache.TryGetValue<SeasonInfo>(cachedKey, out var seasonInfo)) { - Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId); + Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo?.Shoko.Name, seriesId); return seasonInfo; } @@ -641,9 +626,6 @@ private Task<SeasonInfo> CreateSeasonInfo(Series series, string seriesId) foreach (var episode in episodes) EpisodeIdToSeriesIdDictionary.TryAdd(episode.Id, seriesId); return seasonInfo; - }, - new() { - AbsoluteExpirationRelativeToNow = DefaultTimeSpan, } ); @@ -662,7 +644,8 @@ private Task<SeasonInfo> CreateSeasonInfo(Series series, string seriesId) ? files.Where(f => f.ImportedAt.HasValue).Select(f => f.ImportedAt!.Value).Max() : files.Select(f => f.CreatedAt).Max() ); - } + }, + new() ); #endregion @@ -813,7 +796,6 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul if (seasonList.Count == 0) { Logger.LogWarning("Creating an empty show info for filter! (Group={GroupId})", groupId); - cachedEntry.AbsoluteExpirationRelativeToNow = DefaultTimeSpan; return null; } } @@ -827,9 +809,6 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaul } return showInfo; - }, - new() { - AbsoluteExpirationRelativeToNow = DefaultTimeSpan, } ); @@ -846,9 +825,6 @@ private ShowInfo GetOrCreateShowInfoForSeasonInfo(SeasonInfo seasonInfo, string? if (!string.IsNullOrEmpty(showInfo.CollectionId)) SeriesIdToCollectionIdDictionary[seasonInfo.Id] = showInfo.CollectionId; return showInfo; - }, - new() { - AbsoluteExpirationRelativeToNow = DefaultTimeSpan, } ); @@ -861,7 +837,7 @@ private ShowInfo GetOrCreateShowInfoForSeasonInfo(SeasonInfo seasonInfo, string? return null; if (DataCache.TryGetValue<CollectionInfo>($"collection:by-group-id:{groupId}", out var collectionInfo)) { - Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", collectionInfo.Name, groupId); + Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", collectionInfo?.Name, groupId); return collectionInfo; } @@ -931,9 +907,6 @@ private Task<CollectionInfo> CreateCollectionInfo(Group group, string groupId) var showList = showDict.Values.ToList(); var collectionInfo = new CollectionInfo(group, showList, groupList); return collectionInfo; - }, - new() { - AbsoluteExpirationRelativeToNow = DefaultTimeSpan, } ); diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index e8625ad3..6732314c 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -14,7 +14,6 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.API.Models; @@ -46,13 +45,9 @@ public class ShokoResolveManager private readonly ExternalPathParser ExternalPathParser; - private GuardedMemoryCache DataCache = new(new MemoryCacheOptions() { - ExpirationScanFrequency = ExpirationScanFrequency, - }); + public bool IsCacheStalled => DataCache.IsStalled; - private static readonly TimeSpan ExpirationScanFrequency = TimeSpan.FromMinutes(25); - - private static readonly TimeSpan DefaultTTL = TimeSpan.FromMinutes(60); + private readonly GuardedMemoryCache DataCache; public ShokoResolveManager( ShokoAPIManager apiManager, @@ -71,6 +66,7 @@ NamingOptions namingOptions LibraryManager = libraryManager; FileSystem = fileSystem; Logger = logger; + DataCache = new(logger, TimeSpan.FromMinutes(15), 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; @@ -79,19 +75,13 @@ NamingOptions namingOptions ~ShokoResolveManager() { LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; - Clear(false); + DataCache.Dispose(); } - public void Clear(bool restore = true) + public void Clear() { Logger.LogDebug("Clearing data…"); - DataCache.Dispose(); - if (restore) { - Logger.LogDebug("Initialising new cache…"); - DataCache = new(new MemoryCacheOptions() { - ExpirationScanFrequency = ExpirationScanFrequency, - }); - } + DataCache.Clear(); } #region Changes Tracking @@ -101,7 +91,8 @@ 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 Folder folder && folder.ParentId == Guid.Empty && !string.IsNullOrEmpty(folder.Path) && !folder.Path.StartsWith(root.Path)) { - DataCache.Remove(folder.Id.ToString()); + DataCache.Remove($"paths-for-media-folder:{folder.Path}"); + DataCache.Remove($"should-skip-media-folder:{folder.Path}"); var mediaFolderConfig = Plugin.Instance.Configuration.MediaFolders.FirstOrDefault(c => c.MediaFolderId == folder.Id); if (mediaFolderConfig != null) { Logger.LogDebug( @@ -364,7 +355,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold // Save which paths we've already generated so we can skip generation // for them and their sub-paths later, and also print the result. if (path.StartsWith(mediaFolder.Path)) { - DataCache.Set($"should-skip-media-folder:{mediaFolder.Path}", vfsPath, DefaultTTL); + DataCache.Set($"should-skip-media-folder:{mediaFolder.Path}", vfsPath); result.Print(Logger, mediaFolder.Path); } else { diff --git a/Shokofin/Tasks/AutoClearPluginCacheTask.cs b/Shokofin/Tasks/AutoClearPluginCacheTask.cs new file mode 100644 index 00000000..79860444 --- /dev/null +++ b/Shokofin/Tasks/AutoClearPluginCacheTask.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.API; +using Shokofin.Resolvers; + +namespace Shokofin.Tasks; + +/// <summary> +/// For automagic maintenance. Will clear the plugin cache if there has been no recent activity to the cache. +/// </summary> +public class AutoClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Clear Plugin Cache"; + + /// <inheritdoc /> + public string Description => "For automagic maintenance. Will clear the plugin cache if there has been no recent activity to the cache."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoAutoClearPluginCache"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => true; + + /// <inheritdoc /> + public bool IsLogged => false; + + private readonly ShokoAPIManager ApiManager; + + private readonly ShokoAPIClient ApiClient; + + private readonly ShokoResolveManager ResolveManager; + + /// <summary> + /// Initializes a new instance of the <see cref="AutoClearPluginCacheTask" /> class. + /// </summary> + public AutoClearPluginCacheTask(ShokoAPIManager apiManager, ShokoAPIClient apiClient, ShokoResolveManager resolveManager) + { + ApiManager = apiManager; + ApiClient = apiClient; + ResolveManager = resolveManager; + } + + /// <summary> + /// Creates the triggers that define when the task will run. + /// </summary> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => new TaskTriggerInfo[] { + new() { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromMinutes(15).Ticks, + } + }; + + /// <summary> + /// Returns the task to be executed. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="progress">The progress.</param> + /// <returns>Task.</returns> + public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + if (ApiClient.IsCacheStalled) + ApiClient.Clear(); + if (ApiManager.IsCacheStalled) + ApiManager.Clear(); + if (ResolveManager.IsCacheStalled) + ResolveManager.Clear(); + return Task.CompletedTask; + } +} diff --git a/Shokofin/Tasks/ClearPluginCacheTask.cs b/Shokofin/Tasks/ClearPluginCacheTask.cs index ed30f973..5a2da479 100644 --- a/Shokofin/Tasks/ClearPluginCacheTask.cs +++ b/Shokofin/Tasks/ClearPluginCacheTask.cs @@ -9,15 +9,15 @@ namespace Shokofin.Tasks; /// <summary> -/// Class ClearPluginCacheTask. +/// 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 { /// <inheritdoc /> - public string Name => "Clear Plugin Cache"; + public string Name => "Clear Plugin Cache (Force)"; /// <inheritdoc /> - public string Description => "For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. Clear the plugin cache."; + public string Description => "Forcefully clear the plugin cache. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING."; /// <inheritdoc /> public string Category => "Shokofin"; diff --git a/Shokofin/Utils/GuardedMemoryCache.cs b/Shokofin/Utils/GuardedMemoryCache.cs index 790d3e01..404bb02d 100644 --- a/Shokofin/Utils/GuardedMemoryCache.cs +++ b/Shokofin/Utils/GuardedMemoryCache.cs @@ -3,24 +3,55 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; namespace Shokofin.Utils; sealed class GuardedMemoryCache : IDisposable, IMemoryCache { - private readonly IMemoryCache Cache; + private readonly MemoryCacheOptions CacheOptions; + + private readonly MemoryCacheEntryOptions? CacheEntryOptions; + + private readonly ILogger Logger; + + private IMemoryCache Cache; private readonly ConcurrentDictionary<object, SemaphoreSlim> Semaphores = new(); - public GuardedMemoryCache(MemoryCacheOptions options) => Cache = new MemoryCache(options); + public DateTime LastClearedAt { get; private set; } + + public DateTime LastAccessedAt { get; private set; } - public GuardedMemoryCache(IMemoryCache cache) => Cache = cache; + public readonly TimeSpan StallTime; + + public bool IsStalled => LastAccessedAt - LastClearedAt > StallTime; + + public GuardedMemoryCache(ILogger logger, TimeSpan stallTime, MemoryCacheOptions options, MemoryCacheEntryOptions? cacheEntryOptions = null) + { + Logger = logger; + CacheOptions = options; + CacheEntryOptions = cacheEntryOptions; + Cache = new MemoryCache(CacheOptions); + StallTime = stallTime; + LastClearedAt = LastAccessedAt = DateTime.Now; + } + + public void Clear() + { + Logger.LogDebug("Clearing cache…"); + var cache = Cache; + Cache = new MemoryCache(CacheOptions); + Semaphores.Clear(); + LastClearedAt = LastAccessedAt = DateTime.Now; + cache.Dispose(); + } public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICacheEntry, TItem> createFactory, MemoryCacheEntryOptions? createOptions = null) { - if (Cache.TryGetValue<TItem>(key, out var value)) { - foundAction(value); - return value; + if (TryGetValue<TItem>(key, out var value)) { + foundAction(value!); + return value!; } var semaphore = GetSemaphore(key); @@ -28,12 +59,13 @@ public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICac semaphore.Wait(); try { - if (Cache.TryGetValue<TItem>(key, out value)) { - foundAction(value); - return value; + if (TryGetValue(key, out value)) { + foundAction(value!); + return value!; } using ICacheEntry entry = Cache.CreateEntry(key); + createOptions ??= CacheEntryOptions; if (createOptions != null) entry.SetOptions(createOptions); @@ -42,16 +74,16 @@ public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICac return value; } finally { - semaphore.Release(); RemoveSemaphore(key); + semaphore.Release(); } } public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> foundAction, Func<ICacheEntry, Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) { - if (Cache.TryGetValue<TItem>(key, out var value)) { - foundAction(value); - return value; + if (TryGetValue<TItem>(key, out var value)) { + foundAction(value!); + return value!; } var semaphore = GetSemaphore(key); @@ -59,12 +91,13 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> found await semaphore.WaitAsync(); try { - if (Cache.TryGetValue<TItem>(key, out value)) { - foundAction(value); - return value; + if (TryGetValue(key, out value)) { + foundAction(value!); + return value!; } using ICacheEntry entry = Cache.CreateEntry(key); + createOptions ??= CacheEntryOptions; if (createOptions != null) entry.SetOptions(createOptions); @@ -73,25 +106,26 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> found return value; } finally { - semaphore.Release(); RemoveSemaphore(key); + semaphore.Release(); } } public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFactory, MemoryCacheEntryOptions? createOptions = null) { - if (Cache.TryGetValue<TItem>(key, out var value)) - return value; + if (TryGetValue<TItem>(key, out var value)) + return value!; var semaphore = GetSemaphore(key); semaphore.Wait(); try { - if (Cache.TryGetValue<TItem>(key, out value)) - return value; + if (TryGetValue(key, out value)) + return value!; using ICacheEntry entry = Cache.CreateEntry(key); + createOptions ??= CacheEntryOptions; if (createOptions != null) entry.SetOptions(createOptions); @@ -100,25 +134,26 @@ public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFacto return value; } finally { - semaphore.Release(); RemoveSemaphore(key); + semaphore.Release(); } } public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) { - if (Cache.TryGetValue<TItem>(key, out var value)) - return value; + if (TryGetValue<TItem>(key, out var value)) + return value!; var semaphore = GetSemaphore(key); await semaphore.WaitAsync(); try { - if (Cache.TryGetValue<TItem>(key, out value)) - return value; + if (TryGetValue(key, out value)) + return value!; using ICacheEntry entry = Cache.CreateEntry(key); + createOptions ??= CacheEntryOptions; if (createOptions != null) entry.SetOptions(createOptions); @@ -127,8 +162,8 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, T return value; } finally { - semaphore.Release(); RemoveSemaphore(key); + semaphore.Release(); } } @@ -155,4 +190,16 @@ public void Remove(object key) public bool TryGetValue(object key, out object value) => Cache.TryGetValue(key, out value); + + public bool TryGetValue<TItem>(object key, out TItem? value) + { + LastAccessedAt = DateTime.Now; + return Cache.TryGetValue(key, out value); + } + + public TItem Set<TItem>(object key, TItem value, MemoryCacheEntryOptions? createOptions = null) + { + LastAccessedAt = DateTime.Now; + return Cache.Set(key, value, createOptions ?? CacheEntryOptions); + } } \ No newline at end of file From 73b8f24fe72360c13628a6248bb03a226dabdbde Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 21 Apr 2024 16:47:04 +0200 Subject: [PATCH 0886/1103] misc: remove unused import --- Shokofin/Resolvers/ShokoResolveManager.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 6732314c..4210f0ad 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -23,7 +23,6 @@ using File = System.IO.File; using TvSeries = MediaBrowser.Controller.Entities.TV.Series; -using TvSeason = MediaBrowser.Controller.Entities.TV.Season; namespace Shokofin.Resolvers; From da7209ba0cae10eb386ce57c70a14aa5aa7c5161 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 21 Apr 2024 16:47:28 +0200 Subject: [PATCH 0887/1103] misc: fix casing of scheduled tasks --- Shokofin/Tasks/MergeEpisodesTask.cs | 2 +- Shokofin/Tasks/MergeMoviesTask.cs | 2 +- Shokofin/Tasks/SplitEpisodesTask.cs | 2 +- Shokofin/Tasks/SplitMoviesTask.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/Tasks/MergeEpisodesTask.cs b/Shokofin/Tasks/MergeEpisodesTask.cs index 00b95e14..13e68dfa 100644 --- a/Shokofin/Tasks/MergeEpisodesTask.cs +++ b/Shokofin/Tasks/MergeEpisodesTask.cs @@ -13,7 +13,7 @@ namespace Shokofin.Tasks; public class MergeEpisodesTask : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> - public string Name => "Merge episodes"; + public string Name => "Merge Episodes"; /// <inheritdoc /> public string Description => "Merge all episode entries with the same Shoko Episode ID set."; diff --git a/Shokofin/Tasks/MergeMoviesTask.cs b/Shokofin/Tasks/MergeMoviesTask.cs index 00aeb46a..f0f8d2e1 100644 --- a/Shokofin/Tasks/MergeMoviesTask.cs +++ b/Shokofin/Tasks/MergeMoviesTask.cs @@ -13,7 +13,7 @@ namespace Shokofin.Tasks; public class MergeMoviesTask : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> - public string Name => "Merge movies"; + public string Name => "Merge Movies"; /// <inheritdoc /> public string Description => "Merge all movie entries with the same Shoko Episode ID set."; diff --git a/Shokofin/Tasks/SplitEpisodesTask.cs b/Shokofin/Tasks/SplitEpisodesTask.cs index be0cb5b0..ea66a964 100644 --- a/Shokofin/Tasks/SplitEpisodesTask.cs +++ b/Shokofin/Tasks/SplitEpisodesTask.cs @@ -13,7 +13,7 @@ namespace Shokofin.Tasks; public class SplitEpisodesTask : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> - public string Name => "Split episodes"; + public string Name => "Split Episodes"; /// <inheritdoc /> public string Description => "Split all episode entries with a Shoko Episode ID set."; diff --git a/Shokofin/Tasks/SplitMoviesTask.cs b/Shokofin/Tasks/SplitMoviesTask.cs index 122d291c..7b296252 100644 --- a/Shokofin/Tasks/SplitMoviesTask.cs +++ b/Shokofin/Tasks/SplitMoviesTask.cs @@ -13,7 +13,7 @@ namespace Shokofin.Tasks; public class SplitMoviesTask : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> - public string Name => "Split movies"; + public string Name => "Split Movies"; /// <inheritdoc /> public string Description => "Split all movie entries with a Shoko Episode ID set."; From 362ff216daab5a9ca0fff4ada50c4c9c594dcb50 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 21 Apr 2024 20:17:30 +0200 Subject: [PATCH 0888/1103] =?UTF-8?q?fix:=20`Path.Combine`=20=E2=86=92=20`?= =?UTF-8?q?Path.Join`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced all occurences of `Path.Combine` with `Path.Join`, because they are not the same, and we want the bahaviour of `.Join`. --- Shokofin/API/ShokoAPIManager.cs | 2 +- Shokofin/Plugin.cs | 6 +++--- Shokofin/Resolvers/ShokoResolveManager.cs | 18 +++++++++--------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 07ee11a7..7a023f96 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -62,7 +62,7 @@ public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient #region Ignore rule public static string GetVirtualRootForMediaFolder(Folder mediaFolder) - => Path.Combine(Plugin.Instance.VirtualRoot, mediaFolder.Id.ToString()); + => Path.Join(Plugin.Instance.VirtualRoot, mediaFolder.Id.ToString()); public (Folder mediaFolder, string partialPath) FindMediaFolder(string path, Folder parent, Folder root) { diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 4d981e9d..8396db6e 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -37,12 +37,12 @@ public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, Instance = this; ConfigurationChanged += OnConfigChanged; IgnoredFolders = Configuration.IgnoredFolders.ToHashSet(); - VirtualRoot = Path.Combine(applicationPaths.ProgramDataPath, "Shokofin", "VFS"); + VirtualRoot = Path.Join(applicationPaths.ProgramDataPath, "Shokofin", "VFS"); Logger = logger; CanCreateSymbolicLinks = true; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - var target = Path.Combine(Path.GetDirectoryName(VirtualRoot)!, "TestTarget.txt"); - var link = Path.Combine(Path.GetDirectoryName(VirtualRoot)!, "TestLink.txt"); + var target = Path.Join(Path.GetDirectoryName(VirtualRoot)!, "TestTarget.txt"); + var link = Path.Join(Path.GetDirectoryName(VirtualRoot)!, "TestLink.txt"); try { if (!Directory.Exists(Path.GetDirectoryName(VirtualRoot)!)) Directory.CreateDirectory(Path.GetDirectoryName(VirtualRoot)!); diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 4210f0ad..072841a5 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -755,10 +755,10 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { if (isMovieSeason && collectionType != CollectionType.TvShows) { if (!string.IsNullOrEmpty(extrasFolder)) { foreach (var episodeInfo in season.EpisodeList) - folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episodeInfo.Id}]", extrasFolder)); + folders.Add(Path.Join(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episodeInfo.Id}]", extrasFolder)); } else { - folders.Add(Path.Combine(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episode.Id}]")); + folders.Add(Path.Join(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episode.Id}]")); episodeName = "Movie"; } } @@ -768,14 +768,14 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { var seasonFolder = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}"; var showFolder = $"{showName} [{ShokoSeriesId.Name}={show.Id}]"; if (!string.IsNullOrEmpty(extrasFolder)) { - folders.Add(Path.Combine(vfsPath, showFolder, extrasFolder)); + folders.Add(Path.Join(vfsPath, showFolder, extrasFolder)); // Only place the extra within the season if we have a season number assigned to the episode. if (seasonNumber != 0) - folders.Add(Path.Combine(vfsPath, showFolder, seasonFolder, extrasFolder)); + folders.Add(Path.Join(vfsPath, showFolder, seasonFolder, extrasFolder)); } else { - folders.Add(Path.Combine(vfsPath, showFolder, seasonFolder)); + folders.Add(Path.Join(vfsPath, showFolder, seasonFolder)); episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber.ToString().PadLeft(show.EpisodePadding, '0')}"; } @@ -783,14 +783,14 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { // to allow the built-in movie resolver to detect the directories // properly as tv shows. if (collectionType == null) { - nfoFiles.Add(Path.Combine(vfsPath, showFolder, "tvshow.nfo")); - nfoFiles.Add(Path.Combine(vfsPath, showFolder, seasonFolder, "season.nfo")); + nfoFiles.Add(Path.Join(vfsPath, showFolder, "tvshow.nfo")); + nfoFiles.Add(Path.Join(vfsPath, showFolder, seasonFolder, "season.nfo")); } } var fileName = $"{episodeName} [{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{fileNameSuffix}{Path.GetExtension(sourceLocation)}"; var symbolicLinks = folders - .Select(folderPath => Path.Combine(folderPath, fileName)) + .Select(folderPath => Path.Join(folderPath, fileName)) .ToArray(); foreach (var symbolicLink in symbolicLinks) @@ -861,7 +861,7 @@ private LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); foreach (var subtitleSource in subtitleLinks) { var extName = subtitleSource[sourcePrefixLength..]; - var subtitleLink = Path.Combine(symbolicDirectory, symbolicName + extName); + var subtitleLink = Path.Join(symbolicDirectory, symbolicName + extName); result.Paths.Add(subtitleLink); if (!File.Exists(subtitleLink)) { From 091c689aa25b2c421b3ae6fae23621a3f0b33d7f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 23 Apr 2024 19:24:56 +0200 Subject: [PATCH 0889/1103] refactor: partially wire up file events - Wired up file added/updated events, and laid the ground work for file deleted events and series/episode updated events. --- Shokofin/Resolvers/ShokoResolveManager.cs | 10 +- .../Interfaces/IMetadataUpdatedEventArgs.cs | 59 ++++ .../Models/EpisodeInfoUpdatedEventArgs.cs | 20 +- Shokofin/SignalR/Models/FileEventArgs.cs | 8 +- .../Models/SeriesInfoUpdatedEventArgs.cs | 18 +- Shokofin/SignalR/SignalRConnectionManager.cs | 307 +++++++++++++++--- 6 files changed, 363 insertions(+), 59 deletions(-) create mode 100644 Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 072841a5..ed894ec5 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -134,11 +134,12 @@ private IReadOnlySet<string> GetPathsForMediaFolder(Folder mediaFolder) } ); - public IReadOnlyList<(MediaFolderConfiguration config, Folder mediaFolder)> GetAvailableMediaFolders() + public IReadOnlyList<(MediaFolderConfiguration config, Folder mediaFolder, string vfsPath)> GetAvailableMediaFolders(bool fileEvents = false, bool refreshEvents = false) => Plugin.Instance.Configuration.MediaFolders - .Where(mediaFolder => mediaFolder.IsMapped && mediaFolder.IsFileEventsEnabled) + .Where(mediaFolder => mediaFolder.IsMapped && (!fileEvents || mediaFolder.IsFileEventsEnabled) && (!refreshEvents || mediaFolder.IsRefreshEventsEnabled)) .Select(config => (config, mediaFolder: LibraryManager.GetItemById(config.MediaFolderId) as Folder)) .OfType<(MediaFolderConfiguration config, Folder mediaFolder)>() + .Select(tuple => (tuple.config, tuple.mediaFolder, ShokoAPIManager.GetVirtualRootForMediaFolder(tuple.mediaFolder))) .ToList(); public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFolder(Folder mediaFolder) @@ -798,12 +799,9 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { return (sourceLocation, symbolicLinks, nfoFiles: nfoFiles.ToArray(), file.Shoko.ImportedAt ?? file.Shoko.CreatedAt); } - public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime importedAt) - => GenerateSymbolicLinks(sourceLocation, symbolicLinks, nfoFiles, importedAt, new()); - // TODO: Remove this for 10.9 #pragma warning disable IDE0060 - private LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime importedAt, ConcurrentBag<string> allPathsForVFS) + public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime importedAt, ConcurrentBag<string> allPathsForVFS) #pragma warning restore IDE0060 { var result = new LinkGenerationResult(); diff --git a/Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs b/Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs new file mode 100644 index 00000000..d78c07cd --- /dev/null +++ b/Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.Globalization; +using Jellyfin.Data.Enums; +using Shokofin.SignalR.Models; + +namespace Shokofin.SignalR.Interfaces; + +public interface IMetadataUpdatedEventArgs +{ + /// <summary> + /// The update reason. + /// </summary> + UpdateReason Reason { get; } + + /// <summary> + /// The provider metadata type. + /// </summary> + BaseItemKind Type { get; } + + /// <summary> + /// The provider metadata source. + /// </summary> + string ProviderName { get; } + + /// <summary> + /// The provided metadata episode id. + /// </summary> + int ProviderId { get; } + + /// <summary> + /// Provider unique id. + /// </summary> + string ProviderUId => $"{ProviderName.ToLowerInvariant()}:{ProviderId.ToString(CultureInfo.InvariantCulture)}"; + + /// <summary> + /// The provided metadata series id. + /// </summary> + int? ProviderParentId { get; } + + /// <summary> + /// Provider unique parent id. + /// </summary> + string? ProviderParentUId => ProviderParentId.HasValue ? $"{ProviderName.ToLowerInvariant()}:{ProviderParentId.Value.ToString(CultureInfo.InvariantCulture)}" : null; + + /// <summary> + /// Shoko episode ids affected by this update. + /// </summary> + IReadOnlyList<int> EpisodeIds { get; } + + /// <summary> + /// Shoko series ids affected by this update. + /// </summary> + IReadOnlyList<int> SeriesIds { get; } + + /// <summary> + /// Shoko group ids affected by this update. + /// </summary> + IReadOnlyList<int> GroupIds { get; } +} \ No newline at end of file diff --git a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs index 235cb6f0..8e1246a9 100644 --- a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs @@ -1,9 +1,11 @@ using System.Collections.Generic; using System.Text.Json.Serialization; +using Jellyfin.Data.Enums; +using Shokofin.SignalR.Interfaces; namespace Shokofin.SignalR.Models; -public class EpisodeInfoUpdatedEventArgs +public class EpisodeInfoUpdatedEventArgs : IMetadataUpdatedEventArgs { /// <summary> /// The update reason. @@ -27,7 +29,7 @@ public class EpisodeInfoUpdatedEventArgs /// The provided metadata series id. /// </summary> [JsonInclude, JsonPropertyName("SeriesID")] - public int ProviderSeriesId { get; set; } + public int ProviderParentId { get; set; } /// <summary> /// Shoko episode ids affected by this update. @@ -46,4 +48,18 @@ public class EpisodeInfoUpdatedEventArgs /// </summary> [JsonInclude, JsonPropertyName("ShokoGroupIDs")] public List<int> GroupIds { get; set; } = new(); + + #region IMetadataUpdatedEventArgs Impl. + + BaseItemKind IMetadataUpdatedEventArgs.Type => BaseItemKind.Episode; + + int? IMetadataUpdatedEventArgs.ProviderParentId => ProviderParentId; + + IReadOnlyList<int> IMetadataUpdatedEventArgs.EpisodeIds => EpisodeIds; + + IReadOnlyList<int> IMetadataUpdatedEventArgs.SeriesIds => SeriesIds; + + IReadOnlyList<int> IMetadataUpdatedEventArgs.GroupIds => GroupIds; + + #endregion } \ No newline at end of file diff --git a/Shokofin/SignalR/Models/FileEventArgs.cs b/Shokofin/SignalR/Models/FileEventArgs.cs index 0e65b357..8b1953e8 100644 --- a/Shokofin/SignalR/Models/FileEventArgs.cs +++ b/Shokofin/SignalR/Models/FileEventArgs.cs @@ -40,7 +40,7 @@ public class FileEventArgs : IFileEventArgs /// <inheritdoc/> [JsonIgnore] - public bool HasCrossReferences { get; set; } + public bool HasCrossReferences { get; set; } = false; /// <inheritdoc/> [JsonIgnore] @@ -48,17 +48,17 @@ public class FileEventArgs : IFileEventArgs #pragma warning disable IDE0051 /// <summary> - /// Legacy cross-references of episodes linked to this file. Only present + /// Current cross-references of episodes linked to this file. Only present /// for setting the cross-references when deserializing JSON. /// </summary> [JsonInclude, JsonPropertyName("CrossReferences")] - public List<IFileEventArgs.FileCrossReference> CurrentCrossReferences { set { HasCrossReferences = true; CrossReferences = value; } } + public List<IFileEventArgs.FileCrossReference> CurrentCrossReferences { get => CrossReferences; set { HasCrossReferences = true; CrossReferences = value; } } /// <summary> /// Legacy cross-references of episodes linked to this file. Only present /// for setting the cross-references when deserializing JSON. /// </summary> [JsonInclude, JsonPropertyName("CrossRefs")] - public List<IFileEventArgs.FileCrossReference> LegacyCrossReferences { set { HasCrossReferences = true; CrossReferences = value; } } + public List<IFileEventArgs.FileCrossReference> LegacyCrossReferences { get => CrossReferences; set { HasCrossReferences = true; CrossReferences = value; } } #pragma warning restore IDE0051 } diff --git a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs index b4b1a89b..3a2adb8b 100644 --- a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs @@ -1,9 +1,11 @@ using System.Collections.Generic; using System.Text.Json.Serialization; +using Jellyfin.Data.Enums; +using Shokofin.SignalR.Interfaces; namespace Shokofin.SignalR.Models; -public class SeriesInfoUpdatedEventArgs +public class SeriesInfoUpdatedEventArgs : IMetadataUpdatedEventArgs { /// <summary> /// The update reason. @@ -34,4 +36,18 @@ public class SeriesInfoUpdatedEventArgs /// </summary> [JsonInclude, JsonPropertyName("ShokoGroupIDs")] public List<int> GroupIds { get; set; } = new(); + + #region IMetadataUpdatedEventArgs Impl. + + BaseItemKind IMetadataUpdatedEventArgs.Type => BaseItemKind.Series; + + int? IMetadataUpdatedEventArgs.ProviderParentId => null; + + IReadOnlyList<int> IMetadataUpdatedEventArgs.EpisodeIds => new List<int>(); + + IReadOnlyList<int> IMetadataUpdatedEventArgs.SeriesIds => SeriesIds; + + IReadOnlyList<int> IMetadataUpdatedEventArgs.GroupIds => GroupIds; + + #endregion } \ No newline at end of file diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 1f0b42b9..bdda70f6 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -1,17 +1,26 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; +using System.Timers; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; using MediaBrowser.Model.Plugins; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Shokofin.API; using Shokofin.API.Models; using Shokofin.Configuration; +using Shokofin.ExternalIds; using Shokofin.Resolvers; using Shokofin.SignalR.Interfaces; using Shokofin.SignalR.Models; +using File = System.IO.File; + namespace Shokofin.SignalR; public class SignalRConnectionManager : IDisposable @@ -26,36 +35,53 @@ public class SignalRConnectionManager : IDisposable private const string HubUrl = "/signalr/aggregate?feeds=shoko"; + private static readonly TimeSpan DetectChangesThreshold = TimeSpan.FromSeconds(5); + private readonly ILogger<SignalRConnectionManager> Logger; + private readonly ShokoAPIClient ApiClient; + private readonly ShokoResolveManager ResolveManager; private readonly ILibraryManager LibraryManager; private readonly ILibraryMonitor LibraryMonitor; + private readonly IFileSystem FileSystem; + private HubConnection? Connection = null; + private readonly Timer ChangesDetectionTimer; + private string CachedKey = string.Empty; + private readonly Dictionary<string, (DateTime LastUpdated, List<IMetadataUpdatedEventArgs> List)> ChangesPerSeries = new(); + + private readonly Dictionary<int, (DateTime LastUpdated, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> List)> ChangesPerFile = new(); + public bool IsUsable => CanConnect(Plugin.Instance.Configuration); public bool IsActive => Connection != null; public HubConnectionState State => Connection == null ? HubConnectionState.Disconnected : Connection.State; - public SignalRConnectionManager(ILogger<SignalRConnectionManager> logger, ShokoResolveManager resolveManager, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor) + public SignalRConnectionManager(ILogger<SignalRConnectionManager> logger, ShokoAPIClient apiClient, ShokoResolveManager resolveManager, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IFileSystem fileSystem) { Logger = logger; + ApiClient = apiClient; ResolveManager = resolveManager; LibraryManager = libraryManager; LibraryMonitor = libraryMonitor; + FileSystem = fileSystem; + ChangesDetectionTimer = new() { AutoReset = true, Interval = TimeSpan.FromSeconds(4).TotalMilliseconds }; + ChangesDetectionTimer.Elapsed += OnIntervalElapsed; } public void Dispose() { Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; Disconnect(); + ChangesDetectionTimer.Elapsed -= OnIntervalElapsed; } #region Connection @@ -66,7 +92,7 @@ private async Task ConnectAsync(PluginConfiguration config) return; var builder = new HubConnectionBuilder() - .WithUrl(config.Url + HubUrl, connectionOptions => + .WithUrl(config.Url + HubUrl, connectionOptions => connectionOptions.AccessTokenProvider = () => Task.FromResult<string?>(config.ApiKey) ) .AddJsonProtocol(); @@ -81,8 +107,8 @@ private async Task ConnectAsync(PluginConfiguration config) connection.Reconnected += OnReconnected; // Attach refresh events. - connection.On<EpisodeInfoUpdatedEventArgs>("ShokoEvent:EpisodeUpdated", OnEpisodeInfoUpdated); - connection.On<SeriesInfoUpdatedEventArgs>("ShokoEvent:SeriesUpdated", OnSeriesInfoUpdated); + connection.On<EpisodeInfoUpdatedEventArgs>("ShokoEvent:EpisodeUpdated", OnInfoUpdated); + connection.On<SeriesInfoUpdatedEventArgs>("ShokoEvent:SeriesUpdated", OnInfoUpdated); // Attach file events. connection.On<FileEventArgs>("ShokoEvent:FileMatched", OnFileMatched); @@ -96,6 +122,7 @@ private async Task ConnectAsync(PluginConfiguration config) connection.On<FileRenamedEventArgs>("ShokoEvent:FileRenamed", OnFileRelocated); } + ChangesDetectionTimer.Start(); try { await connection.StartAsync().ConfigureAwait(false); @@ -118,17 +145,14 @@ private Task OnReconnecting(Exception? exception) Logger.LogWarning(exception, "Disconnected from Shoko Server. Attempting to reconnect…"); return Task.CompletedTask; } - + private Task OnDisconnected(Exception? exception) { // Gracefull disconnection. - if (exception == null) { + if (exception == null) Logger.LogInformation("Gracefully disconnected from Shoko Server."); - - } - else { + else Logger.LogWarning(exception, "Abruptly disconnected from Shoko Server."); - } return Task.CompletedTask; } @@ -147,6 +171,12 @@ public async Task DisconnectAsync() await connection.StopAsync(); await connection.DisposeAsync(); + + ChangesDetectionTimer.Stop(); + if (ChangesPerFile.Count > 0) + ClearFileEvents(); + if (ChangesPerSeries.Count > 0) + ClearAnimeEvents(); } public Task ResetConnectionAsync() @@ -193,6 +223,70 @@ private static string ConstructKey(PluginConfiguration config) #endregion #region Events + + #region Intervals + + private void OnIntervalElapsed(object? sender, ElapsedEventArgs eventArgs) + { + var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>)>(); + var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>)>(); + lock (ChangesPerFile) { + if (ChangesPerFile.Count > 0) { + var now = DateTime.Now; + foreach (var (fileId, (lastUpdated, list)) in ChangesPerFile) { + if (now - lastUpdated < DetectChangesThreshold) + continue; + filesToProcess.Add((fileId, list)); + } + foreach (var (fileId, _) in filesToProcess) + ChangesPerFile.Remove(fileId); + } + } + lock (ChangesPerSeries) { + if (ChangesPerSeries.Count > 0) { + var now = DateTime.Now; + foreach (var (metadataId, (lastUpdated, list)) in ChangesPerSeries) { + if (now - lastUpdated < DetectChangesThreshold) + continue; + seriesToProcess.Add((metadataId, list)); + } + foreach (var (metadataId, _) in seriesToProcess) + ChangesPerSeries.Remove(metadataId); + } + } + foreach (var (fileId, changes) in filesToProcess) + Task.Run(() => ProcessFileChanges(fileId, changes)); + foreach (var (metadataId, changes) in seriesToProcess) + Task.Run(() => ProcessSeriesChanges(metadataId, changes)); + } + + private void ClearFileEvents() + { + var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>)>(); + lock (ChangesPerFile) { + foreach (var (fileId, (lastUpdated, list)) in ChangesPerFile) { + filesToProcess.Add((fileId, list)); + } + ChangesPerFile.Clear(); + } + foreach (var (fileId, changes) in filesToProcess) + Task.Run(() => ProcessFileChanges(fileId, changes)); + } + + private void ClearAnimeEvents() + { + var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>)>(); + lock (ChangesPerSeries) { + foreach (var (metadataId, (lastUpdated, list)) in ChangesPerSeries) { + seriesToProcess.Add((metadataId, list)); + } + ChangesPerSeries.Clear(); + } + foreach (var (metadataId, changes) in seriesToProcess) + Task.Run(() => ProcessSeriesChanges(metadataId, changes)); + } + + #endregion #region File Events @@ -207,13 +301,7 @@ private void OnFileMatched(IFileEventArgs eventArgs) eventArgs.HasCrossReferences ); - // also check if the locations we've found are mapped, and if they are - // check if the file events are enabled for the media folder before - // emitting events for the paths within the media filder. - - // check if the file is already in a known media library, and if yes, - // promote it from "unknown" to "known". also generate vfs entries now - // if needed. + AddFileEvent(eventArgs.FileId, UpdateReason.Updated, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); } private void OnFileRelocated(IFileRelocationEventArgs eventArgs) @@ -229,16 +317,8 @@ private void OnFileRelocated(IFileRelocationEventArgs eventArgs) eventArgs.HasCrossReferences ); - // check the previous and current locations, and report the changes. - - // also check if the locations we've found are mapped, and if they are - // check if the file events are enabled for the media folder before - // emitting events for the paths within the media filder. - - // also if the vfs is used, check the vfs for broken links, and fix it, - // or remove the broken links. we can do this a) generating the new links - // and/or b) checking the existing base items for their paths and checking if - // the links broke, and if the newly generated links is not in the list provided by the base items, then remove the broken link. + AddFileEvent(eventArgs.FileId, UpdateReason.Removed, eventArgs.PreviousImportFolderId, eventArgs.PreviousRelativePath, eventArgs); + AddFileEvent(eventArgs.FileId, UpdateReason.Added, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); } private void OnFileDeleted(IFileEventArgs eventArgs) @@ -251,46 +331,181 @@ private void OnFileDeleted(IFileEventArgs eventArgs) eventArgs.FileLocationId, eventArgs.HasCrossReferences ); - // The location has been removed. - // also check if the locations we've found are mapped, and if they are - // check if the file events are enabled for the media folder before - // emitting events for the paths within the media filder. + AddFileEvent(eventArgs.FileId, UpdateReason.Removed, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); + } - // check any base items with the exact path, and any VFS entries with a - // link leading to the exact path, or with broken links. + private void AddFileEvent(int fileId, UpdateReason reason, int importFolderId, string filePath, IFileEventArgs eventArgs) + { + lock (ChangesPerFile) { + if (ChangesPerFile.TryGetValue(fileId, out var tuple)) + tuple.LastUpdated = DateTime.Now; + else + ChangesPerFile.Add(fileId, tuple = (DateTime.Now, new())); + tuple.List.Add((reason, importFolderId, filePath, eventArgs)); + } + } + + private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> changes) + { + Logger.LogInformation("Processing {EventCount} file change events… (File={FileId})", changes.Count, fileId); + + // Something was added or updated. + var locationsToNotify = new List<string>(); + var mediaFolders = ResolveManager.GetAvailableMediaFolders(fileEvents: true); + var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); + if (reason != UpdateReason.Removed) { + var seriesIds = await GetSeriesIds(importFolderId, relativePath, fileId, changes.Select(t => t.Event).LastOrDefault(e => e.HasCrossReferences)); + foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { + if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) + continue; + + var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); + if (!File.Exists(sourceLocation)) + continue; + + // Let the core logic handle the rest. + if (!config.IsVirtualFileSystemEnabled) { + locationsToNotify.Add(sourceLocation); + continue; + } + var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, sourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) + .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) + .ToList(); + + var topFolders = new HashSet<string>(); + var result = new LinkGenerationResult(); + foreach (var (srcLoc, symLnks, nfoFls, imprtDt) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLnks, nfoFls, imprtDt!.Value, result.Paths); + foreach (var path in symLnks.Select(path => Path.Join(vfsPath, path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())).Distinct()) + topFolders.Add(path); + } + + // Remove old links for file. + var videos = LibraryManager + .GetItemList( + new() { + AncestorIds = new[] { mediaFolder.Id }, + IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Movie }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, + DtoOptions = new(true), + }, + true + ) + .Where(item => !string.IsNullOrEmpty(item.Path) && item.Path.StartsWith(vfsPath) && !result.Paths.Contains(item.Path)) + .ToList(); + foreach (var video in videos) { + File.Delete(video.Path); + locationsToNotify.Add(video.Path); + result.RemovedVideos++; + } + + result.Print(Logger, mediaFolder.Path); + + // If all the "top-level-folders" exist, then let the core logic handle the rest. + if (topFolders.All(path => LibraryManager.FindByPath(path, true) != null)) { + var old = locationsToNotify.Count; + locationsToNotify.AddRange(vfsLocations.SelectMany(tuple => tuple.symbolicLinks)); + } + // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. + else { + var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); + if (!string.IsNullOrEmpty(fileOrFolder)) + locationsToNotify.Add(fileOrFolder); + } + } + } + // Something was removed. + else if (changes.FirstOrDefault(t => t.Reason == UpdateReason.Removed).Event is IFileRelocationEventArgs firstRemovedEvent) { + relativePath = firstRemovedEvent.RelativePath; + importFolderId = firstRemovedEvent.ImportFolderId; + var seriesIds = await GetSeriesIds(importFolderId, relativePath, fileId, changes.Select(t => t.Event).LastOrDefault(e => e.HasCrossReferences)); + foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { + if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) + continue; + + var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); + if (!File.Exists(sourceLocation)) + continue; + + // Let the core logic handle the rest. + if (!config.IsVirtualFileSystemEnabled) { + locationsToNotify.Add(sourceLocation); + continue; + } + + // TODO: Detect what was removed, and update any needed links, then report the changes. + // VFS or non-VFS + // non-VFS: just check if the file was within any of our media folders, and forward the event to jellyfin if it were. + // VFS: Check with shoko if the file was removed, or if the file locations within our media folders were removed, and fix up the VFS if needed. + } + } + + // We let jellyfin take it from here. + Logger.LogDebug("Notifying Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count, fileId.ToString()); + foreach (var location in locationsToNotify) + LibraryMonitor.ReportFileSystemChanged(location); + } + + private async Task<IReadOnlySet<string>> GetSeriesIds(int importFolderId, string relativePath, int fileId, IFileEventArgs? fileEvent) + { + // TODO: VERIFY WHICH SERIES IDS TO ADD FOR. WE MAY ACCIDENTIALLY ADD A FILE LINKED TO MULTIPLE SHOWS WHERE WE ONLY HAVE THIS SINGLE FILE, WHICH WE DON'T WANT. + if (fileEvent != null) + return fileEvent.CrossReferences.Select(xref => xref.SeriesId.ToString()).Distinct().ToHashSet(); + + try { + var file = await ApiClient.GetFile(fileId.ToString()); + return file.CrossReferences.Select(xref => xref.Series.Shoko.ToString()).Distinct().ToHashSet(); + } + catch (ApiException ex) { + if (ex.StatusCode != System.Net.HttpStatusCode.NotFound) + Logger.LogWarning(ex, "An exception occured while trying to get file xrefs; {ExceptionMessage}", ex.Message); + } + catch (Exception ex) { + Logger.LogWarning(ex, "An exception occured while trying to get file xrefs; {ExceptionMessage}", ex.Message); + } + + return new HashSet<string>(); } #endregion #region Refresh Events - private void OnEpisodeInfoUpdated(EpisodeInfoUpdatedEventArgs eventArgs) + private void OnInfoUpdated(IMetadataUpdatedEventArgs eventArgs) { Logger.LogDebug( - "{ProviderName} episode {ProviderId} ({ProviderSeriesId}) dispatched event {UpdateReason}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", + "{ProviderName} {MetadataType} {ProviderId} ({ProviderParentId}) dispatched event with {UpdateReason}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", eventArgs.ProviderName, + eventArgs.Type, eventArgs.ProviderId, - eventArgs.ProviderSeriesId, + eventArgs.ProviderParentId, eventArgs.Reason, eventArgs.EpisodeIds, eventArgs.SeriesIds, eventArgs.GroupIds ); - // Refresh all epoisodes and movies linked to the episode. + if (eventArgs.Type is BaseItemKind.Episode or BaseItemKind.Series) + AddSeriesEvent(eventArgs.ProviderParentUId ?? eventArgs.ProviderUId, eventArgs); } - private void OnSeriesInfoUpdated(SeriesInfoUpdatedEventArgs eventArgs) + private void AddSeriesEvent(string metadataId, IMetadataUpdatedEventArgs eventArgs) { - Logger.LogDebug( - "{ProviderName} series {ProviderId} dispatched event {UpdateReason}. (Series={SeriesId},Group={GroupId})", - eventArgs.ProviderName, - eventArgs.ProviderId, - eventArgs.Reason, - eventArgs.SeriesIds, - eventArgs.GroupIds - ); + lock (ChangesPerSeries) { + if (ChangesPerSeries.TryGetValue(metadataId, out var tuple)) + tuple.LastUpdated = DateTime.Now; + else + ChangesPerSeries.Add(metadataId, tuple = (DateTime.Now, new())); + tuple.List.Add(eventArgs); + } + } + + private async Task ProcessSeriesChanges(string metadataId, List<IMetadataUpdatedEventArgs> changes) + { + Logger.LogInformation("Processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); + + // Refresh all epoisodes and movies linked to the episode. // look up the series/season/movie, then check the media folder they're // in to check if the refresh event is enabled for the media folder, and From faa1b97ada537435fb8e3bc834351a3e8b573cb1 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 23 Apr 2024 17:27:08 +0000 Subject: [PATCH 0890/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 83be4b85..e5e3c137 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.136", + "changelog": "refactor: partially wire up file events\n\n- Wired up file added/updated events, and laid the ground work for\n file deleted events and series/episode updated events.\n\nfix: `Path.Combine` \u2192 `Path.Join`\n\n- Replaced all occurences of `Path.Combine` with `Path.Join`, because\n they are not the same, and we want the bahaviour of `.Join`.\n\nmisc: fix casing of scheduled tasks\n\nmisc: remove unused import\n\nrefactor: track cache access\n\n- Track when we accessed the caches last, and clear them in a\n scheduled task when they become stall.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.136/shoko_3.0.1.136.zip", + "checksum": "f6457487468f2ffc88c0876f7561e04c", + "timestamp": "2024-04-23T17:27:06Z" + }, { "version": "3.0.1.135", "changelog": "fix: compensate for missing imported at dates in shoko server\n\n- Compensate for the (since fixed) bug in Shoko Server (hopefully) that\n lead to some files not having an imported at date if they've been\n imported, then removed from shoko, then reimported.\n\nmisc: fix logging of skipped event [skip ci]", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.132/shoko_3.0.1.132.zip", "checksum": "d1762380a90fb7e54218f3e3f8ecf647", "timestamp": "2024-04-19T10:56:45Z" - }, - { - "version": "3.0.1.131", - "changelog": "fix: override date created on episodes/movies\n\nfix: only iterate files in media folder once", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.131/shoko_3.0.1.131.zip", - "checksum": "5f5a6e877d92cf06ce782cd2921c7131", - "timestamp": "2024-04-16T19:01:04Z" } ] } From c771322a27e889ccf4765406b0110281420c8711 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 23 Apr 2024 17:45:29 +0000 Subject: [PATCH 0891/1103] misc: log when automagic cache clearing occurs - Log in the info level when automagic cache clearing occurs. Since not everyone have their jellyfin instance set to log Shokofin message at debug level (or below). --- Shokofin/Tasks/AutoClearPluginCacheTask.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Shokofin/Tasks/AutoClearPluginCacheTask.cs b/Shokofin/Tasks/AutoClearPluginCacheTask.cs index 79860444..cb5a12e2 100644 --- a/Shokofin/Tasks/AutoClearPluginCacheTask.cs +++ b/Shokofin/Tasks/AutoClearPluginCacheTask.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Resolvers; @@ -34,6 +35,8 @@ public class AutoClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTa /// <inheritdoc /> public bool IsLogged => false; + private readonly ILogger<AutoClearPluginCacheTask> Logger; + private readonly ShokoAPIManager ApiManager; private readonly ShokoAPIClient ApiClient; @@ -43,8 +46,9 @@ public class AutoClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTa /// <summary> /// Initializes a new instance of the <see cref="AutoClearPluginCacheTask" /> class. /// </summary> - public AutoClearPluginCacheTask(ShokoAPIManager apiManager, ShokoAPIClient apiClient, ShokoResolveManager resolveManager) + public AutoClearPluginCacheTask(ILogger<AutoClearPluginCacheTask> logger, ShokoAPIManager apiManager, ShokoAPIClient apiClient, ShokoResolveManager resolveManager) { + Logger = logger; ApiManager = apiManager; ApiClient = apiClient; ResolveManager = resolveManager; @@ -69,6 +73,8 @@ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() /// <returns>Task.</returns> public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { + if (ApiClient.IsCacheStalled || ApiManager.IsCacheStalled || ResolveManager.IsCacheStalled) + Logger.LogInformation("Automagically clearing cache…"); if (ApiClient.IsCacheStalled) ApiClient.Clear(); if (ApiManager.IsCacheStalled) From 0b02b0cf86dc5aa3eeb810f8dc92a23bb9286127 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 23 Apr 2024 17:46:34 +0000 Subject: [PATCH 0892/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index e5e3c137..f5c9b811 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.137", + "changelog": "misc: log when automagic cache clearing occurs\n\n- Log in the info level when automagic cache clearing occurs. Since not\n everyone have their jellyfin instance set to log Shokofin message at\n debug level (or below).", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.137/shoko_3.0.1.137.zip", + "checksum": "be30b6fc60b6d8b520b38b2413a1b80a", + "timestamp": "2024-04-23T17:46:32Z" + }, { "version": "3.0.1.136", "changelog": "refactor: partially wire up file events\n\n- Wired up file added/updated events, and laid the ground work for\n file deleted events and series/episode updated events.\n\nfix: `Path.Combine` \u2192 `Path.Join`\n\n- Replaced all occurences of `Path.Combine` with `Path.Join`, because\n they are not the same, and we want the bahaviour of `.Join`.\n\nmisc: fix casing of scheduled tasks\n\nmisc: remove unused import\n\nrefactor: track cache access\n\n- Track when we accessed the caches last, and clear them in a\n scheduled task when they become stall.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.133/shoko_3.0.1.133.zip", "checksum": "07b83cdf08b706f30f488424062892d7", "timestamp": "2024-04-20T11:43:59Z" - }, - { - "version": "3.0.1.132", - "changelog": "fix: fix import order for items in the VFS\n\n- Fix the \"recently added\" section of the dashboard by monkey-patching\n the creation date for each item type.\n\nfix: fix adding missing metadata\n\n- Fixed up the code for adding/removing \"missing\" metadata as needed. It\n will now work even better than it ever did, since we're now handling\n more edge cases. \ud83d\udc4d\n\nrefactor: add fast paths for link generation\n\n- Added back the fast path to skip link generation of all shows/movies\n if we've already created all possible links for the entire VFS for a\n media folder, until the cache expires or is cleared.\n\n- Added a new fast path to skip link generation for all children of\n already generated paths, until the cache expires or is cleared.\n\n- Removed the per-file-slash-series-slash-path caching, since we now\n cache per folder _and_ file location instead.\n\n- Fixed up link generation result logging so it will be consistent\n again.\n\n- Some misc. internal restructor of the resolver manager.\n\nmisc: smarter path set and episode ids generation\n\nMerge pull request #53 from fearnlj01/webhook-back-to-embed\n\nMisc: Change update webhook [skip ci]\nMisc: Change update webhook [skip ci]", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.132/shoko_3.0.1.132.zip", - "checksum": "d1762380a90fb7e54218f3e3f8ecf647", - "timestamp": "2024-04-19T10:56:45Z" } ] } From e7ed9deb4e48908f40de720e8256b872d53c29e3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 24 Apr 2024 00:40:13 +0200 Subject: [PATCH 0893/1103] fix: filter series ids before use --- Shokofin/API/ShokoAPIManager.cs | 3 -- Shokofin/SignalR/SignalRConnectionManager.cs | 39 ++++++++++---------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 7a023f96..d8edd85a 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -296,9 +296,6 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) var xref = file.CrossReferences.First(xref => xref.Series.Shoko.ToString() == seriesId); foreach (var episodeXRef in xref.Episodes) episodeIds.Add(episodeXRef.Shoko.ToString()); - if (file.ImportedAt.HasValue) { - - } } return (pathSet, episodeIds); diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index bdda70f6..e225d413 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -41,6 +41,8 @@ public class SignalRConnectionManager : IDisposable private readonly ShokoAPIClient ApiClient; + private readonly ShokoAPIManager ApiManager; + private readonly ShokoResolveManager ResolveManager; private readonly ILibraryManager LibraryManager; @@ -65,10 +67,11 @@ public class SignalRConnectionManager : IDisposable public HubConnectionState State => Connection == null ? HubConnectionState.Disconnected : Connection.State; - public SignalRConnectionManager(ILogger<SignalRConnectionManager> logger, ShokoAPIClient apiClient, ShokoResolveManager resolveManager, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IFileSystem fileSystem) + public SignalRConnectionManager(ILogger<SignalRConnectionManager> logger, ShokoAPIClient apiClient, ShokoAPIManager apiManager, ShokoResolveManager resolveManager, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IFileSystem fileSystem) { Logger = logger; ApiClient = apiClient; + ApiManager = apiManager; ResolveManager = resolveManager; LibraryManager = libraryManager; LibraryMonitor = libraryMonitor; @@ -352,10 +355,10 @@ private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int // Something was added or updated. var locationsToNotify = new List<string>(); + var seriesIds = await GetSeriesIdsForFile(fileId, changes.Select(t => t.Event).LastOrDefault(e => e.HasCrossReferences)); var mediaFolders = ResolveManager.GetAvailableMediaFolders(fileEvents: true); var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); if (reason != UpdateReason.Removed) { - var seriesIds = await GetSeriesIds(importFolderId, relativePath, fileId, changes.Select(t => t.Event).LastOrDefault(e => e.HasCrossReferences)); foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) continue; @@ -419,7 +422,6 @@ private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int else if (changes.FirstOrDefault(t => t.Reason == UpdateReason.Removed).Event is IFileRelocationEventArgs firstRemovedEvent) { relativePath = firstRemovedEvent.RelativePath; importFolderId = firstRemovedEvent.ImportFolderId; - var seriesIds = await GetSeriesIds(importFolderId, relativePath, fileId, changes.Select(t => t.Event).LastOrDefault(e => e.HasCrossReferences)); foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) continue; @@ -447,25 +449,24 @@ private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int LibraryMonitor.ReportFileSystemChanged(location); } - private async Task<IReadOnlySet<string>> GetSeriesIds(int importFolderId, string relativePath, int fileId, IFileEventArgs? fileEvent) + private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEventArgs? fileEvent) { - // TODO: VERIFY WHICH SERIES IDS TO ADD FOR. WE MAY ACCIDENTIALLY ADD A FILE LINKED TO MULTIPLE SHOWS WHERE WE ONLY HAVE THIS SINGLE FILE, WHICH WE DON'T WANT. - if (fileEvent != null) - return fileEvent.CrossReferences.Select(xref => xref.SeriesId.ToString()).Distinct().ToHashSet(); - - try { - var file = await ApiClient.GetFile(fileId.ToString()); - return file.CrossReferences.Select(xref => xref.Series.Shoko.ToString()).Distinct().ToHashSet(); - } - catch (ApiException ex) { - if (ex.StatusCode != System.Net.HttpStatusCode.NotFound) - Logger.LogWarning(ex, "An exception occured while trying to get file xrefs; {ExceptionMessage}", ex.Message); - } - catch (Exception ex) { - Logger.LogWarning(ex, "An exception occured while trying to get file xrefs; {ExceptionMessage}", ex.Message); + var seriesIds = fileEvent != null + ? fileEvent.CrossReferences.Select(xref => xref.SeriesId.ToString()).Distinct().ToHashSet() + : (await ApiClient.GetFile(fileId.ToString())).CrossReferences.Select(xref => xref.Series.Shoko.ToString()).Distinct().ToHashSet(); + + var filteredSeriesIds = new HashSet<string>(); + foreach (var seriesId in seriesIds) { + var seriesPathSet = await ApiManager.GetPathSetForSeries(seriesId); + if (seriesPathSet.Count > 0) { + filteredSeriesIds.Add(seriesId); + } } - return new HashSet<string>(); + // Return all series if we only have this file for all of them, + // otherwise return only the series were we have other files that are + // not linked to other series. + return filteredSeriesIds.Count == 0 ? seriesIds : filteredSeriesIds; } #endregion From f23cbabc7fdf0560b6e94c97845f7822be695c52 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 24 Apr 2024 00:40:37 +0200 Subject: [PATCH 0894/1103] fix: bypass http cache for import folder files --- Shokofin/API/ShokoAPIClient.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index abd532fd..592a2184 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -67,6 +67,7 @@ private Task<ReturnType> Get<ReturnType>(string url, string? apiKey = null, bool private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, string? apiKey = null, bool skipCache = false) { if (skipCache) { + Logger.LogTrace("Creating object for {Method} {URL}", method, url); var response = await Get(url, method, apiKey).ConfigureAwait(false); if (response.StatusCode != HttpStatusCode.OK) throw ApiException.FromResponse(response); @@ -270,10 +271,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").ConfigureAwait(false); + 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}&folderPath={Uri.EscapeDataString(subPath)}&pageSize=1000&include=XRefs").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); } public async Task<File.UserStats?> GetFileUserStats(string fileId, string? apiKey = null) From 44b20d5d7fd8e927f7077b3917ebdc2b1dd8cf02 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 24 Apr 2024 00:41:00 +0200 Subject: [PATCH 0895/1103] misc: move variables for better time tracking --- Shokofin/SignalR/SignalRConnectionManager.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index e225d413..d711fcd3 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -372,12 +372,12 @@ private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int locationsToNotify.Add(sourceLocation); continue; } + + var result = new LinkGenerationResult(); + var topFolders = new HashSet<string>(); var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, sourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) .ToList(); - - var topFolders = new HashSet<string>(); - var result = new LinkGenerationResult(); foreach (var (srcLoc, symLnks, nfoFls, imprtDt) in vfsLocations) { result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLnks, nfoFls, imprtDt!.Value, result.Paths); foreach (var path in symLnks.Select(path => Path.Join(vfsPath, path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())).Distinct()) From 2cb3e18a673def730bb383a284d126de59b92a06 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 23 Apr 2024 22:44:21 +0000 Subject: [PATCH 0896/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index f5c9b811..a4573759 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.138", + "changelog": "misc: move variables for better time tracking\n\nfix: bypass http cache for import folder files\n\nfix: filter series ids before use", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.138/shoko_3.0.1.138.zip", + "checksum": "63937503973e778eabda44eedb672014", + "timestamp": "2024-04-23T22:44:20Z" + }, { "version": "3.0.1.137", "changelog": "misc: log when automagic cache clearing occurs\n\n- Log in the info level when automagic cache clearing occurs. Since not\n everyone have their jellyfin instance set to log Shokofin message at\n debug level (or below).", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.134/shoko_3.0.1.134.zip", "checksum": "d07968638597639c9dc804c1907c5423", "timestamp": "2024-04-20T22:27:27Z" - }, - { - "version": "3.0.1.133", - "changelog": "misc: add more traces and comments\n\nfix: make scrobbling great again!\n\n- Add the missing initial \"play\" event late if we've enabled playback\n events _and_ lazy sync. Previously it was skipping the initial \"play\"\n event in this config, causing trakt syncing to fail from shoko to\n trakt.\n\n- Fixed event scrobbling when live sync is not enabled.\n\n- Added more documentation about the session fields.\n\nmisc: remove unused method & model [skip ci]\n\nFix discord webhook url [no ci", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.133/shoko_3.0.1.133.zip", - "checksum": "07b83cdf08b706f30f488424062892d7", - "timestamp": "2024-04-20T11:43:59Z" } ] } From e23ec4281c1d9dce46620fb777a1ccfdff421e80 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 25 Apr 2024 14:54:04 +0200 Subject: [PATCH 0897/1103] =?UTF-8?q?misc:=20`Path`=20=E2=86=92=20`Relativ?= =?UTF-8?q?ePath`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/API/Models/File.cs | 25 +++++++++++--------- Shokofin/API/ShokoAPIManager.cs | 6 ++--- Shokofin/Resolvers/ShokoResolveManager.cs | 28 +++++++++++------------ 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index bc9e58bb..32f47484 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -81,25 +81,28 @@ public class Location /// The relative path from the base of the <see cref="ImportFolder"/> to /// where the <see cref="File"/> lies. /// </summary> - public string RelativePath { get; set; } = string.Empty; + [JsonPropertyName("RelativePath")] + public string InternalPath { get; set; } = string.Empty; + + /// <summary> + /// Cached path for later re-use. + /// </summary> + [JsonIgnore] + private string? CachedPath { get; set; } /// <summary> /// The relative path from the base of the <see cref="ImportFolder"/> to /// where the <see cref="File"/> lies, with a leading slash applied at /// the start. /// </summary> - public string Path => - CachedPath ??= System.IO.Path.DirectorySeparatorChar + RelativePath + [JsonIgnore] + public string RelativePath => + CachedPath ??= System.IO.Path.DirectorySeparatorChar + InternalPath .Replace('/', System.IO.Path.DirectorySeparatorChar) .Replace('\\', System.IO.Path.DirectorySeparatorChar); /// <summary> - /// Cached path for later re-use. - /// </summary> - private string? CachedPath { get; set; } - - /// <summary> - /// True if the server can access the the <see cref="Location.Path"/> at + /// True if the server can access the the <see cref="Location.RelativePath"/> at /// the moment of requesting the data. /// </summary> [JsonPropertyName("Accessible")] @@ -178,12 +181,12 @@ public class AniDBReleaseGroup /// <summary> /// The release group's Name (Unlimited Translation Works) /// </summary> - public string Name { get; set; } = string.Empty; + public string? Name { get; set; } /// <summary> /// The release group's Name (UTW) /// </summary> - public string ShortName { get; set; } = string.Empty; + public string? ShortName { get; set; } } /// <summary> diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index d8edd85a..52ee2e11 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -292,7 +292,7 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) foreach (var file in await APIClient.GetFilesForSeries(seriesId).ConfigureAwait(false)) { if (file.CrossReferences.Count == 1) foreach (var fileLocation in file.Locations) - pathSet.Add((Path.GetDirectoryName(fileLocation.Path) ?? string.Empty) + Path.DirectorySeparatorChar); + pathSet.Add((Path.GetDirectoryName(fileLocation.RelativePath) ?? string.Empty) + Path.DirectorySeparatorChar); var xref = file.CrossReferences.First(xref => xref.Series.Shoko.ToString() == seriesId); foreach (var episodeXRef in xref.Episodes) episodeIds.Add(episodeXRef.Shoko.ToString()); @@ -371,7 +371,7 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu // Find the file locations matching the given path. var fileId = file.Id.ToString(); var fileLocations = file.Locations - .Where(location => location.Path.EndsWith(partialPath)) + .Where(location => location.RelativePath.EndsWith(partialPath)) .ToList(); Logger.LogTrace("Found a file match for {Path} (File={FileId})", partialPath, file.Id.ToString()); if (fileLocations.Count != 1) { @@ -382,7 +382,7 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu } // Find the correct series based on the path. - var selectedPath = (Path.GetDirectoryName(fileLocations.First().Path) ?? string.Empty) + Path.DirectorySeparatorChar; + var selectedPath = (Path.GetDirectoryName(fileLocations.First().RelativePath) ?? string.Empty) + Path.DirectorySeparatorChar; foreach (var seriesXRef in file.CrossReferences) { var seriesId = seriesXRef.Series.Shoko.ToString(); diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index ed894ec5..28f794f7 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -177,14 +177,14 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold var fileId = file.Id.ToString(); var fileLocations = file.Locations - .Where(location => location.Path.EndsWith(partialPath)) + .Where(location => location.RelativePath.EndsWith(partialPath)) .ToList(); if (fileLocations.Count == 0) continue; var fileLocation = fileLocations[0]; mediaFolderConfig.ImportFolderId = fileLocation.ImportFolderId; - mediaFolderConfig.ImportFolderRelativePath = fileLocation.Path[..^partialPath.Length]; + mediaFolderConfig.ImportFolderRelativePath = fileLocation.RelativePath[..^partialPath.Length]; break; } @@ -382,12 +382,12 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold ); var location = file.Locations - .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length == 0 || location.Path.StartsWith(importFolderSubPath))) + .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length == 0 || location.RelativePath.StartsWith(importFolderSubPath))) .FirstOrDefault(); if (location == null || file.CrossReferences.Count == 0) yield break; - var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); if (!File.Exists(sourceLocation)) yield break; @@ -428,10 +428,10 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold .SelectMany(file => file.Locations.Select(location => (file, location))) .ToList(); foreach (var (file, location) in fileLocations) { - if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.Path.StartsWith(importFolderSubPath)) + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) continue; - var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); if (!fileSet.Contains(sourceLocation)) continue; @@ -479,10 +479,10 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold .SelectMany(file => file.Locations.Select(location => (file, location))) .ToList(); foreach (var (file, location) in fileLocations) { - if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.Path.StartsWith(importFolderSubPath)) + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) continue; - var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); if (!fileSet.Contains(sourceLocation)) continue; @@ -504,10 +504,10 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold .SelectMany(file => file.Locations.Select(location => (file, location))) .ToList(); foreach (var (file, location) in fileLocations) { - if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.Path.StartsWith(importFolderSubPath)) + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) continue; - var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); if (!fileSet.Contains(sourceLocation)) continue; @@ -525,10 +525,10 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold .SelectMany(file => file.Locations.Select(location => (file, location))) .ToList(); foreach (var (file, location) in fileLocations) { - if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.Path.StartsWith(importFolderSubPath)) + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) continue; - var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); if (!fileSet.Contains(sourceLocation)) continue; @@ -598,12 +598,12 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold continue; var location = file.Locations - .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length == 0 || location.Path.StartsWith(importFolderSubPath))) + .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length == 0 || location.RelativePath.StartsWith(importFolderSubPath))) .FirstOrDefault(); if (location == null) continue; - var sourceLocation = Path.Join(mediaFolderPath, location.Path[importFolderSubPath.Length..]); + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); if (!fileSet.Contains(sourceLocation)) continue; From fe58e7e1b57c67153f1d1c721222d56988f719e8 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 25 Apr 2024 14:54:20 +0200 Subject: [PATCH 0898/1103] fix: fix new file moved event args --- Shokofin/SignalR/Models/FileMovedEventArgs.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/SignalR/Models/FileMovedEventArgs.cs b/Shokofin/SignalR/Models/FileMovedEventArgs.cs index 855cf7b1..0f7a05c8 100644 --- a/Shokofin/SignalR/Models/FileMovedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileMovedEventArgs.cs @@ -16,7 +16,7 @@ public class FileMovedEventArgs: FileEventArgs, IFileRelocationEventArgs /// seperators used on the Shoko side. /// </summary> [JsonInclude, JsonPropertyName("PreviousRelativePath")] - private string PreviousInternalPath { get; set; } = string.Empty; + public string PreviousInternalPath { get; set; } = string.Empty; /// <summary> /// Cached path for later re-use. From a38248255f69beabd8393792036e08de9a734005 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 25 Apr 2024 14:55:29 +0200 Subject: [PATCH 0899/1103] =?UTF-8?q?fix:=20add=20try=E2=80=A6catch=20to?= =?UTF-8?q?=20event=20proccessing=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/SignalR/SignalRConnectionManager.cs | 201 ++++++++++--------- 1 file changed, 106 insertions(+), 95 deletions(-) diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index d711fcd3..1098549c 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -351,102 +351,107 @@ private void AddFileEvent(int fileId, UpdateReason reason, int importFolderId, s private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> changes) { - Logger.LogInformation("Processing {EventCount} file change events… (File={FileId})", changes.Count, fileId); - - // Something was added or updated. - var locationsToNotify = new List<string>(); - var seriesIds = await GetSeriesIdsForFile(fileId, changes.Select(t => t.Event).LastOrDefault(e => e.HasCrossReferences)); - var mediaFolders = ResolveManager.GetAvailableMediaFolders(fileEvents: true); - var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); - if (reason != UpdateReason.Removed) { - foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { - if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) - continue; - - var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); - if (!File.Exists(sourceLocation)) - continue; - - // Let the core logic handle the rest. - if (!config.IsVirtualFileSystemEnabled) { - locationsToNotify.Add(sourceLocation); - continue; - } + try { + Logger.LogInformation("Processing {EventCount} file change events… (File={FileId})", changes.Count, fileId); + + // Something was added or updated. + var locationsToNotify = new List<string>(); + var seriesIds = await GetSeriesIdsForFile(fileId, changes.Select(t => t.Event).LastOrDefault(e => e.HasCrossReferences)); + var mediaFolders = ResolveManager.GetAvailableMediaFolders(fileEvents: true); + var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); + if (reason != UpdateReason.Removed) { + foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { + if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) + continue; - var result = new LinkGenerationResult(); - var topFolders = new HashSet<string>(); - var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, sourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) - .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) - .ToList(); - foreach (var (srcLoc, symLnks, nfoFls, imprtDt) in vfsLocations) { - result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLnks, nfoFls, imprtDt!.Value, result.Paths); - foreach (var path in symLnks.Select(path => Path.Join(vfsPath, path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())).Distinct()) - topFolders.Add(path); - } + var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); + if (!File.Exists(sourceLocation)) + continue; - // Remove old links for file. - var videos = LibraryManager - .GetItemList( - new() { - AncestorIds = new[] { mediaFolder.Id }, - IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Movie }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, - DtoOptions = new(true), - }, - true - ) - .Where(item => !string.IsNullOrEmpty(item.Path) && item.Path.StartsWith(vfsPath) && !result.Paths.Contains(item.Path)) - .ToList(); - foreach (var video in videos) { - File.Delete(video.Path); - locationsToNotify.Add(video.Path); - result.RemovedVideos++; + // Let the core logic handle the rest. + if (!config.IsVirtualFileSystemEnabled) { + locationsToNotify.Add(sourceLocation); + continue; + } + + var result = new LinkGenerationResult(); + var topFolders = new HashSet<string>(); + var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, sourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) + .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) + .ToList(); + foreach (var (srcLoc, symLnks, nfoFls, imprtDt) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLnks, nfoFls, imprtDt!.Value, result.Paths); + foreach (var path in symLnks.Select(path => Path.Join(vfsPath, path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())).Distinct()) + topFolders.Add(path); + } + + // Remove old links for file. + var videos = LibraryManager + .GetItemList( + new() { + AncestorIds = new[] { mediaFolder.Id }, + IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Movie }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, + DtoOptions = new(true), + }, + true + ) + .Where(item => !string.IsNullOrEmpty(item.Path) && item.Path.StartsWith(vfsPath) && !result.Paths.Contains(item.Path)) + .ToList(); + foreach (var video in videos) { + File.Delete(video.Path); + locationsToNotify.Add(video.Path); + result.RemovedVideos++; + } + + result.Print(Logger, mediaFolder.Path); + + // If all the "top-level-folders" exist, then let the core logic handle the rest. + if (topFolders.All(path => LibraryManager.FindByPath(path, true) != null)) { + var old = locationsToNotify.Count; + locationsToNotify.AddRange(vfsLocations.SelectMany(tuple => tuple.symbolicLinks)); + } + // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. + else { + var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); + if (!string.IsNullOrEmpty(fileOrFolder)) + locationsToNotify.Add(fileOrFolder); + } } + } + // Something was removed. + else if (changes.FirstOrDefault(t => t.Reason == UpdateReason.Removed).Event is IFileRelocationEventArgs firstRemovedEvent) { + relativePath = firstRemovedEvent.RelativePath; + importFolderId = firstRemovedEvent.ImportFolderId; + foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { + if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) + continue; + + var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); + if (!File.Exists(sourceLocation)) + continue; - result.Print(Logger, mediaFolder.Path); + // Let the core logic handle the rest. + if (!config.IsVirtualFileSystemEnabled) { + locationsToNotify.Add(sourceLocation); + continue; + } - // If all the "top-level-folders" exist, then let the core logic handle the rest. - if (topFolders.All(path => LibraryManager.FindByPath(path, true) != null)) { - var old = locationsToNotify.Count; - locationsToNotify.AddRange(vfsLocations.SelectMany(tuple => tuple.symbolicLinks)); - } - // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. - else { - var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); - if (!string.IsNullOrEmpty(fileOrFolder)) - locationsToNotify.Add(fileOrFolder); + // TODO: Detect what was removed, and update any needed links, then report the changes. + // VFS or non-VFS + // non-VFS: just check if the file was within any of our media folders, and forward the event to jellyfin if it were. + // VFS: Check with shoko if the file was removed, or if the file locations within our media folders were removed, and fix up the VFS if needed. } } - } - // Something was removed. - else if (changes.FirstOrDefault(t => t.Reason == UpdateReason.Removed).Event is IFileRelocationEventArgs firstRemovedEvent) { - relativePath = firstRemovedEvent.RelativePath; - importFolderId = firstRemovedEvent.ImportFolderId; - foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { - if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) - continue; - - var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); - if (!File.Exists(sourceLocation)) - continue; - - // Let the core logic handle the rest. - if (!config.IsVirtualFileSystemEnabled) { - locationsToNotify.Add(sourceLocation); - continue; - } - // TODO: Detect what was removed, and update any needed links, then report the changes. - // VFS or non-VFS - // non-VFS: just check if the file was within any of our media folders, and forward the event to jellyfin if it were. - // VFS: Check with shoko if the file was removed, or if the file locations within our media folders were removed, and fix up the VFS if needed. - } + // We let jellyfin take it from here. + Logger.LogDebug("Notifying Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count, fileId.ToString()); + foreach (var location in locationsToNotify) + LibraryMonitor.ReportFileSystemChanged(location); + } + catch (Exception ex) { + Logger.LogError(ex, "Error processing {EventCount} file change events. (File={FileId})", changes.Count, fileId); } - - // We let jellyfin take it from here. - Logger.LogDebug("Notifying Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count, fileId.ToString()); - foreach (var location in locationsToNotify) - LibraryMonitor.ReportFileSystemChanged(location); } private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEventArgs? fileEvent) @@ -504,16 +509,22 @@ private void AddSeriesEvent(string metadataId, IMetadataUpdatedEventArgs eventAr private async Task ProcessSeriesChanges(string metadataId, List<IMetadataUpdatedEventArgs> changes) { - Logger.LogInformation("Processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); - - // Refresh all epoisodes and movies linked to the episode. + try { + Logger.LogInformation("Processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); + + // Refresh all epoisodes and movies linked to the episode. - // look up the series/season/movie, then check the media folder they're - // in to check if the refresh event is enabled for the media folder, and - // only send out the events if it's enabled. + // look up the series/season/movie, then check the media folder they're + // in to check if the refresh event is enabled for the media folder, and + // only send out the events if it's enabled. - // Refresh the show and all entries beneath it, or all movies linked to - // the show. + // Refresh the show and all entries beneath it, or all movies linked to + // the show. + } + catch (Exception ex) { + Logger.LogError(ex, "Error processing {EventCount} metadata change events. (Metadata={ProviderUniqueId})", changes.Count, metadataId); + + } } #endregion From cae38a3b3397bbb2b912db4c34278cb7379c4eee Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 25 Apr 2024 12:56:32 +0000 Subject: [PATCH 0900/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index a4573759..0122ed0b 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.139", + "changelog": "fix: add try\u2026catch to event proccessing methods\n\nfix: fix new file moved event args\n\nmisc: `Path` \u2192 `RelativePath`", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.139/shoko_3.0.1.139.zip", + "checksum": "9feb6a039da7c1bc2c783d35c4c2bc6b", + "timestamp": "2024-04-25T12:56:29Z" + }, { "version": "3.0.1.138", "changelog": "misc: move variables for better time tracking\n\nfix: bypass http cache for import folder files\n\nfix: filter series ids before use", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.135/shoko_3.0.1.135.zip", "checksum": "03d339cab0a73bc6baab62e55a9f7d3b", "timestamp": "2024-04-21T10:44:24Z" - }, - { - "version": "3.0.1.134", - "changelog": "fix: temp. disable import order for VFS\n\nmisc: remove unneeded assignment\n\nfix: only emit path for the same file/series once\n\nrefactor: better cleanup logic\n\n- Refactor how we do the cleanup, adding more trace/debug logging,\n only get the file list once, and to only try to clean up a directory\n once.\n\nmisc: decrease default VFS link gen. threads\n\nrefactor: easier import order in VFS\n\n- Set the link creation date instead of monkey-patching the items\n afterwards.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.134/shoko_3.0.1.134.zip", - "checksum": "d07968638597639c9dc804c1907c5423", - "timestamp": "2024-04-20T22:27:27Z" } ] } From e4626a53011578ccb48e57a36a2f488619c9068d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 25 Apr 2024 16:29:47 +0200 Subject: [PATCH 0901/1103] fix: add NotNullWhen attributes --- Shokofin/API/ShokoAPIManager.cs | 13 +++--- Shokofin/IdLookup.cs | 64 ++++++++++++++-------------- Shokofin/Utils/GuardedMemoryCache.cs | 31 +++++++------- 3 files changed, 54 insertions(+), 54 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 52ee2e11..0d47e746 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using Shokofin.API.Info; @@ -565,7 +566,7 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) var key = $"season:{seriesId}"; if (DataCache.TryGetValue<SeasonInfo>(key, out var seasonInfo)) { - Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo?.Shoko.Name, seriesId); + Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId); return seasonInfo; } @@ -580,7 +581,7 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) var cachedKey = $"season:{seriesId}"; if (DataCache.TryGetValue<SeasonInfo>(cachedKey, out var seasonInfo)) { - Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo?.Shoko.Name, seriesId); + Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId); return seasonInfo; } @@ -648,7 +649,7 @@ private Task<SeasonInfo> CreateSeasonInfo(Series series, string seriesId) #endregion #region Series Helpers - public bool TryGetSeriesIdForPath(string path, out string? seriesId) + public bool TryGetSeriesIdForPath(string path, [NotNullWhen(true)] out string? seriesId) { if (string.IsNullOrEmpty(path)) { seriesId = null; @@ -657,7 +658,7 @@ public bool TryGetSeriesIdForPath(string path, out string? seriesId) return PathToSeriesIdDictionary.TryGetValue(path, out seriesId); } - public bool TryGetSeriesPathForId(string seriesId, out string? path) + public bool TryGetSeriesPathForId(string seriesId, [NotNullWhen(true)] out string? path) { if (string.IsNullOrEmpty(seriesId)) { path = null; @@ -666,7 +667,7 @@ public bool TryGetSeriesPathForId(string seriesId, out string? path) return SeriesIdToPathDictionary.TryGetValue(seriesId, out path); } - public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, out string? defaultSeriesId) + public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, [NotNullWhen(true)] out string? defaultSeriesId) { if (string.IsNullOrEmpty(seriesId)) { defaultSeriesId = null; @@ -834,7 +835,7 @@ private ShowInfo GetOrCreateShowInfoForSeasonInfo(SeasonInfo seasonInfo, string? return null; if (DataCache.TryGetValue<CollectionInfo>($"collection:by-group-id:{groupId}", out var collectionInfo)) { - Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", collectionInfo?.Name, groupId); + Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", collectionInfo.Name, groupId); return collectionInfo; } diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index 51e9789a..0504f0e8 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -32,9 +33,9 @@ public interface IIdLookup #endregion #region Series Id - bool TryGetSeriesIdFor(string path, out string seriesId); + bool TryGetSeriesIdFor(string path, [NotNullWhen(true)] out string? seriesId); - bool TryGetSeriesIdFromEpisodeId(string episodeId, out string seriesId); + bool TryGetSeriesIdFromEpisodeId(string episodeId, [NotNullWhen(true)] out string? seriesId); /// <summary> /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Series" />. @@ -42,7 +43,7 @@ public interface IIdLookup /// <param name="series">The <see cref="MediaBrowser.Controller.Entities.TV.Series" /> to check for.</param> /// <param name="seriesId">The variable to put the id in.</param> /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Series" />.</returns> - bool TryGetSeriesIdFor(Series series, out string seriesId); + bool TryGetSeriesIdFor(Series series, [NotNullWhen(true)] out string? seriesId); /// <summary> /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. @@ -50,7 +51,7 @@ public interface IIdLookup /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> /// <param name="seriesId">The variable to put the id in.</param> /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> - bool TryGetSeriesIdFor(Season season, out string seriesId); + bool TryGetSeriesIdFor(Season season, [NotNullWhen(true)] out string? seriesId); /// <summary> /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. @@ -58,7 +59,7 @@ public interface IIdLookup /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> /// <param name="seriesId">The variable to put the id in.</param> /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> - bool TryGetSeriesIdFor(BoxSet boxSet, out string seriesId); + bool TryGetSeriesIdFor(BoxSet boxSet, [NotNullWhen(true)] out string? seriesId); /// <summary> /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. @@ -66,33 +67,33 @@ public interface IIdLookup /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> /// <param name="seriesId">The variable to put the id in.</param> /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> - bool TryGetSeriesIdFor(Movie movie, out string seriesId); + bool TryGetSeriesIdFor(Movie movie, [NotNullWhen(true)] out string? seriesId); #endregion #region Series Path - bool TryGetPathForSeriesId(string seriesId, out string path); + bool TryGetPathForSeriesId(string seriesId, [NotNullWhen(true)] out string? path); #endregion #region Episode Id - bool TryGetEpisodeIdFor(string path, out string episodeId); + bool TryGetEpisodeIdFor(string path, [NotNullWhen(true)] out string? episodeId); - bool TryGetEpisodeIdFor(BaseItem item, out string episodeId); + bool TryGetEpisodeIdFor(BaseItem item, [NotNullWhen(true)] out string? episodeId); - bool TryGetEpisodeIdsFor(string path, out List<string> episodeIds); + bool TryGetEpisodeIdsFor(string path, [NotNullWhen(true)] out List<string>? episodeIds); - bool TryGetEpisodeIdsFor(BaseItem item, out List<string> episodeIds); + bool TryGetEpisodeIdsFor(BaseItem item, [NotNullWhen(true)] out List<string>? episodeIds); #endregion #region Episode Path - bool TryGetPathForEpisodeId(string episodeId, out string path); + bool TryGetPathForEpisodeId(string episodeId, [NotNullWhen(true)] out string? path); #endregion #region File Id - bool TryGetFileIdFor(BaseItem item, out string fileId); + bool TryGetFileIdFor(BaseItem item, [NotNullWhen(true)] out string? fileId); #endregion } @@ -154,7 +155,7 @@ public bool IsEnabledForItem(BaseItem item, out bool isSoleProvider) #endregion #region Series Id - public bool TryGetSeriesIdFor(string path, out string seriesId) + public bool TryGetSeriesIdFor(string path, [NotNullWhen(true)] out string? seriesId) { if (ApiManager.TryGetSeriesIdForPath(path, out seriesId!)) return true; @@ -163,7 +164,7 @@ public bool TryGetSeriesIdFor(string path, out string seriesId) return false; } - public bool TryGetSeriesIdFromEpisodeId(string episodeId, out string seriesId) + public bool TryGetSeriesIdFromEpisodeId(string episodeId, [NotNullWhen(true)] out string? seriesId) { if (ApiManager.TryGetSeriesIdForEpisodeId(episodeId, out seriesId!)) return true; @@ -172,7 +173,7 @@ public bool TryGetSeriesIdFromEpisodeId(string episodeId, out string seriesId) return false; } - public bool TryGetSeriesIdFor(Series series, out string seriesId) + public bool TryGetSeriesIdFor(Series series, [NotNullWhen(true)] out string? seriesId) { if (series.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId!) && !string.IsNullOrEmpty(seriesId)) { return true; @@ -181,7 +182,7 @@ public bool TryGetSeriesIdFor(Series series, out string seriesId) if (TryGetSeriesIdFor(series.Path, out seriesId)) { // Set the ShokoGroupId.Name and ShokoSeriesId.Name provider ids for the series, since it haven't been set again. It doesn't matter if it's not saved to the database, since we only need it while running the following code. if (ApiManager.TryGetDefaultSeriesIdForSeriesId(seriesId, out var defaultSeriesId)) { - SeriesProvider.AddProviderIds(series, defaultSeriesId!); + SeriesProvider.AddProviderIds(series, defaultSeriesId); } // Same as above, but only set the ShokoSeriesId.Name id. else { @@ -195,29 +196,26 @@ public bool TryGetSeriesIdFor(Series series, out string seriesId) return false; } - public bool TryGetSeriesIdFor(Season season, out string seriesId) + public bool TryGetSeriesIdFor(Season season, [NotNullWhen(true)] out string? seriesId) { - if (season.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId!) && !string.IsNullOrEmpty(seriesId)) { + if (season.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId) && !string.IsNullOrEmpty(seriesId)) return true; - } return TryGetSeriesIdFor(season.Path, out seriesId); } - public bool TryGetSeriesIdFor(Movie movie, out string seriesId) + public bool TryGetSeriesIdFor(Movie movie, [NotNullWhen(true)] out string? seriesId) { - if (movie.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId!) && !string.IsNullOrEmpty(seriesId)) { + if (movie.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId!) && !string.IsNullOrEmpty(seriesId)) return true; - } - if (TryGetEpisodeIdFor(movie.Path, out var episodeId) && TryGetSeriesIdFromEpisodeId(episodeId, out seriesId)) { + if (TryGetEpisodeIdFor(movie.Path, out var episodeId) && TryGetSeriesIdFromEpisodeId(episodeId, out seriesId)) return true; - } return false; } - public bool TryGetSeriesIdFor(BoxSet boxSet, out string seriesId) + public bool TryGetSeriesIdFor(BoxSet boxSet, [NotNullWhen(true)] out string? seriesId) { if (boxSet.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId!) && !string.IsNullOrEmpty(seriesId)) { return true; @@ -236,7 +234,7 @@ public bool TryGetSeriesIdFor(BoxSet boxSet, out string seriesId) #endregion #region Series Path - public bool TryGetPathForSeriesId(string seriesId, out string path) + public bool TryGetPathForSeriesId(string seriesId, [NotNullWhen(true)] out string? path) { if (ApiManager.TryGetSeriesPathForId(seriesId, out path!)) return true; @@ -248,7 +246,7 @@ public bool TryGetPathForSeriesId(string seriesId, out string path) #endregion #region Episode Id - public bool TryGetEpisodeIdFor(string path, out string episodeId) + public bool TryGetEpisodeIdFor(string path, [NotNullWhen(true)] out string? episodeId) { if (ApiManager.TryGetEpisodeIdForPath(path, out episodeId!)) return true; @@ -257,7 +255,7 @@ public bool TryGetEpisodeIdFor(string path, out string episodeId) return false; } - public bool TryGetEpisodeIdFor(BaseItem item, out string episodeId) + public bool TryGetEpisodeIdFor(BaseItem item, [NotNullWhen(true)] out string? episodeId) { // This will account for virtual episodes and existing episodes if (item.ProviderIds.TryGetValue(ShokoEpisodeId.Name, out episodeId!) && !string.IsNullOrEmpty(episodeId)) { @@ -272,7 +270,7 @@ public bool TryGetEpisodeIdFor(BaseItem item, out string episodeId) return false; } - public bool TryGetEpisodeIdsFor(string path, out List<string> episodeIds) + public bool TryGetEpisodeIdsFor(string path, [NotNullWhen(true)] out List<string>? episodeIds) { if (ApiManager.TryGetEpisodeIdsForPath(path, out episodeIds!)) return true; @@ -281,7 +279,7 @@ public bool TryGetEpisodeIdsFor(string path, out List<string> episodeIds) return false; } - public bool TryGetEpisodeIdsFor(BaseItem item, out List<string> episodeIds) + public bool TryGetEpisodeIdsFor(BaseItem item, [NotNullWhen(true)] out List<string>? episodeIds) { // This will account for virtual episodes and existing episodes if (item.ProviderIds.TryGetValue(ShokoFileId.Name, out var fileId) && item.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId) && ApiManager.TryGetEpisodeIdsForFileId(fileId, seriesId, out episodeIds!)) { @@ -299,7 +297,7 @@ public bool TryGetEpisodeIdsFor(BaseItem item, out List<string> episodeIds) #endregion #region Episode Path - public bool TryGetPathForEpisodeId(string episodeId, out string path) + public bool TryGetPathForEpisodeId(string episodeId, [NotNullWhen(true)] out string? path) { if (ApiManager.TryGetEpisodePathForId(episodeId, out path!)) return true; @@ -311,7 +309,7 @@ public bool TryGetPathForEpisodeId(string episodeId, out string path) #endregion #region File Id - public bool TryGetFileIdFor(BaseItem episode, out string fileId) + public bool TryGetFileIdFor(BaseItem episode, [NotNullWhen(true)] out string? fileId) { if (episode.ProviderIds.TryGetValue(ShokoFileId.Name, out fileId!)) return true; diff --git a/Shokofin/Utils/GuardedMemoryCache.cs b/Shokofin/Utils/GuardedMemoryCache.cs index 404bb02d..dd1e6cb9 100644 --- a/Shokofin/Utils/GuardedMemoryCache.cs +++ b/Shokofin/Utils/GuardedMemoryCache.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; @@ -50,8 +51,8 @@ public void Clear() public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICacheEntry, TItem> createFactory, MemoryCacheEntryOptions? createOptions = null) { if (TryGetValue<TItem>(key, out var value)) { - foundAction(value!); - return value!; + foundAction(value); + return value; } var semaphore = GetSemaphore(key); @@ -60,8 +61,8 @@ public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICac try { if (TryGetValue(key, out value)) { - foundAction(value!); - return value!; + foundAction(value); + return value; } using ICacheEntry entry = Cache.CreateEntry(key); @@ -82,8 +83,8 @@ public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICac public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> foundAction, Func<ICacheEntry, Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) { if (TryGetValue<TItem>(key, out var value)) { - foundAction(value!); - return value!; + foundAction(value); + return value; } var semaphore = GetSemaphore(key); @@ -92,8 +93,8 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> found try { if (TryGetValue(key, out value)) { - foundAction(value!); - return value!; + foundAction(value); + return value; } using ICacheEntry entry = Cache.CreateEntry(key); @@ -114,7 +115,7 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> found public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFactory, MemoryCacheEntryOptions? createOptions = null) { if (TryGetValue<TItem>(key, out var value)) - return value!; + return value; var semaphore = GetSemaphore(key); @@ -122,7 +123,7 @@ public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFacto try { if (TryGetValue(key, out value)) - return value!; + return value; using ICacheEntry entry = Cache.CreateEntry(key); createOptions ??= CacheEntryOptions; @@ -142,7 +143,7 @@ public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFacto public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) { if (TryGetValue<TItem>(key, out var value)) - return value!; + return value; var semaphore = GetSemaphore(key); @@ -150,7 +151,7 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, T try { if (TryGetValue(key, out value)) - return value!; + return value; using ICacheEntry entry = Cache.CreateEntry(key); createOptions ??= CacheEntryOptions; @@ -188,16 +189,16 @@ public ICacheEntry CreateEntry(object key) public void Remove(object key) => Cache.Remove(key); - public bool TryGetValue(object key, out object value) + public bool TryGetValue(object key, [NotNullWhen(true)] out object? value) => Cache.TryGetValue(key, out value); - public bool TryGetValue<TItem>(object key, out TItem? value) + public bool TryGetValue<TItem>(object key, [NotNullWhen(true)] out TItem? value) { LastAccessedAt = DateTime.Now; return Cache.TryGetValue(key, out value); } - public TItem Set<TItem>(object key, TItem value, MemoryCacheEntryOptions? createOptions = null) + public TItem? Set<TItem>(object key, [NotNullIfNotNull("value")] TItem? value, MemoryCacheEntryOptions? createOptions = null) { LastAccessedAt = DateTime.Now; return Cache.Set(key, value, createOptions ?? CacheEntryOptions); From 7bb91cd802e16281ef6fab0b59d7c20e323188e3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 25 Apr 2024 16:36:09 +0200 Subject: [PATCH 0902/1103] fix: add file delete event logic - Added file delete event logic. It may not be perfect, but at least it's something. Time will tell if i did it right or not. --- Shokofin/SignalR/SignalRConnectionManager.cs | 79 ++++++++++++++++++-- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 1098549c..2edb2413 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -400,6 +400,7 @@ private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int .ToList(); foreach (var video in videos) { File.Delete(video.Path); + topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); locationsToNotify.Add(video.Path); result.RemovedVideos++; } @@ -419,7 +420,7 @@ private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int } } } - // Something was removed. + // Something was removed, so assume the location is gone. else if (changes.FirstOrDefault(t => t.Reason == UpdateReason.Removed).Event is IFileRelocationEventArgs firstRemovedEvent) { relativePath = firstRemovedEvent.RelativePath; importFolderId = firstRemovedEvent.ImportFolderId; @@ -427,20 +428,65 @@ private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) continue; - var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); - if (!File.Exists(sourceLocation)) - continue; // Let the core logic handle the rest. if (!config.IsVirtualFileSystemEnabled) { + var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); locationsToNotify.Add(sourceLocation); continue; } - // TODO: Detect what was removed, and update any needed links, then report the changes. - // VFS or non-VFS - // non-VFS: just check if the file was within any of our media folders, and forward the event to jellyfin if it were. - // VFS: Check with shoko if the file was removed, or if the file locations within our media folders were removed, and fix up the VFS if needed. + // Check if we can use another location for the file. + var result = new LinkGenerationResult(); + var vfsSymbolicLinks = new HashSet<string>(); + var topFolders = new HashSet<string>(); + var newRelativePath = await GetNewRelativePath(config, fileId, relativePath); + if (!string.IsNullOrEmpty(newRelativePath)) { + var newSourceLocation = Path.Join(mediaFolder.Path, newRelativePath[config.ImportFolderRelativePath.Length..]); + var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, newSourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) + .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) + .ToList(); + foreach (var (srcLoc, symLnks, nfoFls, imprtDt) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLnks, nfoFls, imprtDt!.Value, result.Paths); + foreach (var path in symLnks.Select(path => Path.Join(vfsPath, path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())).Distinct()) + topFolders.Add(path); + } + vfsSymbolicLinks = vfsLocations.Select(tuple => tuple.sourceLocation).ToHashSet(); + } + + // Remove old links for file. + var videos = LibraryManager + .GetItemList( + new() { + AncestorIds = new[] { mediaFolder.Id }, + IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Movie }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, + DtoOptions = new(true), + }, + true + ) + .Where(item => !string.IsNullOrEmpty(item.Path) && item.Path.StartsWith(vfsPath) && !result.Paths.Contains(item.Path)) + .ToList(); + foreach (var video in videos) { + File.Delete(video.Path); + topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); + locationsToNotify.Add(video.Path); + result.RemovedVideos++; + } + + result.Print(Logger, mediaFolder.Path); + + // If all the "top-level-folders" exist, then let the core logic handle the rest. + if (topFolders.All(path => LibraryManager.FindByPath(path, true) != null)) { + var old = locationsToNotify.Count; + locationsToNotify.AddRange(vfsSymbolicLinks); + } + // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. + else { + var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); + if (!string.IsNullOrEmpty(fileOrFolder)) + locationsToNotify.Add(fileOrFolder); + } } } @@ -474,6 +520,23 @@ private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEv return filteredSeriesIds.Count == 0 ? seriesIds : filteredSeriesIds; } + private async Task<string?> GetNewRelativePath(MediaFolderConfiguration config, int fileId, string relativePath) + { + // Check if the file still exists, and if it has any other locations we can use. + try { + var file = await ApiClient.GetFile(fileId.ToString()); + var usableLocation = file.Locations + .Where(loc => loc.ImportFolderId == config.ImportFolderId && config.IsEnabledForPath(loc.RelativePath) && loc.RelativePath != relativePath) + .FirstOrDefault(); + return usableLocation?.RelativePath; + } + catch (ApiException ex) { + if (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + return null; + throw; + } + } + #endregion #region Refresh Events From b72b8fccbd36cd30f154988f91993e8ab8ba847b Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 25 Apr 2024 14:37:20 +0000 Subject: [PATCH 0903/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 0122ed0b..e11c712a 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.140", + "changelog": "fix: add file delete event logic\n\n- Added file delete event logic. It may not be perfect, but at least\n it's something. Time will tell if i did it right or not.\n\nfix: add NotNullWhen attributes", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.140/shoko_3.0.1.140.zip", + "checksum": "792dd239d71bccbf4f5530f658ea2785", + "timestamp": "2024-04-25T14:37:19Z" + }, { "version": "3.0.1.139", "changelog": "fix: add try\u2026catch to event proccessing methods\n\nfix: fix new file moved event args\n\nmisc: `Path` \u2192 `RelativePath`", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.136/shoko_3.0.1.136.zip", "checksum": "f6457487468f2ffc88c0876f7561e04c", "timestamp": "2024-04-23T17:27:06Z" - }, - { - "version": "3.0.1.135", - "changelog": "fix: compensate for missing imported at dates in shoko server\n\n- Compensate for the (since fixed) bug in Shoko Server (hopefully) that\n lead to some files not having an imported at date if they've been\n imported, then removed from shoko, then reimported.\n\nmisc: fix logging of skipped event [skip ci]", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.135/shoko_3.0.1.135.zip", - "checksum": "03d339cab0a73bc6baab62e55a9f7d3b", - "timestamp": "2024-04-21T10:44:24Z" } ] } From e877ab06b8bfde79574c68d562c02a12e046123b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 25 Apr 2024 16:37:38 +0200 Subject: [PATCH 0904/1103] misc: remove empty whitespace [skip ci] --- Shokofin/SignalR/SignalRConnectionManager.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 2edb2413..471ec436 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -226,7 +226,7 @@ private static string ConstructKey(PluginConfiguration config) #endregion #region Events - + #region Intervals private void OnIntervalElapsed(object? sender, ElapsedEventArgs eventArgs) @@ -574,7 +574,7 @@ private async Task ProcessSeriesChanges(string metadataId, List<IMetadataUpdated { try { Logger.LogInformation("Processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); - + // Refresh all epoisodes and movies linked to the episode. // look up the series/season/movie, then check the media folder they're @@ -586,7 +586,6 @@ private async Task ProcessSeriesChanges(string metadataId, List<IMetadataUpdated } catch (Exception ex) { Logger.LogError(ex, "Error processing {EventCount} metadata change events. (Metadata={ProviderUniqueId})", changes.Count, metadataId); - } } From d5d45b8f8800ed59a0b7bd8382a551f21a4d503a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 26 Apr 2024 14:25:48 +0200 Subject: [PATCH 0905/1103] misc: log error early if we fail to generate links - Log the error early to more easier tracking it down if we fail to generate links for a source location, instead of having to wait for `Task.WhenAll` to finish before seeing the _first_ error that occured. --- Shokofin/Resolvers/ShokoResolveManager.cs | 204 +++++++++++----------- 1 file changed, 105 insertions(+), 99 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 28f794f7..a836cd2c 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -804,127 +804,133 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime importedAt, ConcurrentBag<string> allPathsForVFS) #pragma warning restore IDE0060 { - var result = new LinkGenerationResult(); - var sourcePrefixLength = sourceLocation.Length - Path.GetExtension(sourceLocation).Length; - var subtitleLinks = FindSubtitlesForPath(sourceLocation); - foreach (var symbolicLink in symbolicLinks) { - var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; - if (!Directory.Exists(symbolicDirectory)) - Directory.CreateDirectory(symbolicDirectory); - - result.Paths.Add(symbolicLink); - if (!File.Exists(symbolicLink)) { - result.CreatedVideos++; - Logger.LogDebug("Linking {Link} → {LinkTarget}", symbolicLink, sourceLocation); - File.CreateSymbolicLink(symbolicLink, sourceLocation); - // TODO: Uncomment this for 10.9 - // // Mock the creation date to fake the "date added" order in Jellyfin. - // File.SetCreationTime(symbolicLink, importedAt); - } - else { - var shouldFix = false; - try { - 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); - } - // TODO: Uncomment this for 10.9 - // var date = File.GetCreationTime(symbolicLink); - // if (date != importedAt) { - // shouldFix = true; - // - // 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 (shouldFix) { - File.Delete(symbolicLink); + try { + var result = new LinkGenerationResult(); + var sourcePrefixLength = sourceLocation.Length - Path.GetExtension(sourceLocation).Length; + var subtitleLinks = FindSubtitlesForPath(sourceLocation); + foreach (var symbolicLink in symbolicLinks) { + var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; + if (!Directory.Exists(symbolicDirectory)) + Directory.CreateDirectory(symbolicDirectory); + + result.Paths.Add(symbolicLink); + if (!File.Exists(symbolicLink)) { + result.CreatedVideos++; + Logger.LogDebug("Linking {Link} → {LinkTarget}", symbolicLink, sourceLocation); File.CreateSymbolicLink(symbolicLink, sourceLocation); // TODO: Uncomment this for 10.9 // // Mock the creation date to fake the "date added" order in Jellyfin. // File.SetCreationTime(symbolicLink, importedAt); - result.FixedVideos++; } else { - result.SkippedVideos++; - } - } + var shouldFix = false; + try { + var nextTarget = File.ResolveLinkTarget(symbolicLink, false); + if (!string.Equals(sourceLocation, nextTarget?.FullName)) { + shouldFix = true; - if (subtitleLinks.Count > 0) { - var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); - foreach (var subtitleSource in subtitleLinks) { - var extName = subtitleSource[sourcePrefixLength..]; - var subtitleLink = Path.Join(symbolicDirectory, symbolicName + extName); - - result.Paths.Add(subtitleLink); - if (!File.Exists(subtitleLink)) { - result.CreatedSubtitles++; - Logger.LogDebug("Linking {Link} → {LinkTarget}", subtitleLink, subtitleSource); - File.CreateSymbolicLink(subtitleLink, subtitleSource); + Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", symbolicLink, sourceLocation, nextTarget?.FullName); + } + // TODO: Uncomment this for 10.9 + // var date = File.GetCreationTime(symbolicLink); + // if (date != importedAt) { + // shouldFix = true; + // + // 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 (shouldFix) { + File.Delete(symbolicLink); + File.CreateSymbolicLink(symbolicLink, sourceLocation); + // TODO: Uncomment this for 10.9 + // // Mock the creation date to fake the "date added" order in Jellyfin. + // File.SetCreationTime(symbolicLink, importedAt); + result.FixedVideos++; } else { - var shouldFix = false; - try { - var nextTarget = File.ResolveLinkTarget(subtitleLink, false); - if (!string.Equals(subtitleSource, nextTarget?.FullName)) { - shouldFix = true; + result.SkippedVideos++; + } + } - 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 (shouldFix) { - File.Delete(subtitleLink); + if (subtitleLinks.Count > 0) { + var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); + foreach (var subtitleSource in subtitleLinks) { + var extName = subtitleSource[sourcePrefixLength..]; + var subtitleLink = Path.Join(symbolicDirectory, symbolicName + extName); + + result.Paths.Add(subtitleLink); + if (!File.Exists(subtitleLink)) { + result.CreatedSubtitles++; + Logger.LogDebug("Linking {Link} → {LinkTarget}", subtitleLink, subtitleSource); File.CreateSymbolicLink(subtitleLink, subtitleSource); - result.FixedSubtitles++; } else { - result.SkippedSubtitles++; + var shouldFix = false; + try { + 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); + } + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link} for {LinkTarget}", subtitleLink, subtitleSource); + shouldFix = true; + } + if (shouldFix) { + File.Delete(subtitleLink); + File.CreateSymbolicLink(subtitleLink, subtitleSource); + result.FixedSubtitles++; + } + else { + result.SkippedSubtitles++; + } } } } } - } - // TODO: Remove these two hacks once we have proper support for adding multiple series at once. - foreach (var nfoFile in nfoFiles) - { - if (allPathsForVFS.Contains(nfoFile)) { - if (!result.Paths.Contains(nfoFile)) - result.Paths.Add(nfoFile); - continue; - } - if (result.Paths.Contains(nfoFile)) { - if (!allPathsForVFS.Contains(nfoFile)) - allPathsForVFS.Add(nfoFile); - continue; - } - allPathsForVFS.Add(nfoFile); - result.Paths.Add(nfoFile); + // TODO: Remove these two hacks once we have proper support for adding multiple series at once. + foreach (var nfoFile in nfoFiles) + { + if (allPathsForVFS.Contains(nfoFile)) { + if (!result.Paths.Contains(nfoFile)) + result.Paths.Add(nfoFile); + continue; + } + if (result.Paths.Contains(nfoFile)) { + if (!allPathsForVFS.Contains(nfoFile)) + allPathsForVFS.Add(nfoFile); + continue; + } + allPathsForVFS.Add(nfoFile); + result.Paths.Add(nfoFile); - var nfoDirectory = Path.GetDirectoryName(nfoFile)!; - if (!Directory.Exists(nfoDirectory)) - Directory.CreateDirectory(nfoDirectory); + var nfoDirectory = Path.GetDirectoryName(nfoFile)!; + if (!Directory.Exists(nfoDirectory)) + Directory.CreateDirectory(nfoDirectory); - if (!File.Exists(nfoFile)) { - result.CreatedNfos++; - Logger.LogDebug("Adding stub show/season NFO file {Target} ", nfoFile); - File.WriteAllText(nfoFile, string.Empty); - } - else { - result.SkippedNfos++; + if (!File.Exists(nfoFile)) { + result.CreatedNfos++; + Logger.LogDebug("Adding stub show/season NFO file {Target} ", nfoFile); + File.WriteAllText(nfoFile, string.Empty); + } + else { + result.SkippedNfos++; + } } - } - return result; + return result; + } + catch (Exception ex) { + Logger.LogError(ex, "An error occurred while trying to generate {LinkCount} links for {SourceLocation}; {ErrorMessage}", symbolicLinks.Length, sourceLocation, ex.Message); + throw; + } } private LinkGenerationResult CleanupStructure(string directoryToClean, ConcurrentBag<string> allKnownPaths) From cfaec6ddf3186ff24910a962250efb267dce4133 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 26 Apr 2024 12:27:03 +0000 Subject: [PATCH 0906/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index e11c712a..ff539524 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.141", + "changelog": "misc: log error early if we fail to generate links\n\n- Log the error early to more easier tracking it down if we fail to\n generate links for a source location, instead of having to wait\n for `Task.WhenAll` to finish before seeing the _first_ error that\n occured.\n\nmisc: remove empty whitespace [skip ci]", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.141/shoko_3.0.1.141.zip", + "checksum": "fab023912a30f71189d4b427443f412c", + "timestamp": "2024-04-26T12:27:00Z" + }, { "version": "3.0.1.140", "changelog": "fix: add file delete event logic\n\n- Added file delete event logic. It may not be perfect, but at least\n it's something. Time will tell if i did it right or not.\n\nfix: add NotNullWhen attributes", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.137/shoko_3.0.1.137.zip", "checksum": "be30b6fc60b6d8b520b38b2413a1b80a", "timestamp": "2024-04-23T17:46:32Z" - }, - { - "version": "3.0.1.136", - "changelog": "refactor: partially wire up file events\n\n- Wired up file added/updated events, and laid the ground work for\n file deleted events and series/episode updated events.\n\nfix: `Path.Combine` \u2192 `Path.Join`\n\n- Replaced all occurences of `Path.Combine` with `Path.Join`, because\n they are not the same, and we want the bahaviour of `.Join`.\n\nmisc: fix casing of scheduled tasks\n\nmisc: remove unused import\n\nrefactor: track cache access\n\n- Track when we accessed the caches last, and clear them in a\n scheduled task when they become stall.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.136/shoko_3.0.1.136.zip", - "checksum": "f6457487468f2ffc88c0876f7561e04c", - "timestamp": "2024-04-23T17:27:06Z" } ] } From 9efa117e23da1da1fe472e972ece4fe9e456317a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 26 Apr 2024 14:38:09 +0200 Subject: [PATCH 0907/1103] fix: expect the unexpected - Always check if the link has been created/removed if the operation fails, and if it has successfully changed to our expected outcome, then just proceed as normal. --- Shokofin/Resolvers/ShokoResolveManager.cs | 40 +++++++++++++++++++---- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index a836cd2c..514c58cc 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -817,7 +817,14 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ if (!File.Exists(symbolicLink)) { result.CreatedVideos++; Logger.LogDebug("Linking {Link} → {LinkTarget}", symbolicLink, sourceLocation); - File.CreateSymbolicLink(symbolicLink, sourceLocation); + // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. + try { + File.CreateSymbolicLink(symbolicLink, sourceLocation); + } + catch { + if (!File.Exists(symbolicLink)) + throw; + } // TODO: Uncomment this for 10.9 // // Mock the creation date to fake the "date added" order in Jellyfin. // File.SetCreationTime(symbolicLink, importedAt); @@ -844,8 +851,15 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ shouldFix = true; } if (shouldFix) { - File.Delete(symbolicLink); - File.CreateSymbolicLink(symbolicLink, sourceLocation); + // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. + try { + File.Delete(symbolicLink); + File.CreateSymbolicLink(symbolicLink, sourceLocation); + } + catch { + if (!File.Exists(symbolicLink)) + throw; + } // TODO: Uncomment this for 10.9 // // Mock the creation date to fake the "date added" order in Jellyfin. // File.SetCreationTime(symbolicLink, importedAt); @@ -866,7 +880,14 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ if (!File.Exists(subtitleLink)) { result.CreatedSubtitles++; Logger.LogDebug("Linking {Link} → {LinkTarget}", subtitleLink, subtitleSource); - File.CreateSymbolicLink(subtitleLink, subtitleSource); + // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. + try { + File.CreateSymbolicLink(subtitleLink, subtitleSource); + } + catch { + if (!File.Exists(subtitleLink)) + throw; + } } else { var shouldFix = false; @@ -883,8 +904,15 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ shouldFix = true; } if (shouldFix) { - File.Delete(subtitleLink); - File.CreateSymbolicLink(subtitleLink, subtitleSource); + // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. + try { + File.Delete(subtitleLink); + File.CreateSymbolicLink(subtitleLink, subtitleSource); + } + catch { + if (!File.Exists(subtitleLink)) + throw; + } result.FixedSubtitles++; } else { From 9164d90f3d00bbd3e13831fa31541b9c65792bd9 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 26 Apr 2024 12:45:36 +0000 Subject: [PATCH 0908/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index ff539524..9e6703c7 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.142", + "changelog": "fix: expect the unexpected\n\n- Always check if the link has been created/removed if the operation\n fails, and if it has successfully changed to our expected outcome,\n then just proceed as normal.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.142/shoko_3.0.1.142.zip", + "checksum": "743c09872a57108bc6e7c1a0aa765057", + "timestamp": "2024-04-26T12:45:35Z" + }, { "version": "3.0.1.141", "changelog": "misc: log error early if we fail to generate links\n\n- Log the error early to more easier tracking it down if we fail to\n generate links for a source location, instead of having to wait\n for `Task.WhenAll` to finish before seeing the _first_ error that\n occured.\n\nmisc: remove empty whitespace [skip ci]", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.138/shoko_3.0.1.138.zip", "checksum": "63937503973e778eabda44eedb672014", "timestamp": "2024-04-23T22:44:20Z" - }, - { - "version": "3.0.1.137", - "changelog": "misc: log when automagic cache clearing occurs\n\n- Log in the info level when automagic cache clearing occurs. Since not\n everyone have their jellyfin instance set to log Shokofin message at\n debug level (or below).", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.137/shoko_3.0.1.137.zip", - "checksum": "be30b6fc60b6d8b520b38b2413a1b80a", - "timestamp": "2024-04-23T17:46:32Z" } ] } From 07b76dc4c29d23175c9743e00147d78dac1a7f07 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Sat, 27 Apr 2024 22:34:07 +0530 Subject: [PATCH 0909/1103] Fix chronological season order --- Shokofin/API/Models/Relation.cs | 8 ++++---- Shokofin/Utils/SeriesInfoRelationComparer.cs | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Shokofin/API/Models/Relation.cs b/Shokofin/API/Models/Relation.cs index fb3d8b5f..d7e1f29c 100644 --- a/Shokofin/API/Models/Relation.cs +++ b/Shokofin/API/Models/Relation.cs @@ -10,12 +10,12 @@ public class Relation /// <summary> /// The IDs of the series. /// </summary> - public RelationIDs IDs = new(); + public RelationIDs IDs { get; set; } = new(); /// <summary> /// The IDs of the related series. /// </summary> - public RelationIDs RelatedIDs = new(); + public RelationIDs RelatedIDs { get; set; } = new(); /// <summary> /// The relation between <see cref="Relation.IDs"/> and <see cref="Relation.RelatedIDs"/>. @@ -64,7 +64,7 @@ public enum RelationType /// The entries use the same base story, but is set in alternate settings. /// </summary> AlternativeSetting = 2, - + /// <summary> /// The entries tell the same story in the same settings but are made at different times. /// </summary> @@ -129,4 +129,4 @@ public static RelationType Reverse(this RelationType type) _ => type }; } -} \ No newline at end of file +} diff --git a/Shokofin/Utils/SeriesInfoRelationComparer.cs b/Shokofin/Utils/SeriesInfoRelationComparer.cs index 8bb033e1..6560fe0a 100644 --- a/Shokofin/Utils/SeriesInfoRelationComparer.cs +++ b/Shokofin/Utils/SeriesInfoRelationComparer.cs @@ -55,20 +55,20 @@ private int CompareDirectRelations(SeasonInfo a, SeasonInfo b) // so the relation may only present on one of the entries. if (a.RelationMap.TryGetValue(b.Id, out var relationType)) if (relationType == RelationType.Prequel || relationType == RelationType.MainStory) - return -1; - else if (relationType == RelationType.Sequel || relationType == RelationType.SideStory) return 1; + else if (relationType == RelationType.Sequel || relationType == RelationType.SideStory) + return -1; if (b.RelationMap.TryGetValue(a.Id, out relationType)) if (relationType == RelationType.Prequel || relationType == RelationType.MainStory) - return 1; - else if (relationType == RelationType.Sequel || relationType == RelationType.SideStory) return -1; + else if (relationType == RelationType.Sequel || relationType == RelationType.SideStory) + return 1; // The entries are not considered to be directly related. return 0; } - + private int CompareIndirectRelations(SeasonInfo a, SeasonInfo b) { var xRelations = a.Relations From f1f0d285961a0daa1fed35f1b9004401c53997cb Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Sat, 27 Apr 2024 22:34:18 +0530 Subject: [PATCH 0910/1103] Add option to ignore indirect relations for chronological order --- Shokofin/API/Info/ShowInfo.cs | 7 ++++--- Shokofin/Configuration/configPage.html | 3 ++- Shokofin/Utils/Ordering.cs | 5 +++++ Shokofin/Utils/SeriesInfoRelationComparer.cs | 9 ++++++--- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index b0fc71d8..5958739b 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -55,7 +55,7 @@ public class ShowInfo /// <summary> /// Ended date of the show. /// </summary> - public DateTime? EndDate => + public DateTime? EndDate => SeasonList.Any(s => s.AniDB.EndDate == null) ? null : SeasonList .Select(s => s.AniDB.AirDate) .OrderBy(s => s) @@ -190,6 +190,7 @@ public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool u seasonList = seasonList.OrderBy(s => s?.AniDB?.AirDate ?? DateTime.MaxValue).ToList(); break; case Ordering.OrderType.Chronological: + case Ordering.OrderType.ChronologicalIgnoreIndirect: seasonList.Sort(new SeriesInfoRelationComparer()); break; } @@ -201,11 +202,11 @@ public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool u foundIndex = 0; break; case Ordering.OrderType.Default: - case Ordering.OrderType.Chronological: { + case Ordering.OrderType.Chronological: + case Ordering.OrderType.ChronologicalIgnoreIndirect: int targetId = group.IDs.MainSeries; foundIndex = seasonList.FindIndex(s => s.Shoko.IDs.Shoko == targetId); break; - } } // Fallback to the first series if we can't get a base point for seasons. diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 048ef7e3..f17df8a9 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -164,7 +164,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</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> </select> <div class="fieldDescription">Determines how to order seasons within each show using the Shoko groups.</div> </div> diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index d2feb977..b014e3dc 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -49,6 +49,11 @@ public enum OrderType /// Order seasons based on the chronological order of relations. /// </summary> Chronological = 2, + + /// <summary> + /// Order seasons based on the chronological order of only direct relations. + /// </summary> + ChronologicalIgnoreIndirect = 3, } public enum SpecialOrderType { diff --git a/Shokofin/Utils/SeriesInfoRelationComparer.cs b/Shokofin/Utils/SeriesInfoRelationComparer.cs index 6560fe0a..8b38d18e 100644 --- a/Shokofin/Utils/SeriesInfoRelationComparer.cs +++ b/Shokofin/Utils/SeriesInfoRelationComparer.cs @@ -40,9 +40,12 @@ public int Compare(SeasonInfo? a, SeasonInfo? b) return directRelationComparison; // Check for indirect relations. - var indirectRelationComparison = CompareIndirectRelations(a, b); - if (indirectRelationComparison != 0) - return indirectRelationComparison; + if (Plugin.Instance.Configuration.SeasonOrdering != Ordering.OrderType.ChronologicalIgnoreIndirect) + { + var indirectRelationComparison = CompareIndirectRelations(a, b); + if (indirectRelationComparison != 0) + return indirectRelationComparison; + } // Fallback to checking the air dates if they're not indirectly related // or if they have the same relations. From 5fb155953b6a961b1b26556f4fde9b06e6935829 Mon Sep 17 00:00:00 2001 From: harshithmohan <harshithmohan@users.noreply.github.com> Date: Sat, 27 Apr 2024 17:05:16 +0000 Subject: [PATCH 0911/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 9e6703c7..924951e0 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.143", + "changelog": "Add option to ignore indirect relations for chronological order\n\nFix chronological season order", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.143/shoko_3.0.1.143.zip", + "checksum": "0c438c76793f2fcf1192d786ccd25575", + "timestamp": "2024-04-27T17:05:15Z" + }, { "version": "3.0.1.142", "changelog": "fix: expect the unexpected\n\n- Always check if the link has been created/removed if the operation\n fails, and if it has successfully changed to our expected outcome,\n then just proceed as normal.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.139/shoko_3.0.1.139.zip", "checksum": "9feb6a039da7c1bc2c783d35c4c2bc6b", "timestamp": "2024-04-25T12:56:29Z" - }, - { - "version": "3.0.1.138", - "changelog": "misc: move variables for better time tracking\n\nfix: bypass http cache for import folder files\n\nfix: filter series ids before use", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.138/shoko_3.0.1.138.zip", - "checksum": "63937503973e778eabda44eedb672014", - "timestamp": "2024-04-23T22:44:20Z" } ] } From d94172446b6a1aecbc43b079864c71e4f3fe63e9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 28 Apr 2024 15:03:29 +0200 Subject: [PATCH 0912/1103] fix: fix directory cleanup order --- Shokofin/Resolvers/ShokoResolveManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 514c58cc..70b68bfe 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -1017,7 +1017,7 @@ private LinkGenerationResult CleanupStructure(string directoryToClean, Concurren return paths; }) .DistinctBy(tuple => tuple.path) - .OrderBy(tuple => tuple.level) + .OrderByDescending(tuple => tuple.level) .ThenBy(tuple => tuple.path) .Select(tuple => tuple.path) .ToList(); From f507bea1e5c5eaeb085767b2f5220031ba60b0f4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 28 Apr 2024 15:03:48 +0200 Subject: [PATCH 0913/1103] fix: add back scope in switch --- Shokofin/API/Info/ShowInfo.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index 5958739b..f765767f 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -203,10 +203,11 @@ public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool u break; case Ordering.OrderType.Default: case Ordering.OrderType.Chronological: - case Ordering.OrderType.ChronologicalIgnoreIndirect: + case Ordering.OrderType.ChronologicalIgnoreIndirect: { int targetId = group.IDs.MainSeries; foundIndex = seasonList.FindIndex(s => s.Shoko.IDs.Shoko == targetId); break; + } } // Fallback to the first series if we can't get a base point for seasons. From e9b4f31c1295edf6a9ad4c892f092e680f70a401 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 28 Apr 2024 13:06:27 +0000 Subject: [PATCH 0914/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 924951e0..70d05b8c 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.144", + "changelog": "fix: add back scope in switch\n\nfix: fix directory cleanup order", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.144/shoko_3.0.1.144.zip", + "checksum": "aa224c2b565a04b9eb81c740c876f3d1", + "timestamp": "2024-04-28T13:06:25Z" + }, { "version": "3.0.1.143", "changelog": "Add option to ignore indirect relations for chronological order\n\nFix chronological season order", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.140/shoko_3.0.1.140.zip", "checksum": "792dd239d71bccbf4f5530f658ea2785", "timestamp": "2024-04-25T14:37:19Z" - }, - { - "version": "3.0.1.139", - "changelog": "fix: add try\u2026catch to event proccessing methods\n\nfix: fix new file moved event args\n\nmisc: `Path` \u2192 `RelativePath`", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.139/shoko_3.0.1.139.zip", - "checksum": "9feb6a039da7c1bc2c783d35c4c2bc6b", - "timestamp": "2024-04-25T12:56:29Z" } ] } From 3c7ae1b6d5930b683f9f4de3c7b8eb1939216039 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 28 Apr 2024 15:25:55 +0200 Subject: [PATCH 0915/1103] fix: band-aid for latest server changes - Apply a band-aid for the latest server changes for now. The new server changes will allow better handling of when to create the links in the future. --- Shokofin/SignalR/Interfaces/IFileEventArgs.cs | 18 +++++++++++++++--- Shokofin/SignalR/SignalRConnectionManager.cs | 8 +++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Shokofin/SignalR/Interfaces/IFileEventArgs.cs b/Shokofin/SignalR/Interfaces/IFileEventArgs.cs index fad6ae5b..8aa67d25 100644 --- a/Shokofin/SignalR/Interfaces/IFileEventArgs.cs +++ b/Shokofin/SignalR/Interfaces/IFileEventArgs.cs @@ -41,22 +41,34 @@ public interface IFileEventArgs public class FileCrossReference { + /// <summary> + /// AniDB episode id. + /// </summary> + [JsonPropertyName("AnidbEpisodeID")] + public int AnidbEpisodeId { get; set; } + + /// <summary> + /// AniDB anime id. + /// </summary> + [JsonPropertyName("AnidbAnimeID")] + public int AnidbAnimeId { get; set; } + /// <summary> /// Shoko episode id. /// </summary> [JsonPropertyName("EpisodeID")] - public int EpisodeId { get; set; } + public int? ShokoEpisodeId { get; set; } /// <summary> /// Shoko series id. /// </summary> [JsonPropertyName("SeriesID")] - public int SeriesId { get; set; } + public int? ShokoSeriesId { get; set; } /// <summary> /// Shoko group id. /// </summary> [JsonPropertyName("GroupID")] - public int GroupId { get; set; } + public int? ShokoGroupId { get; set; } } } \ No newline at end of file diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 471ec436..d8ad3bc8 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -502,9 +502,11 @@ private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEventArgs? fileEvent) { - var seriesIds = fileEvent != null - ? fileEvent.CrossReferences.Select(xref => xref.SeriesId.ToString()).Distinct().ToHashSet() - : (await ApiClient.GetFile(fileId.ToString())).CrossReferences.Select(xref => xref.Series.Shoko.ToString()).Distinct().ToHashSet(); + HashSet<string> seriesIds; + if (fileEvent != null && fileEvent.CrossReferences.All(xref => xref.ShokoSeriesId.HasValue)) + seriesIds = fileEvent.CrossReferences.Select(xref => xref.ShokoSeriesId!.Value.ToString()).Distinct().ToHashSet(); + else + seriesIds = (await ApiClient.GetFile(fileId.ToString())).CrossReferences.Select(xref => xref.Series.Shoko.ToString()).Distinct().ToHashSet(); var filteredSeriesIds = new HashSet<string>(); foreach (var seriesId in seriesIds) { From f7cd288f7d4ec6414ee6b40f5d5fe3694e3d56d0 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 28 Apr 2024 13:27:37 +0000 Subject: [PATCH 0916/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 70d05b8c..7d5fc012 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.145", + "changelog": "fix: band-aid for latest server changes\n\n- Apply a band-aid for the latest server changes for now. The new\n server changes will allow better handling of when to create the links\n in the future.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.145/shoko_3.0.1.145.zip", + "checksum": "1fc84a2d6b6bcd3ff4d12705daee77b0", + "timestamp": "2024-04-28T13:27:35Z" + }, { "version": "3.0.1.144", "changelog": "fix: add back scope in switch\n\nfix: fix directory cleanup order", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.141/shoko_3.0.1.141.zip", "checksum": "fab023912a30f71189d4b427443f412c", "timestamp": "2024-04-26T12:27:00Z" - }, - { - "version": "3.0.1.140", - "changelog": "fix: add file delete event logic\n\n- Added file delete event logic. It may not be perfect, but at least\n it's something. Time will tell if i did it right or not.\n\nfix: add NotNullWhen attributes", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.140/shoko_3.0.1.140.zip", - "checksum": "792dd239d71bccbf4f5530f658ea2785", - "timestamp": "2024-04-25T14:37:19Z" } ] } From 6b8254c394236214dc67eea55bfb132d7df3dda4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 28 Apr 2024 16:40:19 +0200 Subject: [PATCH 0917/1103] refactor: move subtitles and ignore videos - Refactored the cleanup function to also move subtitles from the VFS back to the source video file, if found. And to ignore extras which are not provided by Shoko placed in the VFS, if found. These two changes should better allow third party plugins to add subtitle files and other features without breaking because of the VFS. --- Shokofin/Resolvers/ShokoResolveManager.cs | 211 ++++++++++++++++------ 1 file changed, 159 insertions(+), 52 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 70b68bfe..b9bb3470 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Threading; @@ -40,14 +41,32 @@ public class ShokoResolveManager private readonly ILogger<ShokoResolveManager> Logger; - private readonly NamingOptions _namingOptions; + private readonly NamingOptions NamingOptions; private readonly ExternalPathParser ExternalPathParser; - public bool IsCacheStalled => DataCache.IsStalled; - private readonly GuardedMemoryCache DataCache; + // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 chacters. + private const int NameCutOff = 64; + + private static readonly IReadOnlySet<string> IgnoreFolderNames = new HashSet<string>() { + "backdrops", + "behind the scenes", + "deleted scenes", + "interviews", + "scenes", + "samples", + "shorts", + "featurettes", + "clips", + "other", + "extras", + "trailers", + }; + + public bool IsCacheStalled => DataCache.IsStalled; + public ShokoResolveManager( ShokoAPIManager apiManager, ShokoAPIClient apiClient, @@ -66,7 +85,7 @@ NamingOptions namingOptions FileSystem = fileSystem; Logger = logger; DataCache = new(logger, TimeSpan.FromMinutes(15), new() { ExpirationScanFrequency = TimeSpan.FromMinutes(25) }, new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1), SlidingExpiration = TimeSpan.FromMinutes(15) }); - _namingOptions = namingOptions; + NamingOptions = namingOptions; ExternalPathParser = new ExternalPathParser(namingOptions, localizationManager, MediaBrowser.Model.Dlna.DlnaProfileType.Subtitle); LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; } @@ -124,7 +143,7 @@ private IReadOnlySet<string> GetPathsForMediaFolder(Folder mediaFolder) Logger.LogDebug("Looking for files in folder at {Path}", mediaFolder.Path); var start = DateTime.UtcNow; var paths = FileSystem.GetFilePaths(mediaFolder.Path, true) - .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + .Where(path => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) .ToHashSet(); Logger.LogDebug("Found {FileCount} files in folder at {Path} in {TimeSpan}.", paths.Count, mediaFolder.Path, DateTime.UtcNow - start); return paths; @@ -162,7 +181,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold var start = DateTime.UtcNow; var attempts = 0; var samplePaths = FileSystem.GetFilePaths(mediaFolder.Path, true) - .Where(path => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + .Where(path => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) .Take(100) .ToList(); @@ -297,7 +316,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold break; // movie - if (seriesName.TryGetAttributeValue(ShokoEpisodeId.Name, out var episodeId)) { + if (seriesName.TryGetAttributeValue(ShokoEpisodeId.Name, out _)) { if (!seasonOrMovieName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) break; @@ -323,7 +342,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) break; - if (!seasonName.StartsWith("Season ") || !int.TryParse(seasonName.Split(' ').Last(), out var seasonNumber)) + if (!seasonName.StartsWith("Season ") || !int.TryParse(seasonName.Split(' ').Last(), out _)) break; if (!episodeName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) @@ -350,7 +369,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold // Generate and cleanup the structure in the VFS. var result = await GenerateStructure(mediaFolder, vfsPath, allFiles); if (!string.IsNullOrEmpty(pathToClean)) - result += CleanupStructure(pathToClean, result.Paths); + result += CleanupStructure(vfsPath, pathToClean, result.Paths.ToArray()); // Save which paths we've already generated so we can skip generation // for them and their sub-paths later, and also print the result. @@ -687,9 +706,6 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { return result; } - // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 chacters. - private const int NameCutOff = 64; - public async Task<(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime? importedAt)> GenerateLocationsForFile(Folder mediaFolder, string sourceLocation, string fileId, string seriesId) { var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); @@ -961,14 +977,41 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ } } - private LinkGenerationResult CleanupStructure(string directoryToClean, ConcurrentBag<string> allKnownPaths) + private IReadOnlyList<string> FindSubtitlesForPath(string sourcePath) + { + var externalPaths = new List<string>(); + var folderPath = Path.GetDirectoryName(sourcePath); + if (string.IsNullOrEmpty(folderPath) || !FileSystem.DirectoryExists(folderPath)) + return externalPaths; + + var files = FileSystem.GetFilePaths(folderPath) + .Except(new[] { sourcePath }) + .ToList(); + var sourcePrefix = Path.GetFileNameWithoutExtension(sourcePath); + foreach (var file in files) { + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file); + if ( + fileNameWithoutExtension.Length >= sourcePrefix.Length && + sourcePrefix.Equals(fileNameWithoutExtension[..sourcePrefix.Length], StringComparison.OrdinalIgnoreCase) && + (fileNameWithoutExtension.Length == sourcePrefix.Length || NamingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[sourcePrefix.Length])) + ) { + var externalPathInfo = ExternalPathParser.ParseFile(file, fileNameWithoutExtension[sourcePrefix.Length..].ToString()); + if (externalPathInfo != null && !string.IsNullOrEmpty(externalPathInfo.Path)) + externalPaths.Add(externalPathInfo.Path); + } + } + + return externalPaths; + } + + private LinkGenerationResult CleanupStructure(string vfsPath, string directoryToClean, IReadOnlyList<string> allKnownPaths) { // Search the selected paths for files to remove. Logger.LogDebug("Looking for files to remove in folder at {Path}", directoryToClean); var start = DateTime.Now; var previousStep = start; var result = new LinkGenerationResult(); - var searchFiles = _namingOptions.VideoFileExtensions.Concat(_namingOptions.SubtitleFileExtensions).Append(".nfo").ToHashSet(); + var searchFiles = NamingOptions.VideoFileExtensions.Concat(NamingOptions.SubtitleFileExtensions).Append(".nfo").ToHashSet(); var toBeRemoved = FileSystem.GetFilePaths(directoryToClean, true) .Select(path => (path, extName: Path.GetExtension(path))) .Where(tuple => !string.IsNullOrEmpty(tuple.extName) && searchFiles.Contains(tuple.extName)) @@ -980,22 +1023,54 @@ private LinkGenerationResult CleanupStructure(string directoryToClean, Concurren previousStep = nextStep; foreach (var (location, extName) in toBeRemoved) { - try { - Logger.LogTrace("Removing file at {Path}", location); - File.Delete(location); + // NFOs. + if (extName == ".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; + } + result.RemovedNfos++; } - catch (Exception ex) { - Logger.LogError(ex, "Encountered an error trying to remove {FilePath}", location); - continue; + // Subtitle files. + else if (NamingOptions.SubtitleFileExtensions.Contains(extName)) { + // Try moving subtitle if possible, otherwise remove it. There is no in-between. + if (TryMoveSubtitleFile(allKnownPaths, location)) { + result.FixedSubtitles++; + } + else { + 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.RemovedSubtitles++; + + } + } + // Video files. + else { + if (ShouldIgnoreVideo(vfsPath, location)) { + result.SkippedVideos++; + } + else { + 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.RemovedVideos++; + } } - - // Stats tracking. - if (_namingOptions.VideoFileExtensions.Contains(extName)) - result.RemovedVideos++; - else if (extName == ".nfo") - result.RemovedNfos++; - else - result.RemovedSubtitles++; } nextStep = DateTime.Now; @@ -1039,31 +1114,63 @@ private LinkGenerationResult CleanupStructure(string directoryToClean, Concurren return result; } - private IReadOnlyList<string> FindSubtitlesForPath(string sourcePath) + private static bool TryMoveSubtitleFile(IReadOnlyList<string> allKnownPaths, string subtitlePath) { - var externalPaths = new List<string>(); - var folderPath = Path.GetDirectoryName(sourcePath); - if (string.IsNullOrEmpty(folderPath) || !FileSystem.DirectoryExists(folderPath)) - return externalPaths; + if (!TryGetIdsForPath(subtitlePath, out var seriesId, out var fileId)) + return false; - var files = FileSystem.GetFilePaths(folderPath) - .Except(new[] { sourcePath }) - .ToList(); - var sourcePrefix = Path.GetFileNameWithoutExtension(sourcePath); - foreach (var file in files) { - var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file); - if ( - fileNameWithoutExtension.Length >= sourcePrefix.Length && - sourcePrefix.Equals(fileNameWithoutExtension[..sourcePrefix.Length], StringComparison.OrdinalIgnoreCase) && - (fileNameWithoutExtension.Length == sourcePrefix.Length || _namingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[sourcePrefix.Length])) - ) { - var externalPathInfo = ExternalPathParser.ParseFile(file, fileNameWithoutExtension[sourcePrefix.Length..].ToString()); - if (externalPathInfo != null && !string.IsNullOrEmpty(externalPathInfo.Path)) - externalPaths.Add(externalPathInfo.Path); - } + var symbolicLink = allKnownPaths.FirstOrDefault(knownPath => + TryGetIdsForPath(knownPath, out var knownSeriesId, out var knownFileId) && seriesId == knownSeriesId && fileId == knownFileId + ); + if (string.IsNullOrEmpty(symbolicLink)) + return false; + + var sourcePathWithoutExt = symbolicLink[..^Path.GetExtension(symbolicLink).Length]; + if (!subtitlePath.StartsWith(sourcePathWithoutExt)) + return false; + + var extName = subtitlePath[sourcePathWithoutExt.Length..]; + string? realTarget = null; + try { + realTarget = File.ResolveLinkTarget(symbolicLink, false)?.FullName; } + catch { } + if (string.IsNullOrEmpty(realTarget)) + return false; - return externalPaths; + var realSubtitlePath = realTarget[..^Path.GetExtension(realTarget).Length] + extName; + if (!File.Exists(realSubtitlePath)) + File.Move(subtitlePath, realSubtitlePath); + else + File.Delete(subtitlePath); + File.CreateSymbolicLink(subtitlePath, realSubtitlePath); + + return true; + } + + private static bool ShouldIgnoreVideo(string vfsPath, string path) + { + // Ignore the video if it's within one of the folders to potentially ignore _and_ it doesn't have any shoko ids set. + var parentDirectories = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).SkipLast(1).ToArray(); + return parentDirectories.Length > 1 && IgnoreFolderNames.Contains(parentDirectories.Last()) && !TryGetIdsForPath(path, out _, out _); + } + + private static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string? seriesId, [NotNullWhen(true)] out string? fileId) + { + var fileName = Path.GetFileNameWithoutExtension(path); + if (!fileName.TryGetAttributeValue(ShokoFileId.Name, out fileId) || !int.TryParse(fileId, out _)) { + seriesId = null; + fileId = null; + return false; + } + + if (!fileName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) { + seriesId = null; + fileId = null; + return false; + } + + return true; } #endregion @@ -1096,7 +1203,7 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file return true; } - if (!fileInfo.IsDirectory && !_namingOptions.VideoFileExtensions.Contains(fileInfo.Extension.ToLowerInvariant())) { + if (!fileInfo.IsDirectory && !NamingOptions.VideoFileExtensions.Contains(fileInfo.Extension.ToLowerInvariant())) { Logger.LogDebug("Skipped excluded file at path {Path}", fileInfo.FullName); return false; } @@ -1297,10 +1404,10 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b .AsParallel() .Select(fileInfo => { // Only allow the video files, since the subtitle files also have the ids set. - if (!_namingOptions.VideoFileExtensions.Contains(Path.GetExtension(fileInfo.Name))) + if (!NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(fileInfo.Name))) return null; - if (!fileInfo.Name.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) + if (!TryGetIdsForPath(fileInfo.FullName, out seriesId, out var fileId)) return null; // This will hopefully just re-use the pre-cached entries from the cache, but it may From 62bcd8df4956532880fe11475149990de0e0ef8a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 28 Apr 2024 16:41:21 +0200 Subject: [PATCH 0918/1103] misc: move resolvers above ignore rules --- Shokofin/Resolvers/ShokoResolveManager.cs | 278 +++++++++++----------- 1 file changed, 139 insertions(+), 139 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index b9bb3470..027f6b5a 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -1175,6 +1175,145 @@ private static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string #endregion + #region Resolvers + + public async Task<BaseItem?> ResolveSingle(Folder? parent, string? collectionType, FileSystemMetadata fileInfo) + { + if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || parent == null || fileInfo == null) + return null; + + var root = LibraryManager.RootFolder; + if (root == null || parent == root) + return null; + + try { + if (!Lookup.IsEnabledForItem(parent)) + return null; + + // Skip anything outside the VFS. + if (!fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) + return null; + + if (parent.GetTopParent() is not Folder mediaFolder) + return null; + + var vfsPath = await GenerateStructureInVFS(mediaFolder, fileInfo.FullName).ConfigureAwait(false); + if (string.IsNullOrEmpty(vfsPath)) + return null; + + if (parent.Id == mediaFolder.Id && fileInfo.IsDirectory) { + if (!fileInfo.Name.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + return null; + + return new TvSeries() { + Path = fileInfo.FullName, + }; + } + + return null; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + throw; + } + } + + public async Task<MultiItemResolverResult?> ResolveMultiple(Folder? parent, string? collectionType, List<FileSystemMetadata> fileInfoList) + { + if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || parent == null) + return null; + + var root = LibraryManager.RootFolder; + if (root == null || parent == root) + return null; + + try { + if (!Lookup.IsEnabledForItem(parent)) + return null; + + if (parent.GetTopParent() is not Folder mediaFolder) + return null; + + var vfsPath = await GenerateStructureInVFS(mediaFolder, parent.Path).ConfigureAwait(false); + if (string.IsNullOrEmpty(vfsPath)) + return null; + + // Redirect children of a VFS managed media folder to the VFS. + if (parent.IsTopParent) { + var createMovies = collectionType == CollectionType.Movies || (collectionType == null && Plugin.Instance.Configuration.SeparateMovies); + var items = FileSystem.GetDirectories(vfsPath) + .AsParallel() + .SelectMany(dirInfo => { + if (!dirInfo.Name.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + return Array.Empty<BaseItem>(); + + var season = ApiManager.GetSeasonInfoForSeries(seriesId) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + if (season == null) + return Array.Empty<BaseItem>(); + + if (createMovies && season.Type == SeriesType.Movie) { + return FileSystem.GetFiles(dirInfo.FullName) + .AsParallel() + .Select(fileInfo => { + // Only allow the video files, since the subtitle files also have the ids set. + if (!NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(fileInfo.Name))) + return null; + + if (!TryGetIdsForPath(fileInfo.FullName, out seriesId, out var fileId)) + return null; + + // This will hopefully just re-use the pre-cached entries from the cache, but it may + // also get it from remote if the cache was emptied for whatever reason. + var file = ApiManager.GetFileInfo(fileId, seriesId) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + + // Abort if the file was not recognised. + if (file == null || file.ExtraType != null) + return null; + + return new Movie() { + Path = fileInfo.FullName, + } as BaseItem; + }) + .ToArray(); + } + + return new BaseItem[1] { + new TvSeries() { + Path = dirInfo.FullName, + }, + }; + }) + .OfType<BaseItem>() + .ToList(); + + // TODO: uncomment the code snippet once the PR is in stable JF. + // return new() { Items = items, ExtraFiles = new() }; + + // TODO: Remove these two hacks once we have proper support for adding multiple series at once. + if (!items.Any(i => i is Movie) && items.Count > 0) { + fileInfoList.Clear(); + fileInfoList.AddRange(items.OrderBy(s => int.Parse(s.Path.GetAttributeValue(ShokoSeriesId.Name)!)).Select(s => FileSystem.GetFileSystemInfo(s.Path))); + } + + return new() { Items = items.Where(i => i is Movie).ToList(), ExtraFiles = items.OfType<TvSeries>().Select(s => FileSystem.GetFileSystemInfo(s.Path)).ToList() }; + } + + return null; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + throw; + } + } + + #endregion + #region Ignore Rule public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) @@ -1319,143 +1458,4 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b } #endregion - - #region Resolvers - - public async Task<BaseItem?> ResolveSingle(Folder? parent, string? collectionType, FileSystemMetadata fileInfo) - { - if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || parent == null || fileInfo == null) - return null; - - var root = LibraryManager.RootFolder; - if (root == null || parent == root) - return null; - - try { - if (!Lookup.IsEnabledForItem(parent)) - return null; - - // Skip anything outside the VFS. - if (!fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) - return null; - - if (parent.GetTopParent() is not Folder mediaFolder) - return null; - - var vfsPath = await GenerateStructureInVFS(mediaFolder, fileInfo.FullName).ConfigureAwait(false); - if (string.IsNullOrEmpty(vfsPath)) - return null; - - if (parent.Id == mediaFolder.Id && fileInfo.IsDirectory) { - if (!fileInfo.Name.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) - return null; - - return new TvSeries() { - Path = fileInfo.FullName, - }; - } - - return null; - } - catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - throw; - } - } - - public async Task<MultiItemResolverResult?> ResolveMultiple(Folder? parent, string? collectionType, List<FileSystemMetadata> fileInfoList) - { - if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || parent == null) - return null; - - var root = LibraryManager.RootFolder; - if (root == null || parent == root) - return null; - - try { - if (!Lookup.IsEnabledForItem(parent)) - return null; - - if (parent.GetTopParent() is not Folder mediaFolder) - return null; - - var vfsPath = await GenerateStructureInVFS(mediaFolder, parent.Path).ConfigureAwait(false); - if (string.IsNullOrEmpty(vfsPath)) - return null; - - // Redirect children of a VFS managed media folder to the VFS. - if (parent.IsTopParent) { - var createMovies = collectionType == CollectionType.Movies || (collectionType == null && Plugin.Instance.Configuration.SeparateMovies); - var items = FileSystem.GetDirectories(vfsPath) - .AsParallel() - .SelectMany(dirInfo => { - if (!dirInfo.Name.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) - return Array.Empty<BaseItem>(); - - var season = ApiManager.GetSeasonInfoForSeries(seriesId) - .ConfigureAwait(false) - .GetAwaiter() - .GetResult(); - if (season == null) - return Array.Empty<BaseItem>(); - - if (createMovies && season.Type == SeriesType.Movie) { - return FileSystem.GetFiles(dirInfo.FullName) - .AsParallel() - .Select(fileInfo => { - // Only allow the video files, since the subtitle files also have the ids set. - if (!NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(fileInfo.Name))) - return null; - - if (!TryGetIdsForPath(fileInfo.FullName, out seriesId, out var fileId)) - return null; - - // This will hopefully just re-use the pre-cached entries from the cache, but it may - // also get it from remote if the cache was emptied for whatever reason. - var file = ApiManager.GetFileInfo(fileId, seriesId) - .ConfigureAwait(false) - .GetAwaiter() - .GetResult(); - - // Abort if the file was not recognised. - if (file == null || file.ExtraType != null) - return null; - - return new Movie() { - Path = fileInfo.FullName, - } as BaseItem; - }) - .ToArray(); - } - - return new BaseItem[1] { - new TvSeries() { - Path = dirInfo.FullName, - }, - }; - }) - .OfType<BaseItem>() - .ToList(); - - // TODO: uncomment the code snippet once the PR is in stable JF. - // return new() { Items = items, ExtraFiles = new() }; - - // TODO: Remove these two hacks once we have proper support for adding multiple series at once. - if (!items.Any(i => i is Movie) && items.Count > 0) { - fileInfoList.Clear(); - fileInfoList.AddRange(items.OrderBy(s => int.Parse(s.Path.GetAttributeValue(ShokoSeriesId.Name)!)).Select(s => FileSystem.GetFileSystemInfo(s.Path))); - } - - return new() { Items = items.Where(i => i is Movie).ToList(), ExtraFiles = items.OfType<TvSeries>().Select(s => FileSystem.GetFileSystemInfo(s.Path)).ToList() }; - } - - return null; - } - catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - throw; - } - } - - #endregion } \ No newline at end of file From 30435f17f39720b6d698cfea1a39a42aa22fffda Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 28 Apr 2024 14:43:12 +0000 Subject: [PATCH 0919/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 7d5fc012..06090988 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.146", + "changelog": "misc: move resolvers above ignore rules\n\nrefactor: move subtitles and ignore videos\n\n- Refactored the cleanup function to also move subtitles from the VFS\n back to the source video file, if found. And to ignore extras which\n are not provided by Shoko placed in the VFS, if found. These two\n changes should better allow third party plugins to add subtitle\n files and other features without breaking because of the VFS.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.146/shoko_3.0.1.146.zip", + "checksum": "e0b2206cdbf0c0b1240b68e877fe4f56", + "timestamp": "2024-04-28T14:43:09Z" + }, { "version": "3.0.1.145", "changelog": "fix: band-aid for latest server changes\n\n- Apply a band-aid for the latest server changes for now. The new\n server changes will allow better handling of when to create the links\n in the future.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.142/shoko_3.0.1.142.zip", "checksum": "743c09872a57108bc6e7c1a0aa765057", "timestamp": "2024-04-26T12:45:35Z" - }, - { - "version": "3.0.1.141", - "changelog": "misc: log error early if we fail to generate links\n\n- Log the error early to more easier tracking it down if we fail to\n generate links for a source location, instead of having to wait\n for `Task.WhenAll` to finish before seeing the _first_ error that\n occured.\n\nmisc: remove empty whitespace [skip ci]", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.141/shoko_3.0.1.141.zip", - "checksum": "fab023912a30f71189d4b427443f412c", - "timestamp": "2024-04-26T12:27:00Z" } ] } From 0d1a9e99d293c1824cd11994e0cddebb5d8f635d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 28 Apr 2024 16:46:32 +0200 Subject: [PATCH 0920/1103] misc: add "Auto" to the auto clear cache task [skip ci] --- Shokofin/Tasks/AutoClearPluginCacheTask.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Tasks/AutoClearPluginCacheTask.cs b/Shokofin/Tasks/AutoClearPluginCacheTask.cs index cb5a12e2..03c6f555 100644 --- a/Shokofin/Tasks/AutoClearPluginCacheTask.cs +++ b/Shokofin/Tasks/AutoClearPluginCacheTask.cs @@ -15,7 +15,7 @@ namespace Shokofin.Tasks; public class AutoClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> - public string Name => "Clear Plugin Cache"; + public string Name => "Clear Plugin Cache (Auto)"; /// <inheritdoc /> public string Description => "For automagic maintenance. Will clear the plugin cache if there has been no recent activity to the cache."; From cd42b1821cca121d0c1291d0dd1b333c19089f29 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 May 2024 15:35:31 +0200 Subject: [PATCH 0921/1103] fix: always remove duplicates + more - Always remove duplicates and unknown/unwanted seasons/episodes, but conditionally add missing seasons/episodes only if the option to add missing metadata is enabled. This commit will also fix the missing clean-up after disabling the 'add missing metadata' option which will now happen. --- Shokofin/Providers/CustomSeasonProvider.cs | 135 +++++++++-------- Shokofin/Providers/CustomSeriesProvider.cs | 163 +++++++++++---------- 2 files changed, 155 insertions(+), 143 deletions(-) diff --git a/Shokofin/Providers/CustomSeasonProvider.cs b/Shokofin/Providers/CustomSeasonProvider.cs index 3e2ff3d6..513d3e51 100644 --- a/Shokofin/Providers/CustomSeasonProvider.cs +++ b/Shokofin/Providers/CustomSeasonProvider.cs @@ -34,6 +34,8 @@ public class CustomSeasonProvider : ICustomMetadataProvider<Season> private readonly ILibraryManager LibraryManager; + private static bool ShouldAddMetadata => Plugin.Instance.Configuration.AddMissingMetadata; + public CustomSeasonProvider(ILogger<CustomSeasonProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager) { Logger = logger; @@ -48,11 +50,12 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio if (!season.IndexNumber.HasValue) return ItemUpdateType.None; - // Abort if we're unable to get the shoko series id + // Silently abort if we're unable to get the shoko series id. var series = season.Series; if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) return ItemUpdateType.None; + // Loudly abort if the show metadata doesn't exist. var seasonNumber = season.IndexNumber!.Value; var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); if (showInfo == null || showInfo.SeasonList.Count == 0) { @@ -60,35 +63,40 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio return ItemUpdateType.None; } + // Remove duplicates of the same season. var itemUpdated = ItemUpdateType.None; - if (Plugin.Instance.Configuration.AddMissingMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { - // Special handling of specials (pun intended). - if (seasonNumber == 0) { - var goodKnownEpisodeIds = showInfo.SpecialsSet; - var toRemove = new List<Episode>(); - var existingEpisodes = new HashSet<string>(); - foreach (var episode in season.Children.OfType<Episode>()) { - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - if (episode.IsVirtualItem && !goodKnownEpisodeIds.Overlaps(episodeIds)) { - toRemove.Add(episode); - } - else { - foreach (var episodeId in episodeIds) - existingEpisodes.Add(episodeId); - } - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { - if (episode.IsVirtualItem && !goodKnownEpisodeIds.Contains(episodeId)) - toRemove.Add(episode); - else + if (RemoveDuplicates(LibraryManager, Logger, seasonNumber, season, series, seriesId)) + itemUpdated |= ItemUpdateType.MetadataEdit; + + // Special handling of specials (pun intended). + if (seasonNumber == 0) { + // Get known episodes, existing episodes, and episodes to remove. + var knownEpisodeIds = ShouldAddMetadata ? showInfo.SpecialsSet : new HashSet<string>(); + 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)) + toRemoveEpisodes.Add(episode); + 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); + else + existingEpisodes.Add(episodeId); } + } - foreach (var episode in toRemove) { - Logger.LogDebug("Removing unknown Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, 0, series.Name, seriesId); - LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); - } + // Remove unknown or unwanted episodes. + foreach (var episode in toRemoveEpisodes) { + Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, 0, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } + // Add missing episodes. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { foreach (var sI in showInfo.SeasonList) { foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(sI.Id)) existingEpisodes.Add(episodeId); @@ -102,43 +110,46 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio } } } - // Every other "season". - else { - var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber); - if (seasonInfo == null || !showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var baseSeasonNumber)) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); - return ItemUpdateType.None; - } - var offset = Math.Abs(seasonNumber - baseSeasonNumber); - - var episodeList = offset == 0 ? seasonInfo.EpisodeList : seasonInfo.AlternateEpisodesList; - var goodKnownEpisodeIds = episodeList - .Select(episodeInfo => episodeInfo.Id) - .ToHashSet(); - var toRemove = new List<Episode>(); - var existingEpisodes = new HashSet<string>(); - foreach (var episode in season.Children.OfType<Episode>()) { - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - if (episode.IsVirtualItem && !goodKnownEpisodeIds.Overlaps(episodeIds)) { - toRemove.Add(episode); - } - else { - foreach (var episodeId in episodeIds) - existingEpisodes.Add(episodeId); - } - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { - if (episode.IsVirtualItem && !goodKnownEpisodeIds.Contains(episodeId)) - toRemove.Add(episode); - else + } + // Every other "season." + else { + // Loudly abort if the season metadata doesn't exist. + var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber); + if (seasonInfo == null || !showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var baseSeasonNumber)) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); + return ItemUpdateType.None; + } + + // 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 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)) + toRemoveEpisodes.Add(episode); + 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); + else + existingEpisodes.Add(episodeId); } + } - foreach (var episode in toRemove) { - Logger.LogDebug("Removing unknown Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, seasonNumber, series.Name, seriesId); - LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); - } + // Remove unknown or unwanted episodes. + foreach (var episode in toRemoveEpisodes) { + Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, seasonNumber, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } + // Add missing episodes. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) existingEpisodes.Add(episodeId); @@ -152,14 +163,12 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio } } - if (RemoveDuplicates(LibraryManager, Logger, seasonNumber, season, series, seriesId)) - itemUpdated |= ItemUpdateType.MetadataEdit; - return itemUpdated; } + private static bool RemoveDuplicates(ILibraryManager libraryManager, ILogger logger, int seasonNumber, Season season, Series series, string seriesId) { - // Remove the virtual season/episode that matches the newly updated item + // Remove the virtual season that matches the season. var searchList = libraryManager .GetItemList( new() { @@ -175,7 +184,7 @@ private static bool RemoveDuplicates(ILibraryManager libraryManager, ILogger log .ToList(); if (searchList.Count > 0) { - logger.LogDebug("Removing {Count:00} duplicates of Season {SeasonNumber:00} from Series {SeriesName} (Series={SeriesId})", searchList.Count, seasonNumber, series.Name, seriesId); + logger.LogDebug("Removing {Count} duplicates of Season {SeasonNumber} from Series {SeriesName} (Series={SeriesId})", searchList.Count, seasonNumber, series.Name, seriesId); var deleteOptions = new DeleteOptions { DeleteFileLocation = false }; foreach (var item in searchList) diff --git a/Shokofin/Providers/CustomSeriesProvider.cs b/Shokofin/Providers/CustomSeriesProvider.cs index 1802912e..4d010d0a 100644 --- a/Shokofin/Providers/CustomSeriesProvider.cs +++ b/Shokofin/Providers/CustomSeriesProvider.cs @@ -26,6 +26,8 @@ public class CustomSeriesProvider : ICustomMetadataProvider<Series> private readonly ILibraryManager LibraryManager; + private static bool ShouldAddMetadata => Plugin.Instance.Configuration.AddMissingMetadata; + public CustomSeriesProvider(ILogger<CustomSeriesProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager) { Logger = logger; @@ -47,62 +49,62 @@ public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptio return ItemUpdateType.None; } - // Get the existing seasons and episode ids + // Get the existing seasons and known seasons. var itemUpdated = ItemUpdateType.None; - if (Plugin.Instance.Configuration.AddMissingMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { - // Get the existing seasons and episode ids - var seasons = series.Children - .OfType<Season>() - .Where(season => season.IndexNumber.HasValue) - .ToDictionary(season => season.IndexNumber!.Value); - - var knownSeasonIds = showInfo.SeasonOrderDictionary.Select(s => s.Key).ToHashSet(); - if (showInfo.HasSpecials) - knownSeasonIds.Add(0); - - var toRemove = seasons - .ExceptBy(knownSeasonIds, season => season.Key) - .Where(season => season.Value.IsVirtualItem) - .ToList(); - foreach (var (seasonNumber, season) in toRemove) { - Logger.LogDebug("Removing unknown Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Name, seriesId); - seasons.Remove(seasonNumber); - LibraryManager.DeleteItem(season, new() { DeleteFileLocation = false }); - } + var seasons = series.Children + .OfType<Season>() + .Where(season => season.IndexNumber.HasValue) + .ToDictionary(season => season.IndexNumber!.Value); + var knownSeasonIds = showInfo.SeasonOrderDictionary.Keys.ToHashSet(); + if (showInfo.HasSpecials) + knownSeasonIds.Add(0); + + // Remove unknown or unwanted seasons. + var toRemove = (ShouldAddMetadata ? seasons.ExceptBy(knownSeasonIds, season => season.Key) : seasons) + .Where(season => string.IsNullOrEmpty(season.Value.Path) || season.Value.IsVirtualItem) + .ToList(); + foreach (var (seasonNumber, season) in toRemove) { + Logger.LogDebug("Removing Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Name, seriesId); + seasons.Remove(seasonNumber); + LibraryManager.DeleteItem(season, new() { DeleteFileLocation = false }); + } - // Add missing seasons + // Add missing seasons. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) foreach (var (seasonNumber, season) in CreateMissingSeasons(showInfo, series, seasons)) { itemUpdated |= ItemUpdateType.MetadataImport; seasons.TryAdd(seasonNumber, season); } - // Specials. - if (seasons.TryGetValue(0, out var zeroSeason)) { - var goodKnownEpisodeIds = showInfo.SpecialsSet; - var toRemoveEpisodes = new List<Episode>(); - var existingEpisodes = new HashSet<string>(); - foreach (var episode in zeroSeason.Children.OfType<Episode>()) { - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - if (episode.IsVirtualItem && !goodKnownEpisodeIds.Overlaps(episodeIds)) { - toRemoveEpisodes.Add(episode); - } - else { - foreach (var episodeId in episodeIds) - existingEpisodes.Add(episodeId); - } - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { - if (episode.IsVirtualItem && !goodKnownEpisodeIds.Contains(episodeId)) - toRemoveEpisodes.Add(episode); - else + // Special handling of Specials (pun intended). + if (seasons.TryGetValue(0, out var zeroSeason)) { + // Get known episodes, existing episodes, and episodes to remove. + var knownEpisodeIds = ShouldAddMetadata ? showInfo.SpecialsSet : new HashSet<string>(); + var existingEpisodes = new HashSet<string>(); + var toRemoveEpisodes = new List<Episode>(); + foreach (var episode in zeroSeason.Children.OfType<Episode>()) { + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) + toRemoveEpisodes.Add(episode); + 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); + else + existingEpisodes.Add(episodeId); } + } - foreach (var episode in toRemoveEpisodes) { - Logger.LogDebug("Removing unknown Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, 0, series.Name, seriesId); - LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); - } + // Remove unknown or unwanted episodes. + foreach (var episode in toRemoveEpisodes) { + Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, 0, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } + // Add missing episodes. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) foreach (var seasonInfo in showInfo.SeasonList) { foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) existingEpisodes.Add(episodeId); @@ -115,47 +117,48 @@ public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptio itemUpdated |= ItemUpdateType.MetadataImport; } } - } + } - // All other seasons. - foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; + // All other seasons. + foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { + // Silently continue if the season doesn't exist. + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; - if (seasonInfo == null || !showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var baseSeasonNumber)) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); - return ItemUpdateType.None; - } - var offset = Math.Abs(seasonNumber - baseSeasonNumber); - - var episodeList = offset == 0 ? seasonInfo.EpisodeList : seasonInfo.AlternateEpisodesList; - var goodKnownEpisodeIds = episodeList - .Select(episodeInfo => episodeInfo.Id) - .ToHashSet(); - var toRemoveEpisodes = new List<Episode>(); - var existingEpisodes = new HashSet<string>(); - foreach (var episode in season.Children.OfType<Episode>()) { - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - if (episode.IsVirtualItem && !goodKnownEpisodeIds.Overlaps(episodeIds)) { - toRemoveEpisodes.Add(episode); - } - else { - foreach (var episodeId in episodeIds) - existingEpisodes.Add(episodeId); - } - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { - if (episode.IsVirtualItem && !goodKnownEpisodeIds.Contains(episodeId)) - toRemoveEpisodes.Add(episode); - else + // Loudly skip if the season metadata doesn't exist. + if (seasonInfo == null || !showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var baseSeasonNumber)) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); + continue; + } + + // 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 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)) + toRemoveEpisodes.Add(episode); + 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); + else + existingEpisodes.Add(episodeId); } + } - foreach (var episode in toRemoveEpisodes) { - Logger.LogDebug("Removing unknown Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, seasonNumber, series.Name, seriesId); - LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); - } + // Remove unknown or unwanted episodes. + foreach (var episode in toRemoveEpisodes) { + Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, seasonNumber, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } + // Add missing episodes. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) existingEpisodes.Add(episodeId); From b2cda418c8c766965baef17d97eaf6e1c9dcaa15 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 1 May 2024 13:36:28 +0000 Subject: [PATCH 0922/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 06090988..9513e4b0 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.147", + "changelog": "fix: always remove duplicates + more\n\n- Always remove duplicates and unknown/unwanted seasons/episodes, but\n conditionally add missing seasons/episodes only if the option to add\n missing metadata is enabled. This commit will also fix the missing\n clean-up after disabling the 'add missing metadata' option which\n will now happen.\n\nmisc: add \"Auto\" to the auto clear cache task [skip ci]", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.147/shoko_3.0.1.147.zip", + "checksum": "97599739431efd2da7ef6aec21c4d2c8", + "timestamp": "2024-05-01T13:36:26Z" + }, { "version": "3.0.1.146", "changelog": "misc: move resolvers above ignore rules\n\nrefactor: move subtitles and ignore videos\n\n- Refactored the cleanup function to also move subtitles from the VFS\n back to the source video file, if found. And to ignore extras which\n are not provided by Shoko placed in the VFS, if found. These two\n changes should better allow third party plugins to add subtitle\n files and other features without breaking because of the VFS.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.143/shoko_3.0.1.143.zip", "checksum": "0c438c76793f2fcf1192d786ccd25575", "timestamp": "2024-04-27T17:05:15Z" - }, - { - "version": "3.0.1.142", - "changelog": "fix: expect the unexpected\n\n- Always check if the link has been created/removed if the operation\n fails, and if it has successfully changed to our expected outcome,\n then just proceed as normal.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.142/shoko_3.0.1.142.zip", - "checksum": "743c09872a57108bc6e7c1a0aa765057", - "timestamp": "2024-04-26T12:45:35Z" } ] } From 2794c1515f8b352c1b9af977e4915747ab597e77 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 May 2024 18:15:28 +0200 Subject: [PATCH 0923/1103] misc: split resolver and ignore rule [skip ci] --- Shokofin/Resolvers/ShokoIgnoreRule.cs | 27 +++++++++++++++++++++++++++ Shokofin/Resolvers/ShokoResolver.cs | 8 +------- 2 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 Shokofin/Resolvers/ShokoIgnoreRule.cs diff --git a/Shokofin/Resolvers/ShokoIgnoreRule.cs b/Shokofin/Resolvers/ShokoIgnoreRule.cs new file mode 100644 index 00000000..7761d228 --- /dev/null +++ b/Shokofin/Resolvers/ShokoIgnoreRule.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Model.IO; + +namespace Shokofin.Resolvers; +#pragma warning disable CS8766 + +public class ShokoIgnoreRule : IResolverIgnoreRule +{ + private readonly ShokoResolveManager ResolveManager; + + public ResolverPriority Priority => ResolverPriority.Plugin; + + public ShokoIgnoreRule(ShokoResolveManager resolveManager) + { + ResolveManager = resolveManager; + } + + public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent) + => ResolveManager.ShouldFilterItem(parent as Folder, fileInfo) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); +} diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs index cff0d862..245eef89 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -8,7 +8,7 @@ namespace Shokofin.Resolvers; #pragma warning disable CS8766 -public class ShokoResolver : IItemResolver, IMultiItemResolver, IResolverIgnoreRule +public class ShokoResolver : IItemResolver, IMultiItemResolver { private readonly ShokoResolveManager ResolveManager; @@ -19,12 +19,6 @@ public ShokoResolver(ShokoResolveManager resolveManager) ResolveManager = resolveManager; } - public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent) - => ResolveManager.ShouldFilterItem(parent as Folder, fileInfo) - .ConfigureAwait(false) - .GetAwaiter() - .GetResult(); - public BaseItem? ResolvePath(ItemResolveArgs args) => ResolveManager.ResolveSingle(args.Parent, args.CollectionType, args.FileInfo) .ConfigureAwait(false) From bcb4f8239cad89d12009c59f865bdf2884eb5711 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 May 2024 19:10:59 +0200 Subject: [PATCH 0924/1103] refactor: add true disabling of filtering - Reconfigured the setting to allow truely disabling the filtering. Do note that **__THIS IS NOT ADVICED TO BE USED UNLESS YOU KNOW EXACTLY WHAT YOU'RE DOING__**. Anyways, it lets you disable the filtering, which required changing the settings, so now everything is back to "auto" (the default) and you need to tweak your filtering setting if you want it back to the _strict_ or _lax_ mode. --- Shokofin/API/Info/SeasonInfo.cs | 13 +++++- .../Configuration/MediaFolderConfiguration.cs | 4 +- Shokofin/Configuration/PluginConfiguration.cs | 11 ++--- Shokofin/Configuration/configController.js | 17 +++---- Shokofin/Configuration/configPage.html | 38 ++++++++++------ Shokofin/Resolvers/ShokoResolveManager.cs | 18 ++++++-- Shokofin/Utils/Ordering.cs | 45 +++++++++++++++++++ 7 files changed, 113 insertions(+), 33 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 757dae0e..e92cb35e 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -81,6 +81,11 @@ public class SeasonInfo /// </summary> public readonly List<EpisodeInfo> SpecialsList; + /// <summary> + /// All leftover episodes that will not be used by the plugin. + /// </summary> + public readonly List<EpisodeInfo> LeftoverList; + /// <summary> /// Related series data available in Shoko. /// </summary> @@ -109,6 +114,7 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp var specialsList = new List<EpisodeInfo>(); var episodesList = new List<EpisodeInfo>(); var extrasList = new List<EpisodeInfo>(); + var leftoverList = new List<EpisodeInfo>(); var altEpisodesList = new List<EpisodeInfo>(); // Iterate over the episodes once and store some values for later use. @@ -124,8 +130,9 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp altEpisodesList.Add(episode); break; default: - if (episode.ExtraType != null) + if (episode.ExtraType != null) { extrasList.Add(episode); + } else if (episode.AniDB.Type == EpisodeType.Special) { specialsList.Add(episode); var previousEpisode = episodes @@ -134,6 +141,9 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp if (previousEpisode != null) specialsAnchorDictionary[episode] = previousEpisode; } + else { + leftoverList.Add(episode); + } break; } index++; @@ -179,6 +189,7 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp EpisodeList = episodesList; AlternateEpisodesList = altEpisodesList; ExtrasList = extrasList; + LeftoverList = leftoverList; SpecialsAnchors = specialsAnchorDictionary; SpecialsList = specialsList; Relations = relations; diff --git a/Shokofin/Configuration/MediaFolderConfiguration.cs b/Shokofin/Configuration/MediaFolderConfiguration.cs index 74296c62..16a5d579 100644 --- a/Shokofin/Configuration/MediaFolderConfiguration.cs +++ b/Shokofin/Configuration/MediaFolderConfiguration.cs @@ -3,6 +3,8 @@ using System.Text.Json.Serialization; using System.Xml.Serialization; +using LibraryFilteringMode = Shokofin.Utils.Ordering.LibraryFilteringMode; + namespace Shokofin.Configuration; public class MediaFolderConfiguration @@ -61,7 +63,7 @@ public class MediaFolderConfiguration /// Enable or disable the library filterin on a per-media-folder basis. Do /// note that this will only take effect if the VFS is not used. /// </summary> - public bool? IsLibraryFilteringEnabled { get; set; } = null; + public LibraryFilteringMode LibraryFilteringMode { get; set; } = LibraryFilteringMode.Auto; /// <summary> /// Check if a relative path within the import folder is potentially available in this media folder. diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 3f069697..ff48b64e 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -4,11 +4,12 @@ using System.Xml.Serialization; using Shokofin.API.Models; -using TextSourceType = Shokofin.Utils.Text.TextSourceType; -using DisplayLanguageType = Shokofin.Utils.Text.DisplayLanguageType; using CollectionCreationType = Shokofin.Utils.Ordering.CollectionCreationType; +using DisplayLanguageType = Shokofin.Utils.Text.DisplayLanguageType; +using LibraryFilteringMode = Shokofin.Utils.Ordering.LibraryFilteringMode; using OrderType = Shokofin.Utils.Ordering.OrderType; using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; +using TextSourceType = Shokofin.Utils.Text.TextSourceType; namespace Shokofin.Configuration; @@ -215,8 +216,8 @@ public virtual string PrettyUrl /// <summary> /// Enable/disable the filtering for new media-folders/libraries. /// </summary> - [XmlElement("LibraryFilteringMode")] - public bool? LibraryFiltering { get; set; } + [XmlElement("LibraryFiltering")] + public LibraryFilteringMode LibraryFilteringMode { get; set; } /// <summary> /// Per media folder configuration. @@ -312,7 +313,7 @@ public PluginConfiguration() UserList = new(); MediaFolders = new(); IgnoredFolders = new[] { ".streams", "@recently-snapshot" }; - LibraryFiltering = null; + LibraryFilteringMode = LibraryFilteringMode.Auto; SignalR_AutoConnectEnabled = false; SignalR_AutoReconnectInSeconds = new[] { 0, 2, 10, 30, 60, 120, 300 }; SignalR_RefreshEnabled = false; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 5a4d5cc6..b0eeb8ff 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -168,7 +168,7 @@ async function loadMediaFolderConfig(form, mediaFolderId, config) { // Configure the elements within the user container form.querySelector("#MediaFolderVirtualFileSystem").checked = mediaFolderConfig.IsVirtualFileSystemEnabled; - form.querySelector("#MediaFolderLibraryFiltering").value = `${mediaFolderConfig.IsLibraryFilteringEnabled != null ? mediaFolderConfig.IsLibraryFilteringEnabled : null}`; + form.querySelector("#MediaFolderLibraryFilteringMode").value = mediaFolderConfig.LibraryFilteringMode; // Show the user settings now if it was previously hidden. form.querySelector("#MediaFolderDefaultSettingsContainer").setAttribute("hidden", ""); @@ -301,14 +301,13 @@ async function defaultSubmit(form) { let mediaFolderId = form.querySelector("#MediaFolderSelector").value; let mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; if (mediaFolderConfig) { - const filteringMode = form.querySelector("#MediaFolderLibraryFiltering").value; + const filteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; mediaFolderConfig.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; - mediaFolderConfig.IsLibraryFilteringEnabled = filteringMode === "true" ? true : filteringMode === "false" ? false : null; + mediaFolderConfig.LibraryFilteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; } else { - const filteringMode = form.querySelector("#LibraryFiltering").value; config.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; - config.LibraryFiltering = filteringMode === "true" ? true : filteringMode === "false" ? false : null; + config.LibraryFilteringMode = form.querySelector("#LibraryFilteringMode").value; } // SignalR settings @@ -534,14 +533,12 @@ async function syncMediaFolderSettings(form) { const mediaFolderId = form.querySelector("#MediaFolderSelector").value; const mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; if (mediaFolderConfig) { - const filteringMode = form.querySelector("#MediaFolderLibraryFiltering").value; mediaFolderConfig.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; - mediaFolderConfig.IsLibraryFilteringEnabled = filteringMode === "true" ? true : filteringMode === "false" ? false : null; + mediaFolderConfig.LibraryFilteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; } else { - const filteringMode = form.querySelector("#LibraryFiltering").value; config.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; - config.LibraryFiltering = filteringMode === "true" ? true : filteringMode === "false" ? false : null; + config.LibraryFilteringMode = form.querySelector("#LibraryFilteringMode").value; } const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); @@ -790,7 +787,7 @@ export default function (page) { // Media Folder settings form.querySelector("#VirtualFileSystem").checked = config.VirtualFileSystem != null ? config.VirtualFileSystem : true; - form.querySelector("#LibraryFiltering").value = `${config.LibraryFiltering != null ? config.LibraryFiltering : null}`; + form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; mediaFolderSelector.innerHTML += config.MediaFolders.map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.MediaFolderPath}</option>`).join(""); // SignalR settings diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index f17df8a9..088c6066 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -239,11 +239,12 @@ <h3>Media Folder Settings</h3> </div> </div> <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="LibraryFiltering">Library Filtering:</label> - <select is="emby-select" id="LibraryFiltering" name="LibraryFiltering" class="emby-select-withcolor emby-select"> - <option value="null">Auto</option> - <option value="true" selected>Strict</option> - <option value="false">Disabled</option> + <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> + <option value="Disabled">Disabled</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> @@ -255,9 +256,14 @@ <h3>Media Folder Settings</h3> <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> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does disabling filtering entail?</summary> - Disabling the filtering means the plugin will not filter out anything from the library. Use this only if you're having trouble. + Disabling the filtering means no filtering at all. Use at your own risk of breaking the library. Also no complaining if you do disable the + filtering entirely. </details> </div> </div> @@ -284,14 +290,15 @@ <h3>Media Folder Settings</h3> </div> </div> <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="MediaFolderLibraryFiltering">Library Filtering:</label> - <select is="emby-select" id="MediaFolderLibraryFiltering" name="MediaFolderLibraryFilteringMode" class="emby-select-withcolor emby-select"> - <option value="null">Auto</option> - <option value="true" selected>Strict</option> - <option value="false">Disabled</option> + <label class="selectLabel" for="MediaFolderLibraryFilteringMode">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> + <option value="Lax" selected>Lax</option> + <option value="Disabled">Disabled</option> </select> <div class="fieldDescription"> - <div>Choose how the plugin filters out videos in your library. This option only applies if the VFS is not used.</div> + <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. @@ -300,9 +307,14 @@ <h3>Media Folder Settings</h3> <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> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does disabling filtering entail?</summary> - Disabling the filtering means the plugin will not filter out anything from the library. Use this only if you're having trouble. + Disabling the filtering means no filtering at all. Use at your own risk of breaking the library. Also no complaining if you do disable the + filtering entirely. </details> </div> </div> diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 027f6b5a..0a478abb 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -172,10 +172,10 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold mediaFolderConfig = new() { MediaFolderId = mediaFolder.Id, MediaFolderPath = mediaFolder.Path, - IsVirtualFileSystemEnabled = config.VirtualFileSystem, - IsLibraryFilteringEnabled = config.LibraryFiltering, IsFileEventsEnabled = config.SignalR_FileEvents, IsRefreshEventsEnabled = config.SignalR_RefreshEnabled, + IsVirtualFileSystemEnabled = config.VirtualFileSystem, + LibraryFilteringMode = config.LibraryFilteringMode, }; var start = DateTime.UtcNow; @@ -1364,7 +1364,19 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file if (mediaFolderConfig.IsVirtualFileSystemEnabled) return true; - var shouldIgnore = mediaFolderConfig.IsLibraryFilteringEnabled ?? mediaFolderConfig.IsVirtualFileSystemEnabled || isSoleProvider; + // Don't do any filtering if the library filtering mode is set to + // disabled. Only experts and idiots will disable it, but let them + // do their thing and watch the chaos following their decision + // themselves. + if (mediaFolderConfig.LibraryFilteringMode == Ordering.LibraryFilteringMode.Disabled) + return false; + + var shouldIgnore = mediaFolderConfig.LibraryFilteringMode switch { + Ordering.LibraryFilteringMode.Strict => true, + Ordering.LibraryFilteringMode.Lax => false, + // Ordering.LibraryFilteringMode.Auto => + _ => mediaFolderConfig.IsVirtualFileSystemEnabled || isSoleProvider, + }; var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); if (fileInfo.IsDirectory) return await ShouldFilterDirectory(partialPath, fullPath, collectionType, shouldIgnore).ConfigureAwait(false); diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index b014e3dc..28653ac0 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using Shokofin.API.Info; using Shokofin.API.Models; @@ -8,6 +9,33 @@ namespace Shokofin.Utils; public class Ordering { + /// <summary> + /// Library filtering mode. + /// </summary> + public enum LibraryFilteringMode + { + /// <summary> + /// Will use either <see cref="Strict"/> or <see cref="Lax"/> depending + /// on which metadata providers are enabled for the library. + /// </summary> + Auto = 0, + /// <summary> + /// Will only allow files/folders that are recognised and it knows + /// should be part of the library. + /// </summary> + Strict = 1, + /// <summary> + /// Will premit files/folders that are not recognised to exist in the + /// library, but will filter out anything it knows should not be part of + /// the library. + /// </summary> + Lax = 2, + /// <summary> + /// Use at your own risk. And also don't complain about the results. + /// </summary> + Disabled = 3, + } + /// <summary> /// Group series or movie box-sets /// </summary> @@ -118,10 +146,27 @@ public static int GetEpisodeNumber(ShowInfo showInfo, SeasonInfo seasonInfo, Epi return offset + index + 1; } + // All normal episodes will find their index in here. index = seasonInfo.EpisodeList.FindIndex(ep => ep.Id == episodeInfo.Id); if (index == -1) index = seasonInfo.AlternateEpisodesList.FindIndex(ep => ep.Id == episodeInfo.Id); + // Extras that show up in the season because _somebody_ decided to disable filtering. + if (index == -1) { + offset += seasonInfo.EpisodeList.Count > 0 ? seasonInfo.EpisodeList.Count : seasonInfo.AlternateEpisodesList.Count; + index = seasonInfo.ExtrasList.FindIndex(ep => ep.Id == episodeInfo.Id); + } + + // All other episodes that show up in the season because _somebody_ decided to disable filtering. + if (index == -1) { + offset += seasonInfo.ExtrasList.Count; + index = seasonInfo.LeftoverList.FindIndex(ep => ep.Id == episodeInfo.Id); + } + + // If we still cannot find the episode for whatever reason, then bail. I don't fudging know why, but I know it's not the plugin's fault. + if (index == -1) + throw new IndexOutOfRangeException($"Unable to find index to use for \"{episodeInfo.Shoko.Name}\". (Episode={episodeInfo.Id},Series={seasonInfo.Id})"); + return index + 1; } From f1ca44a70ba5a7a84f211fd13c36e2b3c777b181 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 1 May 2024 17:20:55 +0000 Subject: [PATCH 0925/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 9513e4b0..82811535 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.148", + "changelog": "refactor: add true disabling of filtering\n\n- Reconfigured the setting to allow truely disabling the filtering.\n Do note that **__THIS IS NOT ADVICED TO BE USED UNLESS YOU\n KNOW EXACTLY WHAT YOU'RE DOING__**. Anyways, it lets you\n disable the filtering, which required changing the settings, so\n now everything is back to \"auto\" (the default) and you need to\n tweak your filtering setting if you want it back to the _strict_ or\n _lax_ mode.\n\nmisc: split resolver and ignore rule [skip ci]", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.148/shoko_3.0.1.148.zip", + "checksum": "01998cb2c5497930126759f48b9a9155", + "timestamp": "2024-05-01T17:20:54Z" + }, { "version": "3.0.1.147", "changelog": "fix: always remove duplicates + more\n\n- Always remove duplicates and unknown/unwanted seasons/episodes, but\n conditionally add missing seasons/episodes only if the option to add\n missing metadata is enabled. This commit will also fix the missing\n clean-up after disabling the 'add missing metadata' option which\n will now happen.\n\nmisc: add \"Auto\" to the auto clear cache task [skip ci]", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.144/shoko_3.0.1.144.zip", "checksum": "aa224c2b565a04b9eb81c740c876f3d1", "timestamp": "2024-04-28T13:06:25Z" - }, - { - "version": "3.0.1.143", - "changelog": "Add option to ignore indirect relations for chronological order\n\nFix chronological season order", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.143/shoko_3.0.1.143.zip", - "checksum": "0c438c76793f2fcf1192d786ccd25575", - "timestamp": "2024-04-27T17:05:15Z" } ] } From fb06a39a8a9484aa3a65a7843ad0b0168f60b37a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 May 2024 21:44:15 +0200 Subject: [PATCH 0926/1103] fix: don't remove empty seasons that should exist - Don't remove the empty seasons that should exist, according to our shoko data, not using jellyfin since it's not reliable enough to use for this check. --- Shokofin/API/Info/SeasonInfo.cs | 22 ++++++++++++++++++++++ Shokofin/API/Info/ShowInfo.cs | 20 +++++++++++++------- Shokofin/Providers/CustomSeasonProvider.cs | 7 ++++++- Shokofin/Providers/CustomSeriesProvider.cs | 20 +++++++++++++++----- 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index e92cb35e..ecdab8a6 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -196,6 +196,28 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp RelationMap = relationMap; } + public bool IsEmpty(int offset = 0) + { + // The extra "season" for this season info. + if (offset == 1) + return EpisodeList.Count == 0 || !AlternateEpisodesList.Any(eI => eI.Shoko.Size > 0); + + // The default "season" for this season info. + var episodeList = EpisodeList.Count == 0 ? AlternateEpisodesList : EpisodeList; + if (!episodeList.Any(eI => eI.Shoko.Size > 0)) + return false; + + // The extras because some people don't know what they're doing and now we need to compensate for that. + if (Plugin.Instance.Configuration.LibraryFilteringMode == Utils.Ordering.LibraryFilteringMode.Disabled) { + if (!ExtrasList.Any(eI => eI.Shoko.Size > 0)) + return false; + if (!LeftoverList.Any(eI => eI.Shoko.Size > 0)) + return false; + } + + return true; + } + private static string? GetImagePath(Image image) => image != null && image.IsAvailable ? image.ToURLString() : null; diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index f765767f..cc92bdee 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -130,13 +130,19 @@ public class ShowInfo /// A pre-filtered set of special episode ids without an ExtraType /// attached. /// </summary> - public readonly IReadOnlySet<string> SpecialsSet; + public readonly IReadOnlyDictionary<string, bool> SpecialsDict; /// <summary> /// Indicates that the show has specials. /// </summary> public bool HasSpecials => - SpecialsSet.Count > 0; + SpecialsDict.Count > 0; + + /// <summary> + /// Indicates that the show has specials with files. + /// </summary> + public bool HasSpecialsWithFiles => + SpecialsDict.Values.Contains(true); /// <summary> /// The default season for the show. @@ -172,7 +178,7 @@ public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) SeasonList = new List<SeasonInfo>() { seasonInfo }; SeasonNumberBaseDictionary = seasonNumberBaseDictionary; SeasonOrderDictionary = seasonOrderDictionary; - SpecialsSet = seasonInfo.SpecialsList.Select(episodeInfo => episodeInfo.Id).ToHashSet(); + SpecialsDict = seasonInfo.SpecialsList.ToDictionary(episodeInfo => episodeInfo.Id, episodeInfo => episodeInfo.Shoko.Size > 0); DefaultSeason = seasonInfo; EpisodePadding = Math.Max(2, (new int[] { seasonInfo.EpisodeList.Count, seasonInfo.AlternateEpisodesList.Count, seasonInfo.SpecialsList.Count }).Max().ToString().Length); } @@ -217,7 +223,7 @@ public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool u } var defaultSeason = seasonList[foundIndex]; - var specialsSet = new HashSet<string>(); + var specialsSet = new Dictionary<string, bool>(); var seasonOrderDictionary = new Dictionary<int, SeasonInfo>(); var seasonNumberBaseDictionary = new Dictionary<string, int>(); var seasonNumberOffset = 1; @@ -229,7 +235,7 @@ public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool u if (seasonInfo.AlternateEpisodesList.Count > 0) seasonOrderDictionary.Add(seasonNumberOffset++, seasonInfo); foreach (var episodeInfo in seasonInfo.SpecialsList) - specialsSet.Add(episodeInfo.Id); + specialsSet.Add(episodeInfo.Id, episodeInfo.Shoko.Size > 0); } Id = defaultSeason.Id; @@ -246,13 +252,13 @@ public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool u SeasonList = seasonList; SeasonNumberBaseDictionary = seasonNumberBaseDictionary; SeasonOrderDictionary = seasonOrderDictionary; - SpecialsSet = specialsSet; + SpecialsDict = specialsSet; DefaultSeason = defaultSeason; EpisodePadding = Math.Max(2, seasonList.SelectMany(s => new int[] { s.EpisodeList.Count, s.AlternateEpisodesList.Count }).Append(specialsSet.Count).Max().ToString().Length); } public bool IsSpecial(EpisodeInfo episodeInfo) - => SpecialsSet.Contains(episodeInfo.Id); + => SpecialsDict.ContainsKey(episodeInfo.Id); public bool TryGetBaseSeasonNumberForSeasonInfo(SeasonInfo season, out int baseSeasonNumber) => SeasonNumberBaseDictionary.TryGetValue(season.Id, out baseSeasonNumber); diff --git a/Shokofin/Providers/CustomSeasonProvider.cs b/Shokofin/Providers/CustomSeasonProvider.cs index 513d3e51..88fe0b0d 100644 --- a/Shokofin/Providers/CustomSeasonProvider.cs +++ b/Shokofin/Providers/CustomSeasonProvider.cs @@ -71,7 +71,12 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio // Special handling of specials (pun intended). if (seasonNumber == 0) { // Get known episodes, existing episodes, and episodes to remove. - var knownEpisodeIds = ShouldAddMetadata ? showInfo.SpecialsSet : new HashSet<string>(); + var knownEpisodeIds = ShouldAddMetadata + ? showInfo.SpecialsDict.Keys.ToHashSet() + : showInfo.SpecialsDict + .Where(pair => pair.Value) + .Select(pair => pair.Key) + .ToHashSet(); var existingEpisodes = new HashSet<string>(); var toRemoveEpisodes = new List<Episode>(); foreach (var episode in season.Children.OfType<Episode>()) { diff --git a/Shokofin/Providers/CustomSeriesProvider.cs b/Shokofin/Providers/CustomSeriesProvider.cs index 4d010d0a..a3749c7f 100644 --- a/Shokofin/Providers/CustomSeriesProvider.cs +++ b/Shokofin/Providers/CustomSeriesProvider.cs @@ -55,15 +55,20 @@ public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptio .OfType<Season>() .Where(season => season.IndexNumber.HasValue) .ToDictionary(season => season.IndexNumber!.Value); - var knownSeasonIds = showInfo.SeasonOrderDictionary.Keys.ToHashSet(); - if (showInfo.HasSpecials) + var knownSeasonIds = ShouldAddMetadata + ? showInfo.SeasonOrderDictionary.Keys.ToHashSet() + : showInfo.SeasonOrderDictionary + .Where(pair => !pair.Value.IsEmpty(Math.Abs(pair.Key - showInfo.GetBaseSeasonNumberForSeasonInfo(pair.Value)))) + .Select(pair => pair.Key) + .ToHashSet(); + if (ShouldAddMetadata ? showInfo.HasSpecials : showInfo.HasSpecialsWithFiles) knownSeasonIds.Add(0); // Remove unknown or unwanted seasons. - var toRemove = (ShouldAddMetadata ? seasons.ExceptBy(knownSeasonIds, season => season.Key) : seasons) + var toRemoveSeasons = seasons.ExceptBy(knownSeasonIds, season => season.Key) .Where(season => string.IsNullOrEmpty(season.Value.Path) || season.Value.IsVirtualItem) .ToList(); - foreach (var (seasonNumber, season) in toRemove) { + foreach (var (seasonNumber, season) in toRemoveSeasons) { Logger.LogDebug("Removing Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Name, seriesId); seasons.Remove(seasonNumber); LibraryManager.DeleteItem(season, new() { DeleteFileLocation = false }); @@ -79,7 +84,12 @@ public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptio // Special handling of Specials (pun intended). if (seasons.TryGetValue(0, out var zeroSeason)) { // Get known episodes, existing episodes, and episodes to remove. - var knownEpisodeIds = ShouldAddMetadata ? showInfo.SpecialsSet : new HashSet<string>(); + var knownEpisodeIds = ShouldAddMetadata + ? showInfo.SpecialsDict.Keys.ToHashSet() + : showInfo.SpecialsDict + .Where(pair => pair.Value) + .Select(pair => pair.Key) + .ToHashSet(); var existingEpisodes = new HashSet<string>(); var toRemoveEpisodes = new List<Episode>(); foreach (var episode in zeroSeason.Children.OfType<Episode>()) { From 73bffa1c2b0cd466562665a6694647ff74b84299 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 1 May 2024 19:45:14 +0000 Subject: [PATCH 0927/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 82811535..f60c8568 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.149", + "changelog": "fix: don't remove empty seasons that should exist\n\n- Don't remove the empty seasons that should exist, according to our\n shoko data, not using jellyfin since it's not reliable enough to use\n for this check.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.149/shoko_3.0.1.149.zip", + "checksum": "a646ae721eb48b15067c3c4ba7230129", + "timestamp": "2024-05-01T19:45:13Z" + }, { "version": "3.0.1.148", "changelog": "refactor: add true disabling of filtering\n\n- Reconfigured the setting to allow truely disabling the filtering.\n Do note that **__THIS IS NOT ADVICED TO BE USED UNLESS YOU\n KNOW EXACTLY WHAT YOU'RE DOING__**. Anyways, it lets you\n disable the filtering, which required changing the settings, so\n now everything is back to \"auto\" (the default) and you need to\n tweak your filtering setting if you want it back to the _strict_ or\n _lax_ mode.\n\nmisc: split resolver and ignore rule [skip ci]", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.145/shoko_3.0.1.145.zip", "checksum": "1fc84a2d6b6bcd3ff4d12705daee77b0", "timestamp": "2024-04-28T13:27:35Z" - }, - { - "version": "3.0.1.144", - "changelog": "fix: add back scope in switch\n\nfix: fix directory cleanup order", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.144/shoko_3.0.1.144.zip", - "checksum": "aa224c2b565a04b9eb81c740c876f3d1", - "timestamp": "2024-04-28T13:06:25Z" } ] } From 22527f44ef06b792b7c43e9dc23c7f0ab91f5717 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 1 May 2024 21:59:16 +0200 Subject: [PATCH 0928/1103] refactor: remove the true disabled option This partially reverts commits bcb4f8239cad89d12009c59f865bdf2884eb571 and fb06a39a8a9484aa3a65a7843ad0b0168f60b37a because it's not needed anymore. --- Shokofin/API/Info/SeasonInfo.cs | 18 ------------------ Shokofin/Configuration/configPage.html | 12 ------------ Shokofin/Resolvers/ShokoResolveManager.cs | 7 ------- Shokofin/Utils/Ordering.cs | 16 ---------------- 4 files changed, 53 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index ecdab8a6..e977da1a 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -81,11 +81,6 @@ public class SeasonInfo /// </summary> public readonly List<EpisodeInfo> SpecialsList; - /// <summary> - /// All leftover episodes that will not be used by the plugin. - /// </summary> - public readonly List<EpisodeInfo> LeftoverList; - /// <summary> /// Related series data available in Shoko. /// </summary> @@ -114,7 +109,6 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp var specialsList = new List<EpisodeInfo>(); var episodesList = new List<EpisodeInfo>(); var extrasList = new List<EpisodeInfo>(); - var leftoverList = new List<EpisodeInfo>(); var altEpisodesList = new List<EpisodeInfo>(); // Iterate over the episodes once and store some values for later use. @@ -141,9 +135,6 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp if (previousEpisode != null) specialsAnchorDictionary[episode] = previousEpisode; } - else { - leftoverList.Add(episode); - } break; } index++; @@ -189,7 +180,6 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp EpisodeList = episodesList; AlternateEpisodesList = altEpisodesList; ExtrasList = extrasList; - LeftoverList = leftoverList; SpecialsAnchors = specialsAnchorDictionary; SpecialsList = specialsList; Relations = relations; @@ -207,14 +197,6 @@ public bool IsEmpty(int offset = 0) if (!episodeList.Any(eI => eI.Shoko.Size > 0)) return false; - // The extras because some people don't know what they're doing and now we need to compensate for that. - if (Plugin.Instance.Configuration.LibraryFilteringMode == Utils.Ordering.LibraryFilteringMode.Disabled) { - if (!ExtrasList.Any(eI => eI.Shoko.Size > 0)) - return false; - if (!LeftoverList.Any(eI => eI.Shoko.Size > 0)) - return false; - } - return true; } diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 088c6066..1cd0f6ba 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -244,7 +244,6 @@ <h3>Media Folder Settings</h3> <option value="Auto">Auto</option> <option value="Strict" selected>Strict</option> <option value="Lax" selected>Lax</option> - <option value="Disabled">Disabled</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> @@ -260,11 +259,6 @@ <h3>Media Folder Settings</h3> <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> - <details style="margin-top: 0.5em"> - <summary style="margin-bottom: 0.25em">What does disabling filtering entail?</summary> - Disabling the filtering means no filtering at all. Use at your own risk of breaking the library. Also no complaining if you do disable the - filtering entirely. - </details> </div> </div> </div> @@ -295,7 +289,6 @@ <h3>Media Folder Settings</h3> <option value="Auto">Auto</option> <option value="Strict" selected>Strict</option> <option value="Lax" selected>Lax</option> - <option value="Disabled">Disabled</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> @@ -311,11 +304,6 @@ <h3>Media Folder Settings</h3> <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> - <details style="margin-top: 0.5em"> - <summary style="margin-bottom: 0.25em">What does disabling filtering entail?</summary> - Disabling the filtering means no filtering at all. Use at your own risk of breaking the library. Also no complaining if you do disable the - filtering entirely. - </details> </div> </div> </div> diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 0a478abb..b0df3176 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -1364,13 +1364,6 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file if (mediaFolderConfig.IsVirtualFileSystemEnabled) return true; - // Don't do any filtering if the library filtering mode is set to - // disabled. Only experts and idiots will disable it, but let them - // do their thing and watch the chaos following their decision - // themselves. - if (mediaFolderConfig.LibraryFilteringMode == Ordering.LibraryFilteringMode.Disabled) - return false; - var shouldIgnore = mediaFolderConfig.LibraryFilteringMode switch { Ordering.LibraryFilteringMode.Strict => true, Ordering.LibraryFilteringMode.Lax => false, diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index 28653ac0..376321c7 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -30,10 +30,6 @@ public enum LibraryFilteringMode /// the library. /// </summary> Lax = 2, - /// <summary> - /// Use at your own risk. And also don't complain about the results. - /// </summary> - Disabled = 3, } /// <summary> @@ -151,18 +147,6 @@ public static int GetEpisodeNumber(ShowInfo showInfo, SeasonInfo seasonInfo, Epi if (index == -1) index = seasonInfo.AlternateEpisodesList.FindIndex(ep => ep.Id == episodeInfo.Id); - // Extras that show up in the season because _somebody_ decided to disable filtering. - if (index == -1) { - offset += seasonInfo.EpisodeList.Count > 0 ? seasonInfo.EpisodeList.Count : seasonInfo.AlternateEpisodesList.Count; - index = seasonInfo.ExtrasList.FindIndex(ep => ep.Id == episodeInfo.Id); - } - - // All other episodes that show up in the season because _somebody_ decided to disable filtering. - if (index == -1) { - offset += seasonInfo.ExtrasList.Count; - index = seasonInfo.LeftoverList.FindIndex(ep => ep.Id == episodeInfo.Id); - } - // If we still cannot find the episode for whatever reason, then bail. I don't fudging know why, but I know it's not the plugin's fault. if (index == -1) throw new IndexOutOfRangeException($"Unable to find index to use for \"{episodeInfo.Shoko.Name}\". (Episode={episodeInfo.Id},Series={seasonInfo.Id})"); From 62ec0bbbf5349fa4d9f1bb3ccb1528bdefa406b4 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 1 May 2024 20:00:41 +0000 Subject: [PATCH 0929/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index f60c8568..e053a9ec 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.150", + "changelog": "refactor: remove the true disabled option\n\nThis partially reverts commits bcb4f8239cad89d12009c59f865bdf2884eb571\nand fb06a39a8a9484aa3a65a7843ad0b0168f60b37a because it's not needed\nanymore.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.150/shoko_3.0.1.150.zip", + "checksum": "1dd15a38a23b58f4c2cd19ef9e62ae41", + "timestamp": "2024-05-01T20:00:40Z" + }, { "version": "3.0.1.149", "changelog": "fix: don't remove empty seasons that should exist\n\n- Don't remove the empty seasons that should exist, according to our\n shoko data, not using jellyfin since it's not reliable enough to use\n for this check.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.146/shoko_3.0.1.146.zip", "checksum": "e0b2206cdbf0c0b1240b68e877fe4f56", "timestamp": "2024-04-28T14:43:09Z" - }, - { - "version": "3.0.1.145", - "changelog": "fix: band-aid for latest server changes\n\n- Apply a band-aid for the latest server changes for now. The new\n server changes will allow better handling of when to create the links\n in the future.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.145/shoko_3.0.1.145.zip", - "checksum": "1fc84a2d6b6bcd3ff4d12705daee77b0", - "timestamp": "2024-04-28T13:27:35Z" } ] } From 6dbef9630231bdd37c86711255aa1e1a082c168c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 2 May 2024 04:03:57 +0200 Subject: [PATCH 0930/1103] feat: force movie special featurettes - Added an option to force all specials in a movie series to appear as special featurettes for the movie/season. This option applies across all libraries. - Added two more automatically recognized extra features types without the new option enabled. --- Shokofin/API/Info/SeasonInfo.cs | 9 +++++++++ Shokofin/Configuration/PluginConfiguration.cs | 7 +++++++ Shokofin/Configuration/configController.js | 3 +++ Shokofin/Configuration/configPage.html | 7 +++++++ Shokofin/Resolvers/ShokoResolveManager.cs | 5 +++-- Shokofin/Utils/Ordering.cs | 10 +++++++--- 6 files changed, 36 insertions(+), 5 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index e977da1a..c3bf6199 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -165,6 +165,12 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp altEpisodesList = new(); } + if (Plugin.Instance.Configuration.MovieSpecialsAsExtraFeaturettes && type == SeriesType.Movie && specialsList.Count > 0) { + extrasList.AddRange(specialsList); + specialsAnchorDictionary.Clear(); + specialsList = new(); + } + Id = seriesId; Shoko = series; AniDB = series.AniDBEntity; @@ -186,6 +192,9 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp RelationMap = relationMap; } + public bool IsExtraEpisode(EpisodeInfo episodeInfo) + => ExtrasList.Any(eI => eI.Id == episodeInfo.Id); + public bool IsEmpty(int offset = 0) { // The extra "season" for this season info. diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index ff48b64e..fee8cc22 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -175,6 +175,12 @@ public virtual string PrettyUrl /// </summary> public bool SeparateMovies { get; set; } + /// <summary> + /// Append all specials in AniDB movie series as special featurettes for + /// the movies. + /// </summary> + public bool MovieSpecialsAsExtraFeaturettes { get; set; } + /// <summary> /// Determines how collections are made. /// </summary> @@ -305,6 +311,7 @@ public PluginConfiguration() VirtualFileSystemThreads = 4; UseGroupsForShows = false; SeparateMovies = false; + MovieSpecialsAsExtraFeaturettes = false; SeasonOrdering = OrderType.Default; SpecialsPlacement = SpecialOrderType.AfterSeason; AddMissingMetadata = true; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index b0eeb8ff..d0456772 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -295,6 +295,7 @@ async function defaultSubmit(form) { config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; config.SeparateMovies = form.querySelector("#SeparateMovies").checked; config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; + config.MovieSpecialsAsExtraFeaturettes = form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked; config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; // Media Folder settings @@ -484,6 +485,7 @@ async function syncSettings(form) { config.SeparateMovies = form.querySelector("#SeparateMovies").checked; config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; + config.MovieSpecialsAsExtraFeaturettes = form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked; config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; // Tag settings @@ -783,6 +785,7 @@ export default function (page) { form.querySelector("#CollectionGrouping").value = config.CollectionGrouping || "Default"; form.querySelector("#SeparateMovies").checked = config.SeparateMovies != null ? config.SeparateMovies : true; form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" ? "AfterSeason" : config.SpecialsPlacement; + form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked = config.MovieSpecialsAsExtraFeaturettes || false; form.querySelector("#AddMissingMetadata").checked = config.AddMissingMetadata || false; // Media Folder settings diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 1cd0f6ba..684f7afc 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -196,6 +196,13 @@ <h3>Library Settings</h3> </select> <div class="fieldDescription">Determines how to group entities into collections.</div> </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="MovieSpecialsAsExtraFeaturettes" /> + <span>Force movie special featurettes</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Append all specials in AniDB movie series as special featurettes for the movies. By default only some specials will be automatically recognized as special featurettes, but by enabling this option you will force all specials to be used as special featurettes. This setting applies to movie series across all library types.</div> + </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="AddMissingMetadata" /> diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index b0df3176..611bb6b9 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -750,10 +750,11 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { if (episodeName.Length >= NameCutOff) episodeName = episodeName[..NameCutOff].Split(' ').SkipLast(1).Join(' ') + "…"; + var isExtra = season.IsExtraEpisode(episode); var nfoFiles = new List<string>(); var folders = new List<string>(); var extrasFolder = file.ExtraType switch { - null => null, + null => isExtra ? "extras" : null, ExtraType.ThemeSong => "theme-music", ExtraType.ThemeVideo => "backdrops", ExtraType.Trailer => "trailers", @@ -767,7 +768,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { ExtraType.Scene => "-scene", ExtraType.Sample => "-other", ExtraType.Unknown => "-other", - _ => string.Empty, + _ => isExtra ? "-other" : string.Empty, }; if (isMovieSeason && collectionType != CollectionType.TvShows) { if (!string.IsNullOrEmpty(extrasFolder)) { diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index 376321c7..9f222424 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -269,9 +269,11 @@ public static int GetSeasonNumber(ShowInfo showInfo, SeasonInfo seasonInfo, Epis // Interview if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) return ExtraType.Interview; - // Cinema intro/outro - if (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) && - (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase))) + // 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; // Behind the Scenes if (title.Contains("making of", System.StringComparison.CurrentCultureIgnoreCase)) @@ -280,6 +282,8 @@ public static int GetSeasonNumber(ShowInfo showInfo, SeasonInfo seasonInfo, Epis return ExtraType.BehindTheScenes; if (title.Contains("advance screening", System.StringComparison.CurrentCultureIgnoreCase)) return ExtraType.BehindTheScenes; + if (title.Contains("premiere", System.StringComparison.CurrentCultureIgnoreCase)) + return ExtraType.BehindTheScenes; return null; } default: From 4258c0592488795ca0196778dc271cb9dac668ab Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 2 May 2024 02:04:43 +0000 Subject: [PATCH 0931/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index e053a9ec..9fc0e336 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.151", + "changelog": "feat: force movie special featurettes\n\n- Added an option to force all specials in a movie series to appear as\n special featurettes for the movie/season. This option applies across\n all libraries.\n\n- Added two more automatically recognized extra features types without\n the new option enabled.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.151/shoko_3.0.1.151.zip", + "checksum": "8f17f3ecf098e4953c2e2d616ada1921", + "timestamp": "2024-05-02T02:04:41Z" + }, { "version": "3.0.1.150", "changelog": "refactor: remove the true disabled option\n\nThis partially reverts commits bcb4f8239cad89d12009c59f865bdf2884eb571\nand fb06a39a8a9484aa3a65a7843ad0b0168f60b37a because it's not needed\nanymore.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.147/shoko_3.0.1.147.zip", "checksum": "97599739431efd2da7ef6aec21c4d2c8", "timestamp": "2024-05-01T13:36:26Z" - }, - { - "version": "3.0.1.146", - "changelog": "misc: move resolvers above ignore rules\n\nrefactor: move subtitles and ignore videos\n\n- Refactored the cleanup function to also move subtitles from the VFS\n back to the source video file, if found. And to ignore extras which\n are not provided by Shoko placed in the VFS, if found. These two\n changes should better allow third party plugins to add subtitle\n files and other features without breaking because of the VFS.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.146/shoko_3.0.1.146.zip", - "checksum": "e0b2206cdbf0c0b1240b68e877fe4f56", - "timestamp": "2024-04-28T14:43:09Z" } ] } From 1fb54de238168c3eeae017c51d0b5e3591a24c10 Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Thu, 2 May 2024 03:20:19 +0100 Subject: [PATCH 0932/1103] refactor: overhaul metadata settings (#52) - Overhaul the metadata settings section in the plugin settings. By default we let the plugin decide what to do for the main title, alternate title, and description, but each of the three behaviors can be overridden by enabling a checkbox to reveal the new-and-shiny (for the titles at least) advanced selectors. The description selector has also been moved under an override checkbox in this commit, while previously it was always visible. - Cleaned up the rest of the metadata settings section to be more tidy by renaming and moving around the other settings. - Overhauled the internals to support the new settings. In practice you shouldn't see much difference in behavior compared to the previous options. **Warning**: **The title and description settings has been reset to the new default values**, meaning any people that had altered their title/description settings previously should edit them if they don't want to use the new defaults. This is a side-effect of us not having settings migrations. Maybe in the future we will have it, but not now. :slightly_smiling_face: --- Shokofin/Configuration/PluginConfiguration.cs | 79 +++- Shokofin/Configuration/configController.js | 111 ++++- Shokofin/Configuration/configPage.html | 155 +++++-- Shokofin/Providers/BoxSetProvider.cs | 2 +- Shokofin/Providers/EpisodeProvider.cs | 9 +- Shokofin/Providers/MovieProvider.cs | 2 +- Shokofin/Providers/SeasonProvider.cs | 2 +- Shokofin/Providers/SeriesProvider.cs | 2 +- Shokofin/Utils/Ordering.cs | 2 +- Shokofin/Utils/Text.cs | 378 +++++++++--------- 10 files changed, 483 insertions(+), 259 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index fee8cc22..a5478288 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,15 +1,16 @@ -using MediaBrowser.Model.Plugins; using System.Collections.Generic; +using System.Linq; using System.Text.Json.Serialization; using System.Xml.Serialization; +using MediaBrowser.Model.Plugins; using Shokofin.API.Models; using CollectionCreationType = Shokofin.Utils.Ordering.CollectionCreationType; -using DisplayLanguageType = Shokofin.Utils.Text.DisplayLanguageType; +using DescriptionProvider = Shokofin.Utils.Text.DescriptionProvider; using LibraryFilteringMode = Shokofin.Utils.Ordering.LibraryFilteringMode; using OrderType = Shokofin.Utils.Ordering.OrderType; using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; -using TextSourceType = Shokofin.Utils.Text.TextSourceType; +using TitleProvider = Shokofin.Utils.Text.TitleProvider; namespace Shokofin.Configuration; @@ -75,15 +76,35 @@ public virtual string PrettyUrl #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> - public DisplayLanguageType TitleMainType { get; set; } + public TitleProvider[] TitleMainList { get; set; } + + /// <summary> + /// The order of which we will be selecting our main title for entries. + /// </summary> + public TitleProvider[] TitleMainOrder { get; set; } /// <summary> - /// Determines how we'll be selecting the alternate title for our entries. + /// Determines if we use the overridden settings for how the alternate title is fetched for entries. /// </summary> - public DisplayLanguageType TitleAlternateType { get; set; } + public bool TitleAlternateOverride { get; set; } + + /// <summary> + /// Determines how we'll be selecting our alternate title for entries. + /// </summary> + public TitleProvider[] TitleAlternateList { get; set; } + + /// <summary> + /// The order of which we will be selecting our alternate title for entries. + /// </summary> + public TitleProvider[] TitleAlternateOrder { get; set; } /// <summary> /// Allow choosing any title in the selected language if no official @@ -98,20 +119,25 @@ public virtual string PrettyUrl public bool TitleAddForMultipleEpisodes { get; set; } /// <summary> - /// Mark any episode that is not considered a normal season epiode with a + /// Mark any episode that is not considered a normal season episode with a /// prefix and number. /// </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> - public TextSourceType[] DescriptionSourceList { get; set; } + public DescriptionProvider[] DescriptionSourceList { get; set; } /// <summary> /// The prioritisation order of source providers for description sources. /// </summary> - public TextSourceType[] DescriptionSourceOrder { get; set; } + public DescriptionProvider[] DescriptionSourceOrder { get; set; } /// <summary> /// Clean up links within the AniDB description for entries. @@ -302,11 +328,36 @@ public PluginConfiguration() SynopsisCleanMultiEmptyLines = true; AddAniDBId = true; AddTMDBId = true; - TitleMainType = DisplayLanguageType.Default; - TitleAlternateType = DisplayLanguageType.Origin; - TitleAllowAny = false; - DescriptionSourceList = new[] { TextSourceType.AniDb, TextSourceType.TvDb, TextSourceType.TMDB }; - DescriptionSourceOrder = new[] { TextSourceType.AniDb, TextSourceType.TvDb, TextSourceType.TMDB }; + TitleMainOverride = false; + TitleMainList = new[] { + TitleProvider.Shoko_Default, + }; + TitleMainOrder = new[] { + TitleProvider.Shoko_Default, + TitleProvider.AniDB_Default, + TitleProvider.AniDB_LibraryLanguage, + TitleProvider.AniDB_CountryOfOrigin, + TitleProvider.TMDB_Default, + TitleProvider.TMDB_LibraryLanguage, + TitleProvider.TMDB_CountryOfOrigin, + }; + TitleAlternateOverride = false; + TitleAlternateList = new[] { + TitleProvider.AniDB_CountryOfOrigin + }; + TitleAlternateOrder = TitleMainOrder.ToArray(); + TitleAllowAny = true; + DescriptionSourceOverride = false; + DescriptionSourceList = new[] { + DescriptionProvider.AniDB, + DescriptionProvider.TvDB, + DescriptionProvider.TMDB, + }; + DescriptionSourceOrder = new[] { + DescriptionProvider.AniDB, + DescriptionProvider.TvDB, + DescriptionProvider.TMDB, + }; VirtualFileSystem = CanCreateSymbolicLinks; VirtualFileSystemThreads = 4; UseGroupsForShows = false; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index d0456772..e9cb63f1 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -46,7 +46,7 @@ function filterReconnectIntervals(value) { // Convert it back into an array. return Array.from(filteredSet).sort((a, b) => a - b); - } +} function adjustSortableListElement(element) { const btnSortable = element.querySelector(".btnSortable"); @@ -274,16 +274,15 @@ async function defaultSubmit(form) { const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); // Metadata settings - config.TitleMainType = form.querySelector("#TitleMainType").value; - config.TitleAlternateType = form.querySelector("#TitleAlternateType").value; + ["Main", "Alternate"].forEach((type) => setTitleIntoConfig(form, type, config)); config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; setDescriptionSourcesIntoConfig(form, config); config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; - config.SynopsisCleanMiscLines = form.querySelector("#MinimalAniDBDescriptions").checked; - config.SynopsisRemoveSummary = form.querySelector("#MinimalAniDBDescriptions").checked; + config.SynopsisCleanMiscLines = form.querySelector("#CleanupAniDBDescriptions").checked; + config.SynopsisRemoveSummary = form.querySelector("#CleanupAniDBDescriptions").checked; // Provider settings config.AddAniDBId = form.querySelector("#AddAniDBId").checked; @@ -464,16 +463,15 @@ async function syncSettings(form) { const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); // Metadata settings - config.TitleMainType = form.querySelector("#TitleMainType").value; - config.TitleAlternateType = form.querySelector("#TitleAlternateType").value; + ["Main", "Alternate"].forEach((type) => { setTitleIntoConfig(form, type, config) }); config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; setDescriptionSourcesIntoConfig(form, config); config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; - config.SynopsisCleanMiscLines = form.querySelector("#MinimalAniDBDescriptions").checked; - config.SynopsisRemoveSummary = form.querySelector("#MinimalAniDBDescriptions").checked; + config.SynopsisCleanMiscLines = form.querySelector("#CleanupAniDBDescriptions").checked; + config.SynopsisRemoveSummary = form.querySelector("#CleanupAniDBDescriptions").checked; // Provider settings config.AddAniDBId = form.querySelector("#AddAniDBId").checked; @@ -744,7 +742,23 @@ export default function (page) { } }); - form.querySelector("#descriptionSourceList").addEventListener("click", onSortableContainerClick); + Array.prototype.forEach.call( + form.querySelectorAll("#descriptionSourceList, #TitleMainList, #TitleAlternateList"), + (el) => el.addEventListener("click", onSortableContainerClick) + ); + + ["Main", "Alternate"].forEach((type) => { + const settingsList = form.querySelector(`#Title${type}List`); + + form.querySelector(`#Title${type}Override`).addEventListener("change", ({ target: { checked } }) => { + checked ? settingsList.removeAttribute("hidden") : settingsList.setAttribute("hidden", ""); + }); + }); + + form.querySelector("#DescriptionSourceOverride").addEventListener("change", ({ target: { checked } }) => { + const root = form.querySelector("#descriptionSourceList"); + checked ? root.removeAttribute("hidden") : root.setAttribute("hidden", ""); + }); page.addEventListener("viewshow", async function () { Dashboard.showLoadingMsg(); @@ -759,14 +773,12 @@ export default function (page) { form.querySelector("#Password").value = ""; // Metadata settings - form.querySelector("#TitleMainType").value = config.TitleMainType; - form.querySelector("#TitleAlternateType").value = config.TitleAlternateType; + ["Main", "Alternate"].forEach((t) => { setTitleFromConfig(form, t, config) }); form.querySelector("#TitleAllowAny").checked = config.TitleAllowAny; form.querySelector("#TitleAddForMultipleEpisodes").checked = config.TitleAddForMultipleEpisodes != null ? config.TitleAddForMultipleEpisodes : true; form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; - await setDescriptionSourcesFromConfig(form, config); - form.querySelector("#CleanupAniDBDescriptions").checked = config.SynopsisCleanMultiEmptyLines || config.SynopsisCleanLinks; - form.querySelector("#MinimalAniDBDescriptions").checked = config.SynopsisRemoveSummary || config.SynopsisCleanMiscLines; + setDescriptionSourcesFromConfig(form, config); + form.querySelector("#CleanupAniDBDescriptions").checked = config.SynopsisCleanMultiEmptyLines || config.SynopsisCleanLinks || config.SynopsisRemoveSummary || config.SynopsisCleanMiscLines; // Provider settings form.querySelector("#AddAniDBId").checked = config.AddAniDBId; @@ -879,6 +891,7 @@ export default function (page) { } function setDescriptionSourcesIntoConfig(form, config) { + const override = form.querySelector("#DescriptionSourceOverride"); const descriptionElements = form.querySelectorAll(`#descriptionSourceList .chkDescriptionSource`); config.DescriptionSourceList = Array.prototype.filter.call(descriptionElements, (el) => el.checked) @@ -887,12 +900,19 @@ function setDescriptionSourcesIntoConfig(form, config) { config.DescriptionSourceOrder = Array.prototype.map.call(descriptionElements, (el) => el.dataset.descriptionsource ); + + config.DescriptionSourceOverride = override.checked; } -async function setDescriptionSourcesFromConfig(form, config) { - const list = form.querySelector("#descriptionSourceList .checkboxList"); +function setDescriptionSourcesFromConfig(form, config) { + const root = form.querySelector("#descriptionSourceList"); + const override = form.querySelector("#DescriptionSourceOverride"); + const list = root.querySelector(".checkboxList"); const listItems = list.querySelectorAll('.listItem'); + override.checked = config.DescriptionSourceOverride; + override.checked ? root.removeAttribute("hidden") : root.setAttribute("hidden", ""); + for (const item of listItems) { const source = item.dataset.descriptionsource; if (config.DescriptionSourceList.includes(source)) { @@ -909,7 +929,64 @@ async function setDescriptionSourcesFromConfig(form, config) { list.append(targetElement); } } + for (const option of list.querySelectorAll(".sortableOption")) { adjustSortableListElement(option) }; } + +/** + * This function **must** be called for each type of title separately, lest the config be incomplete. + * @param {"Main"|"Alternate"} type + */ +function setTitleIntoConfig(form, type, config) { + const titleElements = form.querySelectorAll(`#Title${type}List .chkTitleSource`); + const getSettingName = (el) => `${el.dataset.titleprovider}_${el.dataset.titlestyle}`; + + config[`Title${type}List`] = Array.prototype.filter.call(titleElements, + (el) => el.checked) + .map((el) => getSettingName(el)); + + config[`Title${type}Order`] = Array.prototype.map.call(titleElements, + (el) => getSettingName(el) + ); + + config[`Title${type}Override`] = form.querySelector(`#Title${type}Override`).checked +} + +/** + * This function **must** be called for each type of title separately, lest the config be incomplete. + * @param {"Main"|"Alternate"} type + */ +function setTitleFromConfig(form, type, config) { + const root = form.querySelector(`#Title${type}List`); + const override = form.querySelector(`#Title${type}Override`); + const list = root.querySelector(`.checkboxList`); + const listItems = list.querySelectorAll('.listItem'); + + override.checked = config[`Title${type}Override`]; + override.checked ? root.removeAttribute("hidden") : root.setAttribute("hidden", ""); + + const getSettingName = (el) => `${el.dataset.titleprovider}_${el.dataset.titlestyle}`; + + for (const item of listItems) { + const setting = getSettingName(item); + if (config[`Title${type}List`].includes(setting)) + item.querySelector(".chkTitleSource").checked = true; + if (config[`Title${type}Order`].includes(setting)) + list.removeChild(item); + } + + for (const setting of config[`Title${type}Order`]) { + const targetElement = Array.prototype.find.call( + listItems, + (el) => getSettingName(el) === setting + ); + if (targetElement) + list.append(targetElement); + } + + for (const option of list.querySelectorAll(".sortableOption")) { + adjustSortableListElement(option) + } +} \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 684f7afc..6be83ede 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -44,45 +44,126 @@ <h3>Connection Settings</h3> <legend> <h3>Metadata Settings</h3> </legend> - <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="TitleMainType">Main title source:</label> - <select is="emby-select" id="TitleMainType" name="TitleMainType" class="emby-select-withcolor emby-select"> - <option value="Default">Let Shoko decide the title</option> - <option value="MetadataPreferred">Use the preferred library metadata language</option> - <option value="Origin">Use the language from country of origin</option> - <option value="Main">Use the main title on AniDB</option> - </select> - <div class="fieldDescription selectFieldDescription">How to select the main title for each item. The plugin will fallback to the default title if no title can be found in the target language.</div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <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 class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="TitleAlternateType">Alternate title source:</label> - <select is="emby-select" id="TitleAlternateType" name="TitleAlternateType" class="emby-select-withcolor emby-select"> - <option value="Default">Let Shoko decide the title</option> - <option value="MetadataPreferred">Use the preferred library metadata language</option> - <option value="Origin">Use the language from country of origin</option> - <option value="Main">Use the main title on AniDB</option> - <option value="Ignore">Do not use alternate titles</option> - </select> - <div class="fieldDescription selectFieldDescription">How to select the alternate title for each item. The plugin will fallback to the default title if no title can be found in the target language.</div> + <div id="TitleMainList" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced main title source:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem titleSourceItem sortableOption" data-titleprovider="Shoko" data-titlestyle="Default"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="Shoko" data-titlestyle="Default"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">Shoko | Let Shoko decide</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 titleSourceItem sortableOption" data-titleprovider="AniDB" data-titlestyle="Default"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="AniDB" data-titlestyle="Default"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">AniDB | Default title</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 titleSourceItem sortableOption" data-titleprovider="AniDB" data-titlestyle="LibraryLanguage"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="AniDB" data-titlestyle="LibraryLanguage"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">AniDB | 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 titleSourceItem sortableOption" data-titleprovider="AniDB" data-titlestyle="CountryOfOrigin"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="AniDB" data-titlestyle="CountryOfOrigin"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">AniDB | Use the language from the 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></button> + </div> + <div class="listItem titleSourceItem sortableOption" data-titleprovider="TMDB" data-titlestyle="Default"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="TMDB" data-titlestyle="Default"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">TMDB | Default title</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 titleSourceItem sortableOption" data-titleprovider="TMDB" data-titlestyle="LibraryLanguage"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="TMDB" data-titlestyle="LibraryLanguage"><span></span></label> + <div class="listItemBody"><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 titleSourceItem sortableOption" data-titleprovider="TMDB" data-titlestyle="CountryOfOrigin"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="TMDB" data-titlestyle="CountryOfOrigin"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">TMDB | Use the language from the 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></button> + </div> + </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"> <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> - <span>Mark specials</span> + <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" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced alternate title source:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem titleSourceItem sortableOption" data-titleprovider="Shoko" data-titlestyle="Default"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="Shoko" data-titlestyle="Default"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">Shoko | Let Shoko decide</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 titleSourceItem sortableOption" data-titleprovider="AniDB" data-titlestyle="Default"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="AniDB" data-titlestyle="Default"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">AniDB | Default title</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 titleSourceItem sortableOption" data-titleprovider="AniDB" data-titlestyle="LibraryLanguage"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="AniDB" data-titlestyle="LibraryLanguage"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">AniDB | 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 titleSourceItem sortableOption" data-titleprovider="AniDB" data-titlestyle="CountryOfOrigin"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="AniDB" data-titlestyle="CountryOfOrigin"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">AniDB | Use the language from the 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></button> + </div> + <div class="listItem titleSourceItem sortableOption" data-titleprovider="TMDB" data-titlestyle="Default"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="TMDB" data-titlestyle="Default"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">TMDB | Default title</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 titleSourceItem sortableOption" data-titleprovider="TMDB" data-titlestyle="LibraryLanguage"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="TMDB" data-titlestyle="LibraryLanguage"><span></span></label> + <div class="listItemBody"><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 titleSourceItem sortableOption" data-titleprovider="TMDB" data-titlestyle="CountryOfOrigin"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="TMDB" data-titlestyle="CountryOfOrigin"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">TMDB | Use the language from the 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></button> + </div> + </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"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="DescriptionSourceOverride" /> + <span>Override description source</span> </label> - <div class="fieldDescription checkboxFieldDescription">Add a number to the title of each specials episode</div> + <div class="fieldDescription checkboxFieldDescription"> + Enables the advanced selector for description source selection. + </div> </div> <div id="descriptionSourceList" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Description source:</h3> + <h3 class="checkboxListLabel">Advanced description source:</h3> <div class="checkboxList paperList checkboxList-paperList"> - <div class="listItem descriptionSourceItem sortableOption" data-descriptionsource="AniDb"> - <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-descriptionsource="AniDb"><span></span></label> + <div class="listItem descriptionSourceItem sortableOption" data-descriptionsource="AniDB"> + <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-descriptionsource="AniDB"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">AniDB</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 descriptionSourceItem sortableOption" data-descriptionsource="TvDb"> - <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-descriptionsource="TvDb"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">TVDB</h3></div> + <div class="listItem descriptionSourceItem sortableOption" data-descriptionsource="TvDB"> + <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-descriptionsource="TvDB"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">TvDB</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 descriptionSourceItem sortableOption" data-descriptionsource="TMDB"> @@ -91,35 +172,35 @@ <h3 class="checkboxListLabel">Description source:</h3> <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 episode/series/season descriptions.</div> + <div class="fieldDescription">The metadata providers to use as the source of descriptions, in priority order.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <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> <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> + <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="CleanupAniDBDescriptions" /> - <span>Cleanup AniDB descriptions</span> + <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> + <span>Add prefix to episodes</span> </label> - <div class="fieldDescription checkboxFieldDescription">Remove links and collapse multiple empty lines into one empty line</div> + <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="MinimalAniDBDescriptions" /> - <span>Minimalistic AniDB descriptions</span> + <input is="emby-checkbox" type="checkbox" id="CleanupAniDBDescriptions" /> + <span>Cleanup AniDB descriptions</span> </label> - <div class="fieldDescription checkboxFieldDescription">Trim any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summary'</div> + <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> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 61cce0c8..01a18b93 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -66,7 +66,7 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info) return result; } - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(season.AniDB.Titles, season.AniDB.Title, info.MetadataLanguage); + var (displayTitle, alternateTitle) = Text.GetSeasonTitles(season, info.MetadataLanguage); result.Item = new BoxSet { Name = displayTitle, diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index d46966f0..1514d51c 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -110,12 +110,12 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie (series.AniDB.Type == SeriesType.OVA && episodeInfo.AniDB.Type == EpisodeType.Normal && episodeInfo.AniDB.EpisodeNumber == 1 && episodeInfo.Shoko.Name == "OVA") ) { string defaultSeriesTitle = series.Shoko.Name; - var ( dTitle, aTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episodeInfo.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); + var (dTitle, aTitle) = Text.GetMovieTitles(episodeInfo, series, metadataLanguage); displayTitles.Add(dTitle); alternateTitles.Add(aTitle); } else { - var ( dTitle, aTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episodeInfo.AniDB.Titles, defaultEpisodeTitle, metadataLanguage); + var (dTitle, aTitle) = Text.GetEpisodeTitles(episodeInfo, series, metadataLanguage); displayTitles.Add(dTitle); alternateTitles.Add(aTitle); } @@ -133,16 +133,15 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie (series.AniDB.Type == SeriesType.OVA && episode.AniDB.Type == EpisodeType.Normal && episode.AniDB.EpisodeNumber == 1 && episode.Shoko.Name == "OVA") ) { string defaultSeriesTitle = series.Shoko.Name; - ( displayTitle, alternateTitle ) = Text.GetMovieTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultSeriesTitle, defaultEpisodeTitle, metadataLanguage); + (displayTitle, alternateTitle) = Text.GetMovieTitles(episode, series, metadataLanguage); } else { - ( displayTitle, alternateTitle ) = Text.GetEpisodeTitles(series.AniDB.Titles, episode.AniDB.Titles, defaultEpisodeTitle, metadataLanguage); + (displayTitle, alternateTitle) = Text.GetEpisodeTitles(episode, series, metadataLanguage); } description = Text.GetDescription(episode); } if (config.MarkSpecialsWhenGrouped) switch (episode.AniDB.Type) { - case EpisodeType.Unknown: case EpisodeType.Other: case EpisodeType.Normal: break; diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index eb40c6c5..700add11 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -46,7 +46,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio return result; } - var ( displayTitle, alternateTitle ) = Text.GetMovieTitles(season.AniDB.Titles, episode.AniDB.Titles, season.Shoko.Name, episode.Shoko.Name, info.MetadataLanguage); + var (displayTitle, alternateTitle) = Text.GetMovieTitles(episode, season, info.MetadataLanguage); Logger.LogInformation("Found movie {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", displayTitle, file.Id, episode.Id, season.Id); bool isMultiEntry = season.Shoko.Sizes.Total.Episodes > 1; diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index a2873ab7..aaf84bfc 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -100,7 +100,7 @@ public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage, Series? series, Guid seasonId) { - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(seasonInfo.AniDB.Titles, seasonInfo.Shoko.Name, metadataLanguage); + var (displayTitle, alternateTitle) = Text.GetSeasonTitles(seasonInfo, metadataLanguage); var sortTitle = $"S{seasonNumber} - {seasonInfo.Shoko.Name}"; if (offset > 0) { diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index 123fefc0..fa7120ce 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -61,7 +61,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat } } - var ( displayTitle, alternateTitle ) = Text.GetSeriesTitles(show.DefaultSeason.AniDB.Titles, show.Name, info.MetadataLanguage); + var (displayTitle, alternateTitle) = Text.GetShowTitles(show, info.MetadataLanguage); var premiereDate = show.PremiereDate; var endDate = show.EndDate; result.Item = new Series { diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index 9f222424..4a3e835b 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -263,7 +263,7 @@ public static int GetSeasonNumber(ShowInfo showInfo, SeasonInfo seasonInfo, Epis case EpisodeType.Trailer: return ExtraType.Trailer; case EpisodeType.Special: { - var title = Text.GetTitleByLanguages(episode.Titles, "en"); + var title = Text.GetTitlesForLanguage(episode.Titles, false, "en"); if (string.IsNullOrEmpty(title)) return null; // Interview diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index 44a12c89..c3f8973b 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -67,135 +67,152 @@ public static class Text }; /// <summary> - /// Where to get text the text from. + /// Determines which provider to use to provide the descriptions. /// </summary> - public enum TextSourceType { + public enum DescriptionProvider { /// <summary> - /// Use data from AniDB. + /// Provide the Shoko Group description for the show, if the show is + /// constructed using Shoko's groups feature. /// </summary> - AniDb = 0, + Shoko = 1, /// <summary> - /// Use data from TvDB. + /// Provide the description from AniDB. /// </summary> - TvDb = 1, + AniDB = 2, /// <summary> - /// Use data from TMDB + /// Provide the description from TvDB. /// </summary> - TMDB = 2 + TvDB = 3, + + /// <summary> + /// Provide the description from TMDB. + /// </summary> + TMDB = 4 } /// <summary> - /// Determines the language to construct the title in. + /// Determines which provider and method to use to look-up the title. /// </summary> - public enum DisplayLanguageType { + public enum TitleProvider { /// <summary> /// Let Shoko decide what to display. /// </summary> - Default = 1, + Shoko_Default = 1, /// <summary> - /// Prefer to use the selected metadata language for the library if - /// available, but fallback to the default view if it's not - /// available. + /// Use the default title as provided by AniDB. /// </summary> - MetadataPreferred = 2, + AniDB_Default = 2, /// <summary> - /// Use the origin language for the series. + /// Use the selected metadata language for the library as provided by + /// AniDB. /// </summary> - Origin = 3, + AniDB_LibraryLanguage = 3, /// <summary> - /// Don't display a title. + /// Use the title in the origin language as provided by AniDB. /// </summary> - Ignore = 4, + AniDB_CountryOfOrigin = 4, /// <summary> - /// Use the main title for the series. + /// Use the default title as provided by TMDB. /// </summary> - Main = 5, - } + TMDB_Default = 5, + + /// <summary> + /// Use the selected metadata language for the library as provided by + /// TMDB. + /// </summary> + TMDB_LibraryLanguage = 6, - /// <summary> - /// Determines the type of title to construct. - /// </summary> - public enum DisplayTitleType { /// <summary> - /// Only construct the main title. + /// Use the title in the origin language as provided by TMDB. /// </summary> - MainTitle = 1, + TMDB_CountryOfOrigin = 7, + } + /// <summary> + /// Determines which type of title to look-up. + /// </summary> + public enum TitleProviderType { /// <summary> - /// Only construct the sub title. + /// The main title used for metadata entries. /// </summary> - SubTitle = 2, + Main = 0, /// <summary> - /// Construct a combined main and sub title. + /// The secondary title used for metadata entries. /// </summary> - FullTitle = 3, + Alternate = 1, } public static string GetDescription(ShowInfo show) - => GetDescription(show.DefaultSeason); + => GetDescriptionByDict(new() { + {DescriptionProvider.Shoko, show.Shoko?.Description}, + {DescriptionProvider.AniDB, show.DefaultSeason.AniDB.Description}, + {DescriptionProvider.TvDB, show.DefaultSeason.TvDB?.Description}, + }); public static string GetDescription(SeasonInfo season) - => GetDescription(new Dictionary<TextSourceType, string>() { - {TextSourceType.AniDb, season.AniDB.Description ?? string.Empty}, - {TextSourceType.TvDb, season.TvDB?.Description ?? string.Empty}, + => GetDescriptionByDict(new() { + {DescriptionProvider.AniDB, season.AniDB.Description}, + {DescriptionProvider.TvDB, season.TvDB?.Description}, }); public static string GetDescription(EpisodeInfo episode) - => GetDescription(new Dictionary<TextSourceType, string>() { - {TextSourceType.AniDb, episode.AniDB.Description ?? string.Empty}, - {TextSourceType.TvDb, episode.TvDB?.Description ?? string.Empty}, + => GetDescriptionByDict(new() { + {DescriptionProvider.AniDB, episode.AniDB.Description}, + {DescriptionProvider.TvDB, episode.TvDB?.Description}, }); public static string GetDescription(IEnumerable<EpisodeInfo> episodeList) => JoinText(episodeList.Select(episode => GetDescription(episode))) ?? string.Empty; - private static string GetDescription(Dictionary<TextSourceType, string> descriptions) - { - var overview = string.Empty; - - var providerOrder = Plugin.Instance.Configuration.DescriptionSourceOrder; - var providers = Plugin.Instance.Configuration.DescriptionSourceList; - - if (providers.Length == 0) { - return overview; // This is what they want if everything is unticked... - } + /// <summary> + /// 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() + : new[] { DescriptionProvider.Shoko, DescriptionProvider.AniDB, DescriptionProvider.TvDB, DescriptionProvider.TMDB }; - foreach (var provider in providerOrder.Where(provider => providers.Contains(provider))) + private static string GetDescriptionByDict(Dictionary<DescriptionProvider, string?> descriptions) + { + foreach (var provider in GetOrderedDescriptionProviders()) { - if (!string.IsNullOrEmpty(overview)) { - return overview; - } - - overview = provider switch + var overview = provider switch { - TextSourceType.AniDb => descriptions.TryGetValue(TextSourceType.AniDb, out var desc) ? SanitizeTextSummary(desc) : string.Empty, - TextSourceType.TvDb => descriptions.TryGetValue(TextSourceType.TvDb, out var desc) ? desc : string.Empty, - _ => string.Empty + DescriptionProvider.Shoko => + descriptions.TryGetValue(DescriptionProvider.Shoko, out var desc) ? desc : null, + DescriptionProvider.AniDB => + descriptions.TryGetValue(DescriptionProvider.AniDB, out var desc) ? SanitizeAnidbDescription(desc ?? string.Empty) : null, + DescriptionProvider.TvDB => + descriptions.TryGetValue(DescriptionProvider.TvDB, out var desc) ? desc : null, + _ => null }; + if (!string.IsNullOrEmpty(overview)) + return overview; } - - return overview; + return string.Empty; } /// <summary> - /// Based on ShokoMetadata's summary sanitizer which in turn is based on HAMA's summary sanitizer. + /// Sanitize the AniDB entry description to something usable by Jellyfin. /// </summary> - /// <param name="summary">The raw AniDB summary</param> - /// <returns>The sanitized AniDB summary</returns> - public static string SanitizeTextSummary(string summary) + /// <remarks> + /// Based on ShokoMetadata's summary sanitizer which in turn is based on HAMA's summary sanitizer. + /// </remarks> + /// <param name="summary">The raw AniDB description.</param> + /// <returns>The sanitized AniDB description.</returns> + public static string SanitizeAnidbDescription(string summary) { if (string.IsNullOrWhiteSpace(summary)) return string.Empty; var config = Plugin.Instance.Configuration; - if (config.SynopsisCleanLinks) summary = Regex.Replace(summary, @"https?:\/\/\w+.\w+(?:\/?\w+)? \[([^\]]+)\]", match => match.Groups[1].Value); @@ -211,26 +228,6 @@ public static string SanitizeTextSummary(string summary) return summary.Trim(); } - public static (string?, string?) GetEpisodeTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) - => GetTitles(seriesTitles, episodeTitles, null, episodeTitle, DisplayTitleType.SubTitle, metadataLanguage); - - public static (string?, string?) GetSeriesTitles(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) - => GetTitles(seriesTitles, null, seriesTitle, null, DisplayTitleType.MainTitle, metadataLanguage); - - public static (string?, string?) GetMovieTitles(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) - => GetTitles(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplayTitleType.FullTitle, metadataLanguage); - - public static (string?, string?) GetTitles(IEnumerable<Title>? seriesTitles, IEnumerable<Title>? episodeTitles, string? seriesTitle, string? episodeTitle, DisplayTitleType outputType, string metadataLanguage) - { - // Don't process anything if the series titles are not provided. - if (seriesTitles == null) - return (null, null); - return ( - GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage), - GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleAlternateType, outputType, metadataLanguage) - ); - } - public static string? JoinText(IEnumerable<string?> textList) { var filteredList = textList @@ -257,134 +254,153 @@ public static (string?, string?) GetTitles(IEnumerable<Title>? seriesTitles, IEn return outputText; } - public static string? GetEpisodeTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string episodeTitle, string metadataLanguage) - => GetTitle(seriesTitles, episodeTitles, null, episodeTitle, DisplayTitleType.SubTitle, metadataLanguage); + public static (string?, string?) GetEpisodeTitles(EpisodeInfo episode, SeasonInfo series, string metadataLanguage) + => ( + GetEpisodeTitleByType(episode, series, TitleProviderType.Main, metadataLanguage), + GetEpisodeTitleByType(episode, series, TitleProviderType.Alternate, metadataLanguage) + ); - public static string? GetSeriesTitle(IEnumerable<Title> seriesTitles, string seriesTitle, string metadataLanguage) - => GetTitle(seriesTitles, null, seriesTitle, null, DisplayTitleType.MainTitle, metadataLanguage); + public static (string?, string?) GetSeasonTitles(SeasonInfo series, string metadataLanguage) + => ( + GetSeriesTitleByType(series, series.Shoko.Name, TitleProviderType.Main, metadataLanguage), + GetSeriesTitleByType(series, series.Shoko.Name, TitleProviderType.Alternate, metadataLanguage) + ); - public static string? GetMovieTitle(IEnumerable<Title> seriesTitles, IEnumerable<Title> episodeTitles, string seriesTitle, string episodeTitle, string metadataLanguage) - => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, DisplayTitleType.FullTitle, metadataLanguage); + public static (string?, string?) GetShowTitles(ShowInfo series, string metadataLanguage) + => ( + GetSeriesTitleByType(series.DefaultSeason, series.Name, TitleProviderType.Main, metadataLanguage), + GetSeriesTitleByType(series.DefaultSeason, series.Name, TitleProviderType.Alternate, metadataLanguage) + ); - public static string? GetTitle(IEnumerable<Title>? seriesTitles, IEnumerable<Title>? episodeTitles, string? seriesTitle, string? episodeTitle, DisplayTitleType outputType, string metadataLanguage) - => GetTitle(seriesTitles, episodeTitles, seriesTitle, episodeTitle, Plugin.Instance.Configuration.TitleMainType, outputType, metadataLanguage); + public static (string?, string?) GetMovieTitles(EpisodeInfo episode, SeasonInfo series, string metadataLanguage) + => ( + GetMovieTitleByType(episode, series, TitleProviderType.Main, metadataLanguage), + GetMovieTitleByType(episode, series, TitleProviderType.Alternate, metadataLanguage) + ); - public static string? GetTitle(IEnumerable<Title>? seriesTitles, IEnumerable<Title>? episodeTitles, string? seriesTitle, string? episodeTitle, DisplayLanguageType languageType, DisplayTitleType outputType, string displayLanguage) - { - // Don't process anything if the series titles are not provided. - if (seriesTitles == null) - return null; - var mainTitleLanguage = GetMainLanguage(seriesTitles); - var originLanguages = GuessOriginLanguage(mainTitleLanguage); - switch (languageType) { - // 'Ignore' will always return null, and all other values will also return null. - default: - case DisplayLanguageType.Ignore: - return null; - // Let Shoko decide the title. - case DisplayLanguageType.Default: - return ConstructTitle(() => seriesTitle, () => episodeTitle, outputType); - // Display in metadata-preferred language, or fallback to default. - case DisplayLanguageType.MetadataPreferred: { - var allowAny = Plugin.Instance.Configuration.TitleAllowAny; - string? getSeriesTitle() => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, displayLanguage) ?? (allowAny ? GetTitleByLanguages(seriesTitles, displayLanguage) : null) ?? seriesTitle; - string? getEpisodeTitle() => GetTitleByLanguages(episodeTitles, displayLanguage) ?? episodeTitle; - var title = ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); - if (string.IsNullOrEmpty(title)) - goto case DisplayLanguageType.Default; - return title; - } - // Display in origin language. - case DisplayLanguageType.Origin: { - var allowAny = Plugin.Instance.Configuration.TitleAllowAny; - string? getSeriesTitle() => GetTitleByTypeAndLanguage(seriesTitles, TitleType.Official, originLanguages) ?? (allowAny ? GetTitleByLanguages(seriesTitles, originLanguages) : null) ?? seriesTitle; - string? getEpisodeTitle() => GetTitleByLanguages(episodeTitles, originLanguages) ?? episodeTitle; - return ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); - } - // Display the main title. - case DisplayLanguageType.Main: { - string? getSeriesTitle() => GetTitleByType(seriesTitles, TitleType.Main) ?? seriesTitle; - string? getEpisodeTitle() => GetTitleByLanguages(episodeTitles, "en", mainTitleLanguage) ?? episodeTitle; - return ConstructTitle(getSeriesTitle, getEpisodeTitle, outputType); - } - } - } + /// <summary> + /// Returns a list of the providers to check, and in what order + /// </summary> + 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() + : new[] { 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>(), + }; - private static string? ConstructTitle(Func<string?> getSeriesTitle, Func<string?> getEpisodeTitle, DisplayTitleType outputType) + private static string? GetMovieTitleByType(EpisodeInfo episode, SeasonInfo series, TitleProviderType type, string metadataLanguage) { - switch (outputType) { - // Return series title. - case DisplayTitleType.MainTitle: - return getSeriesTitle()?.Trim(); - // Return episode title. - case DisplayTitleType.SubTitle: - return getEpisodeTitle()?.Trim(); - // Return combined series and episode title. - case DisplayTitleType.FullTitle: { - var mainTitle = getSeriesTitle()?.Trim(); - var subTitle = getEpisodeTitle()?.Trim(); - // Include sub-title if it does not strictly equals any ignored sub titles. - if (!string.IsNullOrWhiteSpace(subTitle) && !IgnoredSubTitles.Contains(subTitle)) - return $"{mainTitle}: {subTitle}"; - return mainTitle; - } - default: - return null; - } + var mainTitle = GetSeriesTitleByType(series, series.Shoko.Name, type, metadataLanguage); + var subTitle = GetEpisodeTitleByType(episode, series, type, metadataLanguage); + + if (!(string.IsNullOrEmpty(subTitle) || IgnoredSubTitles.Contains(subTitle))) + return $"{mainTitle}: {subTitle}".Trim(); + return mainTitle?.Trim(); } - public static string? GetTitleByType(IEnumerable<Title> titles, TitleType type) + private static string? GetEpisodeTitleByType(EpisodeInfo episode, SeasonInfo series, TitleProviderType type, string metadataLanguage) { - if (titles != null) { - var title = titles.FirstOrDefault(s => s.Type == type)?.Value; - if (title != null) - return title; + foreach (var provider in GetOrderedTitleProvidersByType(type)) { + var title = provider switch { + TitleProvider.Shoko_Default => + episode.Shoko.Name, + TitleProvider.AniDB_Default => + GetDefaultTitle(episode.AniDB.Titles), + TitleProvider.AniDB_LibraryLanguage => + GetTitlesForLanguage(episode.AniDB.Titles, false, metadataLanguage), + TitleProvider.AniDB_CountryOfOrigin => + GetTitlesForLanguage(episode.AniDB.Titles, false, GuessOriginLanguage(GetMainLanguage(series.AniDB.Titles))), + _ => null, + }; + if (!string.IsNullOrEmpty(title)) + return title.Trim(); } return null; } - public static string? GetTitleByTypeAndLanguage(IEnumerable<Title>? titles, TitleType type, params string[] langs) + private static string? GetSeriesTitleByType(SeasonInfo series, string defaultName, TitleProviderType type, string metadataLanguage) { - if (titles != null) foreach (string lang in langs) { - var title = titles.FirstOrDefault(s => s.LanguageCode == lang && s.Type == type)?.Value; - if (title != null) - return title; + foreach (var provider in GetOrderedTitleProvidersByType(type)) { + var title = provider switch { + TitleProvider.Shoko_Default => + defaultName, + TitleProvider.AniDB_Default => + GetDefaultTitle(series.AniDB.Titles), + TitleProvider.AniDB_LibraryLanguage => + GetTitlesForLanguage(series.AniDB.Titles, true, metadataLanguage), + TitleProvider.AniDB_CountryOfOrigin => + GetTitlesForLanguage(series.AniDB.Titles, true, GuessOriginLanguage(GetMainLanguage(series.AniDB.Titles))), + _ => null, + }; + if (!string.IsNullOrEmpty(title)) + return title.Trim(); } return null; } - public static string? GetTitleByLanguages(IEnumerable<Title>? titles, params string[] langs) + /// <summary> + /// Get the default title from the title list. + /// </summary> + /// <param name="titles"></param> + /// <returns>The default title.</returns> + private static string? GetDefaultTitle(List<Title> titles) + => titles.FirstOrDefault(t => t.IsDefault)?.Value; + + /// <summary> + /// Get the first title available for the language, optionally using types + /// to filter the list in addition to the metadata languages provided. + /// </summary> + /// <param name="titles">Title list to search.</param> + /// <param name="usingTypes">Search using titles</param> + /// <param name="metadataLanguages">The metadata languages to search for.</param> + /// <returns>The first found title in any of the provided metadata languages, or null.</returns> + public static string? GetTitlesForLanguage(List<Title> titles, bool usingTypes, params string[] metadataLanguages) { - if (titles != null) foreach (string lang in langs) { - var title = titles.FirstOrDefault(s => lang.Equals(s.LanguageCode, System.StringComparison.OrdinalIgnoreCase))?.Value; - if (title != null) - return title; + foreach (string lang in metadataLanguages) { + var titleList = titles.Where(t => t.LanguageCode == lang).ToList(); + if (titleList.Count == 0) + continue; + + if (usingTypes) { + var title = titleList.FirstOrDefault(t => t.Type == TitleType.Official)?.Value; + if (string.IsNullOrEmpty(title) && Plugin.Instance.Configuration.TitleAllowAny) + title = titleList.FirstOrDefault()?.Value; + if (title != null) + return title; + } + else { + var title = titles.FirstOrDefault()?.Value; + if (title != null) + return title; + } } return null; } /// <summary> - /// Get the main title language from the series list. + /// Get the main title language from the title list. /// </summary> - /// <param name="titles">Series title list.</param> - /// <returns></returns> - private static string GetMainLanguage(IEnumerable<Title> titles) { - return titles.FirstOrDefault(t => t?.Type == TitleType.Main)?.LanguageCode ?? titles.FirstOrDefault()?.LanguageCode ?? "x-other"; - } + /// <param name="titles">Title list.</param> + /// <returns>The main title language code.</returns> + private static string GetMainLanguage(IEnumerable<Title> titles) + => titles.FirstOrDefault(t => t?.Type == TitleType.Main)?.LanguageCode ?? titles.FirstOrDefault()?.LanguageCode ?? "x-other"; /// <summary> - /// Guess the origin language based on the main title. + /// Guess the origin language based on the main title language. /// </summary> - /// <param name="titles">Series title list.</param> - /// <returns></returns> + /// <param name="langCode">The main title language code.</param> + /// <returns>The list of origin language codes to try and use.</returns> private static string[] GuessOriginLanguage(string langCode) - { - // Guess the origin language based on the main title language. - return langCode switch { + => 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 }, }; - } } From fb4647be80ca241d789a827822c3c8251fee8ea2 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 2 May 2024 02:21:05 +0000 Subject: [PATCH 0933/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 9fc0e336..f3ea8ae6 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.152", + "changelog": "refactor: overhaul metadata settings (#52)\n\n- Overhaul the metadata settings section in the plugin settings. By default we let the plugin decide what to do for the main title, alternate title, and description, but each of the three behaviors can be overridden by enabling a checkbox to reveal the new-and-shiny (for the titles at least) advanced selectors. The description selector has also been moved under an override checkbox in this commit, while previously it was always visible.\r\n\r\n- Cleaned up the rest of the metadata settings section to be more tidy by renaming and moving around the other settings.\r\n\r\n- Overhauled the internals to support the new settings. In practice you shouldn't see much difference in behavior compared to the previous options.\r\n\r\n**Warning**: **The title and description settings has been reset to the new default values**, meaning any people that had altered their title/description settings previously should edit them if they don't want to use the new defaults. This is a side-effect of us not having settings migrations. Maybe in the future we will have it, but not now. :slightly_smiling_face", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.152/shoko_3.0.1.152.zip", + "checksum": "030d38b5d39be12fe7f72c380953b335", + "timestamp": "2024-05-02T02:21:04Z" + }, { "version": "3.0.1.151", "changelog": "feat: force movie special featurettes\n\n- Added an option to force all specials in a movie series to appear as\n special featurettes for the movie/season. This option applies across\n all libraries.\n\n- Added two more automatically recognized extra features types without\n the new option enabled.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.148/shoko_3.0.1.148.zip", "checksum": "01998cb2c5497930126759f48b9a9155", "timestamp": "2024-05-01T17:20:54Z" - }, - { - "version": "3.0.1.147", - "changelog": "fix: always remove duplicates + more\n\n- Always remove duplicates and unknown/unwanted seasons/episodes, but\n conditionally add missing seasons/episodes only if the option to add\n missing metadata is enabled. This commit will also fix the missing\n clean-up after disabling the 'add missing metadata' option which\n will now happen.\n\nmisc: add \"Auto\" to the auto clear cache task [skip ci]", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.147/shoko_3.0.1.147.zip", - "checksum": "97599739431efd2da7ef6aec21c4d2c8", - "timestamp": "2024-05-01T13:36:26Z" } ] } From e657677643bf95b52bfda7123b059816caadbf93 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 2 May 2024 05:06:22 +0200 Subject: [PATCH 0934/1103] fix: fix force movie special featurettes --- Shokofin/API/Info/SeasonInfo.cs | 4 ++-- Shokofin/Resolvers/ShokoResolveManager.cs | 8 ++++---- Shokofin/Utils/Ordering.cs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index c3bf6199..c5ab6857 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -192,8 +192,8 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp RelationMap = relationMap; } - public bool IsExtraEpisode(EpisodeInfo episodeInfo) - => ExtrasList.Any(eI => eI.Id == episodeInfo.Id); + public bool IsExtraEpisode(EpisodeInfo? episodeInfo) + => episodeInfo != null && ExtrasList.Any(eI => eI.Id == episodeInfo.Id); public bool IsEmpty(int offset = 0) { diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 611bb6b9..769106ce 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -750,7 +750,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { if (episodeName.Length >= NameCutOff) episodeName = episodeName[..NameCutOff].Split(' ').SkipLast(1).Join(' ') + "…"; - var isExtra = season.IsExtraEpisode(episode); + var isExtra = file.EpisodeList.Any(eI => season.IsExtraEpisode(eI)); var nfoFiles = new List<string>(); var folders = new List<string>(); var extrasFolder = file.ExtraType switch { @@ -1273,8 +1273,8 @@ private static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string .GetAwaiter() .GetResult(); - // Abort if the file was not recognised. - if (file == null || file.ExtraType != null) + // Abort if the file was not recognized. + if (file == null || file.EpisodeList.Any(eI => season.IsExtraEpisode(eI))) return null; return new Movie() { @@ -1455,7 +1455,7 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b Logger.LogInformation("Found {EpisodeCount} shoko episode(s) for {SeriesName} (Series={SeriesId},File={FileId})", file.EpisodeList.Count, season.Shoko.Name, season.Id, file.Id); // We're going to post process this file later, but we don't want to include it in our library for now. - if (file.ExtraType != null) { + if (file.EpisodeList.Any(eI => season.IsExtraEpisode(eI))) { Logger.LogInformation("File was assigned an extra type, ignoring file. (Series={SeriesId},File={FileId})", season.Id, file.Id); return true; } diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index 4a3e835b..91ba4e55 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -120,7 +120,7 @@ public static int GetEpisodeNumber(ShowInfo showInfo, SeasonInfo seasonInfo, Epi { var index = 0; var offset = 0; - if (episodeInfo.ExtraType != null) { + if (seasonInfo.IsExtraEpisode(episodeInfo)) { var seasonIndex = showInfo.SeasonList.FindIndex(s => string.Equals(s.Id, seasonInfo.Id)); if (seasonIndex == -1) throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={showInfo.GroupId},Series={seasonInfo.Id},Episode={episodeInfo.Id})"); From 03791347a13278a5e5547a046bdd017141b4b190 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 2 May 2024 03:07:06 +0000 Subject: [PATCH 0935/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index f3ea8ae6..dfaf8515 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.153", + "changelog": "fix: fix force movie special featurettes", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.153/shoko_3.0.1.153.zip", + "checksum": "385b153fc6537723a899973d82fa5007", + "timestamp": "2024-05-02T03:07:05Z" + }, { "version": "3.0.1.152", "changelog": "refactor: overhaul metadata settings (#52)\n\n- Overhaul the metadata settings section in the plugin settings. By default we let the plugin decide what to do for the main title, alternate title, and description, but each of the three behaviors can be overridden by enabling a checkbox to reveal the new-and-shiny (for the titles at least) advanced selectors. The description selector has also been moved under an override checkbox in this commit, while previously it was always visible.\r\n\r\n- Cleaned up the rest of the metadata settings section to be more tidy by renaming and moving around the other settings.\r\n\r\n- Overhauled the internals to support the new settings. In practice you shouldn't see much difference in behavior compared to the previous options.\r\n\r\n**Warning**: **The title and description settings has been reset to the new default values**, meaning any people that had altered their title/description settings previously should edit them if they don't want to use the new defaults. This is a side-effect of us not having settings migrations. Maybe in the future we will have it, but not now. :slightly_smiling_face", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.149/shoko_3.0.1.149.zip", "checksum": "a646ae721eb48b15067c3c4ba7230129", "timestamp": "2024-05-01T19:45:13Z" - }, - { - "version": "3.0.1.148", - "changelog": "refactor: add true disabling of filtering\n\n- Reconfigured the setting to allow truely disabling the filtering.\n Do note that **__THIS IS NOT ADVICED TO BE USED UNLESS YOU\n KNOW EXACTLY WHAT YOU'RE DOING__**. Anyways, it lets you\n disable the filtering, which required changing the settings, so\n now everything is back to \"auto\" (the default) and you need to\n tweak your filtering setting if you want it back to the _strict_ or\n _lax_ mode.\n\nmisc: split resolver and ignore rule [skip ci]", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.148/shoko_3.0.1.148.zip", - "checksum": "01998cb2c5497930126759f48b9a9155", - "timestamp": "2024-05-01T17:20:54Z" } ] } From 917c43f6fc3688591c0410c2192552ca47beda01 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 2 May 2024 05:38:56 +0200 Subject: [PATCH 0936/1103] fix: one more fix for force movie special featurettes --- Shokofin/API/Info/SeasonInfo.cs | 14 ++++++++++---- Shokofin/Configuration/configPage.html | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index c5ab6857..f90614e7 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -165,10 +165,16 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp altEpisodesList = new(); } - if (Plugin.Instance.Configuration.MovieSpecialsAsExtraFeaturettes && type == SeriesType.Movie && specialsList.Count > 0) { - extrasList.AddRange(specialsList); - specialsAnchorDictionary.Clear(); - specialsList = new(); + if (Plugin.Instance.Configuration.MovieSpecialsAsExtraFeaturettes && type == SeriesType.Movie) { + if (specialsList.Count > 0) { + extrasList.AddRange(specialsList); + specialsAnchorDictionary.Clear(); + specialsList = new(); + } + if (altEpisodesList.Count > 0) { + extrasList.AddRange(altEpisodesList); + altEpisodesList = new(); + } } Id = seriesId; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 6be83ede..c7f08b3b 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -282,7 +282,7 @@ <h3>Library Settings</h3> <input is="emby-checkbox" type="checkbox" id="MovieSpecialsAsExtraFeaturettes" /> <span>Force movie special featurettes</span> </label> - <div class="fieldDescription checkboxFieldDescription">Append all specials in AniDB movie series as special featurettes for the movies. By default only some specials will be automatically recognized as special featurettes, but by enabling this option you will force all specials to be used as special featurettes. This setting applies to movie series across all library types.</div> + <div class="fieldDescription checkboxFieldDescription">Append all specials in AniDB movie series as special featurettes for the movies. By default only some specials will be automatically recognized as special featurettes, but by enabling this option you will force all specials to be used as special featurettes. 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"> From 128b246e22e536682c184a22b065ba2ad3637f1f Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 2 May 2024 03:39:45 +0000 Subject: [PATCH 0937/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index dfaf8515..9fac25ff 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.154", + "changelog": "fix: one more fix for force movie special featurettes", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.154/shoko_3.0.1.154.zip", + "checksum": "453677e7d05402dbd73e44790944da70", + "timestamp": "2024-05-02T03:39:43Z" + }, { "version": "3.0.1.153", "changelog": "fix: fix force movie special featurettes", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.150/shoko_3.0.1.150.zip", "checksum": "1dd15a38a23b58f4c2cd19ef9e62ae41", "timestamp": "2024-05-01T20:00:40Z" - }, - { - "version": "3.0.1.149", - "changelog": "fix: don't remove empty seasons that should exist\n\n- Don't remove the empty seasons that should exist, according to our\n shoko data, not using jellyfin since it's not reliable enough to use\n for this check.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.149/shoko_3.0.1.149.zip", - "checksum": "a646ae721eb48b15067c3c4ba7230129", - "timestamp": "2024-05-01T19:45:13Z" } ] } From 9fbfc785a8d775446898d4c4f8a626f978a419c4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 2 May 2024 05:43:30 +0200 Subject: [PATCH 0938/1103] misc: add more special featurettes detections - Add more special featurettes detections, but this time for the 'other' type episodes. --- Shokofin/API/Info/SeasonInfo.cs | 5 ++++- Shokofin/Utils/Ordering.cs | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index f90614e7..2eb8e514 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -121,7 +121,10 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp lastNormalEpisode = index; break; case EpisodeType.Other: - altEpisodesList.Add(episode); + if (episode.ExtraType != null) + extrasList.Add(episode); + else + altEpisodesList.Add(episode); break; default: if (episode.ExtraType != null) { diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index 91ba4e55..35e1562a 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -254,7 +254,6 @@ public static int GetSeasonNumber(ShowInfo showInfo, SeasonInfo seasonInfo, Epis switch (episode.Type) { case EpisodeType.Normal: - case EpisodeType.Other: return null; case EpisodeType.ThemeSong: case EpisodeType.OpeningSong: @@ -262,6 +261,21 @@ 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.Special: { var title = Text.GetTitlesForLanguage(episode.Titles, false, "en"); if (string.IsNullOrEmpty(title)) From e66dfca7d74547c91ed6d5684b41577535891bc7 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 2 May 2024 03:44:13 +0000 Subject: [PATCH 0939/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 9fac25ff..a799f6b6 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.155", + "changelog": "misc: add more special featurettes detections\n\n- Add more special featurettes detections, but this time for the 'other'\n type episodes.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.155/shoko_3.0.1.155.zip", + "checksum": "e1f7dc6906cb6d2deb61721738197408", + "timestamp": "2024-05-02T03:44:12Z" + }, { "version": "3.0.1.154", "changelog": "fix: one more fix for force movie special featurettes", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.151/shoko_3.0.1.151.zip", "checksum": "8f17f3ecf098e4953c2e2d616ada1921", "timestamp": "2024-05-02T02:04:41Z" - }, - { - "version": "3.0.1.150", - "changelog": "refactor: remove the true disabled option\n\nThis partially reverts commits bcb4f8239cad89d12009c59f865bdf2884eb571\nand fb06a39a8a9484aa3a65a7843ad0b0168f60b37a because it's not needed\nanymore.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.150/shoko_3.0.1.150.zip", - "checksum": "1dd15a38a23b58f4c2cd19ef9e62ae41", - "timestamp": "2024-05-01T20:00:40Z" } ] } From 56c667e96dae01429a8def4925af3d7856e1fdf9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 4 May 2024 01:37:22 +0200 Subject: [PATCH 0940/1103] fix: don't append suffix to theme-videos/trailers --- Shokofin/Resolvers/ShokoResolveManager.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 769106ce..f1d49178 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -768,6 +768,9 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { ExtraType.Scene => "-scene", ExtraType.Sample => "-other", ExtraType.Unknown => "-other", + ExtraType.ThemeSong => string.Empty, + ExtraType.ThemeVideo => string.Empty, + ExtraType.Trailer => string.Empty, _ => isExtra ? "-other" : string.Empty, }; if (isMovieSeason && collectionType != CollectionType.TvShows) { From e1eacdad0dc7d934b6139c6c03d770e25f943884 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 3 May 2024 23:38:17 +0000 Subject: [PATCH 0941/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index a799f6b6..9a4cad5c 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.156", + "changelog": "fix: don't append suffix to theme-videos/trailers", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.156/shoko_3.0.1.156.zip", + "checksum": "5811fb06e58a82f2b88bf9a90cf1bbf7", + "timestamp": "2024-05-03T23:38:15Z" + }, { "version": "3.0.1.155", "changelog": "misc: add more special featurettes detections\n\n- Add more special featurettes detections, but this time for the 'other'\n type episodes.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.152/shoko_3.0.1.152.zip", "checksum": "030d38b5d39be12fe7f72c380953b335", "timestamp": "2024-05-02T02:21:04Z" - }, - { - "version": "3.0.1.151", - "changelog": "feat: force movie special featurettes\n\n- Added an option to force all specials in a movie series to appear as\n special featurettes for the movie/season. This option applies across\n all libraries.\n\n- Added two more automatically recognized extra features types without\n the new option enabled.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.151/shoko_3.0.1.151.zip", - "checksum": "8f17f3ecf098e4953c2e2d616ada1921", - "timestamp": "2024-05-02T02:04:41Z" } ] } From 7c8732c727231af16621272bc357d23db8e2eb69 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 4 May 2024 02:06:32 +0200 Subject: [PATCH 0942/1103] fix: fix paths for file events in daily server --- Shokofin/API/Models/File.cs | 18 +++++-- Shokofin/SignalR/Models/FileEventArgs.cs | 20 +++++-- Shokofin/SignalR/Models/FileMovedEventArgs.cs | 54 ++++++++++++++----- .../SignalR/Models/FileRenamedEventArgs.cs | 20 +++++-- 4 files changed, 86 insertions(+), 26 deletions(-) diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index 32f47484..08f19962 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -96,10 +96,20 @@ public class Location /// the start. /// </summary> [JsonIgnore] - public string RelativePath => - CachedPath ??= System.IO.Path.DirectorySeparatorChar + InternalPath - .Replace('/', System.IO.Path.DirectorySeparatorChar) - .Replace('\\', System.IO.Path.DirectorySeparatorChar); + public string RelativePath + { + get + { + if (CachedPath != null) + return CachedPath; + var relativePath = System.IO.Path.DirectorySeparatorChar + InternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) + relativePath = System.IO.Path.DirectorySeparatorChar + relativePath; + return CachedPath = relativePath; + } + } /// <summary> /// True if the server can access the the <see cref="Location.RelativePath"/> at diff --git a/Shokofin/SignalR/Models/FileEventArgs.cs b/Shokofin/SignalR/Models/FileEventArgs.cs index 8b1953e8..64cc70a2 100644 --- a/Shokofin/SignalR/Models/FileEventArgs.cs +++ b/Shokofin/SignalR/Models/FileEventArgs.cs @@ -19,7 +19,7 @@ public class FileEventArgs : IFileEventArgs public int ImportFolderId { get; set; } /// <summary> - /// The relative path with no leading slash and directory seperators used on + /// The relative path with no leading slash and directory separators used on /// the Shoko side. /// </summary> [JsonInclude, JsonPropertyName("RelativePath")] @@ -33,10 +33,20 @@ public class FileEventArgs : IFileEventArgs /// <inheritdoc/> [JsonIgnore] - public string RelativePath => - CachedPath ??= System.IO.Path.DirectorySeparatorChar + InternalPath - .Replace('/', System.IO.Path.DirectorySeparatorChar) - .Replace('\\', System.IO.Path.DirectorySeparatorChar); + public string RelativePath + { + get + { + if (CachedPath != null) + return CachedPath; + var relativePath = System.IO.Path.DirectorySeparatorChar + InternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) + relativePath = System.IO.Path.DirectorySeparatorChar + relativePath; + return CachedPath = relativePath; + } + } /// <inheritdoc/> [JsonIgnore] diff --git a/Shokofin/SignalR/Models/FileMovedEventArgs.cs b/Shokofin/SignalR/Models/FileMovedEventArgs.cs index 0f7a05c8..517152e0 100644 --- a/Shokofin/SignalR/Models/FileMovedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileMovedEventArgs.cs @@ -26,10 +26,20 @@ public class FileMovedEventArgs: FileEventArgs, IFileRelocationEventArgs /// <inheritdoc/> [JsonIgnore] - public string PreviousRelativePath => - PreviousCachedPath ??= System.IO.Path.DirectorySeparatorChar + PreviousInternalPath - .Replace('/', System.IO.Path.DirectorySeparatorChar) - .Replace('\\', System.IO.Path.DirectorySeparatorChar); + public string PreviousRelativePath + { + get + { + if (PreviousCachedPath != null) + return PreviousCachedPath; + var relativePath = System.IO.Path.DirectorySeparatorChar + PreviousInternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) + relativePath = System.IO.Path.DirectorySeparatorChar + relativePath; + return PreviousCachedPath = relativePath; + } + } public class V0 : IFileRelocationEventArgs { @@ -64,10 +74,20 @@ public class V0 : IFileRelocationEventArgs /// <inheritdoc/> [JsonIgnore] - public string RelativePath => - CachedPath ??= System.IO.Path.DirectorySeparatorChar + InternalPath - .Replace('/', System.IO.Path.DirectorySeparatorChar) - .Replace('\\', System.IO.Path.DirectorySeparatorChar); + public string RelativePath + { + get + { + if (CachedPath != null) + return CachedPath; + var relativePath = System.IO.Path.DirectorySeparatorChar + InternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) + relativePath = System.IO.Path.DirectorySeparatorChar + relativePath; + return CachedPath = relativePath; + } + } /// <summary> @@ -85,10 +105,20 @@ public class V0 : IFileRelocationEventArgs /// <inheritdoc/> [JsonIgnore] - public string PreviousRelativePath => - PreviousCachedPath ??= System.IO.Path.DirectorySeparatorChar + PreviousInternalPath - .Replace('/', System.IO.Path.DirectorySeparatorChar) - .Replace('\\', System.IO.Path.DirectorySeparatorChar); + public string PreviousRelativePath + { + get + { + if (PreviousCachedPath != null) + return PreviousCachedPath; + var relativePath = System.IO.Path.DirectorySeparatorChar + PreviousInternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) + relativePath = System.IO.Path.DirectorySeparatorChar + relativePath; + return PreviousCachedPath = relativePath; + } + } /// <inheritdoc/> [JsonIgnore] diff --git a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs index 9344f8be..d8dee126 100644 --- a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs @@ -42,7 +42,7 @@ public class V0 : IFileRelocationEventArgs public int ImportFolderId { get; set; } /// <summary> - /// The relative path with no leading slash and directory seperators used on + /// The relative path with no leading slash and directory separators used on /// the Shoko side. /// </summary> [JsonInclude, JsonPropertyName("RelativePath")] @@ -56,10 +56,20 @@ public class V0 : IFileRelocationEventArgs /// <inheritdoc/> [JsonIgnore] - public string RelativePath => - CachedPath ??= System.IO.Path.DirectorySeparatorChar + InternalPath - .Replace('/', System.IO.Path.DirectorySeparatorChar) - .Replace('\\', System.IO.Path.DirectorySeparatorChar); + public string RelativePath + { + get + { + if (CachedPath != null) + return CachedPath; + var relativePath = System.IO.Path.DirectorySeparatorChar + InternalPath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) + relativePath = System.IO.Path.DirectorySeparatorChar + relativePath; + return CachedPath = relativePath; + } + } /// <summary> /// The new File name. From e43ec61773cc2d34a51b5d7a7073c20257c38484 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 4 May 2024 00:07:21 +0000 Subject: [PATCH 0943/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 9a4cad5c..bb0eacbe 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.157", + "changelog": "fix: fix paths for file events in daily server", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.157/shoko_3.0.1.157.zip", + "checksum": "a8922542493cfd0f2842dfa08f00abcb", + "timestamp": "2024-05-04T00:07:19Z" + }, { "version": "3.0.1.156", "changelog": "fix: don't append suffix to theme-videos/trailers", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.153/shoko_3.0.1.153.zip", "checksum": "385b153fc6537723a899973d82fa5007", "timestamp": "2024-05-02T03:07:05Z" - }, - { - "version": "3.0.1.152", - "changelog": "refactor: overhaul metadata settings (#52)\n\n- Overhaul the metadata settings section in the plugin settings. By default we let the plugin decide what to do for the main title, alternate title, and description, but each of the three behaviors can be overridden by enabling a checkbox to reveal the new-and-shiny (for the titles at least) advanced selectors. The description selector has also been moved under an override checkbox in this commit, while previously it was always visible.\r\n\r\n- Cleaned up the rest of the metadata settings section to be more tidy by renaming and moving around the other settings.\r\n\r\n- Overhauled the internals to support the new settings. In practice you shouldn't see much difference in behavior compared to the previous options.\r\n\r\n**Warning**: **The title and description settings has been reset to the new default values**, meaning any people that had altered their title/description settings previously should edit them if they don't want to use the new defaults. This is a side-effect of us not having settings migrations. Maybe in the future we will have it, but not now. :slightly_smiling_face", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.152/shoko_3.0.1.152.zip", - "checksum": "030d38b5d39be12fe7f72c380953b335", - "timestamp": "2024-05-02T02:21:04Z" } ] } From 06f3be17eadc5d8bb8582b320adf57bc5db50b6f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 4 May 2024 02:35:09 +0200 Subject: [PATCH 0944/1103] fix: remove hard-coded path separator --- Shokofin/API/Models/File.cs | 2 +- Shokofin/SignalR/Models/FileEventArgs.cs | 2 +- Shokofin/SignalR/Models/FileMovedEventArgs.cs | 6 +++--- Shokofin/SignalR/Models/FileRenamedEventArgs.cs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index 08f19962..a4e15afd 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -102,7 +102,7 @@ public string RelativePath { if (CachedPath != null) return CachedPath; - var relativePath = System.IO.Path.DirectorySeparatorChar + InternalPath + var relativePath = InternalPath .Replace('/', System.IO.Path.DirectorySeparatorChar) .Replace('\\', System.IO.Path.DirectorySeparatorChar); if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) diff --git a/Shokofin/SignalR/Models/FileEventArgs.cs b/Shokofin/SignalR/Models/FileEventArgs.cs index 64cc70a2..a722d1f8 100644 --- a/Shokofin/SignalR/Models/FileEventArgs.cs +++ b/Shokofin/SignalR/Models/FileEventArgs.cs @@ -39,7 +39,7 @@ public string RelativePath { if (CachedPath != null) return CachedPath; - var relativePath = System.IO.Path.DirectorySeparatorChar + InternalPath + var relativePath = InternalPath .Replace('/', System.IO.Path.DirectorySeparatorChar) .Replace('\\', System.IO.Path.DirectorySeparatorChar); if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) diff --git a/Shokofin/SignalR/Models/FileMovedEventArgs.cs b/Shokofin/SignalR/Models/FileMovedEventArgs.cs index 517152e0..6d982d01 100644 --- a/Shokofin/SignalR/Models/FileMovedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileMovedEventArgs.cs @@ -32,7 +32,7 @@ public string PreviousRelativePath { if (PreviousCachedPath != null) return PreviousCachedPath; - var relativePath = System.IO.Path.DirectorySeparatorChar + PreviousInternalPath + var relativePath = PreviousInternalPath .Replace('/', System.IO.Path.DirectorySeparatorChar) .Replace('\\', System.IO.Path.DirectorySeparatorChar); if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) @@ -80,7 +80,7 @@ public string RelativePath { if (CachedPath != null) return CachedPath; - var relativePath = System.IO.Path.DirectorySeparatorChar + InternalPath + var relativePath = InternalPath .Replace('/', System.IO.Path.DirectorySeparatorChar) .Replace('\\', System.IO.Path.DirectorySeparatorChar); if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) @@ -111,7 +111,7 @@ public string PreviousRelativePath { if (PreviousCachedPath != null) return PreviousCachedPath; - var relativePath = System.IO.Path.DirectorySeparatorChar + PreviousInternalPath + var relativePath = PreviousInternalPath .Replace('/', System.IO.Path.DirectorySeparatorChar) .Replace('\\', System.IO.Path.DirectorySeparatorChar); if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) diff --git a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs index d8dee126..340305aa 100644 --- a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs @@ -62,7 +62,7 @@ public string RelativePath { if (CachedPath != null) return CachedPath; - var relativePath = System.IO.Path.DirectorySeparatorChar + InternalPath + var relativePath = InternalPath .Replace('/', System.IO.Path.DirectorySeparatorChar) .Replace('\\', System.IO.Path.DirectorySeparatorChar); if (relativePath[0] != System.IO.Path.DirectorySeparatorChar) From 8e71656b324b4c1eb225b36c337d09c437768b4f Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 4 May 2024 00:35:54 +0000 Subject: [PATCH 0945/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index bb0eacbe..b8ef39fd 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.158", + "changelog": "fix: remove hard-coded path separator", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.158/shoko_3.0.1.158.zip", + "checksum": "6372792bb2eb41692cd9df8a20bbcce4", + "timestamp": "2024-05-04T00:35:52Z" + }, { "version": "3.0.1.157", "changelog": "fix: fix paths for file events in daily server", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.154/shoko_3.0.1.154.zip", "checksum": "453677e7d05402dbd73e44790944da70", "timestamp": "2024-05-02T03:39:43Z" - }, - { - "version": "3.0.1.153", - "changelog": "fix: fix force movie special featurettes", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.153/shoko_3.0.1.153.zip", - "checksum": "385b153fc6537723a899973d82fa5007", - "timestamp": "2024-05-02T03:07:05Z" } ] } From 045f6f6fd925269ecc2973947cd0e06438070796 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 4 May 2024 03:23:11 +0200 Subject: [PATCH 0946/1103] misc: tweak comments [skip ci] --- Shokofin/SignalR/Models/FileRenamedEventArgs.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs index 340305aa..82e538a9 100644 --- a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs @@ -8,13 +8,13 @@ namespace Shokofin.SignalR.Models; public class FileRenamedEventArgs : FileEventArgs, IFileRelocationEventArgs { /// <summary> - /// The new File name. + /// The current file name. /// </summary> [JsonInclude, JsonPropertyName("FileName")] public string FileName { get; set; } = string.Empty; /// <summary> - /// The old file name. + /// The previous file name. /// </summary> [JsonInclude, JsonPropertyName("PreviousFileName")] public string PreviousFileName { get; set; } = string.Empty; @@ -72,13 +72,13 @@ public string RelativePath } /// <summary> - /// The new File name. + /// The current file name. /// </summary> [JsonInclude, JsonPropertyName("NewFileName")] public string FileName { get; set; } = string.Empty; /// <summary> - /// The old file name. + /// The previous file name. /// </summary> [JsonInclude, JsonPropertyName("OldFileName")] public string PreviousFileName { get; set; } = string.Empty; From 16a8b751c3f1bc3ad3e07447a35fba29b11fd30a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 4 May 2024 04:24:31 +0200 Subject: [PATCH 0947/1103] misc: the typo squashing commit [skip ci] I went full grammar nazi. I have no excuse. --- .vscode/extensions.json | 12 +++ .vscode/settings.json | 38 +++++++++- Shokofin/API/Info/CollectionInfo.cs | 1 - Shokofin/API/Models/ApiException.cs | 6 +- Shokofin/API/Models/Image.cs | 2 +- Shokofin/API/Models/Role.cs | 4 +- Shokofin/API/Models/Series.cs | 14 ++-- Shokofin/API/ShokoAPIManager.cs | 4 +- .../Configuration/MediaFolderConfiguration.cs | 4 +- Shokofin/Configuration/PluginConfiguration.cs | 4 +- Shokofin/Configuration/UserConfiguration.cs | 2 +- Shokofin/Configuration/configController.js | 12 +-- Shokofin/Configuration/configPage.html | 76 +++++++++---------- Shokofin/MergeVersions/MergeVersionManager.cs | 16 ++-- Shokofin/Providers/BoxSetProvider.cs | 19 ----- Shokofin/Resolvers/ShokoIgnoreRule.cs | 2 - Shokofin/Resolvers/ShokoResolveManager.cs | 6 +- Shokofin/SignalR/Interfaces/IFileEventArgs.cs | 4 +- Shokofin/SignalR/Models/FileMovedEventArgs.cs | 7 +- Shokofin/SignalR/SignalRConnectionManager.cs | 40 +++++----- Shokofin/SignalR/SignalREntryPoint.cs | 8 +- Shokofin/StringExtensions.cs | 2 +- Shokofin/Sync/UserDataSyncManager.cs | 2 +- Shokofin/Utils/Ordering.cs | 12 +-- Shokofin/Utils/SeriesInfoRelationComparer.cs | 6 +- Shokofin/Utils/Text.cs | 2 - Shokofin/Web/ShokoApiController.cs | 2 +- Shokofin/Web/SignalRApiController.cs | 6 +- 28 files changed, 170 insertions(+), 143 deletions(-) create mode 100644 .vscode/extensions.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..c1aa147a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "ms-dotnettools.csharp", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "ms-dotnettools.vscode-dotnet-runtime", + "ms-dotnettools.csdevkit", + "eamodio.gitlens", + "streetsidesoftware.code-spell-checker" + ], + "unwantedRecommendations": [] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 647ec169..f708a64a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,41 @@ "files.trimTrailingWhitespace": false, "files.trimFinalNewlines": false, "files.insertFinalNewline": false, - "dotnet.defaultSolution": "Shokofin.sln" + "dotnet.defaultSolution": "Shokofin.sln", + "cSpell.words": [ + "anidb", + "apikey", + "automagic", + "automagically", + "dlna", + "emby", + "eroge", + "fanart", + "fanarts", + "hentai", + "imdb", + "imdbid", + "interrobang", + "jellyfin", + "koma", + "linkbutton", + "manhua", + "manhwa", + "nfo", + "nfos", + "outro", + "registrator", + "scrobble", + "scrobbled", + "scrobbling", + "seiyuu", + "shoko", + "shokofin", + "signalr", + "tmdb", + "tvshow", + "viewshow", + "webui", + "whitespaces" + ] } diff --git a/Shokofin/API/Info/CollectionInfo.cs b/Shokofin/API/Info/CollectionInfo.cs index 4f8cffdf..71785f70 100644 --- a/Shokofin/API/Info/CollectionInfo.cs +++ b/Shokofin/API/Info/CollectionInfo.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using Shokofin.API.Models; -using Shokofin.Utils; namespace Shokofin.API.Info; diff --git a/Shokofin/API/Models/ApiException.cs b/Shokofin/API/Models/ApiException.cs index 035576ca..34e229fe 100644 --- a/Shokofin/API/Models/ApiException.cs +++ b/Shokofin/API/Models/ApiException.cs @@ -62,8 +62,8 @@ public static ApiException FromResponse(HttpResponseMessage response) } var index = text.IndexOf("HEADERS"); if (index != -1) { - var (firstLine, lines) = text.Substring(0, index).TrimEnd().Split('\n'); - var (name, splitMessage) = firstLine?.Split(':') ?? new string[] {}; + var (firstLine, lines) = text[..index].TrimEnd().Split('\n'); + var (name, splitMessage) = firstLine?.Split(':') ?? Array.Empty<string>(); var message = string.Join(':', splitMessage).Trim(); var stackTrace = string.Join('\n', lines); return new ApiException(response.StatusCode, new RemoteApiException(name ?? "InternalServerException", message, stackTrace)); @@ -93,7 +93,7 @@ public enum ApiExceptionType public static class IListExtension { public static void Deconstruct<T>(this IList<T> list, out T? first, out IList<T> rest) { - first = list.Count > 0 ? list[0] : default(T); // or throw + first = list.Count > 0 ? list[0] : default; // or throw rest = list.Skip(1).ToList(); } } diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index dfe81465..534ee7ac 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -63,7 +63,7 @@ public virtual bool IsAvailable /// </summary> [JsonIgnore] public virtual string Path - => $"/api/v3/Image/{Source.ToString()}/{Type.ToString()}/{ID}"; + => $"/api/v3/Image/{Source}/{Type}/{ID}"; /// <summary> /// Get an URL to both download the image on the backend and preview it for diff --git a/Shokofin/API/Models/Role.cs b/Shokofin/API/Models/Role.cs index 143fceef..af2f9882 100644 --- a/Shokofin/API/Models/Role.cs +++ b/Shokofin/API/Models/Role.cs @@ -34,7 +34,7 @@ public class Person { /// <summary> /// Main Name, romanized if needed - /// ex. Sawano Hiroyuki + /// ex. John Smith /// </summary> public string Name { get; set; } = string.Empty; @@ -46,7 +46,7 @@ public class Person /// <summary> /// A description, bio, etc - /// ex. Sawano Hiroyuki was born September 12, 1980 in Tokyo, Japan. He is a composer and arranger. + /// ex. John Smith was born September 12, 1980 in Tokyo, Japan. He is a composer and arranger. /// </summary> public string Description { get; set; } = string.Empty; diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index c8e3da23..1592bf0d 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -145,25 +145,25 @@ public class AniDBWithDate : AniDB public new int EpisodeCount { get; set; } [JsonIgnore] - private DateTime? _airDate { get; set; } = null; + private DateTime? InternalAirDate { get; set; } = null; /// <summary> - /// Air date (2013-02-27, shut up avael). Anything without an air date is going to be missing a lot of info. + /// Air date (2013-02-27). Anything without an air date is going to be missing a lot of info. /// </summary> public DateTime? AirDate { get { - return _airDate; + return InternalAirDate; } set { - _airDate = value.HasValue && (value.Value == DateTime.UnixEpoch || value.Value == DateTime.MinValue || value.Value == DateTime.MaxValue) ? null : value; + InternalAirDate = value.HasValue && (value.Value == DateTime.UnixEpoch || value.Value == DateTime.MinValue || value.Value == DateTime.MaxValue) ? null : value; } } [JsonIgnore] - private DateTime? _endDate { get; set; } = null; + private DateTime? InternalEndDate { get; set; } = null; /// <summary> /// End date, can be omitted. Omitted means that it's still airing (2013-02-27) @@ -172,11 +172,11 @@ public DateTime? EndDate { get { - return _endDate; + return InternalEndDate; } set { - _endDate = value.HasValue && (value.Value == DateTime.UnixEpoch || value.Value == DateTime.MinValue || value.Value == DateTime.MaxValue) ? null : value; + InternalEndDate = value.HasValue && (value.Value == DateTime.UnixEpoch || value.Value == DateTime.MinValue || value.Value == DateTime.MaxValue) ? null : value; } } } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 0d47e746..6bd522d6 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -768,7 +768,7 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, [NotNullWhen(true) if (!Plugin.Instance.Configuration.UseGroupsForShows || group.Sizes.SubGroups > 0) return GetOrCreateShowInfoForSeasonInfo(seasonInfo); - // If we found a movie, and we're assiging movies as stand-alone shows, and we didn't create a stand-alone show + // If we found a movie, and we're assigning movies as stand-alone shows, and we didn't create a stand-alone show // above, then attach the stand-alone show to the parent group of the group that might other if (seasonInfo.Type == SeriesType.Movie && Plugin.Instance.Configuration.SeparateMovies) return GetOrCreateShowInfoForSeasonInfo(seasonInfo, group.Size > 0 ? group.IDs.ParentGroup.ToString() : null); @@ -901,7 +901,7 @@ private Task<CollectionInfo> CreateCollectionInfo(Group group, string groupId) } } - Logger.LogTrace("Finalising info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); + Logger.LogTrace("Finalizing info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); var showList = showDict.Values.ToList(); var collectionInfo = new CollectionInfo(group, showList, groupList); return collectionInfo; diff --git a/Shokofin/Configuration/MediaFolderConfiguration.cs b/Shokofin/Configuration/MediaFolderConfiguration.cs index 16a5d579..119c821f 100644 --- a/Shokofin/Configuration/MediaFolderConfiguration.cs +++ b/Shokofin/Configuration/MediaFolderConfiguration.cs @@ -27,7 +27,7 @@ public class MediaFolderConfiguration /// <summary> /// The friendly name of the import folder, if any. Stored only for showing - /// in the setttings page of the plugin… since it's very hard to get in + /// in the settings page of the plugin… since it's very hard to get in /// there otherwise. /// </summary> public string? ImportFolderName { get; set; } @@ -60,7 +60,7 @@ public class MediaFolderConfiguration public bool IsVirtualFileSystemEnabled { get; set; } = true; /// <summary> - /// Enable or disable the library filterin on a per-media-folder basis. Do + /// Enable or disable the library filtering on a per-media-folder basis. Do /// note that this will only take effect if the VFS is not used. /// </summary> public LibraryFilteringMode LibraryFilteringMode { get; set; } = LibraryFilteringMode.Auto; diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index a5478288..a3c6a99a 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -18,12 +18,14 @@ public class PluginConfiguration : BasePluginConfiguration { #region Connection +#pragma warning disable CA1822 /// <summary> /// Helper for the web ui to show the windows only warning, and to disable /// the VFS by default if we cannot create symbolic links. /// </summary> [XmlIgnore, JsonInclude] public bool CanCreateSymbolicLinks => Plugin.Instance.CanCreateSymbolicLinks; +#pragma warning restore CA1822 /// <summary> /// The URL for where to connect to shoko internally. @@ -135,7 +137,7 @@ public virtual string PrettyUrl public DescriptionProvider[] DescriptionSourceList { get; set; } /// <summary> - /// The prioritisation order of source providers for description sources. + /// The prioritization order of source providers for description sources. /// </summary> public DescriptionProvider[] DescriptionSourceOrder { get; set; } diff --git a/Shokofin/Configuration/UserConfiguration.cs b/Shokofin/Configuration/UserConfiguration.cs index d8500303..c2df2967 100644 --- a/Shokofin/Configuration/UserConfiguration.cs +++ b/Shokofin/Configuration/UserConfiguration.cs @@ -37,7 +37,7 @@ public class UserConfiguration /// <summary> /// Number of playback events to skip before starting to send the events - /// to Shoko. This is to prevent accidentially updating user watch data + /// to Shoko. This is to prevent accidentally updating user watch data /// when a user miss clicked on a video. /// </summary> [Range(0, 200)] diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index e9cb63f1..74967f48 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -895,10 +895,10 @@ function setDescriptionSourcesIntoConfig(form, config) { const descriptionElements = form.querySelectorAll(`#descriptionSourceList .chkDescriptionSource`); config.DescriptionSourceList = Array.prototype.filter.call(descriptionElements, (el) => el.checked) - .map((el) => el.dataset.descriptionsource); + .map((el) => el.dataset.descriptionSource); config.DescriptionSourceOrder = Array.prototype.map.call(descriptionElements, - (el) => el.dataset.descriptionsource + (el) => el.dataset.descriptionSource ); config.DescriptionSourceOverride = override.checked; @@ -914,7 +914,7 @@ function setDescriptionSourcesFromConfig(form, config) { override.checked ? root.removeAttribute("hidden") : root.setAttribute("hidden", ""); for (const item of listItems) { - const source = item.dataset.descriptionsource; + const source = item.dataset.descriptionSource; if (config.DescriptionSourceList.includes(source)) { item.querySelector(".chkDescriptionSource").checked = true; } @@ -924,7 +924,7 @@ function setDescriptionSourcesFromConfig(form, config) { } for (const source of config.DescriptionSourceOrder) { - const targetElement = Array.prototype.find.call(listItems, (el) => el.dataset.descriptionsource === source); + const targetElement = Array.prototype.find.call(listItems, (el) => el.dataset.descriptionSource === source); if (targetElement) { list.append(targetElement); } @@ -941,7 +941,7 @@ function setDescriptionSourcesFromConfig(form, config) { */ function setTitleIntoConfig(form, type, config) { const titleElements = form.querySelectorAll(`#Title${type}List .chkTitleSource`); - const getSettingName = (el) => `${el.dataset.titleprovider}_${el.dataset.titlestyle}`; + const getSettingName = (el) => `${el.dataset.titleProvider}_${el.dataset.titleStyle}`; config[`Title${type}List`] = Array.prototype.filter.call(titleElements, (el) => el.checked) @@ -967,7 +967,7 @@ function setTitleFromConfig(form, type, config) { override.checked = config[`Title${type}Override`]; override.checked ? root.removeAttribute("hidden") : root.setAttribute("hidden", ""); - const getSettingName = (el) => `${el.dataset.titleprovider}_${el.dataset.titlestyle}`; + const getSettingName = (el) => `${el.dataset.titleProvider}_${el.dataset.titleStyle}`; for (const item of listItems) { const setting = getSettingName(item); diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index c7f08b3b..e391288a 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -56,38 +56,38 @@ <h3>Metadata Settings</h3> <div id="TitleMainList" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced main title source:</h3> <div class="checkboxList paperList checkboxList-paperList"> - <div class="listItem titleSourceItem sortableOption" data-titleprovider="Shoko" data-titlestyle="Default"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="Shoko" data-titlestyle="Default"><span></span></label> + <div class="listItem titleSourceItem sortableOption" data-title-provider="Shoko" data-title-style="Default"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="Shoko" data-title-style="Default"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">Shoko | Let Shoko decide</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 titleSourceItem sortableOption" data-titleprovider="AniDB" data-titlestyle="Default"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="AniDB" data-titlestyle="Default"><span></span></label> + <div class="listItem titleSourceItem sortableOption" data-title-provider="AniDB" data-title-style="Default"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="AniDB" data-title-style="Default"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">AniDB | Default title</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 titleSourceItem sortableOption" data-titleprovider="AniDB" data-titlestyle="LibraryLanguage"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="AniDB" data-titlestyle="LibraryLanguage"><span></span></label> + <div class="listItem titleSourceItem sortableOption" data-title-provider="AniDB" data-title-style="LibraryLanguage"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="AniDB" data-title-style="LibraryLanguage"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">AniDB | 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 titleSourceItem sortableOption" data-titleprovider="AniDB" data-titlestyle="CountryOfOrigin"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="AniDB" data-titlestyle="CountryOfOrigin"><span></span></label> + <div class="listItem titleSourceItem sortableOption" data-title-provider="AniDB" data-title-style="CountryOfOrigin"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="AniDB" data-title-style="CountryOfOrigin"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">AniDB | Use the language from the 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></button> </div> - <div class="listItem titleSourceItem sortableOption" data-titleprovider="TMDB" data-titlestyle="Default"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="TMDB" data-titlestyle="Default"><span></span></label> + <div class="listItem titleSourceItem sortableOption" data-title-provider="TMDB" data-title-style="Default"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="TMDB" data-title-style="Default"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">TMDB | Default title</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 titleSourceItem sortableOption" data-titleprovider="TMDB" data-titlestyle="LibraryLanguage"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="TMDB" data-titlestyle="LibraryLanguage"><span></span></label> + <div class="listItem titleSourceItem sortableOption" data-title-provider="TMDB" data-title-style="LibraryLanguage"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="TMDB" data-title-style="LibraryLanguage"><span></span></label> <div class="listItemBody"><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 titleSourceItem sortableOption" data-titleprovider="TMDB" data-titlestyle="CountryOfOrigin"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="TMDB" data-titlestyle="CountryOfOrigin"><span></span></label> + <div class="listItem titleSourceItem sortableOption" data-title-provider="TMDB" data-title-style="CountryOfOrigin"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="TMDB" data-title-style="CountryOfOrigin"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">TMDB | Use the language from the 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></button> </div> @@ -106,38 +106,38 @@ <h3 class="checkboxListLabel">Advanced main title source:</h3> <div id="TitleAlternateList" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced alternate title source:</h3> <div class="checkboxList paperList checkboxList-paperList"> - <div class="listItem titleSourceItem sortableOption" data-titleprovider="Shoko" data-titlestyle="Default"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="Shoko" data-titlestyle="Default"><span></span></label> + <div class="listItem titleSourceItem sortableOption" data-title-provider="Shoko" data-title-style="Default"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="Shoko" data-title-style="Default"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">Shoko | Let Shoko decide</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 titleSourceItem sortableOption" data-titleprovider="AniDB" data-titlestyle="Default"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="AniDB" data-titlestyle="Default"><span></span></label> + <div class="listItem titleSourceItem sortableOption" data-title-provider="AniDB" data-title-style="Default"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="AniDB" data-title-style="Default"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">AniDB | Default title</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 titleSourceItem sortableOption" data-titleprovider="AniDB" data-titlestyle="LibraryLanguage"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="AniDB" data-titlestyle="LibraryLanguage"><span></span></label> + <div class="listItem titleSourceItem sortableOption" data-title-provider="AniDB" data-title-style="LibraryLanguage"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="AniDB" data-title-style="LibraryLanguage"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">AniDB | 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 titleSourceItem sortableOption" data-titleprovider="AniDB" data-titlestyle="CountryOfOrigin"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="AniDB" data-titlestyle="CountryOfOrigin"><span></span></label> + <div class="listItem titleSourceItem sortableOption" data-title-provider="AniDB" data-title-style="CountryOfOrigin"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="AniDB" data-title-style="CountryOfOrigin"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">AniDB | Use the language from the 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></button> </div> - <div class="listItem titleSourceItem sortableOption" data-titleprovider="TMDB" data-titlestyle="Default"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="TMDB" data-titlestyle="Default"><span></span></label> + <div class="listItem titleSourceItem sortableOption" data-title-provider="TMDB" data-title-style="Default"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="TMDB" data-title-style="Default"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">TMDB | Default title</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 titleSourceItem sortableOption" data-titleprovider="TMDB" data-titlestyle="LibraryLanguage"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="TMDB" data-titlestyle="LibraryLanguage"><span></span></label> + <div class="listItem titleSourceItem sortableOption" data-title-provider="TMDB" data-title-style="LibraryLanguage"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="TMDB" data-title-style="LibraryLanguage"><span></span></label> <div class="listItemBody"><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 titleSourceItem sortableOption" data-titleprovider="TMDB" data-titlestyle="CountryOfOrigin"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-titleprovider="TMDB" data-titlestyle="CountryOfOrigin"><span></span></label> + <div class="listItem titleSourceItem sortableOption" data-title-provider="TMDB" data-title-style="CountryOfOrigin"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="TMDB" data-title-style="CountryOfOrigin"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">TMDB | Use the language from the 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></button> </div> @@ -156,18 +156,18 @@ <h3 class="checkboxListLabel">Advanced alternate title source:</h3> <div id="descriptionSourceList" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced description source:</h3> <div class="checkboxList paperList checkboxList-paperList"> - <div class="listItem descriptionSourceItem sortableOption" data-descriptionsource="AniDB"> - <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-descriptionsource="AniDB"><span></span></label> + <div class="listItem descriptionSourceItem sortableOption" data-description-source="AniDB"> + <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-description-source="AniDB"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">AniDB</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 descriptionSourceItem sortableOption" data-descriptionsource="TvDB"> - <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-descriptionsource="TvDB"><span></span></label> + <div class="listItem descriptionSourceItem sortableOption" data-description-source="TvDB"> + <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-description-source="TvDB"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">TvDB</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 descriptionSourceItem sortableOption" data-descriptionsource="TMDB"> - <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-descriptionsource="TMDB"><span></span></label> + <div class="listItem descriptionSourceItem sortableOption" data-description-source="TMDB"> + <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-description-source="TMDB"><span></span></label> <div class="listItemBody"><h3 class="listItemBodyText">TMDB</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> @@ -225,7 +225,7 @@ <h3>Library Settings</h3> 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 infomation look up the + 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. </details> <details style="margin-top: 0.5em"> @@ -235,7 +235,7 @@ <h3>Library Settings</h3> 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 infomation look up the + 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. </details> </div> @@ -544,7 +544,7 @@ <h3>User Settings</h3> <input is="emby-checkbox" type="checkbox" id="SyncUserDataInitialSkipEventCount" /> <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 accidential 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">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> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> @@ -692,7 +692,7 @@ <h3>Experimental Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_MergeSeasons" /> - <span>Automatically merge cour seasons</span> + <span>Automatically merge half seasons</span> </label> <div class="fieldDescription checkboxFieldDescription"><div>Coming soon™ to a media library near you.</div></div> </div> diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index 1576fc40..dad8168e 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -166,7 +166,7 @@ public async Task MergeAllMovies(IProgress<double> progress, CancellationToken c double currentCount = 0d; double totalGroups = duplicationGroups.Count; foreach (var movieGroup in duplicationGroups) { - // Handle cancelation and update progress. + // Handle cancellation and update progress. cancellationToken.ThrowIfCancellationRequested(); var percent = (currentCount++ / totalGroups) * 100; progress?.Report(percent); @@ -192,7 +192,7 @@ public async Task SplitAllMovies(IProgress<double> progress, CancellationToken c double currentCount = 0d; double totalMovies = movies.Count; foreach (var movie in movies) { - // Handle cancelation and update progress. + // Handle cancellation and update progress. cancellationToken.ThrowIfCancellationRequested(); var percent = (currentCount++ / totalMovies) * 100d; progress?.Report(percent); @@ -218,7 +218,7 @@ private async Task SplitAndMergeAllMovies(IProgress<double> progress, Cancellati double currentCount = 0d; double totalCount = movies.Count; foreach (var movie in movies) { - // Handle cancelation and update progress. + // Handle cancellation and update progress. cancellationToken.ThrowIfCancellationRequested(); var percent = (currentCount++ / totalCount) * 50d; progress?.Report(percent); @@ -235,7 +235,7 @@ private async Task SplitAndMergeAllMovies(IProgress<double> progress, Cancellati currentCount = 0d; totalCount = duplicationGroups.Count; foreach (var movieGroup in duplicationGroups) { - // Handle cancelation and update progress. + // Handle cancellation and update progress. cancellationToken.ThrowIfCancellationRequested(); var percent = 50d + ((currentCount++ / totalCount) * 50d); progress?.Report(percent); @@ -301,7 +301,7 @@ public async Task MergeAllEpisodes(IProgress<double> progress, CancellationToken double currentCount = 0d; double totalGroups = duplicationGroups.Count; foreach (var episodeGroup in duplicationGroups) { - // Handle cancelation and update progress. + // Handle cancellation and update progress. cancellationToken.ThrowIfCancellationRequested(); var percent = (currentCount++ / totalGroups) * 100d; progress?.Report(percent); @@ -327,7 +327,7 @@ public async Task SplitAllEpisodes(IProgress<double> progress, CancellationToken double currentCount = 0d; double totalEpisodes = episodes.Count; foreach (var e in episodes) { - // Handle cancelation and update progress. + // Handle cancellation and update progress. cancellationToken.ThrowIfCancellationRequested(); var percent = (currentCount++ / totalEpisodes) * 100d; progress?.Report(percent); @@ -354,7 +354,7 @@ private async Task SplitAndMergeAllEpisodes(IProgress<double> progress, Cancella double currentCount = 0d; double totalCount = episodes.Count; foreach (var e in episodes) { - // Handle cancelation and update progress. + // Handle cancellation and update progress. cancellationToken.ThrowIfCancellationRequested(); var percent = (currentCount++ / totalCount) * 100d; progress?.Report(percent); @@ -372,7 +372,7 @@ private async Task SplitAndMergeAllEpisodes(IProgress<double> progress, Cancella currentCount = 0d; totalCount = duplicationGroups.Count; foreach (var episodeGroup in duplicationGroups) { - // Handle cancelation and update progress. + // Handle cancellation and update progress. cancellationToken.ThrowIfCancellationRequested(); var percent = currentCount++ / totalCount * 100d; progress?.Report(percent); diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 01a18b93..3f91a40e 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -110,25 +110,6 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in return result; } - private static bool TryGetBoxSetName(BoxSetInfo info, out string boxSetName) - { - if (string.IsNullOrWhiteSpace(info.Name)) { - boxSetName = string.Empty; - return false; - } - - var name = info.Name.Trim(); - if (name.EndsWith("[boxset]")) - name = name[..^8].TrimEnd(); - if (string.IsNullOrWhiteSpace(name)) { - boxSetName = string.Empty; - return false; - } - - boxSetName = name; - return true; - } - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); diff --git a/Shokofin/Resolvers/ShokoIgnoreRule.cs b/Shokofin/Resolvers/ShokoIgnoreRule.cs index 7761d228..e76c32a7 100644 --- a/Shokofin/Resolvers/ShokoIgnoreRule.cs +++ b/Shokofin/Resolvers/ShokoIgnoreRule.cs @@ -12,8 +12,6 @@ public class ShokoIgnoreRule : IResolverIgnoreRule { private readonly ShokoResolveManager ResolveManager; - public ResolverPriority Priority => ResolverPriority.Plugin; - public ShokoIgnoreRule(ShokoResolveManager resolveManager) { ResolveManager = resolveManager; diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index f1d49178..c2782843 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -47,7 +47,7 @@ public class ShokoResolveManager private readonly GuardedMemoryCache DataCache; - // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 chacters. + // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 characters. private const int NameCutOff = 64; private static readonly IReadOnlySet<string> IgnoreFolderNames = new HashSet<string>() { @@ -761,9 +761,9 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { _ => "extras", }; var fileNameSuffix = file.ExtraType switch { - ExtraType.BehindTheScenes => "-behindthescenes", + ExtraType.BehindTheScenes => "-behindTheScenes", ExtraType.Clip => "-clip", - ExtraType.DeletedScene => "-deletedscene", + ExtraType.DeletedScene => "-deletedScene", ExtraType.Interview => "-interview", ExtraType.Scene => "-scene", ExtraType.Sample => "-other", diff --git a/Shokofin/SignalR/Interfaces/IFileEventArgs.cs b/Shokofin/SignalR/Interfaces/IFileEventArgs.cs index 8aa67d25..cb1e8c4d 100644 --- a/Shokofin/SignalR/Interfaces/IFileEventArgs.cs +++ b/Shokofin/SignalR/Interfaces/IFileEventArgs.cs @@ -24,13 +24,13 @@ public interface IFileEventArgs /// <summary> /// The relative path from the base of the <see cref="ImportFolder"/> to /// where the <see cref="File"/> lies, with a leading slash applied at - /// the start and normalised for the local system. + /// the start and normalized for the local system. /// </summary> string RelativePath { get; } /// <summary> /// Indicates that the event has cross references provided. They may still - /// be empty, but now we don't need to fetch them seperately. + /// be empty, but now we don't need to fetch them separately. /// </summary> bool HasCrossReferences { get; } diff --git a/Shokofin/SignalR/Models/FileMovedEventArgs.cs b/Shokofin/SignalR/Models/FileMovedEventArgs.cs index 6d982d01..ed1ed2f1 100644 --- a/Shokofin/SignalR/Models/FileMovedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileMovedEventArgs.cs @@ -13,7 +13,7 @@ public class FileMovedEventArgs: FileEventArgs, IFileRelocationEventArgs /// <summary> /// The previous relative path with no leading slash and directory - /// seperators used on the Shoko side. + /// separators used on the Shoko side. /// </summary> [JsonInclude, JsonPropertyName("PreviousRelativePath")] public string PreviousInternalPath { get; set; } = string.Empty; @@ -60,7 +60,7 @@ public class V0 : IFileRelocationEventArgs public int PreviousImportFolderId { get; set; } /// <summary> - /// The relative path with no leading slash and directory seperators used on + /// The relative path with no leading slash and directory separators used on /// the Shoko side. /// </summary> [JsonInclude, JsonPropertyName("NewRelativePath")] @@ -89,10 +89,9 @@ public string RelativePath } } - /// <summary> /// The previous relative path with no leading slash and directory - /// seperators used on the Shoko side. + /// separators used on the Shoko side. /// </summary> [JsonInclude, JsonPropertyName("OldRelativePath")] public string PreviousInternalPath { get; set; } = string.Empty; diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index d8ad3bc8..c1e720f2 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -23,7 +23,7 @@ namespace Shokofin.SignalR; -public class SignalRConnectionManager : IDisposable +public class SignalRConnectionManager { private static ComponentVersion? ServerVersion => Plugin.Instance.Configuration.ServerVersion; @@ -61,7 +61,9 @@ public class SignalRConnectionManager : IDisposable private readonly Dictionary<int, (DateTime LastUpdated, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> List)> ChangesPerFile = new(); +#pragma warning disable CA1822 public bool IsUsable => CanConnect(Plugin.Instance.Configuration); +#pragma warning restore CA1822 public bool IsActive => Connection != null; @@ -80,13 +82,6 @@ public SignalRConnectionManager(ILogger<SignalRConnectionManager> logger, ShokoA ChangesDetectionTimer.Elapsed += OnIntervalElapsed; } - public void Dispose() - { - Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; - Disconnect(); - ChangesDetectionTimer.Elapsed -= OnIntervalElapsed; - } - #region Connection private async Task ConnectAsync(PluginConfiguration config) @@ -151,7 +146,7 @@ private Task OnReconnecting(Exception? exception) private Task OnDisconnected(Exception? exception) { - // Gracefull disconnection. + // Graceful disconnection. if (exception == null) Logger.LogInformation("Gracefully disconnected from Shoko Server."); else @@ -159,9 +154,6 @@ private Task OnDisconnected(Exception? exception) return Task.CompletedTask; } - public void Disconnect() - => DisconnectAsync().ConfigureAwait(false).GetAwaiter().GetResult(); - public async Task DisconnectAsync() { if (Connection == null) @@ -204,6 +196,13 @@ public async Task RunAsync() await ResetConnectionAsync(config, config.SignalR_AutoConnectEnabled); } + public async Task StopAsync() + { + Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; + await DisconnectAsync(); + ChangesDetectionTimer.Elapsed -= OnIntervalElapsed; + } + private void OnConfigurationChanged(object? sender, BasePluginConfiguration baseConfig) { if (baseConfig is not PluginConfiguration config) @@ -379,9 +378,9 @@ private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, sourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) .ToList(); - foreach (var (srcLoc, symLnks, nfoFls, imprtDt) in vfsLocations) { - result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLnks, nfoFls, imprtDt!.Value, result.Paths); - foreach (var path in symLnks.Select(path => Path.Join(vfsPath, path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())).Distinct()) + foreach (var (srcLoc, symLinks, nfoFiles, importDate) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLinks, nfoFiles, importDate!.Value, result.Paths); + foreach (var path in symLinks.Select(path => Path.Join(vfsPath, path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())).Distinct()) topFolders.Add(path); } @@ -446,9 +445,9 @@ private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, newSourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) .ToList(); - foreach (var (srcLoc, symLnks, nfoFls, imprtDt) in vfsLocations) { - result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLnks, nfoFls, imprtDt!.Value, result.Paths); - foreach (var path in symLnks.Select(path => Path.Join(vfsPath, path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())).Distinct()) + foreach (var (srcLoc, symLinks, nfoFiles, importDate) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLinks, nfoFiles, importDate!.Value, result.Paths); + 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(); @@ -572,12 +571,12 @@ private void AddSeriesEvent(string metadataId, IMetadataUpdatedEventArgs eventAr } } - private async Task ProcessSeriesChanges(string metadataId, List<IMetadataUpdatedEventArgs> changes) + private Task ProcessSeriesChanges(string metadataId, List<IMetadataUpdatedEventArgs> changes) { try { Logger.LogInformation("Processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); - // Refresh all epoisodes and movies linked to the episode. + // Refresh all episodes and movies linked to the episode. // look up the series/season/movie, then check the media folder they're // in to check if the refresh event is enabled for the media folder, and @@ -589,6 +588,7 @@ private async Task ProcessSeriesChanges(string metadataId, List<IMetadataUpdated catch (Exception ex) { Logger.LogError(ex, "Error processing {EventCount} metadata change events. (Metadata={ProviderUniqueId})", changes.Count, metadataId); } + return Task.CompletedTask; } #endregion diff --git a/Shokofin/SignalR/SignalREntryPoint.cs b/Shokofin/SignalR/SignalREntryPoint.cs index 2a73bb42..da8862d3 100644 --- a/Shokofin/SignalR/SignalREntryPoint.cs +++ b/Shokofin/SignalR/SignalREntryPoint.cs @@ -1,4 +1,5 @@ +using System; using System.Threading.Tasks; using MediaBrowser.Controller.Plugins; @@ -11,7 +12,12 @@ public class SignalREntryPoint : IServerEntryPoint public SignalREntryPoint(SignalRConnectionManager connectionManager) => ConnectionManager = connectionManager; public void Dispose() - => ConnectionManager.Dispose(); + { + GC.SuppressFinalize(this); + ConnectionManager.StopAsync() + .GetAwaiter() + .GetResult(); + } public Task RunAsync() => ConnectionManager.RunAsync(); diff --git a/Shokofin/StringExtensions.cs b/Shokofin/StringExtensions.cs index 29f4fffb..d352562a 100644 --- a/Shokofin/StringExtensions.cs +++ b/Shokofin/StringExtensions.cs @@ -90,7 +90,7 @@ public static string ReplaceInvalidPathCharacters(this string path) /// https://github.com/jellyfin/jellyfin/blob/25abe479ebe54a341baa72fd07e7d37cefe21a20/Emby.Server.Implementations/Library/PathExtensions.cs#L19-L62 /// </remarks> /// <param name="text">The string to extract the attribute value from.</param> - /// <param name="attribute">The attribibute name to extract.</param> + /// <param name="attribute">The attribute name to extract.</param> /// <returns>The extracted attribute value, or null.</returns> /// <exception cref="ArgumentException"><paramref name="text" /> or <paramref name="attribute" /> is empty.</exception> public static string? GetAttributeValue(this string text, string attribute) diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index ea1167cf..e566b74a 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -112,7 +112,7 @@ internal class SessionMetadata { public bool IsPaused; /// <summary> - /// Indicates we've alredy sent the start event. + /// Indicates we've already sent the start event. /// </summary> public bool SentStartEvent; diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index 35e1562a..0ce3b665 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -20,12 +20,12 @@ public enum LibraryFilteringMode /// </summary> Auto = 0, /// <summary> - /// Will only allow files/folders that are recognised and it knows + /// Will only allow files/folders that are recognized and it knows /// should be part of the library. /// </summary> Strict = 1, /// <summary> - /// Will premit files/folders that are not recognised to exist in the + /// Will permit files/folders that are not recognized to exist in the /// library, but will filter out anything it knows should not be part of /// the library. /// </summary> @@ -178,7 +178,7 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo showInfo, Se airsAfterSeasonNumber = seasonNumber; break; case SpecialOrderType.InBetweenSeasonByAirDate: - byAirdate: + byAirDate: // Reset the order if we come from `SpecialOrderType.InBetweenSeasonMixed`. episodeNumber = null; if (seasonInfo.SpecialsAnchors.TryGetValue(episodeInfo, out var previousEpisode)) @@ -196,7 +196,7 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo showInfo, Se case SpecialOrderType.InBetweenSeasonByOtherData: // We need to have TvDB/TMDB data in the first place to do this method. if (episodeInfo.TvDB == null) { - if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirdate; + if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirDate; break; } @@ -207,7 +207,7 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo showInfo, Se break; } - if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirdate; + if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirDate; airsAfterSeasonNumber = seasonNumber; break; } @@ -219,7 +219,7 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo showInfo, Se break; } - if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirdate; + if (order == SpecialOrderType.InBetweenSeasonMixed) goto byAirDate; break; } diff --git a/Shokofin/Utils/SeriesInfoRelationComparer.cs b/Shokofin/Utils/SeriesInfoRelationComparer.cs index 8b38d18e..b748c5c4 100644 --- a/Shokofin/Utils/SeriesInfoRelationComparer.cs +++ b/Shokofin/Utils/SeriesInfoRelationComparer.cs @@ -52,7 +52,7 @@ public int Compare(SeasonInfo? a, SeasonInfo? b) return CompareAirDates(a.AniDB.AirDate, b.AniDB.AirDate); } - private int CompareDirectRelations(SeasonInfo a, SeasonInfo b) + private static int CompareDirectRelations(SeasonInfo a, SeasonInfo b) { // We check from both sides because one of the entries may be outdated, // so the relation may only present on one of the entries. @@ -72,7 +72,7 @@ private int CompareDirectRelations(SeasonInfo a, SeasonInfo b) return 0; } - private int CompareIndirectRelations(SeasonInfo a, SeasonInfo b) + private static int CompareIndirectRelations(SeasonInfo a, SeasonInfo b) { var xRelations = a.Relations .Where(r => RelationPriority.ContainsKey(r.Type)) @@ -107,7 +107,7 @@ private int CompareIndirectRelations(SeasonInfo a, SeasonInfo b) return 0; } - private int CompareAirDates(DateTime? a, DateTime? b) + private static int CompareAirDates(DateTime? a, DateTime? b) { return a.HasValue ? b.HasValue ? DateTime.Compare(a.Value, b.Value) : 1 : b.HasValue ? -1 : 0; } diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index c3f8973b..0473f3aa 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -43,9 +43,7 @@ public static class Text '⁈', // exclamation mark variation '❕', // white exclamation mark '❔', // white question mark - '‽', // interrobang '⁉', // exclamation mark - '‽', // interrobang '※', // reference mark '⟩', // right angle bracket '❯', // right angle bracket diff --git a/Shokofin/Web/ShokoApiController.cs b/Shokofin/Web/ShokoApiController.cs index c3b80d47..436434be 100644 --- a/Shokofin/Web/ShokoApiController.cs +++ b/Shokofin/Web/ShokoApiController.cs @@ -13,7 +13,7 @@ namespace Shokofin.Web; /// <summary> -/// Pushbullet notifications controller. +/// Shoko API Host Web Controller. /// </summary> [ApiController] [Route("Plugin/Shokofin/Host")] diff --git a/Shokofin/Web/SignalRApiController.cs b/Shokofin/Web/SignalRApiController.cs index 80fd1da8..4ae5636b 100644 --- a/Shokofin/Web/SignalRApiController.cs +++ b/Shokofin/Web/SignalRApiController.cs @@ -1,21 +1,17 @@ using System; -using System.Net.Http; using System.Net.Mime; -using System.Reflection; using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Logging; -using Shokofin.API; -using Shokofin.API.Models; using Shokofin.SignalR; namespace Shokofin.Web; /// <summary> -/// Pushbullet notifications controller. +/// Shoko SignalR Control Web Controller. /// </summary> [ApiController] [Route("Plugin/Shokofin/SignalR")] From 41a3be328ee5462f88dd66d0c27ce0b54de6f495 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 10 May 2024 04:22:53 +0200 Subject: [PATCH 0948/1103] feat: collections be damned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hooked up the rest of the half-implemented collection support, though it is 4am, so expect things to break. Good luck if you want to try it before I get to testing it tomorrow. 🫡 --- Shokofin/API/Info/ShowInfo.cs | 6 + Shokofin/Collections/CollectionManager.cs | 304 ++++++++++++++-------- Shokofin/Configuration/configPage.html | 6 +- Shokofin/Providers/BoxSetProvider.cs | 2 +- Shokofin/Utils/Ordering.cs | 11 +- 5 files changed, 213 insertions(+), 116 deletions(-) diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index cc92bdee..7c3c4201 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -37,6 +37,12 @@ public class ShowInfo public bool IsStandalone => Shoko == null; + /// <summary> + /// Indicates that this show is consistent of only movies. + /// </summary> + public bool IsMovieCollection => + IsStandalone && DefaultSeason.Type == SeriesType.Movie; + /// <summary> /// The Shoko Group, if this is not a standalone show entry. /// </summary> diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index f741d68d..17e2a966 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -44,10 +44,10 @@ public async Task ReconstructCollections(IProgress<double> progress, Cancellatio { default: break; - case Ordering.CollectionCreationType.ShokoSeries: + case Ordering.CollectionCreationType.Movies: await ReconstructMovieSeriesCollections(progress, cancellationToken); break; - case Ordering.CollectionCreationType.ShokoGroup: + case Ordering.CollectionCreationType.Shared: await ReconstructSharedCollections(progress, cancellationToken); break; } @@ -59,19 +59,19 @@ public async Task ReconstructCollections(IProgress<double> progress, Cancellatio private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, CancellationToken cancellationToken) { - // Get all movies - - // Clean up the movies + // Clean up movies and unneeded group collections. await CleanupMovies(); - await CleanupGroupCollections(); + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(10); + + // Get all movies to include in the collection. var movies = GetMovies(); Logger.LogInformation("Reconstructing collections for {MovieCount} movies using Shoko Series.", movies.Count); - // create a tree-map of how it's supposed to be. - var config = Plugin.Instance.Configuration; - var movieDict = new Dictionary<Movie, (FileInfo, SeasonInfo, ShowInfo)>(); + // Create a tree-map of how it's supposed to be. + var movieDict = new Dictionary<Movie, (FileInfo fileInfo, SeasonInfo seasonInfo, ShowInfo showInfo)>(); foreach (var movie in movies) { if (!Lookup.TryGetEpisodeIdsFor(movie, out var episodeIds)) continue; @@ -82,51 +82,27 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, movieDict.Add(movie, (fileInfo, seasonInfo, showInfo)); } - - var seriesDict = movieDict.Values - .Select(tuple => tuple.Item2) + var seasonDict = movieDict.Values + .Select(tuple => tuple.seasonInfo) .DistinctBy(seasonInfo => seasonInfo.Id) .ToDictionary(seasonInfo => seasonInfo.Id); - var groupsDict = await Task - .WhenAll( - seriesDict.Values - .Select(seasonInfo => seasonInfo.Shoko.IDs.ParentGroup.ToString()) - .Distinct() - .Select(groupId => ApiManager.GetCollectionInfoForGroup(groupId)) - ) - .ContinueWith(task => task.Result.ToDictionary(x => x!.Id, x => x!)); - - var finalGroups = new Dictionary<string, CollectionInfo>(); - foreach (var initialGroup in groupsDict.Values) { - var currentGroup = initialGroup; - if (finalGroups.ContainsKey(currentGroup.Id)) - continue; - - finalGroups.Add(currentGroup.Id, currentGroup); - if (currentGroup.IsTopLevel) - continue; - while (!currentGroup.IsTopLevel && !finalGroups.ContainsKey(currentGroup.ParentId!)) - { - currentGroup = await ApiManager.GetCollectionInfoForGroup(currentGroup.ParentId!); - if (currentGroup == null) - break; - finalGroups.Add(currentGroup.Id, currentGroup); - } - } + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(30); + // Find out what to add, what to remove and what to check. var existingCollections = GetSeriesCollections(); var toCheck = new Dictionary<string, BoxSet>(); var toRemove = new Dictionary<Guid, BoxSet>(); - var toAdd = finalGroups.Keys + var toAdd = seasonDict.Keys .Where(groupId => !existingCollections.ContainsKey(groupId)) .ToHashSet(); var idToGuidDict = new Dictionary<string, Guid>(); - foreach (var (groupId, collectionList) in existingCollections) { - if (finalGroups.ContainsKey(groupId)) { - idToGuidDict.Add(groupId, collectionList[0].Id); - toCheck.Add(groupId, collectionList[0]); + foreach (var (seriesId, collectionList) in existingCollections) { + if (seasonDict.ContainsKey(seriesId)) { + idToGuidDict.Add(seriesId, collectionList[0].Id); + toCheck.Add(seriesId, collectionList[0]); foreach (var collection in collectionList.Skip(1)) toRemove.Add(collection.Id, collection); } @@ -136,6 +112,10 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, } } + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(50); + + // Remove unknown collections. foreach (var (id, boxSet) in toRemove) { // Remove the item from all parents. foreach (var parent in boxSet.GetParents().OfType<BoxSet>()) { @@ -152,41 +132,74 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, LibraryManager.DeleteItem(boxSet, new() { DeleteFileLocation = false, DeleteFromExternalProvider = false }); } + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(70); + // Add the missing collections. foreach (var missingId in toAdd) { - var collectionInfo = finalGroups[missingId]; + var seasonInfo = seasonDict[missingId]; + var (displayName, _) = Text.GetSeasonTitles(seasonInfo, "en"); var collection = await Collection.CreateCollectionAsync(new() { - Name = collectionInfo.Name, - ProviderIds = new() { { ShokoGroupId.Name, missingId } }, + Name = displayName, + ProviderIds = new() { { ShokoSeriesId.Name, missingId } }, }); toCheck.Add(missingId, collection); } - // Check the collections. - foreach (var (groupId, collection) in toCheck) + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(80); + + // Check if the collection have the correct children, and add any + // missing and remove any extras. + foreach (var (seriesId, collection) in toCheck) { - var collectionInfo = finalGroups[groupId]; - // Check if the collection have the correct children - + var actualChildren = collection.Children.ToList(); + var actualChildMovies = new List<Movie>(); + foreach (var child in actualChildren) switch (child) { + case Movie movie: + actualChildMovies.Add(movie); + break; + } + + var seasonInfo = seasonDict[seriesId]; + var expectedMovies = seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList) + .Select(episodeInfo => (episodeInfo, seasonInfo)) + .SelectMany(tuple => movieDict.Where(pair => pair.Value.seasonInfo.Id == tuple.seasonInfo.Id && pair.Value.fileInfo.EpisodeList.Any(episodeInfo => episodeInfo.Id == tuple.episodeInfo.Id))) + .Select(pair => pair.Key) + .ToList(); + var missingMovies = expectedMovies + .Select(movie => movie.Id) + .Except(actualChildMovies.Select(a => a.Id).ToHashSet()) + .ToList(); + var childrenToRemove = actualChildren + .Except(actualChildMovies) + .Select(movie => movie.Id) + .ToList(); + await Collection.AddToCollectionAsync(collection.Id, missingMovies); + await Collection.RemoveFromCollectionAsync(collection.Id, childrenToRemove); } + + progress.Report(100); } - private async Task ReconstructMovieGroupCollections(IProgress<double> progress, CancellationToken cancellationToken) + private async Task ReconstructSharedCollections(IProgress<double> progress, CancellationToken cancellationToken) { + // Get all movies - // Clean up the movies + // Clean up movies and unneeded series collections. await CleanupMovies(); - await CleanupSeriesCollections(); - var movies = GetMovies(); - Logger.LogInformation("Reconstructing collections for {MovieCount} movies using Shoko Groups.", movies.Count); + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(10); - // create a tree-map of how it's supposed to be. + // Get all shows/movies to include in the collection. + var movies = GetMovies(); + var shows = GetShows(); + Logger.LogInformation("Reconstructing collections for {MovieCount} movies and {ShowCount} shows using Shoko Groups.", movies.Count, shows.Count); - // create a tree-map of how it's supposed to be. - var config = Plugin.Instance.Configuration; - var movieDict = new Dictionary<Movie, (FileInfo, SeasonInfo, ShowInfo)>(); + // Create a tree-map of how it's supposed to be. + var movieDict = new Dictionary<Movie, (FileInfo fileInfo, SeasonInfo seasonInfo, ShowInfo showInfo)>(); foreach (var movie in movies) { if (!Lookup.TryGetEpisodeIdsFor(movie, out var episodeIds)) continue; @@ -198,19 +211,35 @@ private async Task ReconstructMovieGroupCollections(IProgress<double> progress, movieDict.Add(movie, (fileInfo, seasonInfo, showInfo)); } - var seriesDict = movieDict.Values - .Select(tuple => tuple.Item2) - .DistinctBy(seasonInfo => seasonInfo.Id) - .ToDictionary(seasonInfo => seasonInfo.Id); + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(20); + + var showDict = new Dictionary<Series, ShowInfo>(); + foreach (var show in shows) { + if (!Lookup.TryGetSeriesIdFor(show, out var seriesId)) + continue; + + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo == null) + continue; + + showDict.Add(show, showInfo); + } + + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(30); + var groupsDict = await Task .WhenAll( - seriesDict.Values + movieDict.Values + .Select(tuple => tuple.seasonInfo) + .DistinctBy(seasonInfo => seasonInfo.Id) .Select(seasonInfo => seasonInfo.Shoko.IDs.ParentGroup.ToString()) + .Concat(showDict.Values.Select(showInfo => showInfo.CollectionId).Where(collectionId => !string.IsNullOrEmpty(collectionId)).OfType<string>()) .Distinct() .Select(groupId => ApiManager.GetCollectionInfoForGroup(groupId)) ) .ContinueWith(task => task.Result.ToDictionary(x => x!.Id, x => x!)); - var finalGroups = new Dictionary<string, CollectionInfo>(); foreach (var initialGroup in groupsDict.Values) { var currentGroup = initialGroup; @@ -230,9 +259,13 @@ private async Task ReconstructMovieGroupCollections(IProgress<double> progress, } } + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(40); + + // Find out what to add, what to remove and what to check. var existingCollections = GetGroupCollections(); var toCheck = new Dictionary<string, BoxSet>(); - var toRemove = new Dictionary<Guid, (string GroupId, BoxSet Collection)>(); + var toRemove = new Dictionary<Guid, BoxSet>(); var toAdd = finalGroups.Keys .Where(groupId => !existingCollections.ContainsKey(groupId)) .ToHashSet(); @@ -243,17 +276,36 @@ private async Task ReconstructMovieGroupCollections(IProgress<double> progress, idToGuidDict.Add(groupId, collectionList[0].Id); toCheck.Add(groupId, collectionList[0]); foreach (var collection in collectionList.Skip(1)) - toRemove.Add(collection.Id, (groupId, collection)); + toRemove.Add(collection.Id, collection); } else { foreach (var collection in collectionList) - toRemove.Add(collection.Id, (groupId, collection)); + toRemove.Add(collection.Id, collection); } } - var toRemoveSet = toRemove.Keys.ToHashSet(); - foreach (var (id, (groupId, boxSet)) in toRemove) - await RemoveCollection(boxSet, toRemoveSet, groupId: groupId); + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(50); + + // Remove unknown collections. + foreach (var (id, boxSet) in toRemove) { + // Remove the item from all parents. + foreach (var parent in boxSet.GetParents().OfType<BoxSet>()) { + if (toRemove.ContainsKey(parent.Id)) + continue; + await Collection.RemoveFromCollectionAsync(parent.Id, new[] { id }); + } + + // Remove all children + var children = boxSet.GetChildren(null, true, new()).Select(x => x.Id); + await Collection.RemoveFromCollectionAsync(id, children); + + // Remove the item. + LibraryManager.DeleteItem(boxSet, new() { DeleteFileLocation = false, DeleteFromExternalProvider = false }); + } + + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(70); // Add the missing collections. foreach (var missingId in toAdd) { @@ -265,43 +317,76 @@ private async Task ReconstructMovieGroupCollections(IProgress<double> progress, toCheck.Add(missingId, collection); } - // Check the collections. + cancellationToken.ThrowIfCancellationRequested(); + progress.Report(80); + + // Check if the collection have the correct children, and add any + // missing and remove any extras. foreach (var (groupId, collection) in toCheck) { + var actualChildren = collection.Children.ToList(); + var actualChildCollections = new List<BoxSet>(); + var actualChildSeries = new List<Series>(); + var actualChildMovies = new List<Movie>(); + foreach (var child in actualChildren) switch (child) { + case BoxSet subCollection: + actualChildCollections.Add(subCollection); + break; + case Series series: + actualChildSeries.Add(series); + break; + case Movie movie: + actualChildMovies.Add(movie); + break; + } + var collectionInfo = finalGroups[groupId]; - // Check if the collection have the correct children - + var expectedCollections = collectionInfo.SubCollections + .Select(subCollectionInfo => finalGroups.TryGetValue(subCollectionInfo.Id, out var boxSet) ? boxSet : null) + .OfType<BoxSet>() + .ToList(); + var missingCollections = expectedCollections + .Select(show => show.Id) + .Except(actualChildCollections.Select(a => a.Id).ToHashSet()) + .ToList(); + var expectedShows = collectionInfo.Shows + .Where(showInfo => !showInfo.IsMovieCollection) + .SelectMany(showInfo => showDict.Where(pair => pair.Value.Id == showInfo.Id)) + .Select(pair => pair.Key) + .ToList(); + var missingShows = expectedShows + .Select(show => show.Id) + .Except(actualChildSeries.Select(a => a.Id).ToHashSet()) + .ToList(); + var expectedMovies = collectionInfo.Shows + .Where(showInfo => showInfo.IsMovieCollection) + .SelectMany(showInfo => showInfo.DefaultSeason.EpisodeList.Concat(showInfo.DefaultSeason.AlternateEpisodesList).Select(episodeInfo => (episodeInfo, seasonInfo: showInfo.DefaultSeason))) + .SelectMany(tuple => movieDict.Where(pair => pair.Value.seasonInfo.Id == tuple.seasonInfo.Id && pair.Value.fileInfo.EpisodeList.Any(episodeInfo => episodeInfo.Id == tuple.episodeInfo.Id))) + .Select(pair => pair.Key) + .ToList(); + var missingMovies = expectedMovies + .Select(movie => movie.Id) + .Except(actualChildMovies.Select(a => a.Id).ToHashSet()) + .ToList(); + var childrenToRemove = actualChildren + .Except(actualChildCollections) + .Except(actualChildSeries) + .Except(actualChildMovies) + .Select(movie => movie.Id) + .ToList(); + await Collection.AddToCollectionAsync(collection.Id, missingCollections.Concat(missingShows).Concat(missingMovies)); + await Collection.RemoveFromCollectionAsync(collection.Id, childrenToRemove); } - } - private async Task ReconstructSharedCollections(IProgress<double> progress, CancellationToken cancellationToken) - { - // Get all movies - - // Clean up the movies - await CleanupMovies(); - - await CleanupSeriesCollections(); - - // Get all shows - var movies = GetMovies(); - var shows = GetShows(); - Logger.LogInformation("Reconstructing collections for {MovieCount} movies and {ShowCount} shows using Shoko Groups.", movies.Count, shows.Count); - - // create a tree-map of how it's supposed to be. - - var collections = GetSeriesCollections(); - - // check which nodes are correct, which nodes is not correct, and which are missing. - - // fix the nodes that are not correct. - - // add the missing nodes. + progress.Report(100); } + /// <summary> + /// Check the movies with a shoko series id set, and remove the collection name from them. + /// </summary> + /// <returns>A task to await when it's done.</returns> private async Task CleanupMovies() { - // Check the movies with a shoko series id set, and remove the collection name from them. var movies = GetMovies(); foreach (var movie in movies) { if (string.IsNullOrEmpty(movie.CollectionName)) @@ -320,31 +405,36 @@ private async Task CleanupMovies() private async Task CleanupSeriesCollections() { var collectionDict = GetSeriesCollections(); - var collectionMap = collectionDict.Values + var collectionSet = collectionDict.Values .SelectMany(x => x.Select(y => y.Id)) .ToHashSet(); - Logger.LogInformation("Going to remove {CollectionCount} collection items for {SeriesCount} Shoko Series", collectionMap.Count, collectionDict.Count); + if (collectionDict.Count == 0) + return; + Logger.LogInformation("Going to remove {CollectionCount} collection items for {SeriesCount} Shoko Series", collectionSet.Count, collectionDict.Count); foreach (var (seriesId, collectionList) in collectionDict) foreach (var collection in collectionList) - await RemoveCollection(collection, collectionMap, seriesId: seriesId); + await RemoveCollection(collection, collectionSet, seriesId: seriesId); } private async Task CleanupGroupCollections() { var collectionDict = GetGroupCollections(); - var collectionMap = collectionDict.Values + var collectionSet = collectionDict.Values .SelectMany(x => x.Select(y => y.Id)) .ToHashSet(); - Logger.LogInformation("Going to remove {CollectionCount} collection items for {GroupCount} Shoko Groups", collectionMap.Count, collectionDict.Count); + if (collectionDict.Count == 0) + return; + + Logger.LogInformation("Going to remove {CollectionCount} collection items for {GroupCount} Shoko Groups", collectionSet.Count, collectionDict.Count); foreach (var (groupId, collectionList) in collectionDict) foreach (var collection in collectionList) - await RemoveCollection(collection, collectionMap, groupId: groupId); + await RemoveCollection(collection, collectionSet, groupId: groupId); } private async Task RemoveCollection(BoxSet boxSet, ISet<Guid> allBoxSets, string? seriesId = null, string? groupId = null) @@ -416,7 +506,7 @@ private IReadOnlyDictionary<string, IReadOnlyList<BoxSet>> GetGroupCollections() return LibraryManager.GetItemList(new() { IncludeItemTypes = new[] { BaseItemKind.BoxSet }, - + HasAnyProviderId = new Dictionary<string, string> { { ShokoGroupId.Name, string.Empty } }, IsVirtualItem = false, Recursive = true, diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index e391288a..6b885ed8 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -272,10 +272,10 @@ <h3>Library Settings</h3> <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="ShokoSeries">Create collections for movies based upon Shoko's series</option> - <option value="ShokoGroup">Create collections for movies and shows based upon Shoko's groups and series</option> + <option value="Movies">Create collections for movies based upon Shoko's series</option> + <option value="Shared">Create collections for movies and shows based upon Shoko's groups and series</option> </select> - <div class="fieldDescription">Determines how to group entities into collections.</div> + <div class="fieldDescription">Determines what entities to group into collections.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 3f91a40e..dc922ec3 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -37,7 +37,7 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat try { return Plugin.Instance.Configuration.CollectionGrouping switch { - Ordering.CollectionCreationType.ShokoGroup => await GetShokoGroupedMetadata(info), + Ordering.CollectionCreationType.Shared => await GetShokoGroupedMetadata(info), _ => await GetDefaultMetadata(info), }; } diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index 0ce3b665..62cc3524 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -33,7 +33,8 @@ public enum LibraryFilteringMode } /// <summary> - /// Group series or movie box-sets + /// Helps determine what the user wants to group into collections + /// (AKA "box-sets"). /// </summary> public enum CollectionCreationType { @@ -43,15 +44,15 @@ public enum CollectionCreationType None = 0, /// <summary> - /// Group movies based on Shoko's series. + /// Group movies into collections based on Shoko's series. /// </summary> - ShokoSeries = 1, + Movies = 1, /// <summary> - /// Group both movies and shows into collections based on shoko's + /// Group both movies and shows into collections based on Shoko's /// groups. /// </summary> - ShokoGroup = 2, + Shared = 2, } /// <summary> From 1d8d60a3c5671ac77c401ac3f983c3905877fc8c Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 10 May 2024 02:23:47 +0000 Subject: [PATCH 0949/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index b8ef39fd..8464942d 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.159", + "changelog": "feat: collections be damned\n\n- Hooked up the rest of the half-implemented collection support,\n though it is 4am, so expect things to break. Good luck if you want\n to try it before I get to testing it tomorrow. \ud83e\udee1\n\nmisc: the typo squashing commit [skip ci]\nI went full grammar nazi. I have no excuse.\n\nmisc: tweak comments [skip ci]", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.159/shoko_3.0.1.159.zip", + "checksum": "18bdf21d61e59910cb5205cd3509240a", + "timestamp": "2024-05-10T02:23:45Z" + }, { "version": "3.0.1.158", "changelog": "fix: remove hard-coded path separator", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.155/shoko_3.0.1.155.zip", "checksum": "e1f7dc6906cb6d2deb61721738197408", "timestamp": "2024-05-02T03:44:12Z" - }, - { - "version": "3.0.1.154", - "changelog": "fix: one more fix for force movie special featurettes", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.154/shoko_3.0.1.154.zip", - "checksum": "453677e7d05402dbd73e44790944da70", - "timestamp": "2024-05-02T03:39:43Z" } ] } From a476e9e8a02261821726cb1bffb71099f5a629ae Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 10 May 2024 04:58:44 +0200 Subject: [PATCH 0950/1103] misc: remove nfos from mixed libraries since they weren't needed, and were actually causing issues. --- Shokofin/Resolvers/LinkGenerationResult.cs | 19 ++---- Shokofin/Resolvers/ShokoResolveManager.cs | 61 ++++---------------- Shokofin/SignalR/SignalRConnectionManager.cs | 8 +-- 3 files changed, 19 insertions(+), 69 deletions(-) diff --git a/Shokofin/Resolvers/LinkGenerationResult.cs b/Shokofin/Resolvers/LinkGenerationResult.cs index 3eef7075..3f4d9d7b 100644 --- a/Shokofin/Resolvers/LinkGenerationResult.cs +++ b/Shokofin/Resolvers/LinkGenerationResult.cs @@ -13,16 +13,16 @@ public class LinkGenerationResult public ConcurrentBag<string> Paths { get; init; } = new(); public int Total => - TotalVideos + TotalSubtitles + TotalNfos; + TotalVideos + TotalSubtitles; public int Created => - CreatedVideos + CreatedSubtitles + CreatedNfos; + CreatedVideos + CreatedSubtitles; public int Fixed => FixedVideos + FixedSubtitles; public int Skipped => - SkippedVideos + SkippedSubtitles + SkippedNfos; + SkippedVideos + SkippedSubtitles; public int Removed => RemovedVideos + RemovedSubtitles + RemovedNfos; @@ -49,31 +49,22 @@ public class LinkGenerationResult public int RemovedSubtitles { get; set; } - public int TotalNfos => - CreatedNfos + SkippedNfos; - - public int CreatedNfos { get; set; } - - public int SkippedNfos { get; set; } - public int RemovedNfos { get; set; } public void Print(ILogger logger, string path) { var timeSpent = DateTime.Now - CreatedAt; logger.LogInformation( - "Created {CreatedTotal} ({CreatedMedia},{CreatedSubtitles},{CreatedNFO}), fixed {FixedTotal} ({FixedMedia},{FixedSubtitles}), skipped {SkippedTotal} ({SkippedMedia},{SkippedSubtitles},{SkippedNFO}), and removed {RemovedTotal} ({RemovedMedia},{RemovedSubtitles},{RemovedNFO}) entries in folder at {Path} in {TimeSpan} (Total={Total})", + "Created {CreatedTotal} ({CreatedMedia},{CreatedSubtitles}), fixed {FixedTotal} ({FixedMedia},{FixedSubtitles}), skipped {SkippedTotal} ({SkippedMedia},{SkippedSubtitles}), and removed {RemovedTotal} ({RemovedMedia},{RemovedSubtitles},{RemovedNFO}) entries in folder at {Path} in {TimeSpan} (Total={Total})", Created, CreatedVideos, CreatedSubtitles, - CreatedNfos, Fixed, FixedVideos, FixedSubtitles, Skipped, SkippedVideos, SkippedSubtitles, - SkippedNfos, Removed, RemovedVideos, RemovedSubtitles, @@ -103,8 +94,6 @@ public void Print(ILogger logger, string path) FixedSubtitles = a.FixedSubtitles + b.FixedSubtitles, SkippedSubtitles = a.SkippedSubtitles + b.SkippedSubtitles, RemovedSubtitles = a.RemovedSubtitles + b.RemovedSubtitles, - CreatedNfos = a.CreatedNfos + b.CreatedNfos, - SkippedNfos = a.SkippedNfos + b.SkippedNfos, RemovedNfos = a.RemovedNfos + b.RemovedNfos, }; } diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index c2782843..322a3c82 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -684,13 +684,13 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { try { Logger.LogTrace("Generating links for {Path} (File={FileId},Series={SeriesId})", tuple.sourceLocation, tuple.fileId, tuple.seriesId); - var (sourceLocation, symbolicLinks, nfoFiles, importedAt) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); + var (sourceLocation, symbolicLinks, importedAt) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); // Skip any source files we weren't meant to have in the library. if (string.IsNullOrEmpty(sourceLocation) || !importedAt.HasValue) return; - var subResult = GenerateSymbolicLinks(sourceLocation, symbolicLinks, nfoFiles, importedAt.Value, result.Paths); + var subResult = GenerateSymbolicLinks(sourceLocation, symbolicLinks, importedAt.Value); // Combine the current results with the overall results. lock (semaphore) { @@ -706,18 +706,18 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { return result; } - public async Task<(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime? importedAt)> GenerateLocationsForFile(Folder mediaFolder, string sourceLocation, string fileId, string seriesId) + public async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(Folder mediaFolder, string sourceLocation, string fileId, string seriesId) { var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); return await GenerateLocationsForFile(vfsPath, collectionType, sourceLocation, fileId, seriesId); } - private async Task<(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime? importedAt)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId) + private async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId) { var season = await ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); if (season == null) - return (string.Empty, Array.Empty<string>(), Array.Empty<string>(), null); + return (string.Empty, Array.Empty<string>(), null); var isMovieSeason = season.Type == SeriesType.Movie; var shouldAbort = collectionType switch { @@ -726,19 +726,19 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { _ => false, }; if (shouldAbort) - return (string.Empty, Array.Empty<string>(), Array.Empty<string>(), null); + return (string.Empty, Array.Empty<string>(), null); var show = await ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false); if (show == null) - return (string.Empty, Array.Empty<string>(), Array.Empty<string>(), null); + return (string.Empty, Array.Empty<string>(), null); var file = await ApiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false); var episode = file?.EpisodeList.FirstOrDefault(); if (file == null || episode == null) - return (string.Empty, Array.Empty<string>(), Array.Empty<string>(), null); + return (string.Empty, Array.Empty<string>(), null); if (season == null || episode == null) - return (string.Empty, Array.Empty<string>(), Array.Empty<string>(), null); + return (string.Empty, Array.Empty<string>(), null); var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters() ?? $"Shoko Series {show.Id}"; var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); @@ -751,7 +751,6 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { episodeName = episodeName[..NameCutOff].Split(' ').SkipLast(1).Join(' ') + "…"; var isExtra = file.EpisodeList.Any(eI => season.IsExtraEpisode(eI)); - var nfoFiles = new List<string>(); var folders = new List<string>(); var extrasFolder = file.ExtraType switch { null => isExtra ? "extras" : null, @@ -799,14 +798,6 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { folders.Add(Path.Join(vfsPath, showFolder, seasonFolder)); episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber.ToString().PadLeft(show.EpisodePadding, '0')}"; } - - // Add NFO files for the show and season if we're in a mixed library, - // to allow the built-in movie resolver to detect the directories - // properly as tv shows. - if (collectionType == null) { - nfoFiles.Add(Path.Join(vfsPath, showFolder, "tvshow.nfo")); - nfoFiles.Add(Path.Join(vfsPath, showFolder, seasonFolder, "season.nfo")); - } } var fileName = $"{episodeName} [{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{fileNameSuffix}{Path.GetExtension(sourceLocation)}"; @@ -816,12 +807,12 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { foreach (var symbolicLink in symbolicLinks) ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, file.EpisodeList.Select(episode => episode.Id)); - return (sourceLocation, symbolicLinks, nfoFiles: nfoFiles.ToArray(), file.Shoko.ImportedAt ?? file.Shoko.CreatedAt); + return (sourceLocation, symbolicLinks, file.Shoko.ImportedAt ?? file.Shoko.CreatedAt); } // TODO: Remove this for 10.9 #pragma warning disable IDE0060 - public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, string[] nfoFiles, DateTime importedAt, ConcurrentBag<string> allPathsForVFS) + public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, DateTime importedAt) #pragma warning restore IDE0060 { try { @@ -943,36 +934,6 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ } } - // TODO: Remove these two hacks once we have proper support for adding multiple series at once. - foreach (var nfoFile in nfoFiles) - { - if (allPathsForVFS.Contains(nfoFile)) { - if (!result.Paths.Contains(nfoFile)) - result.Paths.Add(nfoFile); - continue; - } - if (result.Paths.Contains(nfoFile)) { - if (!allPathsForVFS.Contains(nfoFile)) - allPathsForVFS.Add(nfoFile); - continue; - } - allPathsForVFS.Add(nfoFile); - result.Paths.Add(nfoFile); - - var nfoDirectory = Path.GetDirectoryName(nfoFile)!; - if (!Directory.Exists(nfoDirectory)) - Directory.CreateDirectory(nfoDirectory); - - if (!File.Exists(nfoFile)) { - result.CreatedNfos++; - Logger.LogDebug("Adding stub show/season NFO file {Target} ", nfoFile); - File.WriteAllText(nfoFile, string.Empty); - } - else { - result.SkippedNfos++; - } - } - return result; } catch (Exception ex) { diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index c1e720f2..7fc274f9 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -378,8 +378,8 @@ private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, sourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) .ToList(); - foreach (var (srcLoc, symLinks, nfoFiles, importDate) in vfsLocations) { - result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLinks, nfoFiles, importDate!.Value, result.Paths); + foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(srcLoc, 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); } @@ -445,8 +445,8 @@ private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, newSourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) .ToList(); - foreach (var (srcLoc, symLinks, nfoFiles, importDate) in vfsLocations) { - result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLinks, nfoFiles, importDate!.Value, result.Paths); + foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(srcLoc, 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); } From 3024bf91fa439f0153c9d567f7eb8ed7c0964f18 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 10 May 2024 02:59:44 +0000 Subject: [PATCH 0951/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 8464942d..eef33192 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.160", + "changelog": "misc: remove nfos from mixed libraries\nsince they weren't needed, and were actually causing issues.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.160/shoko_3.0.1.160.zip", + "checksum": "881372c02dc18a6b22e782d77d0e3c7c", + "timestamp": "2024-05-10T02:59:42Z" + }, { "version": "3.0.1.159", "changelog": "feat: collections be damned\n\n- Hooked up the rest of the half-implemented collection support,\n though it is 4am, so expect things to break. Good luck if you want\n to try it before I get to testing it tomorrow. \ud83e\udee1\n\nmisc: the typo squashing commit [skip ci]\nI went full grammar nazi. I have no excuse.\n\nmisc: tweak comments [skip ci]", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.156/shoko_3.0.1.156.zip", "checksum": "5811fb06e58a82f2b88bf9a90cf1bbf7", "timestamp": "2024-05-03T23:38:15Z" - }, - { - "version": "3.0.1.155", - "changelog": "misc: add more special featurettes detections\n\n- Add more special featurettes detections, but this time for the 'other'\n type episodes.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.155/shoko_3.0.1.155.zip", - "checksum": "e1f7dc6906cb6d2deb61721738197408", - "timestamp": "2024-05-02T03:44:12Z" } ] } From d676c8c91ce25cec75e89335a34e1453983b2424 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 May 2024 01:11:26 +0000 Subject: [PATCH 0952/1103] fix: use right dict to get collection --- Shokofin/Collections/CollectionManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index 17e2a966..7a8f827c 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -342,7 +342,7 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc var collectionInfo = finalGroups[groupId]; var expectedCollections = collectionInfo.SubCollections - .Select(subCollectionInfo => finalGroups.TryGetValue(subCollectionInfo.Id, out var boxSet) ? boxSet : null) + .Select(subCollectionInfo => toCheck.TryGetValue(subCollectionInfo.Id, out var boxSet) ? boxSet : null) .OfType<BoxSet>() .ToList(); var missingCollections = expectedCollections From 32324c8d46118ab1221048b0013b6a0c7b307791 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 May 2024 01:12:16 +0000 Subject: [PATCH 0953/1103] misc: supposedly increase performance by droping the use of abstract interfaces, according to dotnet8. --- Shokofin/Collections/CollectionManager.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index 7a8f827c..dd2ff312 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -437,7 +437,7 @@ private async Task CleanupGroupCollections() await RemoveCollection(collection, collectionSet, groupId: groupId); } - private async Task RemoveCollection(BoxSet boxSet, ISet<Guid> allBoxSets, string? seriesId = null, string? groupId = null) + private async Task RemoveCollection(BoxSet boxSet, HashSet<Guid> allBoxSets, string? seriesId = null, string? groupId = null) { var parents = boxSet.GetParents().OfType<BoxSet>().ToList(); var children = boxSet.GetChildren(null, true, new()).Select(x => x.Id).ToList(); @@ -457,7 +457,7 @@ private async Task RemoveCollection(BoxSet boxSet, ISet<Guid> allBoxSets, string LibraryManager.DeleteItem(boxSet, new() { DeleteFileLocation = false, DeleteFromExternalProvider = false }); } - private IReadOnlyList<Movie> GetMovies() + private List<Movie> GetMovies() { return LibraryManager.GetItemList(new() { @@ -471,7 +471,7 @@ private IReadOnlyList<Movie> GetMovies() .ToList(); } - private IReadOnlyList<Series> GetShows() + private List<Series> GetShows() { return LibraryManager.GetItemList(new() { @@ -485,7 +485,7 @@ private IReadOnlyList<Series> GetShows() .ToList(); } - private IReadOnlyDictionary<string, IReadOnlyList<BoxSet>> GetSeriesCollections() + private Dictionary<string, IReadOnlyList<BoxSet>> GetSeriesCollections() { return LibraryManager.GetItemList(new() { @@ -501,7 +501,7 @@ private IReadOnlyDictionary<string, IReadOnlyList<BoxSet>> GetSeriesCollections( .ToDictionary(x => x.Key, x => x.ToList() as IReadOnlyList<BoxSet>); } - private IReadOnlyDictionary<string, IReadOnlyList<BoxSet>> GetGroupCollections() + private Dictionary<string, IReadOnlyList<BoxSet>> GetGroupCollections() { return LibraryManager.GetItemList(new() { From cceb7ac5127547b75a1dec9ddfe6e8d5d9258af0 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 12 May 2024 01:13:28 +0000 Subject: [PATCH 0954/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index eef33192..5976cb46 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.161", + "changelog": "misc: supposedly increase performance\nby droping the use of abstract interfaces, according to dotnet8.\n\nfix: use right dict to get collection", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.161/shoko_3.0.1.161.zip", + "checksum": "8059012476cb659621986432db4bf418", + "timestamp": "2024-05-12T01:13:27Z" + }, { "version": "3.0.1.160", "changelog": "misc: remove nfos from mixed libraries\nsince they weren't needed, and were actually causing issues.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.157/shoko_3.0.1.157.zip", "checksum": "a8922542493cfd0f2842dfa08f00abcb", "timestamp": "2024-05-04T00:07:19Z" - }, - { - "version": "3.0.1.156", - "changelog": "fix: don't append suffix to theme-videos/trailers", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.156/shoko_3.0.1.156.zip", - "checksum": "5811fb06e58a82f2b88bf9a90cf1bbf7", - "timestamp": "2024-05-03T23:38:15Z" } ] } From 70e5a4a0e09b0e22bdbeffd42ba79eea5d3f56d0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 May 2024 14:57:53 +0000 Subject: [PATCH 0955/1103] =?UTF-8?q?misc:=20remove=20unuseed=20references?= =?UTF-8?q?=C2=A0[skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/Resolvers/LinkGenerationResult.cs | 1 - Shokofin/Resolvers/ShokoIgnoreRule.cs | 3 --- Shokofin/Resolvers/ShokoResolveManager.cs | 1 - 3 files changed, 5 deletions(-) diff --git a/Shokofin/Resolvers/LinkGenerationResult.cs b/Shokofin/Resolvers/LinkGenerationResult.cs index 3f4d9d7b..f8c1c505 100644 --- a/Shokofin/Resolvers/LinkGenerationResult.cs +++ b/Shokofin/Resolvers/LinkGenerationResult.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Concurrent; -using MediaBrowser.Controller.Entities; using Microsoft.Extensions.Logging; namespace Shokofin.Resolvers; diff --git a/Shokofin/Resolvers/ShokoIgnoreRule.cs b/Shokofin/Resolvers/ShokoIgnoreRule.cs index e76c32a7..2e1bc37f 100644 --- a/Shokofin/Resolvers/ShokoIgnoreRule.cs +++ b/Shokofin/Resolvers/ShokoIgnoreRule.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.IO; diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 322a3c82..eb694a7c 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; From e3c552c04d450dcbac636a195037ba3561ee7933 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 May 2024 21:23:11 +0200 Subject: [PATCH 0956/1103] misc: changes to support latest daily server --- Shokofin/API/Models/File.cs | 63 +++++++++++++++++--- Shokofin/API/ShokoAPIManager.cs | 15 ++--- Shokofin/Resolvers/ShokoResolveManager.cs | 12 ++-- Shokofin/SignalR/SignalRConnectionManager.cs | 14 +++-- 4 files changed, 78 insertions(+), 26 deletions(-) diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index a4e15afd..1f4d5441 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -220,26 +220,71 @@ public class CrossReference /// The Series IDs /// </summary> [JsonPropertyName("SeriesID")] - public CrossReferenceIDs Series { get; set; } = new(); + public SeriesCrossReferenceIDs Series { get; set; } = new(); /// <summary> /// The Episode IDs /// </summary> [JsonPropertyName("EpisodeIDs")] - public List<CrossReferenceIDs> Episodes { get; set; } = new(); - } + public List<EpisodeCrossReferenceIDs> Episodes { get; set; } = new(); - public class CrossReferenceIDs : IDs - { /// <summary> - /// Any AniDB ID linked to this object + /// File episode cross-reference for a series. /// </summary> - public int AniDB { get; set; } + public class EpisodeCrossReferenceIDs + { + /// <summary> + /// The Shoko ID, if the local metadata has been created yet. + /// </summary> + [JsonPropertyName("ID")] + public int? Shoko { get; set; } + + /// <summary> + /// The AniDB ID. + /// </summary> + public int AniDB { get; set; } + + /// <summary> + /// Percentage file is matched to the episode. + /// </summary> + public CrossReferencePercentage? Percentage { get; set; } + } + + public class CrossReferencePercentage + { + /// <summary> + /// File/episode cross-reference percentage range end. + /// </summary> + public int Start { get; set; } + + /// <summary> + /// File/episode cross-reference percentage range end. + /// </summary> + public int End { get; set; } + + /// <summary> + /// The raw percentage to "group" the cross-references by. + /// </summary> + public int Size { get; set; } + } /// <summary> - /// Any TvDB IDs linked to this object + /// File series cross-reference. /// </summary> - public List<int> TvDB { get; set; } = new(); + public class SeriesCrossReferenceIDs + { + /// <summary> + /// The Shoko ID, if the local metadata has been created yet. + /// /// </summary> + [JsonPropertyName("ID")] + + public int? Shoko { get; set; } + + /// <summary> + /// The AniDB ID. + /// </summary> + public int AniDB { get; set; } + } } /// <summary> diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 6bd522d6..6c5f93e0 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -294,9 +294,9 @@ public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) if (file.CrossReferences.Count == 1) 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.ToString() == seriesId); - foreach (var episodeXRef in xref.Episodes) - episodeIds.Add(episodeXRef.Shoko.ToString()); + 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)) + episodeIds.Add(episodeXRef.Shoko!.Value.ToString()); } return (pathSet, episodeIds); @@ -384,8 +384,8 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu // Find the correct series based on the path. var selectedPath = (Path.GetDirectoryName(fileLocations.First().RelativePath) ?? string.Empty) + Path.DirectorySeparatorChar; - foreach (var seriesXRef in file.CrossReferences) { - var seriesId = seriesXRef.Series.Shoko.ToString(); + foreach (var seriesXRef in file.CrossReferences.Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue))) { + var seriesId = seriesXRef.Series.Shoko!.Value.ToString(); // Check if the file is in the series folder. var pathSet = await GetPathSetForSeries(seriesId).ConfigureAwait(false); @@ -441,13 +441,14 @@ private Task<FileInfo> CreateFileInfo(File file, string fileId, string seriesId) Logger.LogTrace("Creating info object for file. (File={FileId},Series={SeriesId})", fileId, seriesId); // Find the cross-references for the selected series. - var seriesXRef = file.CrossReferences.FirstOrDefault(xref => xref.Series.Shoko.ToString() == seriesId) ?? + var seriesXRef = file.CrossReferences.Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) + .FirstOrDefault(xref => xref.Series.Shoko!.Value.ToString() == seriesId) ?? throw new Exception($"Unable to find any cross-references for the specified series for the file. (File={fileId},Series={seriesId})"); // Find a list of the episode info for each episode linked to the file for the series. var episodeList = new List<EpisodeInfo>(); foreach (var episodeXRef in seriesXRef.Episodes) { - var episodeId = episodeXRef.Shoko.ToString(); + var episodeId = episodeXRef.Shoko!.Value.ToString(); var episodeInfo = await GetEpisodeInfo(episodeId).ConfigureAwait(false) ?? throw new Exception($"Unable to find episode cross-reference for the specified series and episode for the file. (File={fileId},Episode={episodeId},Series={seriesId})"); if (episodeInfo.Shoko.IsHidden) { diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index eb694a7c..16016c1f 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -442,7 +442,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold var episodeIds = seasonInfo.ExtrasList.Select(episode => episode.Id).Append(episodeId).ToHashSet(); var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); var fileLocations = files - .Where(files => files.CrossReferences.Any(xref => xref.Episodes.Any(xrefEp => episodeIds.Contains(xrefEp.Shoko.ToString())))) + .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) .SelectMany(file => file.Locations.Select(location => (file, location))) .ToList(); foreach (var (file, location) in fileLocations) { @@ -493,7 +493,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold var episodeIds = seasonInfo.SpecialsList.Select(episode => episode.Id).ToHashSet(); var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); var fileLocations = files - .Where(files => files.CrossReferences.Any(xref => xref.Episodes.Any(xrefEp => episodeIds.Contains(xrefEp.Shoko.ToString())))) + .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) .SelectMany(file => file.Locations.Select(location => (file, location))) .ToList(); foreach (var (file, location) in fileLocations) { @@ -518,7 +518,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold var episodeIds = (offset == 0 ? seasonInfo.EpisodeList.Concat(seasonInfo.ExtrasList) : seasonInfo.AlternateEpisodesList).Select(episode => episode.Id).ToHashSet(); var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); var fileLocations = files - .Where(files => files.CrossReferences.Any(xref => xref.Episodes.Any(xrefEp => episodeIds.Contains(xrefEp.Shoko.ToString())))) + .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) .SelectMany(file => file.Locations.Select(location => (file, location))) .ToList(); foreach (var (file, location) in fileLocations) { @@ -626,7 +626,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold continue; // Yield all single-series files now, and offset the processing of all multi-series files for later. - var seriesIds = file.CrossReferences.Select(x => x.Series.Shoko).ToHashSet(); + var seriesIds = file.CrossReferences.Where(x => x.Series.Shoko.HasValue && x.Episodes.All(e => e.Shoko.HasValue)).Select(x => x.Series.Shoko!.Value).ToHashSet(); if (seriesIds.Count == 1) { totalSingleSeriesFiles++; singleSeriesIds.Add(seriesIds.First()); @@ -645,8 +645,8 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold var totalMultiSeriesFiles = 0; foreach (var (file, sourceLocation) in multiSeriesFiles) { var crossReferences = file.CrossReferences - .Where(xref => singleSeriesIds.Contains(xref.Series.Shoko)) - .Select(xref => xref.Series.Shoko.ToString()) + .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue) && singleSeriesIds.Contains(xref.Series.Shoko!.Value)) + .Select(xref => xref.Series.Shoko!.Value.ToString()) .ToHashSet(); foreach (var seriesId in crossReferences) yield return (sourceLocation, file.Id.ToString(), seriesId); diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 7fc274f9..7f28111d 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -502,10 +502,16 @@ private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEventArgs? fileEvent) { HashSet<string> seriesIds; - if (fileEvent != null && fileEvent.CrossReferences.All(xref => xref.ShokoSeriesId.HasValue)) - seriesIds = fileEvent.CrossReferences.Select(xref => xref.ShokoSeriesId!.Value.ToString()).Distinct().ToHashSet(); - else - seriesIds = (await ApiClient.GetFile(fileId.ToString())).CrossReferences.Select(xref => xref.Series.Shoko.ToString()).Distinct().ToHashSet(); + if (fileEvent != null && fileEvent.CrossReferences.All(xref => xref.ShokoSeriesId.HasValue && xref.ShokoEpisodeId.HasValue)) + seriesIds = fileEvent.CrossReferences.Select(xref => xref.ShokoSeriesId!.Value.ToString()) + .Distinct() + .ToHashSet(); + else + seriesIds = (await ApiClient.GetFile(fileId.ToString())).CrossReferences + .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) + .Select(xref => xref.Series.Shoko!.Value.ToString()) + .Distinct() + .ToHashSet(); var filteredSeriesIds = new HashSet<string>(); foreach (var seriesId in seriesIds) { From 2a3eed4dd910386ee373bdf3f8aadd2511f97ab3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 May 2024 21:23:44 +0200 Subject: [PATCH 0957/1103] =?UTF-8?q?fix:=20extras=20=E2=86=92=20episodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/Resolvers/ShokoResolveManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 16016c1f..d18f6657 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -439,7 +439,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold importFolderSubPath ); - var episodeIds = seasonInfo.ExtrasList.Select(episode => episode.Id).Append(episodeId).ToHashSet(); + var episodeIds = seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList).Concat(seasonInfo.ExtrasList).Select(episode => episode.Id).Append(episodeId).ToHashSet(); var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); var fileLocations = files .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) From 23dcf1557fe8f0e117b6ca65230d4f9f186caf12 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 12 May 2024 21:34:43 +0000 Subject: [PATCH 0958/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 5976cb46..eff8456b 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.162", + "changelog": "fix: extras \u2192 episodes\n\nmisc: changes to support latest daily server\n\nmisc: remove unuseed references\u00a0[skip ci]", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.162/shoko_3.0.1.162.zip", + "checksum": "3d299e615e20b63567b6d247780883c1", + "timestamp": "2024-05-12T21:34:42Z" + }, { "version": "3.0.1.161", "changelog": "misc: supposedly increase performance\nby droping the use of abstract interfaces, according to dotnet8.\n\nfix: use right dict to get collection", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.158/shoko_3.0.1.158.zip", "checksum": "6372792bb2eb41692cd9df8a20bbcce4", "timestamp": "2024-05-04T00:35:52Z" - }, - { - "version": "3.0.1.157", - "changelog": "fix: fix paths for file events in daily server", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.157/shoko_3.0.1.157.zip", - "checksum": "a8922542493cfd0f2842dfa08f00abcb", - "timestamp": "2024-05-04T00:07:19Z" } ] } From ca6a246a7bae9d13b3b0fa5bfc3cd3b97ce32856 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 May 2024 23:39:37 +0200 Subject: [PATCH 0959/1103] feat: add file part support for daily server without breaking stable server support --- Shokofin/API/Info/FileInfo.cs | 8 +-- Shokofin/API/Models/CrossReference.cs | 79 +++++++++++++++++++++++ Shokofin/API/Models/Episode.cs | 5 ++ Shokofin/API/Models/File.cs | 72 --------------------- Shokofin/API/ShokoAPIClient.cs | 4 +- Shokofin/API/ShokoAPIManager.cs | 11 ++-- Shokofin/Providers/EpisodeProvider.cs | 6 +- Shokofin/Providers/MovieProvider.cs | 2 +- Shokofin/Resolvers/ShokoResolveManager.cs | 11 ++-- 9 files changed, 106 insertions(+), 92 deletions(-) create mode 100644 Shokofin/API/Models/CrossReference.cs diff --git a/Shokofin/API/Info/FileInfo.cs b/Shokofin/API/Info/FileInfo.cs index 8b1638a8..012bc516 100644 --- a/Shokofin/API/Info/FileInfo.cs +++ b/Shokofin/API/Info/FileInfo.cs @@ -14,17 +14,17 @@ public class FileInfo public File Shoko; - public List<EpisodeInfo> EpisodeList; + public List<(EpisodeInfo Episode, CrossReference.EpisodeCrossReferenceIDs CrossReference, string Id)> EpisodeList; - public List<List<EpisodeInfo>> AlternateEpisodeLists; + public List<List<(EpisodeInfo Episode, CrossReference.EpisodeCrossReferenceIDs CrossReference, string Id)>> AlternateEpisodeLists; - public FileInfo(File file, List<List<EpisodeInfo>> groupedEpisodeLists, string seriesId) + 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(); Id = file.Id.ToString(); SeriesId = seriesId; - ExtraType = episodeList.FirstOrDefault(episode => episode.ExtraType != null)?.ExtraType; + ExtraType = episodeList.FirstOrDefault(tuple => tuple.Episode.ExtraType != null).Episode?.ExtraType; Shoko = file; EpisodeList = episodeList; AlternateEpisodeLists = alternateEpisodeLists; diff --git a/Shokofin/API/Models/CrossReference.cs b/Shokofin/API/Models/CrossReference.cs new file mode 100644 index 00000000..31168f77 --- /dev/null +++ b/Shokofin/API/Models/CrossReference.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Shokofin.API.Models; + +public class CrossReference +{ + /// <summary> + /// The Series IDs + /// </summary> + [JsonPropertyName("SeriesID")] + public SeriesCrossReferenceIDs Series { get; set; } = new(); + + /// <summary> + /// The Episode IDs + /// </summary> + [JsonPropertyName("EpisodeIDs")] + public List<EpisodeCrossReferenceIDs> Episodes { get; set; } = new(); + + /// <summary> + /// File episode cross-reference for a series. + /// </summary> + public class EpisodeCrossReferenceIDs + { + /// <summary> + /// The Shoko ID, if the local metadata has been created yet. + /// </summary> + [JsonPropertyName("ID")] + public int? Shoko { get; set; } + + /// <summary> + /// The AniDB ID. + /// </summary> + public int AniDB { get; set; } + + public int? ReleaseGroup { get; set; } + + /// <summary> + /// Percentage file is matched to the episode. + /// </summary> + public CrossReferencePercentage? Percentage { get; set; } + } + + public class CrossReferencePercentage + { + /// <summary> + /// File/episode cross-reference percentage range end. + /// </summary> + public int Start { get; set; } + + /// <summary> + /// File/episode cross-reference percentage range end. + /// </summary> + public int End { get; set; } + + /// <summary> + /// The raw percentage to "group" the cross-references by. + /// </summary> + public int Size { get; set; } + } + + /// <summary> + /// File series cross-reference. + /// </summary> + public class SeriesCrossReferenceIDs + { + /// <summary> + /// The Shoko ID, if the local metadata has been created yet. + /// /// </summary> + [JsonPropertyName("ID")] + + public int? Shoko { get; set; } + + /// <summary> + /// The AniDB ID. + /// </summary> + public int AniDB { get; set; } + } +} \ No newline at end of file diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index 0c94a7be..856f78fd 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -44,6 +44,11 @@ public class Episode [JsonPropertyName("TvDB")] public List<TvDB> TvDBEntityList { get; set; } = new(); + /// <summary> + /// File cross-references for the episode. + /// </summary> + public List<CrossReference.EpisodeCrossReferenceIDs> CrossReferences { get; set; } = new(); + public class AniDB { [JsonPropertyName("ID")] diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index 1f4d5441..79463cf1 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -214,78 +214,6 @@ public class HashMap public string MD5 { get; set; } = string.Empty; } - public class CrossReference - { - /// <summary> - /// The Series IDs - /// </summary> - [JsonPropertyName("SeriesID")] - public SeriesCrossReferenceIDs Series { get; set; } = new(); - - /// <summary> - /// The Episode IDs - /// </summary> - [JsonPropertyName("EpisodeIDs")] - public List<EpisodeCrossReferenceIDs> Episodes { get; set; } = new(); - - /// <summary> - /// File episode cross-reference for a series. - /// </summary> - public class EpisodeCrossReferenceIDs - { - /// <summary> - /// The Shoko ID, if the local metadata has been created yet. - /// </summary> - [JsonPropertyName("ID")] - public int? Shoko { get; set; } - - /// <summary> - /// The AniDB ID. - /// </summary> - public int AniDB { get; set; } - - /// <summary> - /// Percentage file is matched to the episode. - /// </summary> - public CrossReferencePercentage? Percentage { get; set; } - } - - public class CrossReferencePercentage - { - /// <summary> - /// File/episode cross-reference percentage range end. - /// </summary> - public int Start { get; set; } - - /// <summary> - /// File/episode cross-reference percentage range end. - /// </summary> - public int End { get; set; } - - /// <summary> - /// The raw percentage to "group" the cross-references by. - /// </summary> - public int Size { get; set; } - } - - /// <summary> - /// File series cross-reference. - /// </summary> - public class SeriesCrossReferenceIDs - { - /// <summary> - /// The Shoko ID, if the local metadata has been created yet. - /// /// </summary> - [JsonPropertyName("ID")] - - public int? Shoko { get; set; } - - /// <summary> - /// The AniDB ID. - /// </summary> - public int AniDB { get; set; } - } - } /// <summary> /// User stats for the file. diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 592a2184..7adec9a7 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -320,12 +320,12 @@ public async Task<bool> ScrobbleFile(string fileId, string episodeId, string eve public Task<Episode> GetEpisode(string id) { - return Get<Episode>($"/api/v3/Episode/{id}?includeDataFrom=AniDB,TvDB"); + return Get<Episode>($"/api/v3/Episode/{id}?includeDataFrom=AniDB,TvDB&includeXRefs=true"); } public Task<ListResult<Episode>> GetEpisodesFromSeries(string seriesId) { - return Get<ListResult<Episode>>($"/api/v3/Series/{seriesId}/Episode?pageSize=0&includeMissing=true&includeDataFrom=AniDB,TvDB"); + return Get<ListResult<Episode>>($"/api/v3/Series/{seriesId}/Episode?pageSize=0&includeMissing=true&includeDataFrom=AniDB,TvDB&includeXRefs=true"); } public Task<Series> GetSeries(string id) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 6c5f93e0..67575add 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -446,7 +446,7 @@ private Task<FileInfo> CreateFileInfo(File file, string fileId, string seriesId) throw new Exception($"Unable to find any cross-references for the specified series for the file. (File={fileId},Series={seriesId})"); // Find a list of the episode info for each episode linked to the file for the series. - var episodeList = new List<EpisodeInfo>(); + var episodeList = new List<(EpisodeInfo Episode, CrossReference.EpisodeCrossReferenceIDs CrossReference, string Id)>(); foreach (var episodeXRef in seriesXRef.Episodes) { var episodeId = episodeXRef.Shoko!.Value.ToString(); var episodeInfo = await GetEpisodeInfo(episodeId).ConfigureAwait(false) ?? @@ -455,14 +455,15 @@ private Task<FileInfo> CreateFileInfo(File file, string fileId, string seriesId) Logger.LogDebug("Skipped hidden episode linked to file. (File={FileId},Episode={EpisodeId},Series={SeriesId})", fileId, episodeId, seriesId); continue; } - episodeList.Add(episodeInfo); + episodeList.Add((episodeInfo, episodeXRef, episodeId)); } // Group and order the episodes. var groupedEpisodeLists = episodeList - .GroupBy(episode => episode.AniDB.Type) - .OrderByDescending(a => Array.IndexOf(EpisodePickOrder, a.Key)) - .Select(epList => epList.OrderBy(episode => episode.AniDB.EpisodeNumber).ToList()) + .GroupBy(tuple => (type: tuple.Episode.AniDB.Type, percentage: tuple.CrossReference.Percentage?.Size ?? 100)) + .OrderByDescending(a => Array.IndexOf(EpisodePickOrder, a.Key.type)) + .ThenByDescending(a => Array.IndexOf(EpisodePickOrder, a.Key.percentage)) + .Select(epList => epList.OrderBy(tuple => tuple.Episode.AniDB.EpisodeNumber).ToList()) .ToList(); var fileInfo = new FileInfo(file, groupedEpisodeLists, seriesId); diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 1514d51c..548558a7 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -66,7 +66,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell } else { (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(info.Path); - episodeInfo = fileInfo?.EpisodeList.FirstOrDefault(); + episodeInfo = fileInfo?.EpisodeList.FirstOrDefault().Episode; } // if the episode info is null then the series info and conditionally the group info is also null. @@ -101,7 +101,7 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie if (config.TitleAddForMultipleEpisodes && file != null && file.EpisodeList.Count > 1) { var displayTitles = new List<string?>(); var alternateTitles = new List<string?>(); - foreach (var episodeInfo in file.EpisodeList) { + foreach (var (episodeInfo, _, _) in file.EpisodeList) { string defaultEpisodeTitle = episodeInfo.Shoko.Name; if ( // Movies @@ -122,7 +122,7 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie } displayTitle = Text.JoinText(displayTitles); alternateTitle = Text.JoinText(alternateTitles); - description = Text.GetDescription(file.EpisodeList); + description = Text.GetDescription(file.EpisodeList.Select(tuple => tuple.Episode)); } else { string defaultEpisodeTitle = episode.Shoko.Name; diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 700add11..ff74907d 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -38,7 +38,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio var result = new MetadataResult<Movie>(); var (file, season, _) = await ApiManager.GetFileInfoByPath(info.Path); - var episode = file?.EpisodeList.FirstOrDefault(); + var episode = file?.EpisodeList.FirstOrDefault().Episode; // if file is null then series and episode is also null. if (file == null || episode == null || season == null) { diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index d18f6657..6e308a98 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -732,7 +732,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { return (string.Empty, Array.Empty<string>(), null); var file = await ApiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false); - var episode = file?.EpisodeList.FirstOrDefault(); + var (episode, episodeXref, _) = (file?.EpisodeList ?? new()).FirstOrDefault(); if (file == null || episode == null) return (string.Empty, Array.Empty<string>(), null); @@ -749,7 +749,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { if (episodeName.Length >= NameCutOff) episodeName = episodeName[..NameCutOff].Split(' ').SkipLast(1).Join(' ') + "…"; - var isExtra = file.EpisodeList.Any(eI => season.IsExtraEpisode(eI)); + var isExtra = file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode)); var folders = new List<string>(); var extrasFolder = file.ExtraType switch { null => isExtra ? "extras" : null, @@ -771,6 +771,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { ExtraType.Trailer => string.Empty, _ => isExtra ? "-other" : string.Empty, }; + var filePartSuffix = (episodeXref.Percentage?.Size ?? 100) != 100 ? $".pt{episode.Shoko.CrossReferences.Where(xref => xref.ReleaseGroup == episodeXref.ReleaseGroup && xref.Percentage!.Size == episodeXref.Percentage!.Size).ToList().FindIndex(xref => xref.Percentage!.Start == episodeXref.Percentage!.Start && xref.Percentage!.End == episodeXref.Percentage!.End) + 1}" : ""; if (isMovieSeason && collectionType != CollectionType.TvShows) { if (!string.IsNullOrEmpty(extrasFolder)) { foreach (var episodeInfo in season.EpisodeList) @@ -795,7 +796,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { } else { folders.Add(Path.Join(vfsPath, showFolder, seasonFolder)); - episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber.ToString().PadLeft(show.EpisodePadding, '0')}"; + episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber.ToString().PadLeft(show.EpisodePadding, '0')}{filePartSuffix}"; } } @@ -1237,7 +1238,7 @@ private static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string .GetResult(); // Abort if the file was not recognized. - if (file == null || file.EpisodeList.Any(eI => season.IsExtraEpisode(eI))) + if (file == null || file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode))) return null; return new Movie() { @@ -1418,7 +1419,7 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b Logger.LogInformation("Found {EpisodeCount} shoko episode(s) for {SeriesName} (Series={SeriesId},File={FileId})", file.EpisodeList.Count, season.Shoko.Name, season.Id, file.Id); // We're going to post process this file later, but we don't want to include it in our library for now. - if (file.EpisodeList.Any(eI => season.IsExtraEpisode(eI))) { + if (file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode))) { Logger.LogInformation("File was assigned an extra type, ignoring file. (Series={SeriesId},File={FileId})", season.Id, file.Id); return true; } From 8ab5db490bf058b034bdb0a26f06248c93a88084 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 May 2024 23:40:16 +0200 Subject: [PATCH 0960/1103] misc: add property watcher --- Shokofin/Utils/PropertyWatcher.cs | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 Shokofin/Utils/PropertyWatcher.cs diff --git a/Shokofin/Utils/PropertyWatcher.cs b/Shokofin/Utils/PropertyWatcher.cs new file mode 100644 index 00000000..733c3698 --- /dev/null +++ b/Shokofin/Utils/PropertyWatcher.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading.Tasks; + +namespace Shokofin.Utils; + +public class PropertyWatcher<T> +{ + private readonly Func<T> _valueGetter; + + private bool _continueMonitoring; + + public T LastKnownValue { get; private set; } + + public event EventHandler<T> OnValueChanged; + + public PropertyWatcher(Func<T> valueGetter) + { + _valueGetter = valueGetter; + LastKnownValue = _valueGetter(); + } + + public void StartMonitoring(int delayInMilliseconds) + { + _continueMonitoring = true; + LastKnownValue = _valueGetter(); + Task.Run(async () => { + while (_continueMonitoring) { + await Task.Delay(delayInMilliseconds); + CheckForChange(); + } + }); + } + + public void StopMonitoring() + { + _continueMonitoring = false; + } + + private void CheckForChange() + { + var currentValue = _valueGetter()!; + if (!LastKnownValue!.Equals(currentValue)) { + OnValueChanged?.Invoke(null, currentValue); + LastKnownValue = currentValue; + } + } +} From bbbce064a86d71cb272f1bd0c5ff9739c77cc3e1 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 12 May 2024 22:01:55 +0000 Subject: [PATCH 0961/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index eff8456b..adc7903c 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.163", + "changelog": "misc: add property watcher\n\nfeat: add file part support for daily server\nwithout breaking stable server support", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.163/shoko_3.0.1.163.zip", + "checksum": "0a023e0b4940f387b8621f70812014e8", + "timestamp": "2024-05-12T22:01:53Z" + }, { "version": "3.0.1.162", "changelog": "fix: extras \u2192 episodes\n\nmisc: changes to support latest daily server\n\nmisc: remove unuseed references\u00a0[skip ci]", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.159/shoko_3.0.1.159.zip", "checksum": "18bdf21d61e59910cb5205cd3509240a", "timestamp": "2024-05-10T02:23:45Z" - }, - { - "version": "3.0.1.158", - "changelog": "fix: remove hard-coded path separator", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.158/shoko_3.0.1.158.zip", - "checksum": "6372792bb2eb41692cd9df8a20bbcce4", - "timestamp": "2024-05-04T00:35:52Z" } ] } From 3923fa8552449c4e31ee06b5ccc7916074989cd1 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 13 May 2024 00:20:59 +0200 Subject: [PATCH 0962/1103] fix: fix episode group ordering for files --- Shokofin/API/ShokoAPIManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 67575add..accd10b0 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -462,7 +462,7 @@ private Task<FileInfo> CreateFileInfo(File file, string fileId, string seriesId) var groupedEpisodeLists = episodeList .GroupBy(tuple => (type: tuple.Episode.AniDB.Type, percentage: tuple.CrossReference.Percentage?.Size ?? 100)) .OrderByDescending(a => Array.IndexOf(EpisodePickOrder, a.Key.type)) - .ThenByDescending(a => Array.IndexOf(EpisodePickOrder, a.Key.percentage)) + .ThenByDescending(a => a.Key.percentage) .Select(epList => epList.OrderBy(tuple => tuple.Episode.AniDB.EpisodeNumber).ToList()) .ToList(); From b1d9cb20bee86b628b68a80263199f5a09aaa906 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 12 May 2024 22:21:41 +0000 Subject: [PATCH 0963/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index adc7903c..e3e434bb 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.164", + "changelog": "fix: fix episode group ordering for files", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.164/shoko_3.0.1.164.zip", + "checksum": "c7fd8fb2da6ebd71f7becc834b728a19", + "timestamp": "2024-05-12T22:21:40Z" + }, { "version": "3.0.1.163", "changelog": "misc: add property watcher\n\nfeat: add file part support for daily server\nwithout breaking stable server support", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.160/shoko_3.0.1.160.zip", "checksum": "881372c02dc18a6b22e782d77d0e3c7c", "timestamp": "2024-05-10T02:59:42Z" - }, - { - "version": "3.0.1.159", - "changelog": "feat: collections be damned\n\n- Hooked up the rest of the half-implemented collection support,\n though it is 4am, so expect things to break. Good luck if you want\n to try it before I get to testing it tomorrow. \ud83e\udee1\n\nmisc: the typo squashing commit [skip ci]\nI went full grammar nazi. I have no excuse.\n\nmisc: tweak comments [skip ci]", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.159/shoko_3.0.1.159.zip", - "checksum": "18bdf21d61e59910cb5205cd3509240a", - "timestamp": "2024-05-10T02:23:45Z" } ] } From bfdc9a47d88617619d1df9655229ded049e71919 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 13 May 2024 00:47:17 +0200 Subject: [PATCH 0964/1103] refactor: only build VFS once per path while the cache is filled --- Shokofin/Resolvers/ShokoResolveManager.cs | 175 +++++++++++----------- 1 file changed, 88 insertions(+), 87 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 6e308a98..ab3c526a 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -253,9 +253,9 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold private async Task<string?> GenerateStructureInVFS(Folder mediaFolder, string path) { // Skip link generation if we've already generated for the media folder. - if (DataCache.TryGetValue<string?>($"should-skip-media-folder:{mediaFolder.Path}", out var vfsPath)) - return vfsPath; - vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + if (DataCache.TryGetValue<bool>($"should-skip-media-folder:{mediaFolder.Path}", out var shouldReturnPath)) + return shouldReturnPath ? vfsPath : null; // Check full path and all parent directories if they have been indexed. if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { @@ -268,120 +268,121 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold } } - var mediaConfig = await GetOrCreateConfigurationForMediaFolder(mediaFolder); - if (!mediaConfig.IsMapped) - return null; + // Only do this once. + var key = path.StartsWith(mediaFolder.Path) + ? $"should-skip-media-folder:{mediaFolder.Path}" + : $"should-skip-vfs-path:{path}"; + shouldReturnPath = await DataCache.GetOrCreateAsync<bool>(key, async (__) => { + var mediaConfig = await GetOrCreateConfigurationForMediaFolder(mediaFolder); + if (!mediaConfig.IsMapped) + return false; - // Return early if we're not going to generate them. - if (!mediaConfig.IsVirtualFileSystemEnabled) - return null; + // Return early if we're not going to generate them. + if (!mediaConfig.IsVirtualFileSystemEnabled) + return 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."); + 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."); + + // Iterate the files already in the VFS. + string? pathToClean = null; + IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; + if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { + var allPaths = GetPathsForMediaFolder(mediaFolder); + var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar); + switch (pathSegments.Length) { + // show/movie-folder level + case 1: { + var seriesName = pathSegments[0]; + if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; - // Iterate the files already in the VFS. - string? pathToClean = null; - IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; - if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { - var allPaths = GetPathsForMediaFolder(mediaFolder); - var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar); - switch (pathSegments.Length) { - // show/movie-folder level - case 1: { - var seriesName = pathSegments[0]; - if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) - break; + // movie-folder + if (seriesName.TryGetAttributeValue(ShokoEpisodeId.Name, out var episodeId) ) { + if (!int.TryParse(episodeId, out _)) + break; - // movie-folder - if (seriesName.TryGetAttributeValue(ShokoEpisodeId.Name, out var episodeId) ) { - if (!int.TryParse(episodeId, out _)) + pathToClean = path; + allFiles = GetFilesForMovie(episodeId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); break; + } + // show pathToClean = path; - allFiles = GetFilesForMovie(episodeId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + allFiles = GetFilesForShow(seriesId, null, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); break; } - // show - pathToClean = path; - allFiles = GetFilesForShow(seriesId, null, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); - break; - } + // season/movie level + case 2: { + var (seriesName, seasonOrMovieName) = pathSegments; + if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; - // season/movie level - case 2: { - var (seriesName, seasonOrMovieName) = pathSegments; - if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) - break; + // movie + if (seriesName.TryGetAttributeValue(ShokoEpisodeId.Name, out _)) { + if (!seasonOrMovieName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) + break; + + if (!seasonOrMovieName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) + break; - // movie - if (seriesName.TryGetAttributeValue(ShokoEpisodeId.Name, out _)) { - if (!seasonOrMovieName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) + allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path); break; + } - if (!seasonOrMovieName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) + // "season" or extras + if (!seasonOrMovieName.StartsWith("Season ") || !int.TryParse(seasonOrMovieName.Split(' ').Last(), out var seasonNumber)) break; - allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path); + pathToClean = path; + allFiles = GetFilesForShow(seriesId, seasonNumber, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); break; } - // "season" or extras - if (!seasonOrMovieName.StartsWith("Season ") || !int.TryParse(seasonOrMovieName.Split(' ').Last(), out var seasonNumber)) - break; - - pathToClean = path; - allFiles = GetFilesForShow(seriesId, seasonNumber, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); - break; - } + // episodes level + case 3: { + var (seriesName, seasonName, episodeName) = pathSegments; + if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; - // episodes level - case 3: { - var (seriesName, seasonName, episodeName) = pathSegments; - if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) - break; + if (!seasonName.StartsWith("Season ") || !int.TryParse(seasonName.Split(' ').Last(), out _)) + break; - if (!seasonName.StartsWith("Season ") || !int.TryParse(seasonName.Split(' ').Last(), out _)) - break; + if (!episodeName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) + break; - if (!episodeName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) - break; + if (!episodeName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) + break; - if (!episodeName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) + allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path); break; - - allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path); - break; + } } } - } - // Iterate files in the "real" media folder. - else if (path.StartsWith(mediaFolder.Path)) { - var allPaths = GetPathsForMediaFolder(mediaFolder); - pathToClean = vfsPath; - allFiles = GetFilesForImportFolder(mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); - } + // Iterate files in the "real" media folder. + else if (path.StartsWith(mediaFolder.Path)) { + var allPaths = GetPathsForMediaFolder(mediaFolder); + pathToClean = vfsPath; + allFiles = GetFilesForImportFolder(mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + } - if (allFiles == null) - return null; + if (allFiles == null) + return false; - // Generate and cleanup the structure in the VFS. - var result = await GenerateStructure(mediaFolder, vfsPath, allFiles); - if (!string.IsNullOrEmpty(pathToClean)) - result += CleanupStructure(vfsPath, pathToClean, result.Paths.ToArray()); + // Generate and cleanup the structure in the VFS. + var result = await GenerateStructure(mediaFolder, vfsPath, allFiles); + if (!string.IsNullOrEmpty(pathToClean)) + result += CleanupStructure(vfsPath, pathToClean, result.Paths.ToArray()); - // Save which paths we've already generated so we can skip generation - // for them and their sub-paths later, and also print the result. - if (path.StartsWith(mediaFolder.Path)) { - DataCache.Set($"should-skip-media-folder:{mediaFolder.Path}", vfsPath); - result.Print(Logger, mediaFolder.Path); - } - else { - DataCache.Set($"should-skip-vfs-path:{path}", true); - result.Print(Logger, path); - } + // Save which paths we've already generated so we can skip generation + // for them and their sub-paths later, and also print the result. + result.Print(Logger, path.StartsWith(mediaFolder.Path) ? mediaFolder.Path : path); + + return true; + }); - return vfsPath; + return shouldReturnPath ? vfsPath : null; } public IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForEpisode(string fileId, string seriesId, int importFolderId, string importFolderSubPath, string mediaFolderPath) From 05e123151aa9fa0c92d7f7b71468258bcae2f1fb Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 12 May 2024 22:48:05 +0000 Subject: [PATCH 0965/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index e3e434bb..f8494bd5 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.165", + "changelog": "refactor: only build VFS once per path while the cache is filled", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.165/shoko_3.0.1.165.zip", + "checksum": "d6426eda03ec5ee698938925f1ea7e2f", + "timestamp": "2024-05-12T22:48:04Z" + }, { "version": "3.0.1.164", "changelog": "fix: fix episode group ordering for files", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.161/shoko_3.0.1.161.zip", "checksum": "8059012476cb659621986432db4bf418", "timestamp": "2024-05-12T01:13:27Z" - }, - { - "version": "3.0.1.160", - "changelog": "misc: remove nfos from mixed libraries\nsince they weren't needed, and were actually causing issues.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.160/shoko_3.0.1.160.zip", - "checksum": "881372c02dc18a6b22e782d77d0e3c7c", - "timestamp": "2024-05-10T02:59:42Z" } ] } From d78f3bb0a7df5e7143a66c9ff3d37763890991f2 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 13 May 2024 00:48:58 +0200 Subject: [PATCH 0966/1103] misc: add media folder changed events [skip ci] --- ...MediaFolderConfigurationChangedEventArgs.cs | 18 ++++++++++++++++++ Shokofin/Resolvers/ShokoResolveManager.cs | 8 ++++++++ 2 files changed, 26 insertions(+) create mode 100644 Shokofin/Resolvers/MediaFolderConfigurationChangedEventArgs.cs diff --git a/Shokofin/Resolvers/MediaFolderConfigurationChangedEventArgs.cs b/Shokofin/Resolvers/MediaFolderConfigurationChangedEventArgs.cs new file mode 100644 index 00000000..ab564092 --- /dev/null +++ b/Shokofin/Resolvers/MediaFolderConfigurationChangedEventArgs.cs @@ -0,0 +1,18 @@ +using System; +using MediaBrowser.Controller.Entities; +using Shokofin.Configuration; + +namespace Shokofin.Resolvers; + +public class MediaConfigurationChangedEventArgs : EventArgs +{ + public MediaFolderConfiguration Configuration { get; private set; } + + public Folder Folder { get; private set; } + + public MediaConfigurationChangedEventArgs(MediaFolderConfiguration config, Folder folder) + { + Configuration = config; + Folder = folder; + } +} \ No newline at end of file diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index ab3c526a..bf413196 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -66,6 +66,10 @@ public class ShokoResolveManager public bool IsCacheStalled => DataCache.IsStalled; + public event EventHandler<MediaConfigurationChangedEventArgs>? AddedConfiguration; + + public event EventHandler<MediaConfigurationChangedEventArgs>? RemovedConfiguration; + public ShokoResolveManager( ShokoAPIManager apiManager, ShokoAPIClient apiClient, @@ -120,6 +124,8 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) ); Plugin.Instance.Configuration.MediaFolders.Remove(mediaFolderConfig); Plugin.Instance.SaveConfiguration(); + + RemovedConfiguration?.Invoke(null, new(mediaFolderConfig, folder)); } var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(folder); if (Directory.Exists(vfsPath)) { @@ -236,6 +242,8 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold ); } + AddedConfiguration?.Invoke(null, new(mediaFolderConfig, mediaFolder)); + return mediaFolderConfig; } From 2a34bde1fc051909c7b64dac93de41e7bef2b7c5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 13 May 2024 21:59:23 +0000 Subject: [PATCH 0967/1103] fix: re-create specials anchors if needed --- Shokofin/API/Info/SeasonInfo.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 2eb8e514..3f6d827b 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -166,6 +166,24 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp episodesList = altEpisodesList; altEpisodesList = new(); + + // Re-create the special anchors because the episode list changed. + index = 0; + lastNormalEpisode = 0; + 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; + } + index++; + } } if (Plugin.Instance.Configuration.MovieSpecialsAsExtraFeaturettes && type == SeriesType.Movie) { From 2ec5ef0beb6c5f7d22c9fad1f0c1a2ccb257e3d3 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 13 May 2024 22:00:15 +0000 Subject: [PATCH 0968/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index f8494bd5..95f6cc35 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.166", + "changelog": "fix: re-create specials anchors if needed\n\nmisc: add media folder changed events [skip ci]", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.166/shoko_3.0.1.166.zip", + "checksum": "157852058957d9d08e959ed7828c562d", + "timestamp": "2024-05-13T22:00:13Z" + }, { "version": "3.0.1.165", "changelog": "refactor: only build VFS once per path while the cache is filled", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.162/shoko_3.0.1.162.zip", "checksum": "3d299e615e20b63567b6d247780883c1", "timestamp": "2024-05-12T21:34:42Z" - }, - { - "version": "3.0.1.161", - "changelog": "misc: supposedly increase performance\nby droping the use of abstract interfaces, according to dotnet8.\n\nfix: use right dict to get collection", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.161/shoko_3.0.1.161.zip", - "checksum": "8059012476cb659621986432db4bf418", - "timestamp": "2024-05-12T01:13:27Z" } ] } From 427cfa609daf55fc4b08db3b7870e87d174983cf Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 16 May 2024 02:09:26 +0200 Subject: [PATCH 0969/1103] misc: cleanup text utility --- Shokofin/Providers/SeasonProvider.cs | 18 +------ Shokofin/Utils/Text.cs | 70 +++++++++++++++++----------- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index aaf84bfc..14cf57ff 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -100,24 +100,8 @@ public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage, Series? series, Guid seasonId) { - var (displayTitle, alternateTitle) = Text.GetSeasonTitles(seasonInfo, metadataLanguage); + var (displayTitle, alternateTitle) = Text.GetSeasonTitles(seasonInfo, offset, metadataLanguage); var sortTitle = $"S{seasonNumber} - {seasonInfo.Shoko.Name}"; - - if (offset > 0) { - string type = string.Empty; - switch (offset) { - default: - break; - case 1: - type = "Alternate Version"; - break; - } - if (!string.IsNullOrEmpty(type)) { - displayTitle += $" ({type})"; - alternateTitle += $" ({type})"; - } - } - Season season; if (series != null) { season = new Season { diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index 0473f3aa..d9914956 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -252,28 +252,46 @@ public static string SanitizeAnidbDescription(string summary) return outputText; } - public static (string?, string?) GetEpisodeTitles(EpisodeInfo episode, SeasonInfo series, string metadataLanguage) + public static (string?, string?) GetEpisodeTitles(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, string metadataLanguage) => ( - GetEpisodeTitleByType(episode, series, TitleProviderType.Main, metadataLanguage), - GetEpisodeTitleByType(episode, series, TitleProviderType.Alternate, metadataLanguage) + GetEpisodeTitleByType(episodeInfo, seasonInfo, TitleProviderType.Main, metadataLanguage), + GetEpisodeTitleByType(episodeInfo, seasonInfo, TitleProviderType.Alternate, metadataLanguage) ); - public static (string?, string?) GetSeasonTitles(SeasonInfo series, string metadataLanguage) - => ( - GetSeriesTitleByType(series, series.Shoko.Name, TitleProviderType.Main, metadataLanguage), - GetSeriesTitleByType(series, series.Shoko.Name, TitleProviderType.Alternate, metadataLanguage) - ); + public static (string?, string?) GetSeasonTitles(SeasonInfo seasonInfo, string metadataLanguage) + => GetSeasonTitles(seasonInfo, 0, metadataLanguage); + + public static (string?, string?) GetSeasonTitles(SeasonInfo seasonInfo, int baseSeasonOffset, string metadataLanguage) + { + var displayTitle = GetSeriesTitleByType(seasonInfo, seasonInfo.Shoko.Name, TitleProviderType.Main, metadataLanguage); + var alternateTitle = GetSeriesTitleByType(seasonInfo, seasonInfo.Shoko.Name, TitleProviderType.Alternate, metadataLanguage); + if (baseSeasonOffset > 0) { + string type = string.Empty; + switch (baseSeasonOffset) { + default: + break; + case 1: + type = "Alternate Version"; + break; + } + if (!string.IsNullOrEmpty(type)) { + displayTitle += $" ({type})"; + alternateTitle += $" ({type})"; + } + } + return (displayTitle, alternateTitle); + } - public static (string?, string?) GetShowTitles(ShowInfo series, string metadataLanguage) + public static (string?, string?) GetShowTitles(ShowInfo showInfo, string metadataLanguage) => ( - GetSeriesTitleByType(series.DefaultSeason, series.Name, TitleProviderType.Main, metadataLanguage), - GetSeriesTitleByType(series.DefaultSeason, series.Name, TitleProviderType.Alternate, metadataLanguage) + GetSeriesTitleByType(showInfo.DefaultSeason, showInfo.Name, TitleProviderType.Main, metadataLanguage), + GetSeriesTitleByType(showInfo.DefaultSeason, showInfo.Name, TitleProviderType.Alternate, metadataLanguage) ); - public static (string?, string?) GetMovieTitles(EpisodeInfo episode, SeasonInfo series, string metadataLanguage) + public static (string?, string?) GetMovieTitles(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, string metadataLanguage) => ( - GetMovieTitleByType(episode, series, TitleProviderType.Main, metadataLanguage), - GetMovieTitleByType(episode, series, TitleProviderType.Alternate, metadataLanguage) + GetMovieTitleByType(episodeInfo, seasonInfo, TitleProviderType.Main, metadataLanguage), + GetMovieTitleByType(episodeInfo, seasonInfo, TitleProviderType.Alternate, metadataLanguage) ); /// <summary> @@ -292,28 +310,28 @@ private static TitleProvider[] GetOrderedTitleProvidersByType(TitleProviderType _ => Array.Empty<TitleProvider>(), }; - private static string? GetMovieTitleByType(EpisodeInfo episode, SeasonInfo series, TitleProviderType type, string metadataLanguage) + private static string? GetMovieTitleByType(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, TitleProviderType type, string metadataLanguage) { - var mainTitle = GetSeriesTitleByType(series, series.Shoko.Name, type, metadataLanguage); - var subTitle = GetEpisodeTitleByType(episode, series, type, metadataLanguage); + var mainTitle = GetSeriesTitleByType(seasonInfo, seasonInfo.Shoko.Name, type, metadataLanguage); + var subTitle = GetEpisodeTitleByType(episodeInfo, seasonInfo, type, metadataLanguage); if (!(string.IsNullOrEmpty(subTitle) || IgnoredSubTitles.Contains(subTitle))) return $"{mainTitle}: {subTitle}".Trim(); return mainTitle?.Trim(); } - private static string? GetEpisodeTitleByType(EpisodeInfo episode, SeasonInfo series, TitleProviderType type, string metadataLanguage) + private static string? GetEpisodeTitleByType(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, TitleProviderType type, string metadataLanguage) { foreach (var provider in GetOrderedTitleProvidersByType(type)) { var title = provider switch { TitleProvider.Shoko_Default => - episode.Shoko.Name, + episodeInfo.Shoko.Name, TitleProvider.AniDB_Default => - GetDefaultTitle(episode.AniDB.Titles), + GetDefaultTitle(episodeInfo.AniDB.Titles), TitleProvider.AniDB_LibraryLanguage => - GetTitlesForLanguage(episode.AniDB.Titles, false, metadataLanguage), + GetTitlesForLanguage(episodeInfo.AniDB.Titles, false, metadataLanguage), TitleProvider.AniDB_CountryOfOrigin => - GetTitlesForLanguage(episode.AniDB.Titles, false, GuessOriginLanguage(GetMainLanguage(series.AniDB.Titles))), + GetTitlesForLanguage(episodeInfo.AniDB.Titles, false, GuessOriginLanguage(GetMainLanguage(seasonInfo.AniDB.Titles))), _ => null, }; if (!string.IsNullOrEmpty(title)) @@ -322,18 +340,18 @@ private static TitleProvider[] GetOrderedTitleProvidersByType(TitleProviderType return null; } - private static string? GetSeriesTitleByType(SeasonInfo series, string defaultName, TitleProviderType type, string metadataLanguage) + private static string? GetSeriesTitleByType(SeasonInfo seasonInfo, string defaultName, TitleProviderType type, string metadataLanguage) { foreach (var provider in GetOrderedTitleProvidersByType(type)) { var title = provider switch { TitleProvider.Shoko_Default => defaultName, TitleProvider.AniDB_Default => - GetDefaultTitle(series.AniDB.Titles), + GetDefaultTitle(seasonInfo.AniDB.Titles), TitleProvider.AniDB_LibraryLanguage => - GetTitlesForLanguage(series.AniDB.Titles, true, metadataLanguage), + GetTitlesForLanguage(seasonInfo.AniDB.Titles, true, metadataLanguage), TitleProvider.AniDB_CountryOfOrigin => - GetTitlesForLanguage(series.AniDB.Titles, true, GuessOriginLanguage(GetMainLanguage(series.AniDB.Titles))), + GetTitlesForLanguage(seasonInfo.AniDB.Titles, true, GuessOriginLanguage(GetMainLanguage(seasonInfo.AniDB.Titles))), _ => null, }; if (!string.IsNullOrEmpty(title)) From aa5cce5152379d6fcdf7354b0c936856ed3d12b8 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 16 May 2024 00:10:30 +0000 Subject: [PATCH 0970/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 95f6cc35..090cb28f 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.167", + "changelog": "misc: cleanup text utility", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.167/shoko_3.0.1.167.zip", + "checksum": "51a3e46dfe8862a180eef893407002f6", + "timestamp": "2024-05-16T00:10:28Z" + }, { "version": "3.0.1.166", "changelog": "fix: re-create specials anchors if needed\n\nmisc: add media folder changed events [skip ci]", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.163/shoko_3.0.1.163.zip", "checksum": "0a023e0b4940f387b8621f70812014e8", "timestamp": "2024-05-12T22:01:53Z" - }, - { - "version": "3.0.1.162", - "changelog": "fix: extras \u2192 episodes\n\nmisc: changes to support latest daily server\n\nmisc: remove unuseed references\u00a0[skip ci]", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.162/shoko_3.0.1.162.zip", - "checksum": "3d299e615e20b63567b6d247780883c1", - "timestamp": "2024-05-12T21:34:42Z" } ] } From 8be07e4bcc9f3b58bf7de519470cd3a264665a32 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 17 May 2024 00:40:48 +0200 Subject: [PATCH 0971/1103] refactor: don't cache media folder search anymore, since it's probably not needed anymore. --- Shokofin/Resolvers/ShokoResolveManager.cs | 25 ++++++++--------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index bf413196..25d2c431 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -141,22 +141,15 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) #region Media Folder Mapping private IReadOnlySet<string> GetPathsForMediaFolder(Folder mediaFolder) - => DataCache.GetOrCreate<IReadOnlySet<string>>( - $"paths-for-media-folder:{mediaFolder.Path}", - (paths) => Logger.LogTrace("Reusing {FileCount} files for folder at {Path}", paths.Count, mediaFolder.Path), - (_) => { - Logger.LogDebug("Looking for files in folder at {Path}", mediaFolder.Path); - var start = DateTime.UtcNow; - var paths = FileSystem.GetFilePaths(mediaFolder.Path, true) - .Where(path => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) - .ToHashSet(); - Logger.LogDebug("Found {FileCount} files in folder at {Path} in {TimeSpan}.", paths.Count, mediaFolder.Path, DateTime.UtcNow - start); - return paths; - }, - new() { - SlidingExpiration = TimeSpan.FromMinutes(30), - } - ); + { + Logger.LogDebug("Looking for files in folder at {Path}", mediaFolder.Path); + var start = DateTime.UtcNow; + var paths = FileSystem.GetFilePaths(mediaFolder.Path, true) + .Where(path => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + .ToHashSet(); + Logger.LogDebug("Found {FileCount} files in folder at {Path} in {TimeSpan}.", paths.Count, mediaFolder.Path, DateTime.UtcNow - start); + return paths; + } public IReadOnlyList<(MediaFolderConfiguration config, Folder mediaFolder, string vfsPath)> GetAvailableMediaFolders(bool fileEvents = false, bool refreshEvents = false) => Plugin.Instance.Configuration.MediaFolders From d6eecaeae91443800c9b4a5825d150d44fb094a4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 17 May 2024 19:06:04 +0200 Subject: [PATCH 0972/1103] feat: add real time monitoring support for the VFS - Added a library scan watcher, responsible for tracking when a library scan starts and ends. - Fixed it so the signalr events won't be emitted (but still logged) if a library scan is running, preventing the events from interfering with the in-progress library scan. This is how jellyfin core already handles the file events during a scan, and will help prevent double updating the entries during a scan. - Refactored it so the resolve manager is responsible for processing file events, so it can consume events from both the signalr event events in addition to the the new file events from the newly added library monitor. - Added an _experimental_ library monitor responsible for emitting file events for the VFS. This should make it so real time monitoring will work with the VFS again, but the implementation haven't been thoroughly tested so there may still be bugs (or not). - Fixed it so the auto clear cache will **never** run during a library scan. It may still run if the caches idles for too long during a refresh of the library outside the library scan though. --- Shokofin/API/Models/File.cs | 6 + Shokofin/API/ShokoAPIManager.cs | 5 +- Shokofin/Configuration/PluginConfiguration.cs | 9 + Shokofin/FolderExtensions.cs | 10 + Shokofin/Plugin.cs | 8 +- Shokofin/PluginServiceRegistrator.cs | 2 + ...ediaFolderConfigurationChangedEventArgs.cs | 6 +- Shokofin/Resolvers/ShokoLibraryMonitor.cs | 262 ++++++++++++ Shokofin/Resolvers/ShokoResolveManager.cs | 403 ++++++++++++++++- Shokofin/Resolvers/ShokoWatcher.cs | 26 ++ Shokofin/SignalR/Interfaces/IFileEventArgs.cs | 6 - .../{Models => Interfaces}/UpdateReason.cs | 2 +- Shokofin/SignalR/SignalRConnectionManager.cs | 404 +++--------------- Shokofin/SignalR/Stub/FileEventArgsStub.cs | 48 +++ Shokofin/Tasks/AutoClearPluginCacheTask.cs | 15 +- Shokofin/Utils/DisposableAction.cs | 17 + Shokofin/Utils/LibraryScanWatcher.cs | 32 ++ Shokofin/Utils/PropertyWatcher.cs | 17 +- 18 files changed, 895 insertions(+), 383 deletions(-) create mode 100644 Shokofin/FolderExtensions.cs create mode 100644 Shokofin/Resolvers/ShokoLibraryMonitor.cs create mode 100644 Shokofin/Resolvers/ShokoWatcher.cs rename Shokofin/SignalR/{Models => Interfaces}/UpdateReason.cs (82%) create mode 100644 Shokofin/SignalR/Stub/FileEventArgsStub.cs create mode 100644 Shokofin/Utils/DisposableAction.cs create mode 100644 Shokofin/Utils/LibraryScanWatcher.cs diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index 79463cf1..ac90f9fd 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -70,6 +70,12 @@ public class File /// </summary> public class Location { + /// <summary> + /// File location ID. + /// </summary> + [JsonPropertyName("ID")] + public int? Id { get; set; } + /// <summary> /// The id of the <see cref="ImportFolder"/> this <see cref="File"/> /// resides in. diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index accd10b0..35be873d 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -62,9 +62,6 @@ public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient #region Ignore rule - public static string GetVirtualRootForMediaFolder(Folder mediaFolder) - => Path.Join(Plugin.Instance.VirtualRoot, mediaFolder.Id.ToString()); - public (Folder mediaFolder, string partialPath) FindMediaFolder(string path, Folder parent, Folder root) { Folder? mediaFolder = null; @@ -72,7 +69,7 @@ public static string GetVirtualRootForMediaFolder(Folder mediaFolder) var mediaFolderId = Guid.Parse(path[(Plugin.Instance.VirtualRoot.Length + 1)..].Split(Path.DirectorySeparatorChar).First()); mediaFolder = LibraryManager.GetItemById(mediaFolderId) as Folder; if (mediaFolder != null) { - var mediaRootVirtualPath = GetVirtualRootForMediaFolder(mediaFolder); + var mediaRootVirtualPath = mediaFolder.GetVirtualRoot(); return (mediaFolder, path[mediaRootVirtualPath.Length..]); } return (root, path); diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index a3c6a99a..ba8dc8a1 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Text.Json.Serialization; using System.Xml.Serialization; @@ -253,6 +254,13 @@ public virtual string PrettyUrl [XmlElement("LibraryFiltering")] public LibraryFilteringMode LibraryFilteringMode { get; set; } + /// <summary> + /// Reaction time to when a library scan starts/ends, because they don't + /// expose it as an event, so we need to poll instead. + /// </summary> + [Range(1, 10)] + public int LibraryScanReactionTimeInSeconds { get; set; } + /// <summary> /// Per media folder configuration. /// </summary> @@ -374,6 +382,7 @@ public PluginConfiguration() MediaFolders = new(); IgnoredFolders = new[] { ".streams", "@recently-snapshot" }; LibraryFilteringMode = LibraryFilteringMode.Auto; + LibraryScanReactionTimeInSeconds = 1; SignalR_AutoConnectEnabled = false; SignalR_AutoReconnectInSeconds = new[] { 0, 2, 10, 30, 60, 120, 300 }; SignalR_RefreshEnabled = false; diff --git a/Shokofin/FolderExtensions.cs b/Shokofin/FolderExtensions.cs new file mode 100644 index 00000000..87aae4eb --- /dev/null +++ b/Shokofin/FolderExtensions.cs @@ -0,0 +1,10 @@ +using System.IO; +using MediaBrowser.Controller.Entities; + +namespace Shokofin; + +public static class FolderExtensions +{ + public static string GetVirtualRoot(this Folder mediaFolder) + => Path.Join(Plugin.Instance.VirtualRoot, mediaFolder.Id.ToString()); +} \ No newline at end of file diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 8396db6e..de287d2f 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -32,10 +32,15 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages /// </summary> public readonly string VirtualRoot; + /// <summary> + /// Gets or sets the event handler that is triggered when this configuration changes. + /// </summary> + public new event EventHandler<PluginConfiguration>? ConfigurationChanged; + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger) : base(applicationPaths, xmlSerializer) { Instance = this; - ConfigurationChanged += OnConfigChanged; + base.ConfigurationChanged += OnConfigChanged; IgnoredFolders = Configuration.IgnoredFolders.ToHashSet(); VirtualRoot = Path.Join(applicationPaths.ProgramDataPath, "Shokofin", "VFS"); Logger = logger; @@ -68,6 +73,7 @@ public void OnConfigChanged(object? sender, BasePluginConfiguration e) if (e is not PluginConfiguration config) return; IgnoredFolders = config.IgnoredFolders.ToHashSet(); + ConfigurationChanged?.Invoke(sender, config); } public HashSet<string> IgnoredFolders; diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index 8f6646ec..5fe8dae3 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -9,6 +9,7 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator /// <inheritdoc /> public void RegisterServices(IServiceCollection serviceCollection) { + serviceCollection.AddSingleton<Utils.LibraryScanWatcher>(); serviceCollection.AddSingleton<API.ShokoAPIClient>(); serviceCollection.AddSingleton<API.ShokoAPIManager>(); serviceCollection.AddSingleton<IIdLookup, IdLookup>(); @@ -16,6 +17,7 @@ public void RegisterServices(IServiceCollection serviceCollection) serviceCollection.AddSingleton<MergeVersions.MergeVersionsManager>(); serviceCollection.AddSingleton<Collections.CollectionManager>(); serviceCollection.AddSingleton<Resolvers.ShokoResolveManager>(); + serviceCollection.AddSingleton<Resolvers.ShokoLibraryMonitor>(); serviceCollection.AddSingleton<SignalR.SignalRConnectionManager>(); } } diff --git a/Shokofin/Resolvers/MediaFolderConfigurationChangedEventArgs.cs b/Shokofin/Resolvers/MediaFolderConfigurationChangedEventArgs.cs index ab564092..9312682a 100644 --- a/Shokofin/Resolvers/MediaFolderConfigurationChangedEventArgs.cs +++ b/Shokofin/Resolvers/MediaFolderConfigurationChangedEventArgs.cs @@ -6,13 +6,13 @@ namespace Shokofin.Resolvers; public class MediaConfigurationChangedEventArgs : EventArgs { - public MediaFolderConfiguration Configuration { get; private set; } + public MediaFolderConfiguration Configuration { get; private init; } - public Folder Folder { get; private set; } + public Folder MediaFolder { get; private init; } public MediaConfigurationChangedEventArgs(MediaFolderConfiguration config, Folder folder) { Configuration = config; - Folder = folder; + MediaFolder = folder; } } \ No newline at end of file diff --git a/Shokofin/Resolvers/ShokoLibraryMonitor.cs b/Shokofin/Resolvers/ShokoLibraryMonitor.cs new file mode 100644 index 00000000..8df4a650 --- /dev/null +++ b/Shokofin/Resolvers/ShokoLibraryMonitor.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Emby.Naming.Common; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.Configuration; +using Shokofin.SignalR.Interfaces; +using Shokofin.SignalR.Models; +using Shokofin.Utils; + +namespace Shokofin.Resolvers; + +public class ShokoLibraryMonitor +{ + private readonly ILogger<ShokoLibraryMonitor> Logger; + + private readonly ShokoAPIClient ApiClient; + + private readonly ShokoResolveManager ResolveManager; + + private readonly ILibraryManager LibraryManager; + + private readonly ILibraryMonitor LibraryMonitor; + + private readonly LibraryScanWatcher LibraryScanWatcher; + + private readonly NamingOptions NamingOptions; + + private readonly ConcurrentDictionary<string, ShokoWatcher> FileSystemWatchers = new(); + + // follow the core jf behavior, but use config added/removed instead of library added/removed. + + public ShokoLibraryMonitor( + ILogger<ShokoLibraryMonitor> logger, + ShokoAPIClient apiClient, + ShokoResolveManager resolveManager, + ILibraryManager libraryManager, + ILibraryMonitor libraryMonitor, + LibraryScanWatcher libraryScanWatcher, + NamingOptions namingOptions + ) + { + Logger = logger; + ApiClient = apiClient; + ResolveManager = resolveManager; + ResolveManager.ConfigurationAdded += OnMediaFolderConfigurationAddedOrUpdated; + ResolveManager.ConfigurationUpdated += OnMediaFolderConfigurationAddedOrUpdated; + ResolveManager.ConfigurationRemoved += OnMediaFolderConfigurationRemoved; + LibraryManager = libraryManager; + LibraryMonitor = libraryMonitor; + LibraryScanWatcher = libraryScanWatcher; + LibraryScanWatcher.ValueChanged += OnLibraryScanRunningChanged; + NamingOptions = namingOptions; + } + + ~ShokoLibraryMonitor() + { + ResolveManager.ConfigurationAdded -= OnMediaFolderConfigurationAddedOrUpdated; + ResolveManager.ConfigurationUpdated -= OnMediaFolderConfigurationAddedOrUpdated; + ResolveManager.ConfigurationRemoved -= OnMediaFolderConfigurationRemoved; + LibraryScanWatcher.ValueChanged -= OnLibraryScanRunningChanged; + } + + public void StartWatching() + { + // add blockers/watchers for every media folder with VFS enabled and real time monitoring enabled. + foreach (var mediaConfig in Plugin.Instance.Configuration.MediaFolders) { + if (LibraryManager.GetItemById(mediaConfig.MediaFolderId) is not Folder mediaFolder) + continue; + + var libraryOptions = LibraryManager.GetLibraryOptions(mediaFolder); + if (libraryOptions != null && libraryOptions.EnableRealtimeMonitor && mediaConfig.IsVirtualFileSystemEnabled) + StartWatchingMediaFolder(mediaFolder, mediaConfig); + } + } + + public void StopWatching() + { + foreach (var path in FileSystemWatchers.Keys.ToList()) + StopWatchingPath(path); + } + + private void OnLibraryScanRunningChanged(object? sender, bool isScanRunning) + { + if (isScanRunning) + StopWatching(); + else + StartWatching(); + } + + private void OnMediaFolderConfigurationAddedOrUpdated(object? sender, MediaConfigurationChangedEventArgs eventArgs) + { + // Don't add/remove watchers during a scan. + if (LibraryScanWatcher.IsScanRunning) + return; + + var libraryOptions = LibraryManager.GetLibraryOptions(eventArgs.MediaFolder); + if (libraryOptions != null && libraryOptions.EnableRealtimeMonitor && eventArgs.Configuration.IsVirtualFileSystemEnabled) + StartWatchingMediaFolder(eventArgs.MediaFolder, eventArgs.Configuration); + else + StopWatchingPath(eventArgs.MediaFolder.Path); + } + + private void OnMediaFolderConfigurationRemoved(object? sender, MediaConfigurationChangedEventArgs eventArgs) + { + // Don't add/remove watchers during a scan. + if (LibraryScanWatcher.IsScanRunning) + return; + + StopWatchingPath(eventArgs.MediaFolder.Path); + } + + private void StartWatchingMediaFolder(Folder mediaFolder, MediaFolderConfiguration config) + { + // Creating a FileSystemWatcher over the LAN can take hundreds of milliseconds, so wrap it in a Task to do it in parallel. + Task.Run(() => { + try { + var watcher = new FileSystemWatcher(mediaFolder.Path, "*") { + IncludeSubdirectories = true, + InternalBufferSize = 65536, + NotifyFilter = NotifyFilters.CreationTime | + NotifyFilters.DirectoryName | + NotifyFilters.FileName | + NotifyFilters.LastWrite | + NotifyFilters.Size | + NotifyFilters.Attributes + }; + + watcher.Created += OnWatcherChanged; + watcher.Deleted += OnWatcherChanged; + watcher.Renamed += OnWatcherChanged; + watcher.Changed += OnWatcherChanged; + watcher.Error += OnWatcherError; + + var lease = ResolveManager.RegisterEventSubmitter(); + if (FileSystemWatchers.TryAdd(mediaFolder.Path, new(mediaFolder, config, watcher, lease))) { + LibraryMonitor.ReportFileSystemChangeBeginning(mediaFolder.Path); + watcher.EnableRaisingEvents = true; + Logger.LogInformation("Watching directory {Path}", mediaFolder.Path); + } + else { + lease.Dispose(); + DisposeWatcher(watcher, false); + } + } + catch (Exception ex) { + Logger.LogError(ex, "Error watching path: {Path}", mediaFolder.Path); + } + }); + } + + private void StopWatchingPath(string path) + { + if (FileSystemWatchers.TryGetValue(path, out var watcher)) + { + DisposeWatcher(watcher.Watcher, true); + } + } + + private void DisposeWatcher(FileSystemWatcher watcher, bool removeFromList = true) + { + try + { + using (watcher) + { + Logger.LogInformation("Stopping directory watching for path {Path}", watcher.Path); + + watcher.Created -= OnWatcherChanged; + watcher.Deleted -= OnWatcherChanged; + watcher.Renamed -= OnWatcherChanged; + watcher.Changed -= OnWatcherChanged; + watcher.Error -= OnWatcherError; + + watcher.EnableRaisingEvents = false; + } + } + finally + { + if (removeFromList && FileSystemWatchers.TryRemove(watcher.Path, out var shokoWatcher)) { + LibraryMonitor.ReportFileSystemChangeComplete(watcher.Path, false); + shokoWatcher.SubmitterLease.Dispose(); + } + } + } + + private void OnWatcherError(object sender, ErrorEventArgs eventArgs) + { + var ex = eventArgs.GetException(); + if (sender is not FileSystemWatcher watcher) + return; + + Logger.LogError(ex, "Error in Directory watcher for: {Path}", watcher.Path); + + DisposeWatcher(watcher); + } + + private void OnWatcherChanged(object? sender, FileSystemEventArgs e) + { + try + { + if (sender is not FileSystemWatcher watcher || !FileSystemWatchers.TryGetValue(watcher.Path, out var shokoWatcher)) + return; + Task.Run(() => ReportFileSystemChanged(shokoWatcher.Configuration, e.ChangeType, e.FullPath)); + } + catch (Exception ex) + { + Logger.LogError(ex, "Exception in ReportFileSystemChanged. Path: {FullPath}", e.FullPath); + } + } + + public async Task ReportFileSystemChanged(MediaFolderConfiguration mediaConfig, WatcherChangeTypes changeTypes, string path) + { + if (!path.StartsWith(mediaConfig.MediaFolderPath)) { + Logger.LogTrace("Skipped path because it is not in the watched folder; {Path}", path); + return; + } + + if (!IsVideoFile(path)) { + Logger.LogTrace("Skipped path because it is not a video file; {Path}", path); + return; + } + + var relativePath = path[mediaConfig.MediaFolderPath.Length..]; + var files = await ApiClient.GetFileByPath(relativePath); + var file = files.FirstOrDefault(file => file.Locations.Any(location => location.ImportFolderId == mediaConfig.ImportFolderId && location.RelativePath == relativePath)); + if (file is null) { + Logger.LogTrace("Skipped file because it is not a shoko managed file; {Path}", path); + return; + } + + var reason = changeTypes == WatcherChangeTypes.Deleted ? UpdateReason.Removed : changeTypes == WatcherChangeTypes.Created ? UpdateReason.Added : UpdateReason.Updated; + var fileLocation = file.Locations.First(location => location.ImportFolderId == mediaConfig.ImportFolderId && location.RelativePath == relativePath); + Logger.LogDebug( + "File {EventName}; {ImportFolderId} {Path} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", + reason, + fileLocation.ImportFolderId, + relativePath, + file.Id, + fileLocation.Id, + true + ); + + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogTrace( + "Library scan is running. Skipping emit of file event. (File={FileId},Location={LocationId})", + file.Id, + fileLocation.Id + ); + return; + } + + ResolveManager.AddFileEvent(file.Id, reason, fileLocation.ImportFolderId, relativePath, new FileEventArgsStub(fileLocation, file)); + } + private bool IsVideoFile(string path) + => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path)); + +} diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 25d2c431..13d0fdba 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -5,8 +5,10 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using System.Timers; using Emby.Naming.Common; using Emby.Naming.ExternalFiles; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; @@ -19,9 +21,11 @@ using Shokofin.API.Models; using Shokofin.Configuration; using Shokofin.ExternalIds; +using Shokofin.SignalR.Interfaces; using Shokofin.Utils; using File = System.IO.File; +using Timer = System.Timers.Timer; using TvSeries = MediaBrowser.Controller.Entities.TV.Series; namespace Shokofin.Resolvers; @@ -35,6 +39,8 @@ public class ShokoResolveManager private readonly IIdLookup Lookup; private readonly ILibraryManager LibraryManager; + + private readonly ILibraryMonitor LibraryMonitor; private readonly IFileSystem FileSystem; @@ -46,9 +52,21 @@ public class ShokoResolveManager private readonly GuardedMemoryCache DataCache; + private int ChangesDetectionSubmitterCount = 0; + + private readonly Timer ChangesDetectionTimer; + + private readonly Dictionary<Guid, string> MediaFolderChangeKeys = new(); + + private readonly Dictionary<string, (DateTime LastUpdated, List<IMetadataUpdatedEventArgs> List)> ChangesPerSeries = new(); + + private readonly Dictionary<int, (DateTime LastUpdated, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> List)> ChangesPerFile = new(); + // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 characters. private const int NameCutOff = 64; + private static readonly TimeSpan DetectChangesThreshold = TimeSpan.FromSeconds(5); + private static readonly IReadOnlySet<string> IgnoreFolderNames = new HashSet<string>() { "backdrops", "behind the scenes", @@ -66,15 +84,18 @@ public class ShokoResolveManager public bool IsCacheStalled => DataCache.IsStalled; - public event EventHandler<MediaConfigurationChangedEventArgs>? AddedConfiguration; + public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationAdded; + + public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationUpdated; - public event EventHandler<MediaConfigurationChangedEventArgs>? RemovedConfiguration; + public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationRemoved; public ShokoResolveManager( ShokoAPIManager apiManager, ShokoAPIClient apiClient, IIdLookup lookup, ILibraryManager libraryManager, + ILibraryMonitor libraryMonitor, IFileSystem fileSystem, ILogger<ShokoResolveManager> logger, ILocalizationManager localizationManager, @@ -85,17 +106,26 @@ NamingOptions namingOptions ApiClient = apiClient; Lookup = lookup; LibraryManager = libraryManager; + LibraryMonitor = libraryMonitor; FileSystem = fileSystem; Logger = logger; DataCache = new(logger, TimeSpan.FromMinutes(15), 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; + ChangesDetectionTimer = new() { AutoReset = true, Interval = TimeSpan.FromSeconds(4).TotalMilliseconds }; + ChangesDetectionTimer.Elapsed += OnIntervalElapsed; + foreach (var mediaConfig in Plugin.Instance.Configuration.MediaFolders) + MediaFolderChangeKeys[mediaConfig.MediaFolderId] = ConstructKey(mediaConfig); + Plugin.Instance.ConfigurationChanged += OnConfigurationChanged; } ~ShokoResolveManager() { LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; + ChangesDetectionTimer.Elapsed -= OnIntervalElapsed; + Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; + MediaFolderChangeKeys.Clear(); DataCache.Dispose(); } @@ -107,6 +137,25 @@ public void Clear() #region Changes Tracking + private static string ConstructKey(MediaFolderConfiguration config) + => $"IsMapped={config.IsMapped},IsFileEventsEnabled={config.IsFileEventsEnabled},IsRefreshEventsEnabled={config.IsRefreshEventsEnabled},IsVirtualFileSystemEnabled={config.IsVirtualFileSystemEnabled},LibraryFilteringMode={config.LibraryFilteringMode}"; + + private void OnConfigurationChanged(object? sender, PluginConfiguration config) + { + foreach (var mediaConfig in config.MediaFolders) { + var currentKey = ConstructKey(mediaConfig); + if (MediaFolderChangeKeys.TryGetValue(mediaConfig.MediaFolderId, out var previousKey) && previousKey != currentKey) { + MediaFolderChangeKeys[mediaConfig.MediaFolderId] = currentKey; + if (LibraryManager.GetItemById(mediaConfig.MediaFolderId) is not Folder mediaFolder) + continue; + ConfigurationUpdated?.Invoke(sender, new(mediaConfig, mediaFolder)); + } + } + var mediaKeys = Plugin.Instance.Configuration.MediaFolders + .ToDictionary(c => c.MediaFolderId, ConstructKey); + + } + private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) { // Remove the VFS directory for any media library folders when they're removed. @@ -125,9 +174,11 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) Plugin.Instance.Configuration.MediaFolders.Remove(mediaFolderConfig); Plugin.Instance.SaveConfiguration(); - RemovedConfiguration?.Invoke(null, new(mediaFolderConfig, folder)); + if (MediaFolderChangeKeys.ContainsKey(folder.Id)) + MediaFolderChangeKeys.Remove(folder.Id); + ConfigurationRemoved?.Invoke(null, new(mediaFolderConfig, folder)); } - var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(folder); + var vfsPath = folder.GetVirtualRoot(); if (Directory.Exists(vfsPath)) { Logger.LogInformation("Removing VFS directory for folder at {Path}", folder.Path); Directory.Delete(vfsPath, true); @@ -156,7 +207,7 @@ private IReadOnlySet<string> GetPathsForMediaFolder(Folder mediaFolder) .Where(mediaFolder => mediaFolder.IsMapped && (!fileEvents || mediaFolder.IsFileEventsEnabled) && (!refreshEvents || mediaFolder.IsRefreshEventsEnabled)) .Select(config => (config, mediaFolder: LibraryManager.GetItemById(config.MediaFolderId) as Folder)) .OfType<(MediaFolderConfiguration config, Folder mediaFolder)>() - .Select(tuple => (tuple.config, tuple.mediaFolder, ShokoAPIManager.GetVirtualRootForMediaFolder(tuple.mediaFolder))) + .Select(tuple => (tuple.config, tuple.mediaFolder, tuple.mediaFolder.GetVirtualRoot())) .ToList(); public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFolder(Folder mediaFolder) @@ -235,7 +286,8 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold ); } - AddedConfiguration?.Invoke(null, new(mediaFolderConfig, mediaFolder)); + MediaFolderChangeKeys[mediaFolder.Id] = ConstructKey(mediaFolderConfig); + ConfigurationAdded?.Invoke(null, new(mediaFolderConfig, mediaFolder)); return mediaFolderConfig; } @@ -254,7 +306,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold private async Task<string?> GenerateStructureInVFS(Folder mediaFolder, string path) { // Skip link generation if we've already generated for the media folder. - var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + var vfsPath = mediaFolder.GetVirtualRoot(); if (DataCache.TryGetValue<bool>($"should-skip-media-folder:{mediaFolder.Path}", out var shouldReturnPath)) return shouldReturnPath ? vfsPath : null; @@ -707,9 +759,9 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { return result; } - public async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(Folder mediaFolder, string sourceLocation, string fileId, string seriesId) + private async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(Folder mediaFolder, string sourceLocation, string fileId, string seriesId) { - var vfsPath = ShokoAPIManager.GetVirtualRootForMediaFolder(mediaFolder); + var vfsPath = mediaFolder.GetVirtualRoot(); var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); return await GenerateLocationsForFile(vfsPath, collectionType, sourceLocation, fileId, seriesId); } @@ -814,7 +866,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { // TODO: Remove this for 10.9 #pragma warning disable IDE0060 - public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, DateTime importedAt) + private LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, DateTime importedAt) #pragma warning restore IDE0060 { try { @@ -1430,4 +1482,335 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b } #endregion + + #region Event Detection + + public IDisposable RegisterEventSubmitter() + { + var count = ChangesDetectionSubmitterCount++; + if (count == 0) + ChangesDetectionTimer.Start(); + + return new DisposableAction(() => DeregisterEventSubmitter()); + } + + private void DeregisterEventSubmitter() + { + var count = --ChangesDetectionSubmitterCount; + if (count == 0) { + ChangesDetectionTimer.Stop(); + if (ChangesPerFile.Count > 0) + ClearFileEvents(); + if (ChangesPerSeries.Count > 0) + ClearMetadataUpdatedEvents(); + } + } + + private void OnIntervalElapsed(object? sender, ElapsedEventArgs eventArgs) + { + var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>)>(); + var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>)>(); + lock (ChangesPerFile) { + if (ChangesPerFile.Count > 0) { + var now = DateTime.Now; + foreach (var (fileId, (lastUpdated, list)) in ChangesPerFile) { + if (now - lastUpdated < DetectChangesThreshold) + continue; + filesToProcess.Add((fileId, list)); + } + foreach (var (fileId, _) in filesToProcess) + ChangesPerFile.Remove(fileId); + } + } + lock (ChangesPerSeries) { + if (ChangesPerSeries.Count > 0) { + var now = DateTime.Now; + foreach (var (metadataId, (lastUpdated, list)) in ChangesPerSeries) { + if (now - lastUpdated < DetectChangesThreshold) + continue; + seriesToProcess.Add((metadataId, list)); + } + foreach (var (metadataId, _) in seriesToProcess) + ChangesPerSeries.Remove(metadataId); + } + } + foreach (var (fileId, changes) in filesToProcess) + Task.Run(() => ProcessFileEvents(fileId, changes)); + foreach (var (metadataId, changes) in seriesToProcess) + Task.Run(() => ProcessSeriesEvents(metadataId, changes)); + } + + private void ClearFileEvents() + { + var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>)>(); + lock (ChangesPerFile) { + foreach (var (fileId, (lastUpdated, list)) in ChangesPerFile) { + filesToProcess.Add((fileId, list)); + } + ChangesPerFile.Clear(); + } + foreach (var (fileId, changes) in filesToProcess) + Task.Run(() => ProcessFileEvents(fileId, changes)); + } + + private void ClearMetadataUpdatedEvents() + { + var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>)>(); + lock (ChangesPerSeries) { + foreach (var (metadataId, (lastUpdated, list)) in ChangesPerSeries) { + seriesToProcess.Add((metadataId, list)); + } + ChangesPerSeries.Clear(); + } + foreach (var (metadataId, changes) in seriesToProcess) + Task.Run(() => ProcessSeriesEvents(metadataId, changes)); + } + + #endregion + + #region File Events + + public void AddFileEvent(int fileId, UpdateReason reason, int importFolderId, string filePath, IFileEventArgs eventArgs) + { + lock (ChangesPerFile) { + if (ChangesPerFile.TryGetValue(fileId, out var tuple)) + tuple.LastUpdated = DateTime.Now; + else + ChangesPerFile.Add(fileId, tuple = (DateTime.Now, new())); + tuple.List.Add((reason, importFolderId, filePath, eventArgs)); + } + } + + private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> changes) + { + try { + Logger.LogInformation("Processing {EventCount} file change events… (File={FileId})", changes.Count, fileId); + + // Something was added or updated. + var locationsToNotify = new List<string>(); + var seriesIds = await GetSeriesIdsForFile(fileId, changes.Select(t => t.Event).LastOrDefault(e => e.HasCrossReferences)); + var mediaFolders = GetAvailableMediaFolders(fileEvents: true); + var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); + if (reason != UpdateReason.Removed) { + foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { + if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) + continue; + + var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); + if (!File.Exists(sourceLocation)) + continue; + + // Let the core logic handle the rest. + if (!config.IsVirtualFileSystemEnabled) { + locationsToNotify.Add(sourceLocation); + continue; + } + + var result = new LinkGenerationResult(); + var topFolders = new HashSet<string>(); + var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => GenerateLocationsForFile(mediaFolder, sourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) + .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) + .ToList(); + foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { + result += GenerateSymbolicLinks(srcLoc, 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); + } + + // Remove old links for file. + var videos = LibraryManager + .GetItemList( + new() { + AncestorIds = new[] { mediaFolder.Id }, + IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Movie }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, + DtoOptions = new(true), + }, + true + ) + .Where(item => !string.IsNullOrEmpty(item.Path) && item.Path.StartsWith(vfsPath) && !result.Paths.Contains(item.Path)) + .ToList(); + foreach (var video in videos) { + File.Delete(video.Path); + topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); + locationsToNotify.Add(video.Path); + result.RemovedVideos++; + } + + result.Print(Logger, mediaFolder.Path); + + // If all the "top-level-folders" exist, then let the core logic handle the rest. + if (topFolders.All(path => LibraryManager.FindByPath(path, true) != null)) { + var old = locationsToNotify.Count; + locationsToNotify.AddRange(vfsLocations.SelectMany(tuple => tuple.symbolicLinks)); + } + // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. + else { + var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); + if (!string.IsNullOrEmpty(fileOrFolder)) + locationsToNotify.Add(fileOrFolder); + } + } + } + // Something was removed, so assume the location is gone. + else if (changes.FirstOrDefault(t => t.Reason == UpdateReason.Removed).Event is IFileRelocationEventArgs firstRemovedEvent) { + relativePath = firstRemovedEvent.RelativePath; + importFolderId = firstRemovedEvent.ImportFolderId; + foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { + if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) + continue; + + + // Let the core logic handle the rest. + if (!config.IsVirtualFileSystemEnabled) { + var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); + locationsToNotify.Add(sourceLocation); + continue; + } + + // Check if we can use another location for the file. + var result = new LinkGenerationResult(); + var vfsSymbolicLinks = new HashSet<string>(); + var topFolders = new HashSet<string>(); + var newRelativePath = await GetNewRelativePath(config, fileId, relativePath); + if (!string.IsNullOrEmpty(newRelativePath)) { + var newSourceLocation = Path.Join(mediaFolder.Path, newRelativePath[config.ImportFolderRelativePath.Length..]); + var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => GenerateLocationsForFile(mediaFolder, newSourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) + .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) + .ToList(); + foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { + result += GenerateSymbolicLinks(srcLoc, 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); + } + vfsSymbolicLinks = vfsLocations.Select(tuple => tuple.sourceLocation).ToHashSet(); + } + + // Remove old links for file. + var videos = LibraryManager + .GetItemList( + new() { + AncestorIds = new[] { mediaFolder.Id }, + IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Movie }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, + DtoOptions = new(true), + }, + true + ) + .Where(item => !string.IsNullOrEmpty(item.Path) && item.Path.StartsWith(vfsPath) && !result.Paths.Contains(item.Path)) + .ToList(); + foreach (var video in videos) { + File.Delete(video.Path); + topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); + locationsToNotify.Add(video.Path); + result.RemovedVideos++; + } + + result.Print(Logger, mediaFolder.Path); + + // If all the "top-level-folders" exist, then let the core logic handle the rest. + if (topFolders.All(path => LibraryManager.FindByPath(path, true) != null)) { + var old = locationsToNotify.Count; + locationsToNotify.AddRange(vfsSymbolicLinks); + } + // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. + else { + var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); + if (!string.IsNullOrEmpty(fileOrFolder)) + locationsToNotify.Add(fileOrFolder); + } + } + } + + // We let jellyfin take it from here. + Logger.LogDebug("Notifying Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count, fileId.ToString()); + foreach (var location in locationsToNotify) + LibraryMonitor.ReportFileSystemChanged(location); + } + catch (Exception ex) { + Logger.LogError(ex, "Error processing {EventCount} file change events. (File={FileId})", changes.Count, fileId); + } + } + + private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEventArgs? fileEvent) + { + HashSet<string> seriesIds; + if (fileEvent != null && fileEvent.CrossReferences.All(xref => xref.ShokoSeriesId.HasValue && xref.ShokoEpisodeId.HasValue)) + seriesIds = fileEvent.CrossReferences.Select(xref => xref.ShokoSeriesId!.Value.ToString()) + .Distinct() + .ToHashSet(); + else + seriesIds = (await ApiClient.GetFile(fileId.ToString())).CrossReferences + .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) + .Select(xref => xref.Series.Shoko!.Value.ToString()) + .Distinct() + .ToHashSet(); + + var filteredSeriesIds = new HashSet<string>(); + foreach (var seriesId in seriesIds) { + var seriesPathSet = await ApiManager.GetPathSetForSeries(seriesId); + if (seriesPathSet.Count > 0) { + filteredSeriesIds.Add(seriesId); + } + } + + // Return all series if we only have this file for all of them, + // otherwise return only the series were we have other files that are + // not linked to other series. + return filteredSeriesIds.Count == 0 ? seriesIds : filteredSeriesIds; + } + + private async Task<string?> GetNewRelativePath(MediaFolderConfiguration config, int fileId, string relativePath) + { + // Check if the file still exists, and if it has any other locations we can use. + try { + var file = await ApiClient.GetFile(fileId.ToString()); + var usableLocation = file.Locations + .Where(loc => loc.ImportFolderId == config.ImportFolderId && config.IsEnabledForPath(loc.RelativePath) && loc.RelativePath != relativePath) + .FirstOrDefault(); + return usableLocation?.RelativePath; + } + catch (ApiException ex) { + if (ex.StatusCode == System.Net.HttpStatusCode.NotFound) + return null; + throw; + } + } + + #endregion + + #region Refresh Events + + public void AddSeriesEvent(string metadataId, IMetadataUpdatedEventArgs eventArgs) + { + lock (ChangesPerSeries) { + if (ChangesPerSeries.TryGetValue(metadataId, out var tuple)) + tuple.LastUpdated = DateTime.Now; + else + ChangesPerSeries.Add(metadataId, tuple = (DateTime.Now, new())); + tuple.List.Add(eventArgs); + } + } + + private Task ProcessSeriesEvents(string metadataId, List<IMetadataUpdatedEventArgs> changes) + { + try { + Logger.LogInformation("Processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); + + // Refresh all episodes and movies linked to the episode. + + // look up the series/season/movie, then check the media folder they're + // in to check if the refresh event is enabled for the media folder, and + // only send out the events if it's enabled. + + // Refresh the show and all entries beneath it, or all movies linked to + // the show. + } + catch (Exception ex) { + Logger.LogError(ex, "Error processing {EventCount} metadata change events. (Metadata={ProviderUniqueId})", changes.Count, metadataId); + } + return Task.CompletedTask; + } + + #endregion } \ No newline at end of file diff --git a/Shokofin/Resolvers/ShokoWatcher.cs b/Shokofin/Resolvers/ShokoWatcher.cs new file mode 100644 index 00000000..56cc017c --- /dev/null +++ b/Shokofin/Resolvers/ShokoWatcher.cs @@ -0,0 +1,26 @@ + +using System; +using System.IO; +using MediaBrowser.Controller.Entities; +using Shokofin.Configuration; + +namespace Shokofin.Resolvers; + +public class ShokoWatcher +{ + public Folder MediaFolder; + + public MediaFolderConfiguration Configuration; + + public FileSystemWatcher Watcher; + + public IDisposable SubmitterLease; + + public ShokoWatcher(Folder mediaFolder, MediaFolderConfiguration configuration, FileSystemWatcher watcher, IDisposable lease) + { + MediaFolder = mediaFolder; + Configuration = configuration; + Watcher = watcher; + SubmitterLease = lease; + } +} diff --git a/Shokofin/SignalR/Interfaces/IFileEventArgs.cs b/Shokofin/SignalR/Interfaces/IFileEventArgs.cs index cb1e8c4d..96713be5 100644 --- a/Shokofin/SignalR/Interfaces/IFileEventArgs.cs +++ b/Shokofin/SignalR/Interfaces/IFileEventArgs.cs @@ -64,11 +64,5 @@ public class FileCrossReference /// </summary> [JsonPropertyName("SeriesID")] public int? ShokoSeriesId { get; set; } - - /// <summary> - /// Shoko group id. - /// </summary> - [JsonPropertyName("GroupID")] - public int? ShokoGroupId { get; set; } } } \ No newline at end of file diff --git a/Shokofin/SignalR/Models/UpdateReason.cs b/Shokofin/SignalR/Interfaces/UpdateReason.cs similarity index 82% rename from Shokofin/SignalR/Models/UpdateReason.cs rename to Shokofin/SignalR/Interfaces/UpdateReason.cs index a3f003e8..b9891dbc 100644 --- a/Shokofin/SignalR/Models/UpdateReason.cs +++ b/Shokofin/SignalR/Interfaces/UpdateReason.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; -namespace Shokofin.SignalR.Models; +namespace Shokofin.SignalR.Interfaces; [JsonConverter(typeof(JsonStringEnumConverter))] public enum UpdateReason diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 7f28111d..b26b471d 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -1,25 +1,16 @@ using System; -using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading.Tasks; -using System.Timers; using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Library; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Plugins; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Shokofin.API; using Shokofin.API.Models; using Shokofin.Configuration; -using Shokofin.ExternalIds; using Shokofin.Resolvers; using Shokofin.SignalR.Interfaces; using Shokofin.SignalR.Models; - -using File = System.IO.File; +using Shokofin.Utils; namespace Shokofin.SignalR; @@ -35,32 +26,18 @@ public class SignalRConnectionManager private const string HubUrl = "/signalr/aggregate?feeds=shoko"; - private static readonly TimeSpan DetectChangesThreshold = TimeSpan.FromSeconds(5); - private readonly ILogger<SignalRConnectionManager> Logger; - private readonly ShokoAPIClient ApiClient; - - private readonly ShokoAPIManager ApiManager; - private readonly ShokoResolveManager ResolveManager; - private readonly ILibraryManager LibraryManager; - - private readonly ILibraryMonitor LibraryMonitor; + private readonly LibraryScanWatcher LibraryScanWatcher; - private readonly IFileSystem FileSystem; + private IDisposable? EventSubmitterLease = null; private HubConnection? Connection = null; - private readonly Timer ChangesDetectionTimer; - private string CachedKey = string.Empty; - private readonly Dictionary<string, (DateTime LastUpdated, List<IMetadataUpdatedEventArgs> List)> ChangesPerSeries = new(); - - private readonly Dictionary<int, (DateTime LastUpdated, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> List)> ChangesPerFile = new(); - #pragma warning disable CA1822 public bool IsUsable => CanConnect(Plugin.Instance.Configuration); #pragma warning restore CA1822 @@ -69,17 +46,15 @@ public class SignalRConnectionManager public HubConnectionState State => Connection == null ? HubConnectionState.Disconnected : Connection.State; - public SignalRConnectionManager(ILogger<SignalRConnectionManager> logger, ShokoAPIClient apiClient, ShokoAPIManager apiManager, ShokoResolveManager resolveManager, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IFileSystem fileSystem) + public SignalRConnectionManager( + ILogger<SignalRConnectionManager> logger, + ShokoResolveManager resolveManager, + LibraryScanWatcher libraryScanWatcher + ) { Logger = logger; - ApiClient = apiClient; - ApiManager = apiManager; ResolveManager = resolveManager; - LibraryManager = libraryManager; - LibraryMonitor = libraryMonitor; - FileSystem = fileSystem; - ChangesDetectionTimer = new() { AutoReset = true, Interval = TimeSpan.FromSeconds(4).TotalMilliseconds }; - ChangesDetectionTimer.Elapsed += OnIntervalElapsed; + LibraryScanWatcher = libraryScanWatcher; } #region Connection @@ -120,7 +95,7 @@ private async Task ConnectAsync(PluginConfiguration config) connection.On<FileRenamedEventArgs>("ShokoEvent:FileRenamed", OnFileRelocated); } - ChangesDetectionTimer.Start(); + EventSubmitterLease = ResolveManager.RegisterEventSubmitter(); try { await connection.StartAsync().ConfigureAwait(false); @@ -167,11 +142,10 @@ public async Task DisconnectAsync() await connection.DisposeAsync(); - ChangesDetectionTimer.Stop(); - if (ChangesPerFile.Count > 0) - ClearFileEvents(); - if (ChangesPerSeries.Count > 0) - ClearAnimeEvents(); + if (EventSubmitterLease is not null) { + EventSubmitterLease.Dispose(); + EventSubmitterLease = null; + } } public Task ResetConnectionAsync() @@ -200,13 +174,10 @@ public async Task StopAsync() { Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; await DisconnectAsync(); - ChangesDetectionTimer.Elapsed -= OnIntervalElapsed; } - private void OnConfigurationChanged(object? sender, BasePluginConfiguration baseConfig) + private void OnConfigurationChanged(object? sender, PluginConfiguration config) { - if (baseConfig is not PluginConfiguration config) - return; var currentKey = ConstructKey(config); if (!string.Equals(currentKey, CachedKey)) { @@ -226,70 +197,6 @@ private static string ConstructKey(PluginConfiguration config) #region Events - #region Intervals - - private void OnIntervalElapsed(object? sender, ElapsedEventArgs eventArgs) - { - var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>)>(); - var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>)>(); - lock (ChangesPerFile) { - if (ChangesPerFile.Count > 0) { - var now = DateTime.Now; - foreach (var (fileId, (lastUpdated, list)) in ChangesPerFile) { - if (now - lastUpdated < DetectChangesThreshold) - continue; - filesToProcess.Add((fileId, list)); - } - foreach (var (fileId, _) in filesToProcess) - ChangesPerFile.Remove(fileId); - } - } - lock (ChangesPerSeries) { - if (ChangesPerSeries.Count > 0) { - var now = DateTime.Now; - foreach (var (metadataId, (lastUpdated, list)) in ChangesPerSeries) { - if (now - lastUpdated < DetectChangesThreshold) - continue; - seriesToProcess.Add((metadataId, list)); - } - foreach (var (metadataId, _) in seriesToProcess) - ChangesPerSeries.Remove(metadataId); - } - } - foreach (var (fileId, changes) in filesToProcess) - Task.Run(() => ProcessFileChanges(fileId, changes)); - foreach (var (metadataId, changes) in seriesToProcess) - Task.Run(() => ProcessSeriesChanges(metadataId, changes)); - } - - private void ClearFileEvents() - { - var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>)>(); - lock (ChangesPerFile) { - foreach (var (fileId, (lastUpdated, list)) in ChangesPerFile) { - filesToProcess.Add((fileId, list)); - } - ChangesPerFile.Clear(); - } - foreach (var (fileId, changes) in filesToProcess) - Task.Run(() => ProcessFileChanges(fileId, changes)); - } - - private void ClearAnimeEvents() - { - var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>)>(); - lock (ChangesPerSeries) { - foreach (var (metadataId, (lastUpdated, list)) in ChangesPerSeries) { - seriesToProcess.Add((metadataId, list)); - } - ChangesPerSeries.Clear(); - } - foreach (var (metadataId, changes) in seriesToProcess) - Task.Run(() => ProcessSeriesChanges(metadataId, changes)); - } - - #endregion - #region File Events private void OnFileMatched(IFileEventArgs eventArgs) @@ -303,7 +210,16 @@ private void OnFileMatched(IFileEventArgs eventArgs) eventArgs.HasCrossReferences ); - AddFileEvent(eventArgs.FileId, UpdateReason.Updated, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogTrace( + "Library scan is running. Skipping emit of file event. (File={FileId},Location={LocationId})", + eventArgs.FileId, + eventArgs.FileLocationId + ); + return; + } + + ResolveManager.AddFileEvent(eventArgs.FileId, UpdateReason.Updated, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); } private void OnFileRelocated(IFileRelocationEventArgs eventArgs) @@ -319,14 +235,23 @@ private void OnFileRelocated(IFileRelocationEventArgs eventArgs) eventArgs.HasCrossReferences ); - AddFileEvent(eventArgs.FileId, UpdateReason.Removed, eventArgs.PreviousImportFolderId, eventArgs.PreviousRelativePath, eventArgs); - AddFileEvent(eventArgs.FileId, UpdateReason.Added, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogTrace( + "Library scan is running. Skipping emit of file event. (File={FileId},Location={LocationId})", + eventArgs.FileId, + eventArgs.FileLocationId + ); + return; + } + + ResolveManager.AddFileEvent(eventArgs.FileId, UpdateReason.Removed, eventArgs.PreviousImportFolderId, eventArgs.PreviousRelativePath, eventArgs); + ResolveManager.AddFileEvent(eventArgs.FileId, UpdateReason.Added, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); } private void OnFileDeleted(IFileEventArgs eventArgs) { Logger.LogDebug( - "File deleted; {ImportFolderIdB} {PathB} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", + "File deleted; {ImportFolderId} {Path} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs.FileId, @@ -334,214 +259,16 @@ private void OnFileDeleted(IFileEventArgs eventArgs) eventArgs.HasCrossReferences ); - AddFileEvent(eventArgs.FileId, UpdateReason.Removed, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); - } - - private void AddFileEvent(int fileId, UpdateReason reason, int importFolderId, string filePath, IFileEventArgs eventArgs) - { - lock (ChangesPerFile) { - if (ChangesPerFile.TryGetValue(fileId, out var tuple)) - tuple.LastUpdated = DateTime.Now; - else - ChangesPerFile.Add(fileId, tuple = (DateTime.Now, new())); - tuple.List.Add((reason, importFolderId, filePath, eventArgs)); - } - } - - private async Task ProcessFileChanges(int fileId, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> changes) - { - try { - Logger.LogInformation("Processing {EventCount} file change events… (File={FileId})", changes.Count, fileId); - - // Something was added or updated. - var locationsToNotify = new List<string>(); - var seriesIds = await GetSeriesIdsForFile(fileId, changes.Select(t => t.Event).LastOrDefault(e => e.HasCrossReferences)); - var mediaFolders = ResolveManager.GetAvailableMediaFolders(fileEvents: true); - var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); - if (reason != UpdateReason.Removed) { - foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { - if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) - continue; - - var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); - if (!File.Exists(sourceLocation)) - continue; - - // Let the core logic handle the rest. - if (!config.IsVirtualFileSystemEnabled) { - locationsToNotify.Add(sourceLocation); - continue; - } - - var result = new LinkGenerationResult(); - var topFolders = new HashSet<string>(); - var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, sourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) - .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) - .ToList(); - foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { - result += ResolveManager.GenerateSymbolicLinks(srcLoc, 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); - } - - // Remove old links for file. - var videos = LibraryManager - .GetItemList( - new() { - AncestorIds = new[] { mediaFolder.Id }, - IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Movie }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, - DtoOptions = new(true), - }, - true - ) - .Where(item => !string.IsNullOrEmpty(item.Path) && item.Path.StartsWith(vfsPath) && !result.Paths.Contains(item.Path)) - .ToList(); - foreach (var video in videos) { - File.Delete(video.Path); - topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); - locationsToNotify.Add(video.Path); - result.RemovedVideos++; - } - - result.Print(Logger, mediaFolder.Path); - - // If all the "top-level-folders" exist, then let the core logic handle the rest. - if (topFolders.All(path => LibraryManager.FindByPath(path, true) != null)) { - var old = locationsToNotify.Count; - locationsToNotify.AddRange(vfsLocations.SelectMany(tuple => tuple.symbolicLinks)); - } - // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. - else { - var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); - if (!string.IsNullOrEmpty(fileOrFolder)) - locationsToNotify.Add(fileOrFolder); - } - } - } - // Something was removed, so assume the location is gone. - else if (changes.FirstOrDefault(t => t.Reason == UpdateReason.Removed).Event is IFileRelocationEventArgs firstRemovedEvent) { - relativePath = firstRemovedEvent.RelativePath; - importFolderId = firstRemovedEvent.ImportFolderId; - foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { - if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) - continue; - - - // Let the core logic handle the rest. - if (!config.IsVirtualFileSystemEnabled) { - var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); - locationsToNotify.Add(sourceLocation); - continue; - } - - // Check if we can use another location for the file. - var result = new LinkGenerationResult(); - var vfsSymbolicLinks = new HashSet<string>(); - var topFolders = new HashSet<string>(); - var newRelativePath = await GetNewRelativePath(config, fileId, relativePath); - if (!string.IsNullOrEmpty(newRelativePath)) { - var newSourceLocation = Path.Join(mediaFolder.Path, newRelativePath[config.ImportFolderRelativePath.Length..]); - var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, newSourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) - .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) - .ToList(); - foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { - result += ResolveManager.GenerateSymbolicLinks(srcLoc, 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); - } - vfsSymbolicLinks = vfsLocations.Select(tuple => tuple.sourceLocation).ToHashSet(); - } - - // Remove old links for file. - var videos = LibraryManager - .GetItemList( - new() { - AncestorIds = new[] { mediaFolder.Id }, - IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Movie }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, - DtoOptions = new(true), - }, - true - ) - .Where(item => !string.IsNullOrEmpty(item.Path) && item.Path.StartsWith(vfsPath) && !result.Paths.Contains(item.Path)) - .ToList(); - foreach (var video in videos) { - File.Delete(video.Path); - topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); - locationsToNotify.Add(video.Path); - result.RemovedVideos++; - } - - result.Print(Logger, mediaFolder.Path); - - // If all the "top-level-folders" exist, then let the core logic handle the rest. - if (topFolders.All(path => LibraryManager.FindByPath(path, true) != null)) { - var old = locationsToNotify.Count; - locationsToNotify.AddRange(vfsSymbolicLinks); - } - // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. - else { - var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); - if (!string.IsNullOrEmpty(fileOrFolder)) - locationsToNotify.Add(fileOrFolder); - } - } - } - - // We let jellyfin take it from here. - Logger.LogDebug("Notifying Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count, fileId.ToString()); - foreach (var location in locationsToNotify) - LibraryMonitor.ReportFileSystemChanged(location); - } - catch (Exception ex) { - Logger.LogError(ex, "Error processing {EventCount} file change events. (File={FileId})", changes.Count, fileId); - } - } - - private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEventArgs? fileEvent) - { - HashSet<string> seriesIds; - if (fileEvent != null && fileEvent.CrossReferences.All(xref => xref.ShokoSeriesId.HasValue && xref.ShokoEpisodeId.HasValue)) - seriesIds = fileEvent.CrossReferences.Select(xref => xref.ShokoSeriesId!.Value.ToString()) - .Distinct() - .ToHashSet(); - else - seriesIds = (await ApiClient.GetFile(fileId.ToString())).CrossReferences - .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) - .Select(xref => xref.Series.Shoko!.Value.ToString()) - .Distinct() - .ToHashSet(); - - var filteredSeriesIds = new HashSet<string>(); - foreach (var seriesId in seriesIds) { - var seriesPathSet = await ApiManager.GetPathSetForSeries(seriesId); - if (seriesPathSet.Count > 0) { - filteredSeriesIds.Add(seriesId); - } + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogTrace( + "Library scan is running. Skipping emit of file event. (File={FileId},Location={LocationId})", + eventArgs.FileId, + eventArgs.FileLocationId + ); + return; } - // Return all series if we only have this file for all of them, - // otherwise return only the series were we have other files that are - // not linked to other series. - return filteredSeriesIds.Count == 0 ? seriesIds : filteredSeriesIds; - } - - private async Task<string?> GetNewRelativePath(MediaFolderConfiguration config, int fileId, string relativePath) - { - // Check if the file still exists, and if it has any other locations we can use. - try { - var file = await ApiClient.GetFile(fileId.ToString()); - var usableLocation = file.Locations - .Where(loc => loc.ImportFolderId == config.ImportFolderId && config.IsEnabledForPath(loc.RelativePath) && loc.RelativePath != relativePath) - .FirstOrDefault(); - return usableLocation?.RelativePath; - } - catch (ApiException ex) { - if (ex.StatusCode == System.Net.HttpStatusCode.NotFound) - return null; - throw; - } + ResolveManager.AddFileEvent(eventArgs.FileId, UpdateReason.Removed, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); } #endregion @@ -562,39 +289,18 @@ private void OnInfoUpdated(IMetadataUpdatedEventArgs eventArgs) eventArgs.GroupIds ); - if (eventArgs.Type is BaseItemKind.Episode or BaseItemKind.Series) - AddSeriesEvent(eventArgs.ProviderParentUId ?? eventArgs.ProviderUId, eventArgs); - } - - private void AddSeriesEvent(string metadataId, IMetadataUpdatedEventArgs eventArgs) - { - lock (ChangesPerSeries) { - if (ChangesPerSeries.TryGetValue(metadataId, out var tuple)) - tuple.LastUpdated = DateTime.Now; - else - ChangesPerSeries.Add(metadataId, tuple = (DateTime.Now, new())); - tuple.List.Add(eventArgs); + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogTrace( + "Library scan is running. Skipping emit of refresh event. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", + eventArgs.EpisodeIds, + eventArgs.SeriesIds, + eventArgs.GroupIds + ); + return; } - } - private Task ProcessSeriesChanges(string metadataId, List<IMetadataUpdatedEventArgs> changes) - { - try { - Logger.LogInformation("Processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); - - // Refresh all episodes and movies linked to the episode. - - // look up the series/season/movie, then check the media folder they're - // in to check if the refresh event is enabled for the media folder, and - // only send out the events if it's enabled. - - // Refresh the show and all entries beneath it, or all movies linked to - // the show. - } - catch (Exception ex) { - Logger.LogError(ex, "Error processing {EventCount} metadata change events. (Metadata={ProviderUniqueId})", changes.Count, metadataId); - } - return Task.CompletedTask; + if (eventArgs.Type is BaseItemKind.Episode or BaseItemKind.Series) + ResolveManager.AddSeriesEvent(eventArgs.ProviderParentUId ?? eventArgs.ProviderUId, eventArgs); } #endregion diff --git a/Shokofin/SignalR/Stub/FileEventArgsStub.cs b/Shokofin/SignalR/Stub/FileEventArgsStub.cs new file mode 100644 index 00000000..63d8e2f4 --- /dev/null +++ b/Shokofin/SignalR/Stub/FileEventArgsStub.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Linq; +using Shokofin.SignalR.Interfaces; + +using File = Shokofin.API.Models.File; + +namespace Shokofin.SignalR.Models; + +public class FileEventArgsStub : IFileEventArgs +{ + /// <inheritdoc/> + public int FileId { get; private init; } + + /// <inheritdoc/> + public int? FileLocationId { get; private init; } + + /// <inheritdoc/> + public int ImportFolderId { get; private init; } + + /// <inheritdoc/> + public string RelativePath { get; private init; } + + /// <inheritdoc/> + public bool HasCrossReferences => true; + + /// <inheritdoc/> + public List<IFileEventArgs.FileCrossReference> CrossReferences { get; private init; } + + public FileEventArgsStub(File.Location location, File file) + { + FileId = file.Id; + ImportFolderId = location.ImportFolderId; + RelativePath = location.RelativePath + .Replace('/', System.IO.Path.DirectorySeparatorChar) + .Replace('\\', System.IO.Path.DirectorySeparatorChar); + if (RelativePath[0] != System.IO.Path.DirectorySeparatorChar) + RelativePath = System.IO.Path.DirectorySeparatorChar + RelativePath; + FileLocationId = location.Id; + CrossReferences = file.CrossReferences + .SelectMany(xref => xref.Episodes.Select(episodeXref => new IFileEventArgs.FileCrossReference() { + AnidbEpisodeId = episodeXref.AniDB, + AnidbAnimeId = xref.Series.AniDB, + ShokoEpisodeId = episodeXref.Shoko, + ShokoSeriesId = xref.Series.Shoko, + })) + .ToList(); + } +} diff --git a/Shokofin/Tasks/AutoClearPluginCacheTask.cs b/Shokofin/Tasks/AutoClearPluginCacheTask.cs index 03c6f555..c697434a 100644 --- a/Shokofin/Tasks/AutoClearPluginCacheTask.cs +++ b/Shokofin/Tasks/AutoClearPluginCacheTask.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Resolvers; +using Shokofin.Utils; namespace Shokofin.Tasks; @@ -42,16 +43,25 @@ public class AutoClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTa private readonly ShokoAPIClient ApiClient; private readonly ShokoResolveManager ResolveManager; + + private readonly LibraryScanWatcher LibraryScanWatcher; /// <summary> /// Initializes a new instance of the <see cref="AutoClearPluginCacheTask" /> class. /// </summary> - public AutoClearPluginCacheTask(ILogger<AutoClearPluginCacheTask> logger, ShokoAPIManager apiManager, ShokoAPIClient apiClient, ShokoResolveManager resolveManager) + public AutoClearPluginCacheTask( + ILogger<AutoClearPluginCacheTask> logger, + ShokoAPIManager apiManager, + ShokoAPIClient apiClient, + ShokoResolveManager resolveManager, + LibraryScanWatcher libraryScanWatcher + ) { Logger = logger; ApiManager = apiManager; ApiClient = apiClient; ResolveManager = resolveManager; + LibraryScanWatcher = libraryScanWatcher; } /// <summary> @@ -73,6 +83,9 @@ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() /// <returns>Task.</returns> public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { + if (LibraryScanWatcher.IsScanRunning) + return Task.CompletedTask; + if (ApiClient.IsCacheStalled || ApiManager.IsCacheStalled || ResolveManager.IsCacheStalled) Logger.LogInformation("Automagically clearing cache…"); if (ApiClient.IsCacheStalled) diff --git a/Shokofin/Utils/DisposableAction.cs b/Shokofin/Utils/DisposableAction.cs new file mode 100644 index 00000000..7a1fecbd --- /dev/null +++ b/Shokofin/Utils/DisposableAction.cs @@ -0,0 +1,17 @@ + +using System; + +namespace Shokofin.Utils; + +public class DisposableAction : IDisposable +{ + private readonly Action DisposeAction; + + public DisposableAction(Action disposeAction) + { + DisposeAction = disposeAction; + } + + public void Dispose() + => DisposeAction(); +} \ No newline at end of file diff --git a/Shokofin/Utils/LibraryScanWatcher.cs b/Shokofin/Utils/LibraryScanWatcher.cs new file mode 100644 index 00000000..9c1f255e --- /dev/null +++ b/Shokofin/Utils/LibraryScanWatcher.cs @@ -0,0 +1,32 @@ +using System; +using MediaBrowser.Controller.Library; + +namespace Shokofin.Utils; + +public class LibraryScanWatcher +{ + private readonly ILibraryManager LibraryManager; + + private readonly PropertyWatcher<bool> Watcher; + + public bool IsScanRunning => Watcher.Value; + + public event EventHandler<bool>? ValueChanged; + + public LibraryScanWatcher(ILibraryManager libraryManager) + { + LibraryManager = libraryManager; + Watcher = new(() => LibraryManager.IsScanRunning); + Watcher.StartMonitoring(Plugin.Instance.Configuration.LibraryScanReactionTimeInSeconds); + Watcher.ValueChanged += OnLibraryScanRunningChanged; + } + + ~LibraryScanWatcher() + { + Watcher.StopMonitoring(); + Watcher.ValueChanged -= OnLibraryScanRunningChanged; + } + + private void OnLibraryScanRunningChanged(object? sender, bool isScanRunning) + => ValueChanged?.Invoke(sender, isScanRunning); +} \ No newline at end of file diff --git a/Shokofin/Utils/PropertyWatcher.cs b/Shokofin/Utils/PropertyWatcher.cs index 733c3698..019580bb 100644 --- a/Shokofin/Utils/PropertyWatcher.cs +++ b/Shokofin/Utils/PropertyWatcher.cs @@ -9,20 +9,21 @@ public class PropertyWatcher<T> private bool _continueMonitoring; - public T LastKnownValue { get; private set; } + public T Value { get; private set; } - public event EventHandler<T> OnValueChanged; + public event EventHandler<T>? ValueChanged; public PropertyWatcher(Func<T> valueGetter) { _valueGetter = valueGetter; - LastKnownValue = _valueGetter(); + Value = _valueGetter(); } - public void StartMonitoring(int delayInMilliseconds) + public void StartMonitoring(int delayInSeconds) { + var delayInMilliseconds = delayInSeconds * 1000; _continueMonitoring = true; - LastKnownValue = _valueGetter(); + Value = _valueGetter(); Task.Run(async () => { while (_continueMonitoring) { await Task.Delay(delayInMilliseconds); @@ -39,9 +40,9 @@ public void StopMonitoring() private void CheckForChange() { var currentValue = _valueGetter()!; - if (!LastKnownValue!.Equals(currentValue)) { - OnValueChanged?.Invoke(null, currentValue); - LastKnownValue = currentValue; + if (!Value!.Equals(currentValue)) { + ValueChanged?.Invoke(null, currentValue); + Value = currentValue; } } } From ab02a5162f548ee31fe28b1fa8dcbd0d3e350c74 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 17 May 2024 19:55:07 +0200 Subject: [PATCH 0973/1103] misc: update build config --- .github/workflows/release-daily.yml | 2 +- .github/workflows/release.yml | 22 ++++++----- .gitignore | 2 + .vscode/settings.json | 1 + build_plugin.py | 61 +++++++++++++++++++---------- 5 files changed, 57 insertions(+), 31 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 55dca01d..1c336dd4 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -99,7 +99,7 @@ jobs: - name: Run JPRM env: CHANGELOG: ${{ needs.current_info.outputs.changelog }} - run: python build_plugin.py --version=${{ needs.current_info.outputs.version }} --prerelease=True + run: python build_plugin.py --repo ${{ github.repository }} --version=${{ needs.current_info.outputs.version }} --prerelease=True - name: Create Pre-Release uses: softprops/action-gh-release@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57288091..9b5925fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,22 +7,24 @@ on: branches: master jobs: - build: + build_plugin: runs-on: ubuntu-latest - name: Build & Release + name: Build Release steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@master with: ref: ${{ github.ref }} - fetch-depth: 0 + fetch-depth: 0 # This is set to download the full git history for the repo - - name: Get release version - id: currenttag - uses: "WyriHaximus/github-action-get-previous-tag@v1" + - name: Get Current Version + id: release_info + uses: revam/gh-action-get-tag-and-version@v1 with: - fallback: 1.0.0 + branch: true + prefix: v + prefixRegex: "[vV]?" - name: Setup .Net uses: actions/setup-dotnet@v1 @@ -41,7 +43,9 @@ jobs: run: python -m pip install jprm - name: Run JPRM - run: python build_plugin.py --version=${{ steps.currenttag.outputs.tag }} + env: + CHANGELOG: "" # Add the release's change-log here maybe. + run: python build_plugin.py --repo ${{ github.repository }} --version=${{ steps.current_info.outputs.version }} - name: Update Release uses: svenstaro/upload-release-action@v2 diff --git a/.gitignore b/.gitignore index e8f10dc5..f95a8599 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ Thumbs.db Desktop.ini .DS_Store /.idea/ +/.venv +artifacts diff --git a/.vscode/settings.json b/.vscode/settings.json index f708a64a..98b82995 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,6 +19,7 @@ "imdbid", "interrobang", "jellyfin", + "jprm", "koma", "linkbutton", "manhua", diff --git a/build_plugin.py b/build_plugin.py index e4f3a5f3..e85b883e 100644 --- a/build_plugin.py +++ b/build_plugin.py @@ -2,53 +2,72 @@ import json import yaml import argparse +import re + +def extract_target_framework(csproj_path): + with open(csproj_path, "r") as file: + content = file.read() + target_framework_match = re.compile(r"<TargetFramework>(.*?)<\/TargetFramework>", re.IGNORECASE).search(content) + target_frameworks_match = re.compile(r"<TargetFrameworks>(.*?)<\/TargetFrameworks>", re.IGNORECASE).search(content) + if target_framework_match: + return target_framework_match.group(1) + elif target_frameworks_match: + return target_frameworks_match.group(1).split(";")[0] # Return the first framework + else: + return None parser = argparse.ArgumentParser() -parser.add_argument('--version', required=True) -parser.add_argument('--prerelease') +parser.add_argument("--repo", required=True) +parser.add_argument("--version", required=True) +parser.add_argument("--prerelease", default=True) opts = parser.parse_args() +framework = extract_target_framework("./Shokofin/Shokofin.csproj") version = opts.version prerelease = bool(opts.prerelease) -artifact_dir = os.path.join(os.getcwd(), 'artifacts') -os.mkdir(artifact_dir) +artifact_dir = os.path.join(os.getcwd(), "artifacts") +if not os.path.exists(artifact_dir): + os.mkdir(artifact_dir) if prerelease: - jellyfin_repo_file="./manifest-unstable.json" + jellyfin_repo_file="./manifest-unstable.json" else: - jellyfin_repo_file="./manifest.json" + jellyfin_repo_file="./manifest.json" -jellyfin_repo_url="https://github.com/ShokoAnime/Shokofin/releases/download" +jellyfin_repo_url=f"https://github.com/{opts.repo}/releases/download" # Add changelog to the build yaml before we generate the release. -build_file = './build.yaml' +build_file = "./build.yaml" -with open(build_file, 'r') as file: +with open(build_file, "r") as file: data = yaml.safe_load(file) if "changelog" in data: - data["changelog"] = os.environ["CHANGELOG"].strip() + if "CHANGELOG" in os.environ: + data["changelog"] = os.environ["CHANGELOG"].strip() + else: + data["changelog"] = "" -with open(build_file, 'w') as file: +with open(build_file, "w") as file: yaml.dump(data, file, sort_keys=False) -zipfile=os.popen('jprm --verbosity=debug plugin build "." --output="%s" --version="%s" --dotnet-framework="net6.0"' % (artifact_dir, version)).read().strip() +zipfile=os.popen("jprm --verbosity=debug plugin build \".\" --output=\"%s\" --version=\"%s\" --dotnet-framework=\"%s\"" % (artifact_dir, version, framework)).read().strip() -jellyfin_plugin_release_url=f'{jellyfin_repo_url}/{version}/shoko_{version}.zip' +jellyfin_plugin_release_url=f"{jellyfin_repo_url}/{version}/shoko_{version}.zip" -os.system('jprm repo add --plugin-url=%s %s %s' % (jellyfin_plugin_release_url, jellyfin_repo_file, zipfile)) +os.system("jprm repo add --plugin-url=%s %s %s" % (jellyfin_plugin_release_url, jellyfin_repo_file, zipfile)) # Compact the unstable manifest after building, so it only contains the last 5 versions. if prerelease: - with open(jellyfin_repo_file, 'r') as file: - data = json.load(file) + with open(jellyfin_repo_file, "r") as file: + data = json.load(file) - for item in data: - if 'versions' in item and len(item['versions']) > 5: - item['versions'] = item['versions'][:5] + for item in data: + if "versions" in item and len(item["versions"]) > 5: + item["versions"] = item["versions"][:5] - with open(jellyfin_repo_file, 'w') as file: - json.dump(data, file, indent=4) + with open(jellyfin_repo_file, "w") as file: + json.dump(data, file, indent=4) print(version) From 29d70aeb82a3864fa7c910ed3ffe2dcc72b82a1d Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 17 May 2024 17:56:29 +0000 Subject: [PATCH 0974/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 090cb28f..df4b5219 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.168", + "changelog": "misc: update build config\n\nfeat: add real time monitoring support for the VFS\n\n- Added a library scan watcher, responsible for tracking when a library scan starts and ends.\n\n- Fixed it so the signalr events won't be emitted (but still logged) if a library scan is running, preventing the events from interfering with the in-progress library scan. This is how jellyfin core already handles the file events during a scan, and will help prevent double updating the entries during a scan.\n\n- Refactored it so the resolve manager is responsible for processing file events, so it can consume events from both the signalr event events in addition to the the new file events from the newly added library monitor.\n\n- Added an _experimental_ library monitor responsible for emitting file events for the VFS. This should make it so real time monitoring will work with the VFS again, but the implementation haven't been thoroughly tested so there may still be bugs (or not).\n\n- Fixed it so the auto clear cache will **never** run during a library scan. It may still run if the caches idles for too long during a refresh of the library outside the library scan though.\n\nrefactor: don't cache media folder search\nanymore, since it's probably not needed anymore.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.168/shoko_3.0.1.168.zip", + "checksum": "5ef4a5c6b976291fd8297e11d4df34d5", + "timestamp": "2024-05-17T17:56:27Z" + }, { "version": "3.0.1.167", "changelog": "misc: cleanup text utility", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.164/shoko_3.0.1.164.zip", "checksum": "c7fd8fb2da6ebd71f7becc834b728a19", "timestamp": "2024-05-12T22:21:40Z" - }, - { - "version": "3.0.1.163", - "changelog": "misc: add property watcher\n\nfeat: add file part support for daily server\nwithout breaking stable server support", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.163/shoko_3.0.1.163.zip", - "checksum": "0a023e0b4940f387b8621f70812014e8", - "timestamp": "2024-05-12T22:01:53Z" } ] } From 8a85de9296e088406379788c7a8933cdc80ca18e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 18 May 2024 00:44:13 +0200 Subject: [PATCH 0975/1103] misc: update build config (take 2) [skip ci] - Generalize the build script so it could be used for _any_ plugin. --- build_plugin.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/build_plugin.py b/build_plugin.py index e85b883e..b7715449 100644 --- a/build_plugin.py +++ b/build_plugin.py @@ -4,6 +4,19 @@ import argparse import re +def find_csproj_file(): + root_dir = os.getcwd() + for file_name in os.listdir(root_dir): + if file_name.endswith('.csproj'): + return os.path.join(root_dir, file_name) + for subdir_name in os.listdir(root_dir): + subdir_path = os.path.join(root_dir, subdir_name) + if os.path.isdir(subdir_path) and not subdir_name.startswith('.'): + for file_name in os.listdir(subdir_path): + if file_name.endswith('.csproj'): + return os.path.join(subdir_path, file_name) + return None + def extract_target_framework(csproj_path): with open(csproj_path, "r") as file: content = file.read() @@ -12,7 +25,7 @@ def extract_target_framework(csproj_path): if target_framework_match: return target_framework_match.group(1) elif target_frameworks_match: - return target_frameworks_match.group(1).split(";")[0] # Return the first framework + return target_frameworks_match.group(1).split(";")[0] else: return None @@ -22,7 +35,7 @@ def extract_target_framework(csproj_path): parser.add_argument("--prerelease", default=True) opts = parser.parse_args() -framework = extract_target_framework("./Shokofin/Shokofin.csproj") +framework = extract_target_framework(find_csproj_file()) version = opts.version prerelease = bool(opts.prerelease) @@ -54,7 +67,7 @@ def extract_target_framework(csproj_path): zipfile=os.popen("jprm --verbosity=debug plugin build \".\" --output=\"%s\" --version=\"%s\" --dotnet-framework=\"%s\"" % (artifact_dir, version, framework)).read().strip() -jellyfin_plugin_release_url=f"{jellyfin_repo_url}/{version}/shoko_{version}.zip" +jellyfin_plugin_release_url=f"{jellyfin_repo_url}/{version}/{data["name"].lower()}_{version}.zip" os.system("jprm repo add --plugin-url=%s %s %s" % (jellyfin_plugin_release_url, jellyfin_repo_file, zipfile)) From 03b2e9d3859b80b2040e8699ae7784d89b0cd338 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 18 May 2024 05:46:23 +0200 Subject: [PATCH 0976/1103] fix: another movie exception - Also switch the type from movie to web if we're hidden the main movies, but the parts are normal episodes. --- Shokofin/API/Info/SeasonInfo.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 3f6d827b..f8ca849f 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -185,6 +185,10 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp index++; } } + // Also switch the type from movie to web if we're hidden the main movies, but the parts are normal episodes. + else if (type == SeriesType.Movie && episodesList.Any(episodeInfo => string.Equals(episodeInfo.AniDB.Titles.FirstOrDefault(title => title.LanguageCode == "en")?.Value, "The Complete Movie", StringComparison.InvariantCultureIgnoreCase) && episodeInfo.Shoko.IsHidden)) { + type = SeriesType.Web; + } if (Plugin.Instance.Configuration.MovieSpecialsAsExtraFeaturettes && type == SeriesType.Movie) { if (specialsList.Count > 0) { From c4c01ebf4974835dee484b024dc240556b290ec9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 18 May 2024 05:53:40 +0200 Subject: [PATCH 0977/1103] revert: "misc: update build config (take 2)" This reverts commit 8a85de9296e088406379788c7a8933cdc80ca18e. --- build_plugin.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/build_plugin.py b/build_plugin.py index b7715449..e85b883e 100644 --- a/build_plugin.py +++ b/build_plugin.py @@ -4,19 +4,6 @@ import argparse import re -def find_csproj_file(): - root_dir = os.getcwd() - for file_name in os.listdir(root_dir): - if file_name.endswith('.csproj'): - return os.path.join(root_dir, file_name) - for subdir_name in os.listdir(root_dir): - subdir_path = os.path.join(root_dir, subdir_name) - if os.path.isdir(subdir_path) and not subdir_name.startswith('.'): - for file_name in os.listdir(subdir_path): - if file_name.endswith('.csproj'): - return os.path.join(subdir_path, file_name) - return None - def extract_target_framework(csproj_path): with open(csproj_path, "r") as file: content = file.read() @@ -25,7 +12,7 @@ def extract_target_framework(csproj_path): if target_framework_match: return target_framework_match.group(1) elif target_frameworks_match: - return target_frameworks_match.group(1).split(";")[0] + return target_frameworks_match.group(1).split(";")[0] # Return the first framework else: return None @@ -35,7 +22,7 @@ def extract_target_framework(csproj_path): parser.add_argument("--prerelease", default=True) opts = parser.parse_args() -framework = extract_target_framework(find_csproj_file()) +framework = extract_target_framework("./Shokofin/Shokofin.csproj") version = opts.version prerelease = bool(opts.prerelease) @@ -67,7 +54,7 @@ def extract_target_framework(csproj_path): zipfile=os.popen("jprm --verbosity=debug plugin build \".\" --output=\"%s\" --version=\"%s\" --dotnet-framework=\"%s\"" % (artifact_dir, version, framework)).read().strip() -jellyfin_plugin_release_url=f"{jellyfin_repo_url}/{version}/{data["name"].lower()}_{version}.zip" +jellyfin_plugin_release_url=f"{jellyfin_repo_url}/{version}/shoko_{version}.zip" os.system("jprm repo add --plugin-url=%s %s %s" % (jellyfin_plugin_release_url, jellyfin_repo_file, zipfile)) From 82693f6d58acdaa58c2f37e5cdde982956058eef Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 18 May 2024 03:54:56 +0000 Subject: [PATCH 0978/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index df4b5219..4083677d 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.169", + "changelog": "revert: \"misc: update build config (take 2)\"\n\nThis reverts commit 8a85de9296e088406379788c7a8933cdc80ca18e.\n\nfix: another movie exception\n\n- Also switch the type from movie to web if we're hidden the main movies, but the parts are normal episodes.\n\nmisc: update build config (take 2) [skip ci]\n\n- Generalize the build script so it could be used for _any_ plugin.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.169/shoko_3.0.1.169.zip", + "checksum": "d776e72a9db0f8ed36ae5256970a4a94", + "timestamp": "2024-05-18T03:54:55Z" + }, { "version": "3.0.1.168", "changelog": "misc: update build config\n\nfeat: add real time monitoring support for the VFS\n\n- Added a library scan watcher, responsible for tracking when a library scan starts and ends.\n\n- Fixed it so the signalr events won't be emitted (but still logged) if a library scan is running, preventing the events from interfering with the in-progress library scan. This is how jellyfin core already handles the file events during a scan, and will help prevent double updating the entries during a scan.\n\n- Refactored it so the resolve manager is responsible for processing file events, so it can consume events from both the signalr event events in addition to the the new file events from the newly added library monitor.\n\n- Added an _experimental_ library monitor responsible for emitting file events for the VFS. This should make it so real time monitoring will work with the VFS again, but the implementation haven't been thoroughly tested so there may still be bugs (or not).\n\n- Fixed it so the auto clear cache will **never** run during a library scan. It may still run if the caches idles for too long during a refresh of the library outside the library scan though.\n\nrefactor: don't cache media folder search\nanymore, since it's probably not needed anymore.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.165/shoko_3.0.1.165.zip", "checksum": "d6426eda03ec5ee698938925f1ea7e2f", "timestamp": "2024-05-12T22:48:04Z" - }, - { - "version": "3.0.1.164", - "changelog": "fix: fix episode group ordering for files", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.164/shoko_3.0.1.164.zip", - "checksum": "c7fd8fb2da6ebd71f7becc834b728a19", - "timestamp": "2024-05-12T22:21:40Z" } ] } From 4d6d8f256227dc60eee87153d14190ebaae1d6cd Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 18 May 2024 06:20:50 +0200 Subject: [PATCH 0979/1103] feat: react to series/episode events - Hooked up series/episode events to refresh shows/seasons/episodes/movies. This is untested and good night. --- Shokofin/Resolvers/ShokoResolveManager.cs | 182 +++++++++++++++++- .../Interfaces/IMetadataUpdatedEventArgs.cs | 18 +- .../Models/EpisodeInfoUpdatedEventArgs.cs | 2 +- .../Models/SeriesInfoUpdatedEventArgs.cs | 2 +- Shokofin/SignalR/SignalRConnectionManager.cs | 4 +- 5 files changed, 192 insertions(+), 16 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 13d0fdba..901754c0 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -18,6 +18,7 @@ using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using Shokofin.API; +using Shokofin.API.Info; using Shokofin.API.Models; using Shokofin.Configuration; using Shokofin.ExternalIds; @@ -25,6 +26,9 @@ using Shokofin.Utils; using File = System.IO.File; +using IDirectoryService = MediaBrowser.Controller.Providers.IDirectoryService; +using ImageType = MediaBrowser.Model.Entities.ImageType; +using MetadataRefreshMode = MediaBrowser.Controller.Providers.MetadataRefreshMode; using Timer = System.Timers.Timer; using TvSeries = MediaBrowser.Controller.Entities.TV.Series; @@ -44,6 +48,8 @@ public class ShokoResolveManager private readonly IFileSystem FileSystem; + private readonly IDirectoryService DirectoryService; + private readonly ILogger<ShokoResolveManager> Logger; private readonly NamingOptions NamingOptions; @@ -97,6 +103,7 @@ public ShokoResolveManager( ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IFileSystem fileSystem, + IDirectoryService directoryService, ILogger<ShokoResolveManager> logger, ILocalizationManager localizationManager, NamingOptions namingOptions @@ -108,6 +115,7 @@ NamingOptions namingOptions LibraryManager = libraryManager; LibraryMonitor = libraryMonitor; FileSystem = fileSystem; + DirectoryService = directoryService; Logger = logger; DataCache = new(logger, TimeSpan.FromMinutes(15), new() { ExpirationScanFrequency = TimeSpan.FromMinutes(25) }, new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1), SlidingExpiration = TimeSpan.FromMinutes(15) }); NamingOptions = namingOptions; @@ -1537,7 +1545,7 @@ private void OnIntervalElapsed(object? sender, ElapsedEventArgs eventArgs) foreach (var (fileId, changes) in filesToProcess) Task.Run(() => ProcessFileEvents(fileId, changes)); foreach (var (metadataId, changes) in seriesToProcess) - Task.Run(() => ProcessSeriesEvents(metadataId, changes)); + Task.Run(() => ProcessMetadataEvents(metadataId, changes)); } private void ClearFileEvents() @@ -1563,7 +1571,7 @@ private void ClearMetadataUpdatedEvents() ChangesPerSeries.Clear(); } foreach (var (metadataId, changes) in seriesToProcess) - Task.Run(() => ProcessSeriesEvents(metadataId, changes)); + Task.Run(() => ProcessMetadataEvents(metadataId, changes)); } #endregion @@ -1746,6 +1754,8 @@ private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEv .Distinct() .ToHashSet(); + // TODO: Postpone the processing of the file if the episode or series is not available yet. + var filteredSeriesIds = new HashSet<string>(); foreach (var seriesId in seriesIds) { var seriesPathSet = await ApiManager.GetPathSetForSeries(seriesId); @@ -1792,24 +1802,176 @@ public void AddSeriesEvent(string metadataId, IMetadataUpdatedEventArgs eventArg } } - private Task ProcessSeriesEvents(string metadataId, List<IMetadataUpdatedEventArgs> changes) + private async Task ProcessMetadataEvents(string metadataId, List<IMetadataUpdatedEventArgs> changes) { try { + if (!changes.Any(e => e.Kind == BaseItemKind.Episode && e.EpisodeId.HasValue || e.Kind == BaseItemKind.Series && e.SeriesId.HasValue)) { + Logger.LogDebug("Skipped processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); + return; + } + Logger.LogInformation("Processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); - // Refresh all episodes and movies linked to the episode. + // Process series events first, so we have the "season" data for the movies already cached. + var seriesId = changes.First(e => e.SeriesId.HasValue).SeriesId!.Value.ToString(); + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo is null) { + Logger.LogDebug("Unable to find show info for series id. (Series={SeriesId},Metadata={ProviderUniqueId})", seriesId, metadataId); + return; + } - // look up the series/season/movie, then check the media folder they're - // in to check if the refresh event is enabled for the media folder, and - // only send out the events if it's enabled. + var seasonInfo = await ApiManager.GetSeasonInfoForSeries(seriesId); + if (seasonInfo is null) { + Logger.LogDebug("Unable to find season info for series id. (Series={SeriesId},Metadata={ProviderUniqueId})", seriesId, metadataId); + return; + } + + await ProcessSeriesEvents(showInfo, changes); - // Refresh the show and all entries beneath it, or all movies linked to - // the show. + await ProcessMovieEvents(seasonInfo, changes); } catch (Exception ex) { Logger.LogError(ex, "Error processing {EventCount} metadata change events. (Metadata={ProviderUniqueId})", changes.Count, metadataId); } - return Task.CompletedTask; + } + + private async Task ProcessSeriesEvents(ShowInfo showInfo, List<IMetadataUpdatedEventArgs> changes) + { + // Update the series if we got a series event _or_ an episode removed event. + var animeEvent = changes.Find(e => e.Kind == BaseItemKind.Series || e.Kind == BaseItemKind.Episode && e.Reason == UpdateReason.Removed); + if (animeEvent is not null) { + var shows = LibraryManager + .GetItemList( + new() { + IncludeItemTypes = new[] { BaseItemKind.Series }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, showInfo.Id } }, + DtoOptions = new(true), + }, + true + ) + .ToList(); + foreach (var show in shows) { + Logger.LogInformation("Refreshing show {ShowName}. (Show={ShowId},Series={SeriesId})", show.Name, show.Id, showInfo.Id); + await show.RefreshMetadata(new(DirectoryService) { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = true, + RemoveOldMetadata = true, + ReplaceImages = Enum.GetValues<ImageType>().ToArray(), + IsAutomated = true, + EnableRemoteContentProbe = true, + }, CancellationToken.None); + } + } + // Otherwise update all season/episodes where appropriate. + else { + var episodeIds = changes + .Where(e => e.EpisodeId.HasValue && e.Reason != UpdateReason.Removed) + .Select(e => e.EpisodeId!.Value.ToString()) + .ToHashSet(); + var seasonIds = changes + .Where(e => e.EpisodeId.HasValue && e.SeriesId.HasValue && e.Reason == UpdateReason.Removed) + .Select(e => e.SeriesId!.Value.ToString()) + .ToHashSet(); + var seasonList = showInfo.SeasonList + .Where(seasonInfo => seasonIds.Contains(seasonInfo.Id)) + .ToList(); + foreach (var seasonInfo in seasonList) { + var seasons = LibraryManager + .GetItemList( + new() { + IncludeItemTypes = new[] { BaseItemKind.Season }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, seasonInfo.Id } }, + DtoOptions = new(true), + }, + true + ) + .ToList(); + foreach (var season in seasons) { + Logger.LogInformation("Refreshing season {SeasonName}. (Season={SeasonId},Series={SeriesId})", season.Name, season.Id, seasonInfo.Id); + await season.RefreshMetadata(new(DirectoryService) { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = true, + RemoveOldMetadata = true, + ReplaceImages = Enum.GetValues<ImageType>().ToArray(), + IsAutomated = true, + EnableRemoteContentProbe = true, + }, CancellationToken.None); + } + } + var episodeList = showInfo.SeasonList + .Except(seasonList) + .SelectMany(seasonInfo => seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList).Concat(seasonInfo.SpecialsList)) + .Where(episodeInfo => episodeIds.Contains(episodeInfo.Id)) + .ToList(); + foreach (var episodeInfo in episodeList) { + var episodes = LibraryManager + .GetItemList( + new() { + IncludeItemTypes = new[] { BaseItemKind.Episode }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoEpisodeId.Name, episodeInfo.Id } }, + DtoOptions = new(true), + }, + true + ) + .ToList(); + foreach (var episode in episodes) { + Logger.LogInformation("Refreshing episode {EpisodeName}. (Episode={EpisodeId},Episode={EpisodeId},Series={SeriesId})", episode.Name, episode.Id, episodeInfo.Id, episodeInfo.Shoko.IDs.Series.ToString()); + await episode.RefreshMetadata(new(DirectoryService) { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = true, + RemoveOldMetadata = true, + ReplaceImages = Enum.GetValues<ImageType>().ToArray(), + IsAutomated = true, + EnableRemoteContentProbe = true, + }, CancellationToken.None); + } + } + } + } + + private async Task ProcessMovieEvents(SeasonInfo seasonInfo, List<IMetadataUpdatedEventArgs> changes) + { + // Find movies and refresh them. + var episodeIds = changes + .Where(e => e.EpisodeId.HasValue && e.Reason != UpdateReason.Removed) + .Select(e => e.EpisodeId!.Value.ToString()) + .ToHashSet(); + var episodeList = seasonInfo.EpisodeList + .Concat(seasonInfo.AlternateEpisodesList) + .Concat(seasonInfo.SpecialsList) + .Where(episodeInfo => episodeIds.Contains(episodeInfo.Id)) + .ToList(); + foreach (var episodeInfo in episodeList) { + var movies = LibraryManager + .GetItemList( + new() { + IncludeItemTypes = new[] { BaseItemKind.Movie }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoEpisodeId.Name, episodeInfo.Id } }, + DtoOptions = new(true), + }, + true + ) + .ToList(); + foreach (var movie in movies) { + Logger.LogInformation("Refreshing movie {MovieName}. (Movie={MovieId},Episode={EpisodeId},Series={SeriesId})", movie.Name, movie.Id, episodeInfo.Id, seasonInfo.Id); + await movie.RefreshMetadata(new(DirectoryService) { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = true, + RemoveOldMetadata = true, + ReplaceImages = Enum.GetValues<ImageType>().ToArray(), + IsAutomated = true, + EnableRemoteContentProbe = true, + }, CancellationToken.None); + } + } } #endregion diff --git a/Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs b/Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs index d78c07cd..df3557de 100644 --- a/Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Globalization; using Jellyfin.Data.Enums; -using Shokofin.SignalR.Models; namespace Shokofin.SignalR.Interfaces; @@ -15,7 +14,7 @@ public interface IMetadataUpdatedEventArgs /// <summary> /// The provider metadata type. /// </summary> - BaseItemKind Type { get; } + BaseItemKind Kind { get; } /// <summary> /// The provider metadata source. @@ -42,16 +41,31 @@ public interface IMetadataUpdatedEventArgs /// </summary> string? ProviderParentUId => ProviderParentId.HasValue ? $"{ProviderName.ToLowerInvariant()}:{ProviderParentId.Value.ToString(CultureInfo.InvariantCulture)}" : null; + /// <summary> + /// The first shoko episode id affected by this update. + /// </summary> + int? EpisodeId => EpisodeIds.Count > 0 ? EpisodeIds[0] : null; + /// <summary> /// Shoko episode ids affected by this update. /// </summary> IReadOnlyList<int> EpisodeIds { get; } + /// <summary> + /// The first shoko series id affected by this update. + /// </summary> + int? SeriesId => SeriesIds.Count > 0 ? SeriesIds[0] : null; + /// <summary> /// Shoko series ids affected by this update. /// </summary> IReadOnlyList<int> SeriesIds { get; } + /// <summary> + /// The first shoko group id affected by this update. + /// </summary> + int? GroupId => GroupIds.Count > 0 ? GroupIds[0] : null; + /// <summary> /// Shoko group ids affected by this update. /// </summary> diff --git a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs index 8e1246a9..ec6662dd 100644 --- a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs @@ -51,7 +51,7 @@ public class EpisodeInfoUpdatedEventArgs : IMetadataUpdatedEventArgs #region IMetadataUpdatedEventArgs Impl. - BaseItemKind IMetadataUpdatedEventArgs.Type => BaseItemKind.Episode; + BaseItemKind IMetadataUpdatedEventArgs.Kind => BaseItemKind.Episode; int? IMetadataUpdatedEventArgs.ProviderParentId => ProviderParentId; diff --git a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs index 3a2adb8b..4447dd73 100644 --- a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs @@ -39,7 +39,7 @@ public class SeriesInfoUpdatedEventArgs : IMetadataUpdatedEventArgs #region IMetadataUpdatedEventArgs Impl. - BaseItemKind IMetadataUpdatedEventArgs.Type => BaseItemKind.Series; + BaseItemKind IMetadataUpdatedEventArgs.Kind => BaseItemKind.Series; int? IMetadataUpdatedEventArgs.ProviderParentId => null; diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index b26b471d..79689cf7 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -280,7 +280,7 @@ private void OnInfoUpdated(IMetadataUpdatedEventArgs eventArgs) Logger.LogDebug( "{ProviderName} {MetadataType} {ProviderId} ({ProviderParentId}) dispatched event with {UpdateReason}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", eventArgs.ProviderName, - eventArgs.Type, + eventArgs.Kind, eventArgs.ProviderId, eventArgs.ProviderParentId, eventArgs.Reason, @@ -299,7 +299,7 @@ private void OnInfoUpdated(IMetadataUpdatedEventArgs eventArgs) return; } - if (eventArgs.Type is BaseItemKind.Episode or BaseItemKind.Series) + if (eventArgs.Kind is BaseItemKind.Episode or BaseItemKind.Series) ResolveManager.AddSeriesEvent(eventArgs.ProviderParentUId ?? eventArgs.ProviderUId, eventArgs); } From 9ad3f4b7f570cf62174987bbccdd2810a2f27f34 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 18 May 2024 04:21:37 +0000 Subject: [PATCH 0980/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 4083677d..8f649fc4 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.170", + "changelog": "feat: react to series/episode events\n\n- Hooked up series/episode events to refresh shows/seasons/episodes/movies. This is untested and good night.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.170/shoko_3.0.1.170.zip", + "checksum": "7d2fd951ad580d4d0a42dd7e005cef07", + "timestamp": "2024-05-18T04:21:36Z" + }, { "version": "3.0.1.169", "changelog": "revert: \"misc: update build config (take 2)\"\n\nThis reverts commit 8a85de9296e088406379788c7a8933cdc80ca18e.\n\nfix: another movie exception\n\n- Also switch the type from movie to web if we're hidden the main movies, but the parts are normal episodes.\n\nmisc: update build config (take 2) [skip ci]\n\n- Generalize the build script so it could be used for _any_ plugin.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.166/shoko_3.0.1.166.zip", "checksum": "157852058957d9d08e959ed7828c562d", "timestamp": "2024-05-13T22:00:13Z" - }, - { - "version": "3.0.1.165", - "changelog": "refactor: only build VFS once per path while the cache is filled", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.165/shoko_3.0.1.165.zip", - "checksum": "d6426eda03ec5ee698938925f1ea7e2f", - "timestamp": "2024-05-12T22:48:04Z" } ] } From bb59dca35bd23823c8158a9dce43161fc35db305 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 18 May 2024 21:34:12 +0200 Subject: [PATCH 0981/1103] fix: fix the new movie exception fixes 03b2e9d3859b80b2040e8699ae7784d89b0cd338 --- Shokofin/API/Info/SeasonInfo.cs | 4 +++- Shokofin/API/ShokoAPIClient.cs | 2 +- Shokofin/API/ShokoAPIManager.cs | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index f8ca849f..1bd1179b 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -115,6 +115,8 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp int index = 0; int lastNormalEpisode = 0; foreach (var episode in episodes) { + if (episode.Shoko.IsHidden) + continue; switch (episode.AniDB.Type) { case EpisodeType.Normal: episodesList.Add(episode); @@ -186,7 +188,7 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp } } // Also switch the type from movie to web if we're hidden the main movies, but the parts are normal episodes. - else if (type == SeriesType.Movie && episodesList.Any(episodeInfo => string.Equals(episodeInfo.AniDB.Titles.FirstOrDefault(title => title.LanguageCode == "en")?.Value, "The Complete Movie", StringComparison.InvariantCultureIgnoreCase) && episodeInfo.Shoko.IsHidden)) { + else if (type == SeriesType.Movie && episodes.Any(episodeInfo => string.Equals(episodeInfo.AniDB.Titles.FirstOrDefault(title => title.LanguageCode == "en")?.Value, "Complete Movie", StringComparison.InvariantCultureIgnoreCase) && episodeInfo.Shoko.IsHidden)) { type = SeriesType.Web; } diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 7adec9a7..5fe80d14 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -325,7 +325,7 @@ public Task<Episode> GetEpisode(string id) public Task<ListResult<Episode>> GetEpisodesFromSeries(string seriesId) { - return Get<ListResult<Episode>>($"/api/v3/Series/{seriesId}/Episode?pageSize=0&includeMissing=true&includeDataFrom=AniDB,TvDB&includeXRefs=true"); + return Get<ListResult<Episode>>($"/api/v3/Series/{seriesId}/Episode?pageSize=0&includeHidden=true&includeMissing=true&includeDataFrom=AniDB,TvDB&includeXRefs=true"); } public Task<Series> GetSeries(string id) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 35be873d..65e96b15 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -611,7 +611,6 @@ private Task<SeasonInfo> CreateSeasonInfo(Series series, string seriesId) var (earliestImportedAt, lastImportedAt)= await GetEarliestImportedAtForSeries(seriesId).ConfigureAwait(false); var episodes = (await APIClient.GetEpisodesFromSeries(seriesId).ConfigureAwait(false) ?? new()).List .Select(e => CreateEpisodeInfo(e, e.IDs.Shoko.ToString())) - .Where(e => !e.Shoko.IsHidden) .OrderBy(e => e.AniDB.AirDate) .ToList(); var cast = await APIClient.GetSeriesCast(seriesId).ConfigureAwait(false); From 53eaf829eec1fe9c8053110888a70a5c517fce3c Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 18 May 2024 19:34:54 +0000 Subject: [PATCH 0982/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 8f649fc4..bde38b13 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.171", + "changelog": "fix: fix the new movie exception\nfixes 03b2e9d3859b80b2040e8699ae7784d89b0cd338", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.171/shoko_3.0.1.171.zip", + "checksum": "1dcfdced8d08683d564214e66a0afef0", + "timestamp": "2024-05-18T19:34:53Z" + }, { "version": "3.0.1.170", "changelog": "feat: react to series/episode events\n\n- Hooked up series/episode events to refresh shows/seasons/episodes/movies. This is untested and good night.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.167/shoko_3.0.1.167.zip", "checksum": "51a3e46dfe8862a180eef893407002f6", "timestamp": "2024-05-16T00:10:28Z" - }, - { - "version": "3.0.1.166", - "changelog": "fix: re-create specials anchors if needed\n\nmisc: add media folder changed events [skip ci]", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.166/shoko_3.0.1.166.zip", - "checksum": "157852058957d9d08e959ed7828c562d", - "timestamp": "2024-05-13T22:00:13Z" } ] } From 7562996823f024c9d42dc7dd83eeaeedf7ece350 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 18 May 2024 23:11:36 +0200 Subject: [PATCH 0983/1103] refactor: better align collection support with JF core - Better align the collection support in the plugin with the collection support in core JF by enforcing the requirement of at least two entries before creating a collection by default, but also allow the requirement to be toggled off so it will always create the collections regardless of the number of items within the collection. --- Shokofin/Collections/CollectionManager.cs | 22 ++++++++++++++----- Shokofin/Configuration/PluginConfiguration.cs | 7 ++++++ Shokofin/Configuration/configController.js | 3 +++ Shokofin/Configuration/configPage.html | 7 ++++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index dd2ff312..39afbab4 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -28,6 +28,8 @@ public class CollectionManager private readonly ShokoAPIManager ApiManager; + private static int MinCollectionSize => Plugin.Instance.Configuration.CollectionMinSizeOfTwo ? 1 : 0; + public CollectionManager(ILibraryManager libraryManager, ICollectionManager collectionManager, ILogger<CollectionManager> logger, IIdLookup lookup, ShokoAPIManager apiManager) { LibraryManager = libraryManager; @@ -82,10 +84,12 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, movieDict.Add(movie, (fileInfo, seasonInfo, showInfo)); } + // Filter to only "seasons" with at least (`MinCollectionSize` + 1) movies in them. var seasonDict = movieDict.Values .Select(tuple => tuple.seasonInfo) - .DistinctBy(seasonInfo => seasonInfo.Id) - .ToDictionary(seasonInfo => seasonInfo.Id); + .GroupBy(seasonInfo => seasonInfo.Id) + .Where(groupBy => groupBy.Count() > MinCollectionSize) + .ToDictionary(groupBy => groupBy.Key, groupBy => groupBy.First()); cancellationToken.ThrowIfCancellationRequested(); progress.Report(30); @@ -229,15 +233,21 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc cancellationToken.ThrowIfCancellationRequested(); progress.Report(30); + // Filter to only collections with at least (`MinCollectionSize` + 1) entries in them. var groupsDict = await Task .WhenAll( movieDict.Values .Select(tuple => tuple.seasonInfo) - .DistinctBy(seasonInfo => seasonInfo.Id) .Select(seasonInfo => seasonInfo.Shoko.IDs.ParentGroup.ToString()) - .Concat(showDict.Values.Select(showInfo => showInfo.CollectionId).Where(collectionId => !string.IsNullOrEmpty(collectionId)).OfType<string>()) - .Distinct() - .Select(groupId => ApiManager.GetCollectionInfoForGroup(groupId)) + .Concat( + showDict.Values + .Select(showInfo => showInfo.CollectionId) + .Where(collectionId => !string.IsNullOrEmpty(collectionId)) + .OfType<string>() + ) + .GroupBy(collectionId => collectionId) + .Where(groupBy => groupBy.Count() > MinCollectionSize) + .Select(groupBy => ApiManager.GetCollectionInfoForGroup(groupBy.Key)) ) .ContinueWith(task => task.Result.ToDictionary(x => x!.Id, x => x!)); var finalGroups = new Dictionary<string, CollectionInfo>(); diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index ba8dc8a1..3b84d564 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -215,6 +215,12 @@ public virtual string PrettyUrl /// </summary> public CollectionCreationType CollectionGrouping { get; set; } + /// <summary> + /// Add a minimum requirement of two entries with the same collection id + /// before creating a collection for them. + /// </summary> + public bool CollectionMinSizeOfTwo { get; set; } + /// <summary> /// Determines how seasons are ordered within a show. /// </summary> @@ -378,6 +384,7 @@ public PluginConfiguration() AddMissingMetadata = true; MarkSpecialsWhenGrouped = true; CollectionGrouping = CollectionCreationType.None; + CollectionMinSizeOfTwo = true; UserList = new(); MediaFolders = new(); IgnoredFolders = new[] { ".streams", "@recently-snapshot" }; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 74967f48..3c1da095 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -292,6 +292,7 @@ async function defaultSubmit(form) { 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.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; config.MovieSpecialsAsExtraFeaturettes = form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked; @@ -482,6 +483,7 @@ async function syncSettings(form) { config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; config.SeparateMovies = form.querySelector("#SeparateMovies").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.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; @@ -795,6 +797,7 @@ export default function (page) { form.querySelector("#SeasonOrderingContainer").setAttribute("hidden", ""); } form.querySelector("#CollectionGrouping").value = config.CollectionGrouping || "Default"; + form.querySelector("#CollectionMinSizeOfTwo").checked = config.CollectionMinSizeOfTwo != null ? config.CollectionMinSizeOfTwo : true; form.querySelector("#SeparateMovies").checked = config.SeparateMovies != null ? config.SeparateMovies : true; form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" ? "AfterSeason" : config.SpecialsPlacement; form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked = config.MovieSpecialsAsExtraFeaturettes || false; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 6b885ed8..8d1d107d 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -277,6 +277,13 @@ <h3>Library Settings</h3> </select> <div class="fieldDescription">Determines what entities to group into collections.</div> </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <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"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="MovieSpecialsAsExtraFeaturettes" /> From 63bc7bcb390ccc1563ed63cd21c5a48d0d522ea8 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 18 May 2024 21:12:23 +0000 Subject: [PATCH 0984/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index bde38b13..badd91e2 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.172", + "changelog": "refactor: better align collection support with JF core\n\n- Better align the collection support in the plugin with the collection\n support in core JF by enforcing the requirement of at least two\n entries before creating a collection by default, but also allow the\n requirement to be toggled off so it will always create the collections regardless of the number of items within the collection.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.172/shoko_3.0.1.172.zip", + "checksum": "fff4c4a37c03ba60f0353dafa352bbac", + "timestamp": "2024-05-18T21:12:21Z" + }, { "version": "3.0.1.171", "changelog": "fix: fix the new movie exception\nfixes 03b2e9d3859b80b2040e8699ae7784d89b0cd338", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.168/shoko_3.0.1.168.zip", "checksum": "5ef4a5c6b976291fd8297e11d4df34d5", "timestamp": "2024-05-17T17:56:27Z" - }, - { - "version": "3.0.1.167", - "changelog": "misc: cleanup text utility", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.167/shoko_3.0.1.167.zip", - "checksum": "51a3e46dfe8862a180eef893407002f6", - "timestamp": "2024-05-16T00:10:28Z" } ] } From 92aa8201b7093b744bad1aa6ffe0a70707457842 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 18 May 2024 23:32:13 +0200 Subject: [PATCH 0985/1103] misc: add ordering to providers --- Shokofin/Providers/BoxSetProvider.cs | 4 +++- Shokofin/Providers/EpisodeProvider.cs | 4 +++- Shokofin/Providers/ImageProvider.cs | 4 +++- Shokofin/Providers/MovieProvider.cs | 4 +++- Shokofin/Providers/SeasonProvider.cs | 4 +++- Shokofin/Providers/SeriesProvider.cs | 4 +++- 6 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index dc922ec3..95c0f96d 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -15,10 +15,12 @@ namespace Shokofin.Providers; -public class BoxSetProvider : IRemoteMetadataProvider<BoxSet, BoxSetInfo> +public class BoxSetProvider : IRemoteMetadataProvider<BoxSet, BoxSetInfo>, IHasOrder { public string Name => Plugin.MetadataProviderName; + public int Order => 0; + private readonly IHttpClientFactory HttpClientFactory; private readonly ILogger<BoxSetProvider> Logger; diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 548558a7..c8b2b220 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -19,10 +19,12 @@ namespace Shokofin.Providers; -public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo> +public class EpisodeProvider: IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder { public string Name => Plugin.MetadataProviderName; + public int Order => 0; + private readonly IHttpClientFactory HttpClientFactory; private readonly ILogger<EpisodeProvider> Logger; diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index ac563df3..1003d4f3 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -15,10 +15,12 @@ namespace Shokofin.Providers; -public class ImageProvider : IRemoteImageProvider +public class ImageProvider : IRemoteImageProvider, IHasOrder { public string Name => Plugin.MetadataProviderName; + public int Order => 0; + private readonly IHttpClientFactory HttpClientFactory; private readonly ILogger<ImageProvider> Logger; diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index ff74907d..03ceddfc 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -15,10 +15,12 @@ namespace Shokofin.Providers; -public class MovieProvider : IRemoteMetadataProvider<Movie, MovieInfo> +public class MovieProvider : IRemoteMetadataProvider<Movie, MovieInfo>, IHasOrder { public string Name => Plugin.MetadataProviderName; + public int Order => 0; + private readonly IHttpClientFactory HttpClientFactory; private readonly ILogger<MovieProvider> Logger; diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 14cf57ff..89b49f77 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -16,10 +16,12 @@ namespace Shokofin.Providers; -public class SeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo> +public class SeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo>, IHasOrder { public string Name => Plugin.MetadataProviderName; + public int Order => 0; + private readonly IHttpClientFactory HttpClientFactory; private readonly ILogger<SeasonProvider> Logger; diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index fa7120ce..d18e50ea 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -17,10 +17,12 @@ namespace Shokofin.Providers; -public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo> +public class SeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>, IHasOrder { public string Name => Plugin.MetadataProviderName; + public int Order => 0; + private readonly IHttpClientFactory HttpClientFactory; private readonly ILogger<SeriesProvider> Logger; From 6e06933d0cf22a6624cafc5ffe61935af14a0b96 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 18 May 2024 21:34:09 +0000 Subject: [PATCH 0986/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index badd91e2..db39323e 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.173", + "changelog": "misc: add ordering to providers", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.173/shoko_3.0.1.173.zip", + "checksum": "7bafe38f384169321aa61560c499b4b9", + "timestamp": "2024-05-18T21:34:07Z" + }, { "version": "3.0.1.172", "changelog": "refactor: better align collection support with JF core\n\n- Better align the collection support in the plugin with the collection\n support in core JF by enforcing the requirement of at least two\n entries before creating a collection by default, but also allow the\n requirement to be toggled off so it will always create the collections regardless of the number of items within the collection.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.169/shoko_3.0.1.169.zip", "checksum": "d776e72a9db0f8ed36ae5256970a4a94", "timestamp": "2024-05-18T03:54:55Z" - }, - { - "version": "3.0.1.168", - "changelog": "misc: update build config\n\nfeat: add real time monitoring support for the VFS\n\n- Added a library scan watcher, responsible for tracking when a library scan starts and ends.\n\n- Fixed it so the signalr events won't be emitted (but still logged) if a library scan is running, preventing the events from interfering with the in-progress library scan. This is how jellyfin core already handles the file events during a scan, and will help prevent double updating the entries during a scan.\n\n- Refactored it so the resolve manager is responsible for processing file events, so it can consume events from both the signalr event events in addition to the the new file events from the newly added library monitor.\n\n- Added an _experimental_ library monitor responsible for emitting file events for the VFS. This should make it so real time monitoring will work with the VFS again, but the implementation haven't been thoroughly tested so there may still be bugs (or not).\n\n- Fixed it so the auto clear cache will **never** run during a library scan. It may still run if the caches idles for too long during a refresh of the library outside the library scan though.\n\nrefactor: don't cache media folder search\nanymore, since it's probably not needed anymore.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.168/shoko_3.0.1.168.zip", - "checksum": "5ef4a5c6b976291fd8297e11d4df34d5", - "timestamp": "2024-05-17T17:56:27Z" } ] } From 26e99f0492c585e9420b2c3e83f566e4bd8b496c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 May 2024 00:48:17 +0200 Subject: [PATCH 0987/1103] fix: fix incompatibly with stable shoko server --- Shokofin/API/Models/Episode.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index 856f78fd..2964655a 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -118,14 +118,14 @@ public class EpisodeIDs : IDs public enum EpisodeType { /// <summary> - /// The episode type is unknown. + /// A catch-all type for future extensions when a provider can't use a current episode type, but knows what the future type should be. /// </summary> - Unknown = 0, + Other = 1, /// <summary> - /// A catch-all type for future extensions when a provider can't use a current episode type, but knows what the future type should be. + /// The episode type is unknown. /// </summary> - Other = 1, + Unknown = Other, /// <summary> /// A normal episode. From 0c34fd612ea44022889b01f6ffc0299e067d816a Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sat, 18 May 2024 22:49:00 +0000 Subject: [PATCH 0988/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index db39323e..d1f77511 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.174", + "changelog": "fix: fix incompatibly with stable shoko server", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.174/shoko_3.0.1.174.zip", + "checksum": "9f6939a66682869cdaeef5625643a02b", + "timestamp": "2024-05-18T22:48:58Z" + }, { "version": "3.0.1.173", "changelog": "misc: add ordering to providers", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.170/shoko_3.0.1.170.zip", "checksum": "7d2fd951ad580d4d0a42dd7e005cef07", "timestamp": "2024-05-18T04:21:36Z" - }, - { - "version": "3.0.1.169", - "changelog": "revert: \"misc: update build config (take 2)\"\n\nThis reverts commit 8a85de9296e088406379788c7a8933cdc80ca18e.\n\nfix: another movie exception\n\n- Also switch the type from movie to web if we're hidden the main movies, but the parts are normal episodes.\n\nmisc: update build config (take 2) [skip ci]\n\n- Generalize the build script so it could be used for _any_ plugin.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.169/shoko_3.0.1.169.zip", - "checksum": "d776e72a9db0f8ed36ae5256970a4a94", - "timestamp": "2024-05-18T03:54:55Z" } ] } From be29fbf0bc2e1f16d018de1993219a93c2244f51 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 May 2024 02:59:41 +0200 Subject: [PATCH 0989/1103] fix: fix multi-series file emits in VFS when emitting for the media folder --- Shokofin/Resolvers/ShokoResolveManager.cs | 37 ++++++++++++++++++----- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 901754c0..e067a304 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -705,14 +705,37 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold // the paths for the series we have. This will fail if an OVA episode is // linked to both the OVA and e.g. a specials for the TV Series. var totalMultiSeriesFiles = 0; - foreach (var (file, sourceLocation) in multiSeriesFiles) { - var crossReferences = file.CrossReferences - .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue) && singleSeriesIds.Contains(xref.Series.Shoko!.Value)) - .Select(xref => xref.Series.Shoko!.Value.ToString()) + if (multiSeriesFiles.Count > 0) { + var mappedSingleSeriesIds = singleSeriesIds + .Select(seriesId => + ApiManager.GetShowInfoForSeries(seriesId.ToString()) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult()?.Id + ) + .OfType<string>() .ToHashSet(); - foreach (var seriesId in crossReferences) - yield return (sourceLocation, file.Id.ToString(), seriesId); - totalMultiSeriesFiles += crossReferences.Count; + + foreach (var (file, sourceLocation) in multiSeriesFiles) { + var seriesIds = file.CrossReferences + .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) + .Select(xref => xref.Series.Shoko!.Value.ToString()) + .ToHashSet(); + var mappedSeriesIds = seriesIds + .Select(seriesId => + ApiManager.GetShowInfoForSeries(seriesId) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult()?.Id + ) + .OfType<string>() + .Distinct() + .Intersect(mappedSingleSeriesIds) + .ToHashSet(); + foreach (var seriesId in mappedSeriesIds) + yield return (sourceLocation, file.Id.ToString(), seriesId); + totalMultiSeriesFiles += mappedSeriesIds.Count; + } } var timeSpent = DateTime.UtcNow - start; From e3c9c79602109e56391f40a8f67681f012ea5d07 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 19 May 2024 01:00:27 +0000 Subject: [PATCH 0990/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index d1f77511..0cb87c47 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.175", + "changelog": "fix: fix multi-series file emits in VFS\nwhen emitting for the media folder", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.175/shoko_3.0.1.175.zip", + "checksum": "f8e454c4ba217808e8cc389d6a996fbc", + "timestamp": "2024-05-19T01:00:25Z" + }, { "version": "3.0.1.174", "changelog": "fix: fix incompatibly with stable shoko server", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.171/shoko_3.0.1.171.zip", "checksum": "1dcfdced8d08683d564214e66a0afef0", "timestamp": "2024-05-18T19:34:53Z" - }, - { - "version": "3.0.1.170", - "changelog": "feat: react to series/episode events\n\n- Hooked up series/episode events to refresh shows/seasons/episodes/movies. This is untested and good night.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.170/shoko_3.0.1.170.zip", - "checksum": "7d2fd951ad580d4d0a42dd7e005cef07", - "timestamp": "2024-05-18T04:21:36Z" } ] } From 373b01f4356a99e28b052c4f6a75a7791f451743 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 May 2024 03:29:32 +0200 Subject: [PATCH 0991/1103] =?UTF-8?q?fix:=20fix=20multi-series=20file=20em?= =?UTF-8?q?its=20in=20VFS=20(take=202)=20=E2=80=A6this=20time=20for=20sure?= =?UTF-8?q?=20it=20will=20work=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/Resolvers/ShokoResolveManager.cs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index e067a304..892af836 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -720,21 +720,17 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold var seriesIds = file.CrossReferences .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) .Select(xref => xref.Series.Shoko!.Value.ToString()) - .ToHashSet(); - var mappedSeriesIds = seriesIds - .Select(seriesId => - ApiManager.GetShowInfoForSeries(seriesId) - .ConfigureAwait(false) - .GetAwaiter() - .GetResult()?.Id - ) - .OfType<string>() .Distinct() - .Intersect(mappedSingleSeriesIds) - .ToHashSet(); - foreach (var seriesId in mappedSeriesIds) + .Select(seriesId => ( + seriesId, + showId: ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult()?.Id + )) + .Where(tuple => !string.IsNullOrEmpty(tuple.showId) && mappedSingleSeriesIds.Contains(tuple.showId)) + .Select(tuple => tuple.seriesId) + .ToList(); + foreach (var seriesId in seriesIds) yield return (sourceLocation, file.Id.ToString(), seriesId); - totalMultiSeriesFiles += mappedSeriesIds.Count; + totalMultiSeriesFiles += seriesIds.Count; } } From 84c557d5daf3c00afd1c22f7835c5c71df7bf35c Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 19 May 2024 01:30:15 +0000 Subject: [PATCH 0992/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 0cb87c47..e9747062 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.176", + "changelog": "fix: fix multi-series file emits in VFS (take 2)\n\u2026this time for sure it will work\u2026", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.176/shoko_3.0.1.176.zip", + "checksum": "f270ec760c88156cb9ce8dd0739705b5", + "timestamp": "2024-05-19T01:30:14Z" + }, { "version": "3.0.1.175", "changelog": "fix: fix multi-series file emits in VFS\nwhen emitting for the media folder", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.172/shoko_3.0.1.172.zip", "checksum": "fff4c4a37c03ba60f0353dafa352bbac", "timestamp": "2024-05-18T21:12:21Z" - }, - { - "version": "3.0.1.171", - "changelog": "fix: fix the new movie exception\nfixes 03b2e9d3859b80b2040e8699ae7784d89b0cd338", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.171/shoko_3.0.1.171.zip", - "checksum": "1dcfdced8d08683d564214e66a0afef0", - "timestamp": "2024-05-18T19:34:53Z" } ] } From 5fade3328af167400b2d32832f49caca8203a654 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 May 2024 04:28:55 +0200 Subject: [PATCH 0993/1103] =?UTF-8?q?misc:=20remove=20unused=20code=C2=A0[?= =?UTF-8?q?skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/Resolvers/ShokoResolveManager.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 892af836..0f61fa11 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -159,9 +159,6 @@ private void OnConfigurationChanged(object? sender, PluginConfiguration config) ConfigurationUpdated?.Invoke(sender, new(mediaConfig, mediaFolder)); } } - var mediaKeys = Plugin.Instance.Configuration.MediaFolders - .ToDictionary(c => c.MediaFolderId, ConstructKey); - } private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) From 62dc9ee10473900f05ceae177de3ce080562e19c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 May 2024 05:29:33 +0200 Subject: [PATCH 0994/1103] misc: fix log point [skip ci] --- Shokofin/Resolvers/ShokoResolveManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 0f61fa11..2f717b23 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -1152,7 +1152,7 @@ private LinkGenerationResult CleanupStructure(string vfsPath, string directoryTo } } - Logger.LogTrace("Cleaned {CleanedCount} directories in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", cleaned, directoriesToClean, nextStep - previousStep, nextStep - start); + Logger.LogTrace("Cleaned {CleanedCount} directories in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", cleaned, directoryToClean, nextStep - previousStep, nextStep - start); return result; } From c27c56a21ab080b9db8170dd9d7139a269426df0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 May 2024 05:46:41 +0200 Subject: [PATCH 0995/1103] misc: emit how many updates are scheduled [skip ci] --- Shokofin/Resolvers/ShokoResolveManager.cs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 2f717b23..aa23f84a 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -1826,9 +1826,6 @@ private async Task ProcessMetadataEvents(string metadataId, List<IMetadataUpdate return; } - Logger.LogInformation("Processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); - - // Process series events first, so we have the "season" data for the movies already cached. var seriesId = changes.First(e => e.SeriesId.HasValue).SeriesId!.Value.ToString(); var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); if (showInfo is null) { @@ -1842,18 +1839,22 @@ private async Task ProcessMetadataEvents(string metadataId, List<IMetadataUpdate return; } - await ProcessSeriesEvents(showInfo, changes); + Logger.LogInformation("Processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); + + var updateCount = await ProcessSeriesEvents(showInfo, changes); + updateCount += await ProcessMovieEvents(seasonInfo, changes); - await ProcessMovieEvents(seasonInfo, changes); + Logger.LogInformation("Scheduled {UpdateCount} updates for {EventCount} metadata change events. (Metadata={ProviderUniqueId})", updateCount, changes.Count, metadataId); } catch (Exception ex) { Logger.LogError(ex, "Error processing {EventCount} metadata change events. (Metadata={ProviderUniqueId})", changes.Count, metadataId); } } - private async Task ProcessSeriesEvents(ShowInfo showInfo, List<IMetadataUpdatedEventArgs> changes) + private async Task<int> ProcessSeriesEvents(ShowInfo showInfo, List<IMetadataUpdatedEventArgs> changes) { // Update the series if we got a series event _or_ an episode removed event. + var updateCount = 0; var animeEvent = changes.Find(e => e.Kind == BaseItemKind.Series || e.Kind == BaseItemKind.Episode && e.Reason == UpdateReason.Removed); if (animeEvent is not null) { var shows = LibraryManager @@ -1878,6 +1879,7 @@ await show.RefreshMetadata(new(DirectoryService) { IsAutomated = true, EnableRemoteContentProbe = true, }, CancellationToken.None); + updateCount++; } } // Otherwise update all season/episodes where appropriate. @@ -1916,6 +1918,7 @@ await season.RefreshMetadata(new(DirectoryService) { IsAutomated = true, EnableRemoteContentProbe = true, }, CancellationToken.None); + updateCount++; } } var episodeList = showInfo.SeasonList @@ -1946,14 +1949,17 @@ await episode.RefreshMetadata(new(DirectoryService) { IsAutomated = true, EnableRemoteContentProbe = true, }, CancellationToken.None); + updateCount++; } } } + return updateCount; } - private async Task ProcessMovieEvents(SeasonInfo seasonInfo, List<IMetadataUpdatedEventArgs> changes) + private async Task<int> ProcessMovieEvents(SeasonInfo seasonInfo, List<IMetadataUpdatedEventArgs> changes) { // Find movies and refresh them. + var updateCount = 0; var episodeIds = changes .Where(e => e.EpisodeId.HasValue && e.Reason != UpdateReason.Removed) .Select(e => e.EpisodeId!.Value.ToString()) @@ -1986,8 +1992,10 @@ await movie.RefreshMetadata(new(DirectoryService) { IsAutomated = true, EnableRemoteContentProbe = true, }, CancellationToken.None); + updateCount++; } } + return updateCount; } #endregion From 817858b38cf3825eb8df9caadc4aab158fc1ea2d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 May 2024 06:18:02 +0200 Subject: [PATCH 0996/1103] misc: add more guards for library scan [skip ci] --- Shokofin/Resolvers/ShokoResolveManager.cs | 34 +++++++++++++++++------ 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index aa23f84a..20317969 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -43,9 +43,11 @@ public class ShokoResolveManager private readonly IIdLookup Lookup; private readonly ILibraryManager LibraryManager; - + private readonly ILibraryMonitor LibraryMonitor; + private readonly LibraryScanWatcher LibraryScanWatcher; + private readonly IFileSystem FileSystem; private readonly IDirectoryService DirectoryService; @@ -102,6 +104,7 @@ public ShokoResolveManager( IIdLookup lookup, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, + LibraryScanWatcher libraryScanWatcher, IFileSystem fileSystem, IDirectoryService directoryService, ILogger<ShokoResolveManager> logger, @@ -114,6 +117,7 @@ NamingOptions namingOptions Lookup = lookup; LibraryManager = libraryManager; LibraryMonitor = libraryMonitor; + LibraryScanWatcher = libraryScanWatcher; FileSystem = fileSystem; DirectoryService = directoryService; Logger = logger; @@ -712,7 +716,6 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold ) .OfType<string>() .ToHashSet(); - foreach (var (file, sourceLocation) in multiSeriesFiles) { var seriesIds = file.CrossReferences .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) @@ -1608,6 +1611,11 @@ public void AddFileEvent(int fileId, UpdateReason reason, int importFolderId, st private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> changes) { try { + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogInformation("Skipped processing {EventCount} file change events because a library scan is running. (File={FileId})", changes.Count, fileId); + return; + } + Logger.LogInformation("Processing {EventCount} file change events… (File={FileId})", changes.Count, fileId); // Something was added or updated. @@ -1684,7 +1692,6 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) continue; - // Let the core logic handle the rest. if (!config.IsVirtualFileSystemEnabled) { var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); @@ -1739,6 +1746,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int } // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. else { + // TODO: MAKE THIS WORK WITH REAL-TIME MONITORING WHEN USING THE VFS. var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); if (!string.IsNullOrEmpty(fileOrFolder)) locationsToNotify.Add(fileOrFolder); @@ -1747,9 +1755,14 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int } // We let jellyfin take it from here. - Logger.LogDebug("Notifying Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count, fileId.ToString()); - foreach (var location in locationsToNotify) - LibraryMonitor.ReportFileSystemChanged(location); + if (!LibraryScanWatcher.IsScanRunning) { + Logger.LogDebug("Notifying Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count, fileId.ToString()); + foreach (var location in locationsToNotify) + LibraryMonitor.ReportFileSystemChanged(location); + } + else { + Logger.LogDebug("Skipped notifying Jellyfin about {LocationCount} changes because a library scan is running. (File={FileId})", locationsToNotify.Count, fileId.ToString()); + } } catch (Exception ex) { Logger.LogError(ex, "Error processing {EventCount} file change events. (File={FileId})", changes.Count, fileId); @@ -1804,7 +1817,7 @@ private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEv } #endregion - + #region Refresh Events public void AddSeriesEvent(string metadataId, IMetadataUpdatedEventArgs eventArgs) @@ -1821,8 +1834,13 @@ public void AddSeriesEvent(string metadataId, IMetadataUpdatedEventArgs eventArg private async Task ProcessMetadataEvents(string metadataId, List<IMetadataUpdatedEventArgs> changes) { try { + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogDebug("Skipped processing {EventCount} metadata change events because a library scan is running. (Metadata={ProviderUniqueId})", changes.Count, metadataId); + return; + } + if (!changes.Any(e => e.Kind == BaseItemKind.Episode && e.EpisodeId.HasValue || e.Kind == BaseItemKind.Series && e.SeriesId.HasValue)) { - Logger.LogDebug("Skipped processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); + Logger.LogDebug("Skipped processing {EventCount} metadata change events because no series or episode ids to use. (Metadata={ProviderUniqueId})", changes.Count, metadataId); return; } From 63cbffe059e788bb6f302d30fce80e3324315625 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 May 2024 06:21:11 +0200 Subject: [PATCH 0997/1103] misc: cleanup cleanup function + more [skip ci] - Clean-up clean-up function. - Fix style in try move subtitle file. - Simplify get ids for path. - Add when to catch in get new relative path. --- Shokofin/Resolvers/ShokoResolveManager.cs | 65 +++++++++-------------- 1 file changed, 25 insertions(+), 40 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 20317969..a8cfacac 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -1052,7 +1052,6 @@ private IReadOnlyList<string> FindSubtitlesForPath(string sourcePath) private LinkGenerationResult CleanupStructure(string vfsPath, string directoryToClean, IReadOnlyList<string> allKnownPaths) { - // Search the selected paths for files to remove. Logger.LogDebug("Looking for files to remove in folder at {Path}", directoryToClean); var start = DateTime.Now; var previousStep = start; @@ -1069,7 +1068,6 @@ private LinkGenerationResult CleanupStructure(string vfsPath, string directoryTo previousStep = nextStep; foreach (var (location, extName) in toBeRemoved) { - // NFOs. if (extName == ".nfo") { try { Logger.LogTrace("Removing NFO file at {Path}", location); @@ -1081,41 +1079,37 @@ private LinkGenerationResult CleanupStructure(string vfsPath, string directoryTo } result.RemovedNfos++; } - // Subtitle files. else if (NamingOptions.SubtitleFileExtensions.Contains(extName)) { - // Try moving subtitle if possible, otherwise remove it. There is no in-between. if (TryMoveSubtitleFile(allKnownPaths, location)) { result.FixedSubtitles++; + continue; } - else { - 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.RemovedSubtitles++; - + + 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.RemovedSubtitles++; } - // Video files. else { if (ShouldIgnoreVideo(vfsPath, location)) { result.SkippedVideos++; + continue; } - else { - 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.RemovedVideos++; + + 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.RemovedVideos++; } } @@ -1165,9 +1159,7 @@ private static bool TryMoveSubtitleFile(IReadOnlyList<string> allKnownPaths, str if (!TryGetIdsForPath(subtitlePath, out var seriesId, out var fileId)) return false; - var symbolicLink = allKnownPaths.FirstOrDefault(knownPath => - TryGetIdsForPath(knownPath, out var knownSeriesId, out var knownFileId) && seriesId == knownSeriesId && fileId == knownFileId - ); + var symbolicLink = allKnownPaths.FirstOrDefault(knownPath => TryGetIdsForPath(knownPath, out var knownSeriesId, out var knownFileId) && seriesId == knownSeriesId && fileId == knownFileId); if (string.IsNullOrEmpty(symbolicLink)) return false; @@ -1204,13 +1196,8 @@ private static bool ShouldIgnoreVideo(string vfsPath, string path) private static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string? seriesId, [NotNullWhen(true)] out string? fileId) { var fileName = Path.GetFileNameWithoutExtension(path); - if (!fileName.TryGetAttributeValue(ShokoFileId.Name, out fileId) || !int.TryParse(fileId, out _)) { - seriesId = null; - fileId = null; - return false; - } - - if (!fileName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) { + if (!fileName.TryGetAttributeValue(ShokoFileId.Name, out fileId) || !int.TryParse(fileId, out _) || + !fileName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) { seriesId = null; fileId = null; return false; @@ -1809,10 +1796,8 @@ private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEv .FirstOrDefault(); return usableLocation?.RelativePath; } - catch (ApiException ex) { - if (ex.StatusCode == System.Net.HttpStatusCode.NotFound) - return null; - throw; + catch (ApiException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { + return null; } } From 6860e7318994c1bf9c18035b8891b72d3feefeab Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 May 2024 06:21:48 +0200 Subject: [PATCH 0998/1103] =?UTF-8?q?misc:=20=3D=3D=20=E2=86=92=20is=20/?= =?UTF-8?q?=20!=3D=20=E2=86=92=20is=20not?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/Resolvers/ShokoResolveManager.cs | 104 +++++++++++----------- 1 file changed, 53 insertions(+), 51 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index a8cfacac..c29e9db5 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -249,14 +249,14 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold var partialPath = path[mediaFolder.Path.Length..]; var files = await ApiClient.GetFileByPath(partialPath).ConfigureAwait(false); var file = files.FirstOrDefault(); - if (file == null) + if (file is null) continue; var fileId = file.Id.ToString(); var fileLocations = file.Locations .Where(location => location.RelativePath.EndsWith(partialPath)) .ToList(); - if (fileLocations.Count == 0) + if (fileLocations.Count is 0) continue; var fileLocation = fileLocations[0]; @@ -429,7 +429,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold allFiles = GetFilesForImportFolder(mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); } - if (allFiles == null) + if (allFiles is null) return false; // Generate and cleanup the structure in the VFS. @@ -451,7 +451,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold { var start = DateTime.UtcNow; var file = ApiClient.GetFile(fileId).ConfigureAwait(false).GetAwaiter().GetResult(); - if (file == null || !file.CrossReferences.Any(xref => xref.Series.ToString() == seriesId)) + if (file is null || !file.CrossReferences.Any(xref => xref.Series.ToString() == seriesId)) yield break; Logger.LogDebug( "Iterating 1 file to potentially use within media folder at {Path} (File={FileId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", @@ -463,9 +463,9 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold ); var location = file.Locations - .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length == 0 || location.RelativePath.StartsWith(importFolderSubPath))) + .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length is 0 || location.RelativePath.StartsWith(importFolderSubPath))) .FirstOrDefault(); - if (location == null || file.CrossReferences.Count == 0) + if (location is null || file.CrossReferences.Count is 0) yield break; var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); @@ -491,7 +491,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold var start = DateTime.UtcNow; var totalFiles = 0; var seasonInfo = ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult(); - if (seasonInfo == null) + if (seasonInfo is null) yield break; Logger.LogDebug( "Iterating files to potentially use within media folder at {Path} (Episode={EpisodeId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", @@ -536,7 +536,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold { var start = DateTime.UtcNow; var showInfo = ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult(); - if (showInfo == null) + if (showInfo is null) yield break; Logger.LogDebug( "Iterating files to potentially use within media folder at {Path} (Series={SeriesId},Season={SeasonNumber},ImportFolder={FolderId},RelativePath={RelativePath})", @@ -551,7 +551,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold var totalFiles = 0; if (seasonNumber.HasValue) { // Special handling of specials (pun intended) - if (seasonNumber.Value == 0) { + if (seasonNumber.Value is 0) { foreach (var seasonInfo in showInfo.SeasonList) { var episodeIds = seasonInfo.SpecialsList.Select(episode => episode.Id).ToHashSet(); var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); @@ -578,7 +578,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold if (seasonInfo != null) { var baseNumber = showInfo.GetBaseSeasonNumberForSeasonInfo(seasonInfo); var offset = seasonNumber.Value - baseNumber; - var episodeIds = (offset == 0 ? seasonInfo.EpisodeList.Concat(seasonInfo.ExtrasList) : seasonInfo.AlternateEpisodesList).Select(episode => episode.Id).ToHashSet(); + var episodeIds = (offset is 0 ? seasonInfo.EpisodeList.Concat(seasonInfo.ExtrasList) : seasonInfo.AlternateEpisodesList).Select(episode => episode.Id).ToHashSet(); var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); var fileLocations = files .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) @@ -675,13 +675,13 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold importFolderSubPath ); foreach (var file in pageData.List) { - if (file.CrossReferences.Count == 0) + if (file.CrossReferences.Count is 0) continue; var location = file.Locations - .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length == 0 || location.RelativePath.StartsWith(importFolderSubPath))) + .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length is 0 || location.RelativePath.StartsWith(importFolderSubPath))) .FirstOrDefault(); - if (location == null) + if (location is null) continue; var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); @@ -690,7 +690,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold // Yield all single-series files now, and offset the processing of all multi-series files for later. var seriesIds = file.CrossReferences.Where(x => x.Series.Shoko.HasValue && x.Episodes.All(e => e.Shoko.HasValue)).Select(x => x.Series.Shoko!.Value).ToHashSet(); - if (seriesIds.Count == 1) { + if (seriesIds.Count is 1) { totalSingleSeriesFiles++; singleSeriesIds.Add(seriesIds.First()); foreach (var seriesId in seriesIds) @@ -796,10 +796,10 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { private async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId) { var season = await ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); - if (season == null) + if (season is null) return (string.Empty, Array.Empty<string>(), null); - var isMovieSeason = season.Type == SeriesType.Movie; + var isMovieSeason = season.Type is SeriesType.Movie; var shouldAbort = collectionType switch { CollectionType.TvShows => isMovieSeason && Plugin.Instance.Configuration.SeparateMovies, CollectionType.Movies => !isMovieSeason, @@ -809,15 +809,15 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { return (string.Empty, Array.Empty<string>(), null); var show = await ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false); - if (show == null) + if (show is null) return (string.Empty, Array.Empty<string>(), null); var file = await ApiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false); var (episode, episodeXref, _) = (file?.EpisodeList ?? new()).FirstOrDefault(); - if (file == null || episode == null) + if (file is null || episode is null) return (string.Empty, Array.Empty<string>(), null); - if (season == null || episode == null) + if (season is null || episode is null) return (string.Empty, Array.Empty<string>(), null); var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters() ?? $"Shoko Series {show.Id}"; @@ -852,8 +852,10 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { ExtraType.Trailer => string.Empty, _ => isExtra ? "-other" : string.Empty, }; - var filePartSuffix = (episodeXref.Percentage?.Size ?? 100) != 100 ? $".pt{episode.Shoko.CrossReferences.Where(xref => xref.ReleaseGroup == episodeXref.ReleaseGroup && xref.Percentage!.Size == episodeXref.Percentage!.Size).ToList().FindIndex(xref => xref.Percentage!.Start == episodeXref.Percentage!.Start && xref.Percentage!.End == episodeXref.Percentage!.End) + 1}" : ""; - if (isMovieSeason && collectionType != CollectionType.TvShows) { + var filePartSuffix = (episodeXref.Percentage?.Size ?? 100) is not 100 + ? $".pt{episode.Shoko.CrossReferences.Where(xref => xref.ReleaseGroup == episodeXref.ReleaseGroup && xref.Percentage!.Size == episodeXref.Percentage!.Size).ToList().FindIndex(xref => xref.Percentage!.Start == episodeXref.Percentage!.Start && xref.Percentage!.End == episodeXref.Percentage!.End) + 1}" + : ""; + if (isMovieSeason && collectionType is not CollectionType.TvShows) { if (!string.IsNullOrEmpty(extrasFolder)) { foreach (var episodeInfo in season.EpisodeList) folders.Add(Path.Join(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episodeInfo.Id}]", extrasFolder)); @@ -872,7 +874,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { folders.Add(Path.Join(vfsPath, showFolder, extrasFolder)); // Only place the extra within the season if we have a season number assigned to the episode. - if (seasonNumber != 0) + if (seasonNumber is not 0) folders.Add(Path.Join(vfsPath, showFolder, seasonFolder, extrasFolder)); } else { @@ -1042,7 +1044,7 @@ private IReadOnlyList<string> FindSubtitlesForPath(string sourcePath) (fileNameWithoutExtension.Length == sourcePrefix.Length || NamingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[sourcePrefix.Length])) ) { var externalPathInfo = ExternalPathParser.ParseFile(file, fileNameWithoutExtension[sourcePrefix.Length..].ToString()); - if (externalPathInfo != null && !string.IsNullOrEmpty(externalPathInfo.Path)) + if (externalPathInfo is not null && !string.IsNullOrEmpty(externalPathInfo.Path)) externalPaths.Add(externalPathInfo.Path); } } @@ -1068,7 +1070,7 @@ private LinkGenerationResult CleanupStructure(string vfsPath, string directoryTo previousStep = nextStep; foreach (var (location, extName) in toBeRemoved) { - if (extName == ".nfo") { + if (extName is ".nfo") { try { Logger.LogTrace("Removing NFO file at {Path}", location); File.Delete(location); @@ -1212,11 +1214,11 @@ private static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string public async Task<BaseItem?> ResolveSingle(Folder? parent, string? collectionType, FileSystemMetadata fileInfo) { - if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || parent == null || fileInfo == null) + if (collectionType is not CollectionType.TvShows or CollectionType.Movies or null || parent is null || fileInfo is null) return null; var root = LibraryManager.RootFolder; - if (root == null || parent == root) + if (root is null || parent == root) return null; try { @@ -1253,11 +1255,11 @@ private static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string public async Task<MultiItemResolverResult?> ResolveMultiple(Folder? parent, string? collectionType, List<FileSystemMetadata> fileInfoList) { - if (!(collectionType == CollectionType.TvShows || collectionType == CollectionType.Movies || collectionType == null) || parent == null) + if (collectionType is not CollectionType.TvShows or CollectionType.Movies or null || parent is null) return null; var root = LibraryManager.RootFolder; - if (root == null || parent == root) + if (root is null || parent == root) return null; try { @@ -1273,7 +1275,7 @@ private static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string // Redirect children of a VFS managed media folder to the VFS. if (parent.IsTopParent) { - var createMovies = collectionType == CollectionType.Movies || (collectionType == null && Plugin.Instance.Configuration.SeparateMovies); + var createMovies = collectionType is CollectionType.Movies || (collectionType is null && Plugin.Instance.Configuration.SeparateMovies); var items = FileSystem.GetDirectories(vfsPath) .AsParallel() .SelectMany(dirInfo => { @@ -1284,10 +1286,10 @@ private static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string .ConfigureAwait(false) .GetAwaiter() .GetResult(); - if (season == null) + if (season is null) return Array.Empty<BaseItem>(); - if (createMovies && season.Type == SeriesType.Movie) { + if (createMovies && season.Type is SeriesType.Movie) { return FileSystem.GetFiles(dirInfo.FullName) .AsParallel() .Select(fileInfo => { @@ -1306,7 +1308,7 @@ private static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string .GetResult(); // Abort if the file was not recognized. - if (file == null || file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode))) + if (file is null || file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode))) return null; return new Movie() { @@ -1352,13 +1354,13 @@ private static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) { // Check if the parent is not made yet, or the file info is missing. - if (parent == null || fileInfo == null) + if (parent is null || fileInfo is null) return false; // Check if the root is not made yet. This should **never** be false at // this point in time, but if it is, then bail. var root = LibraryManager.RootFolder; - if (root == null || parent.Id == root.Id) + if (root is null || parent.Id == root.Id) return false; // Assume anything within the VFS is already okay. @@ -1422,13 +1424,13 @@ private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPa // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given folder path. if (season == null) { // If we're in strict mode, then check the sub-directories if we have a <Show>/<Season>/<Episodes> structure. - if (shouldIgnore && partialPath[1..].Split(Path.DirectorySeparatorChar).Length == 1) { + if (shouldIgnore && partialPath[1..].Split(Path.DirectorySeparatorChar).Length is 1) { try { var entries = FileSystem.GetDirectories(fullPath, false).ToList(); Logger.LogDebug("Unable to find shoko series for {Path}, trying {DirCount} sub-directories.", partialPath, entries.Count); foreach (var entry in entries) { season = await ApiManager.GetSeasonInfoByPath(entry.FullName).ConfigureAwait(false); - if (season != null) { + if (season is not null) { Logger.LogDebug("Found shoko series {SeriesName} for sub-directory of path {Path} (Series={SeriesId})", season.Shoko.Name, partialPath, season.Id); break; } @@ -1436,7 +1438,7 @@ private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPa } catch (DirectoryNotFoundException) { } } - if (season == null) { + if (season is null) { if (shouldIgnore) Logger.LogInformation("Ignored unknown folder at path {Path}", partialPath); else @@ -1446,7 +1448,7 @@ private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPa } // Filter library if we enabled the option. - var isMovieSeason = season.Type == SeriesType.Movie; + var isMovieSeason = season.Type is SeriesType.Movie; switch (collectionType) { case CollectionType.TvShows: if (isMovieSeason && Plugin.Instance.Configuration.SeparateMovies) { @@ -1476,7 +1478,7 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b var (file, season, _) = await ApiManager.GetFileInfoByPath(fullPath).ConfigureAwait(false); // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given file path. - if (file == null || season == null) { + if (file is null || season is null) { if (shouldIgnore) Logger.LogInformation("Ignored unknown file at path {Path}", partialPath); else @@ -1502,7 +1504,7 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b public IDisposable RegisterEventSubmitter() { var count = ChangesDetectionSubmitterCount++; - if (count == 0) + if (count is 0) ChangesDetectionTimer.Start(); return new DisposableAction(() => DeregisterEventSubmitter()); @@ -1511,7 +1513,7 @@ public IDisposable RegisterEventSubmitter() private void DeregisterEventSubmitter() { var count = --ChangesDetectionSubmitterCount; - if (count == 0) { + if (count is 0) { ChangesDetectionTimer.Stop(); if (ChangesPerFile.Count > 0) ClearFileEvents(); @@ -1610,7 +1612,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int var seriesIds = await GetSeriesIdsForFile(fileId, changes.Select(t => t.Event).LastOrDefault(e => e.HasCrossReferences)); var mediaFolders = GetAvailableMediaFolders(fileEvents: true); var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); - if (reason != UpdateReason.Removed) { + if (reason is not UpdateReason.Removed) { foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) continue; @@ -1672,7 +1674,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int } } // Something was removed, so assume the location is gone. - else if (changes.FirstOrDefault(t => t.Reason == UpdateReason.Removed).Event is IFileRelocationEventArgs firstRemovedEvent) { + else if (changes.FirstOrDefault(t => t.Reason is UpdateReason.Removed).Event is IFileRelocationEventArgs firstRemovedEvent) { relativePath = firstRemovedEvent.RelativePath; importFolderId = firstRemovedEvent.ImportFolderId; foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { @@ -1727,7 +1729,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int result.Print(Logger, mediaFolder.Path); // If all the "top-level-folders" exist, then let the core logic handle the rest. - if (topFolders.All(path => LibraryManager.FindByPath(path, true) != null)) { + if (topFolders.All(path => LibraryManager.FindByPath(path, true) is not null)) { var old = locationsToNotify.Count; locationsToNotify.AddRange(vfsSymbolicLinks); } @@ -1759,7 +1761,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEventArgs? fileEvent) { HashSet<string> seriesIds; - if (fileEvent != null && fileEvent.CrossReferences.All(xref => xref.ShokoSeriesId.HasValue && xref.ShokoEpisodeId.HasValue)) + if (fileEvent is not null && fileEvent.CrossReferences.All(xref => xref.ShokoSeriesId.HasValue && xref.ShokoEpisodeId.HasValue)) seriesIds = fileEvent.CrossReferences.Select(xref => xref.ShokoSeriesId!.Value.ToString()) .Distinct() .ToHashSet(); @@ -1783,7 +1785,7 @@ private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEv // Return all series if we only have this file for all of them, // otherwise return only the series were we have other files that are // not linked to other series. - return filteredSeriesIds.Count == 0 ? seriesIds : filteredSeriesIds; + return filteredSeriesIds.Count is 0 ? seriesIds : filteredSeriesIds; } private async Task<string?> GetNewRelativePath(MediaFolderConfiguration config, int fileId, string relativePath) @@ -1824,7 +1826,7 @@ private async Task ProcessMetadataEvents(string metadataId, List<IMetadataUpdate return; } - if (!changes.Any(e => e.Kind == BaseItemKind.Episode && e.EpisodeId.HasValue || e.Kind == BaseItemKind.Series && e.SeriesId.HasValue)) { + if (!changes.Any(e => e.Kind is BaseItemKind.Episode && e.EpisodeId.HasValue || e.Kind is BaseItemKind.Series && e.SeriesId.HasValue)) { Logger.LogDebug("Skipped processing {EventCount} metadata change events because no series or episode ids to use. (Metadata={ProviderUniqueId})", changes.Count, metadataId); return; } @@ -1858,7 +1860,7 @@ private async Task<int> ProcessSeriesEvents(ShowInfo showInfo, List<IMetadataUpd { // Update the series if we got a series event _or_ an episode removed event. var updateCount = 0; - var animeEvent = changes.Find(e => e.Kind == BaseItemKind.Series || e.Kind == BaseItemKind.Episode && e.Reason == UpdateReason.Removed); + var animeEvent = changes.Find(e => e.Kind is BaseItemKind.Series || e.Kind is BaseItemKind.Episode && e.Reason is UpdateReason.Removed); if (animeEvent is not null) { var shows = LibraryManager .GetItemList( @@ -1888,11 +1890,11 @@ await show.RefreshMetadata(new(DirectoryService) { // Otherwise update all season/episodes where appropriate. else { var episodeIds = changes - .Where(e => e.EpisodeId.HasValue && e.Reason != UpdateReason.Removed) + .Where(e => e.EpisodeId.HasValue && e.Reason is not UpdateReason.Removed) .Select(e => e.EpisodeId!.Value.ToString()) .ToHashSet(); var seasonIds = changes - .Where(e => e.EpisodeId.HasValue && e.SeriesId.HasValue && e.Reason == UpdateReason.Removed) + .Where(e => e.EpisodeId.HasValue && e.SeriesId.HasValue && e.Reason is UpdateReason.Removed) .Select(e => e.SeriesId!.Value.ToString()) .ToHashSet(); var seasonList = showInfo.SeasonList @@ -1964,7 +1966,7 @@ private async Task<int> ProcessMovieEvents(SeasonInfo seasonInfo, List<IMetadata // Find movies and refresh them. var updateCount = 0; var episodeIds = changes - .Where(e => e.EpisodeId.HasValue && e.Reason != UpdateReason.Removed) + .Where(e => e.EpisodeId.HasValue && e.Reason is not UpdateReason.Removed) .Select(e => e.EpisodeId!.Value.ToString()) .ToHashSet(); var episodeList = seasonInfo.EpisodeList From 57e0743bd7d5c420fe9bd2efbb2873103bcc7fa5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 19 May 2024 07:19:53 +0200 Subject: [PATCH 0999/1103] fix: add work-around for signalr file events - Added a work-around for signalr file events for when both real time monitoring _and_ the VFS is enabled for the library, to make it function properly and not ignore the event. --- Shokofin/Resolvers/ShokoResolveManager.cs | 63 +++++++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index c29e9db5..797a9f3e 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -28,6 +28,7 @@ using File = System.IO.File; using IDirectoryService = MediaBrowser.Controller.Providers.IDirectoryService; using ImageType = MediaBrowser.Model.Entities.ImageType; +using LibraryOptions = MediaBrowser.Model.Configuration.LibraryOptions; using MetadataRefreshMode = MediaBrowser.Controller.Providers.MetadataRefreshMode; using Timer = System.Timers.Timer; using TvSeries = MediaBrowser.Controller.Entities.TV.Series; @@ -70,9 +71,14 @@ public class ShokoResolveManager private readonly Dictionary<int, (DateTime LastUpdated, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> List)> ChangesPerFile = new(); + private readonly Dictionary<string, (int refCount, DateTime delayEnd)> MediaFolderChangeMonitor = new(); + // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 characters. private const int NameCutOff = 64; + // It's so magical that it matches the magical value in the library monitor in JF core. 🪄 + private const int MagicalDelayValue = 45000; + private static readonly TimeSpan DetectChangesThreshold = TimeSpan.FromSeconds(5); private static readonly IReadOnlySet<string> IgnoreFolderNames = new HashSet<string>() { @@ -1609,6 +1615,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int // Something was added or updated. 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 mediaFolders = GetAvailableMediaFolders(fileEvents: true); var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); @@ -1669,7 +1676,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int else { var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); if (!string.IsNullOrEmpty(fileOrFolder)) - locationsToNotify.Add(fileOrFolder); + mediaFoldersToNotify.TryAdd(mediaFolder.Path, (fileOrFolder, mediaFolder)); } } } @@ -1735,19 +1742,20 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int } // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. else { - // TODO: MAKE THIS WORK WITH REAL-TIME MONITORING WHEN USING THE VFS. var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); if (!string.IsNullOrEmpty(fileOrFolder)) - locationsToNotify.Add(fileOrFolder); + mediaFoldersToNotify.TryAdd(mediaFolder.Path, (fileOrFolder, mediaFolder)); } } } // We let jellyfin take it from here. if (!LibraryScanWatcher.IsScanRunning) { - Logger.LogDebug("Notifying Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count, fileId.ToString()); + Logger.LogDebug("Notifying Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count + mediaFoldersToNotify.Count, fileId.ToString()); foreach (var location in locationsToNotify) LibraryMonitor.ReportFileSystemChanged(location); + if (mediaFoldersToNotify.Count > 0) + await Task.WhenAll(mediaFoldersToNotify.Values.Select(tuple => ReportMediaFolderChanged(tuple.mediaFolder, tuple.pathToReport))).ConfigureAwait(false); } else { Logger.LogDebug("Skipped notifying Jellyfin about {LocationCount} changes because a library scan is running. (File={FileId})", locationsToNotify.Count, fileId.ToString()); @@ -1803,6 +1811,53 @@ private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEv } } + private async Task ReportMediaFolderChanged(Folder mediaFolder, string pathToReport) + { + if (LibraryManager.GetLibraryOptions(mediaFolder) is not LibraryOptions libraryOptions || !libraryOptions.EnableRealtimeMonitor) { + LibraryMonitor.ReportFileSystemChanged(pathToReport); + return; + } + + // Since we're blocking real-time file events on the media folder because + // it uses the VFS then we need to temporarily unblock it, then block it + // afterwards again. + var path = mediaFolder.Path; + var delayTime = TimeSpan.Zero; + lock (MediaFolderChangeMonitor) { + if (MediaFolderChangeMonitor.TryGetValue(path, out var entry)) { + MediaFolderChangeMonitor[path] = (entry.refCount + 1, entry.delayEnd); + delayTime = entry.delayEnd - DateTime.Now; + } + else { + MediaFolderChangeMonitor[path] = (1, DateTime.Now + TimeSpan.FromMilliseconds(MagicalDelayValue)); + delayTime = TimeSpan.FromMilliseconds(MagicalDelayValue); + } + } + + LibraryMonitor.ReportFileSystemChangeComplete(path, false); + + if (delayTime > TimeSpan.Zero) + await Task.Delay((int)delayTime.TotalMilliseconds).ConfigureAwait(false); + + LibraryMonitor.ReportFileSystemChanged(pathToReport); + + var shouldResume = false; + lock (MediaFolderChangeMonitor) { + if (MediaFolderChangeMonitor.TryGetValue(path, out var tuple)) { + if (tuple.refCount is 1) { + shouldResume = true; + MediaFolderChangeMonitor.Remove(path); + } + else { + MediaFolderChangeMonitor[path] = (tuple.refCount - 1, tuple.delayEnd); + } + } + } + + if (shouldResume) + LibraryMonitor.ReportFileSystemChangeBeginning(path); + } + #endregion #region Refresh Events From b971f322954361f26eec1c171607e5065c5cf54d Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 19 May 2024 05:20:48 +0000 Subject: [PATCH 1000/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index e9747062..7615f4d5 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.177", + "changelog": "fix: add work-around for signalr file events\n\n- Added a work-around for signalr file events for when both\n real time monitoring _and_ the VFS is enabled for the library,\n to make it function properly and not ignore the event.\n\nmisc: == \u2192 is / != \u2192 is not\n\nmisc: cleanup cleanup function + more [skip ci]\n\n- Clean-up clean-up function.\n\n- Fix style in try move subtitle file.\n\n- Simplify get ids for path.\n\n- Add when to catch in get new relative path.\n\nmisc: add more guards for library scan [skip ci]\n\nmisc: emit how many updates are scheduled [skip ci]\n\nmisc: fix log point [skip ci]\n\nmisc: remove unused code\u00a0[skip ci]", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.177/shoko_3.0.1.177.zip", + "checksum": "551621544d7fd5230575d540f4be9460", + "timestamp": "2024-05-19T05:20:46Z" + }, { "version": "3.0.1.176", "changelog": "fix: fix multi-series file emits in VFS (take 2)\n\u2026this time for sure it will work\u2026", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.173/shoko_3.0.1.173.zip", "checksum": "7bafe38f384169321aa61560c499b4b9", "timestamp": "2024-05-18T21:34:07Z" - }, - { - "version": "3.0.1.172", - "changelog": "refactor: better align collection support with JF core\n\n- Better align the collection support in the plugin with the collection\n support in core JF by enforcing the requirement of at least two\n entries before creating a collection by default, but also allow the\n requirement to be toggled off so it will always create the collections regardless of the number of items within the collection.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.172/shoko_3.0.1.172.zip", - "checksum": "fff4c4a37c03ba60f0353dafa352bbac", - "timestamp": "2024-05-18T21:12:21Z" } ] } From a1737f504f51d0dc8e0ce062176e23d2ac5ca532 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 20 May 2024 02:00:27 +0200 Subject: [PATCH 1001/1103] fix: fix retrieving children of a collection --- Shokofin/Collections/CollectionManager.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index 39afbab4..688a5f40 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -129,7 +129,7 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, } // Remove all children - var children = boxSet.GetChildren(null, true, new()).Select(x => x.Id); + var children = boxSet.GetLinkedChildren().Select(x => x.Id); await Collection.RemoveFromCollectionAsync(id, children); // Remove the item. @@ -307,7 +307,7 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc } // Remove all children - var children = boxSet.GetChildren(null, true, new()).Select(x => x.Id); + var children = boxSet.GetLinkedChildren().Select(x => x.Id); await Collection.RemoveFromCollectionAsync(id, children); // Remove the item. @@ -450,7 +450,7 @@ private async Task CleanupGroupCollections() private async Task RemoveCollection(BoxSet boxSet, HashSet<Guid> allBoxSets, string? seriesId = null, string? groupId = null) { var parents = boxSet.GetParents().OfType<BoxSet>().ToList(); - var children = boxSet.GetChildren(null, true, new()).Select(x => x.Id).ToList(); + var children = boxSet.GetLinkedChildren().Select(x => x.Id).ToList(); Logger.LogTrace("Removing collection {CollectionName} with {ParentCount} parents and {ChildCount} children. (Collection={CollectionId},Series={SeriesId},Group={GroupId})", boxSet.Name, parents.Count, children.Count, boxSet.Id, seriesId, groupId); // Remove the item from all parents. From db4e27b2511021f29682b10d27b859656eca02db Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 20 May 2024 00:01:13 +0000 Subject: [PATCH 1002/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 7615f4d5..95cb7176 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.178", + "changelog": "fix: fix retrieving children of a collection", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.178/shoko_3.0.1.178.zip", + "checksum": "b5083a5d0c5b0a2e456a35363f5f4194", + "timestamp": "2024-05-20T00:01:12Z" + }, { "version": "3.0.1.177", "changelog": "fix: add work-around for signalr file events\n\n- Added a work-around for signalr file events for when both\n real time monitoring _and_ the VFS is enabled for the library,\n to make it function properly and not ignore the event.\n\nmisc: == \u2192 is / != \u2192 is not\n\nmisc: cleanup cleanup function + more [skip ci]\n\n- Clean-up clean-up function.\n\n- Fix style in try move subtitle file.\n\n- Simplify get ids for path.\n\n- Add when to catch in get new relative path.\n\nmisc: add more guards for library scan [skip ci]\n\nmisc: emit how many updates are scheduled [skip ci]\n\nmisc: fix log point [skip ci]\n\nmisc: remove unused code\u00a0[skip ci]", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.174/shoko_3.0.1.174.zip", "checksum": "9f6939a66682869cdaeef5625643a02b", "timestamp": "2024-05-18T22:48:58Z" - }, - { - "version": "3.0.1.173", - "changelog": "misc: add ordering to providers", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.173/shoko_3.0.1.173.zip", - "checksum": "7bafe38f384169321aa61560c499b4b9", - "timestamp": "2024-05-18T21:34:07Z" } ] } From c16c0438d6568f7cfc7a16b97f196aab891afe32 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 20 May 2024 18:28:32 +0200 Subject: [PATCH 1003/1103] refactor: make collections great again! - Fixed collections. But for real this time, by extensively testing the different paths to both add and remove collections. --- .vscode/settings.json | 1 + Shokofin/API/Info/CollectionInfo.cs | 3 + Shokofin/API/ShokoAPIManager.cs | 2 +- Shokofin/Collections/CollectionManager.cs | 415 +++++++++++++----- .../ExternalIds/ShokoCollectionGroupId.cs | 26 ++ .../ExternalIds/ShokoCollectionSeriesId.cs | 26 ++ Shokofin/ExternalIds/ShokoEpisodeId.cs | 1 - Shokofin/ExternalIds/ShokoFileId.cs | 2 - Shokofin/ExternalIds/ShokoGroupId.cs | 4 +- Shokofin/ExternalIds/ShokoSeriesId.cs | 1 - Shokofin/IdLookup.cs | 24 - Shokofin/Providers/BoxSetProvider.cs | 12 +- Shokofin/Providers/CustomBoxSetProvider.cs | 161 +++++++ Shokofin/Providers/ImageProvider.cs | 9 +- Shokofin/StringExtensions.cs | 3 +- Shokofin/Tasks/ReconstructCollectionsTask.cs | 63 +++ Shokofin/Utils/Text.cs | 23 +- 17 files changed, 627 insertions(+), 149 deletions(-) create mode 100644 Shokofin/ExternalIds/ShokoCollectionGroupId.cs create mode 100644 Shokofin/ExternalIds/ShokoCollectionSeriesId.cs create mode 100644 Shokofin/Providers/CustomBoxSetProvider.cs create mode 100644 Shokofin/Tasks/ReconstructCollectionsTask.cs diff --git a/.vscode/settings.json b/.vscode/settings.json index 98b82995..c0641e12 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ "apikey", "automagic", "automagically", + "boxset", "dlna", "emby", "eroge", diff --git a/Shokofin/API/Info/CollectionInfo.cs b/Shokofin/API/Info/CollectionInfo.cs index 71785f70..3d1fa48d 100644 --- a/Shokofin/API/Info/CollectionInfo.cs +++ b/Shokofin/API/Info/CollectionInfo.cs @@ -10,6 +10,8 @@ public class CollectionInfo public string? ParentId; + public string TopLevelId; + public bool IsTopLevel; public string Name; @@ -24,6 +26,7 @@ public CollectionInfo(Group group, List<ShowInfo> shows, List<CollectionInfo> su { Id = group.IDs.Shoko.ToString(); ParentId = group.IDs.ParentGroup?.ToString(); + TopLevelId = group.IDs.TopLevelGroup.ToString(); IsTopLevel = group.IDs.TopLevelGroup == group.IDs.Shoko; Name = group.Name; Shoko = group; diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 65e96b15..fb84e102 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -769,7 +769,7 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, [NotNullWhen(true) // If we found a movie, and we're assigning movies as stand-alone shows, and we didn't create a stand-alone show // above, then attach the stand-alone show to the parent group of the group that might other if (seasonInfo.Type == SeriesType.Movie && Plugin.Instance.Configuration.SeparateMovies) - return GetOrCreateShowInfoForSeasonInfo(seasonInfo, group.Size > 0 ? group.IDs.ParentGroup.ToString() : null); + return GetOrCreateShowInfoForSeasonInfo(seasonInfo, group.Size > 0 ? group.IDs.ParentGroup?.ToString() : null); return await CreateShowInfoForGroup(group, group.IDs.Shoko.ToString()).ConfigureAwait(false); } diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index 688a5f40..ff41379f 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -4,23 +4,38 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Collections; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.API.Info; using Shokofin.ExternalIds; using Shokofin.Utils; +using Directory = System.IO.Directory; +using Path = System.IO.Path; + namespace Shokofin.Collections; public class CollectionManager { + private readonly IApplicationPaths ApplicationPaths; + private readonly ILibraryManager LibraryManager; + private readonly IFileSystem FileSystem; + private readonly ICollectionManager Collection; + + private readonly ILocalizationManager LocalizationManager; private readonly ILogger<CollectionManager> Logger; @@ -30,21 +45,71 @@ public class CollectionManager private static int MinCollectionSize => Plugin.Instance.Configuration.CollectionMinSizeOfTwo ? 1 : 0; - public CollectionManager(ILibraryManager libraryManager, ICollectionManager collectionManager, ILogger<CollectionManager> logger, IIdLookup lookup, ShokoAPIManager apiManager) + public CollectionManager( + IApplicationPaths applicationPaths, + ILibraryManager libraryManager, + IFileSystem fileSystem, + ICollectionManager collectionManager, + ILocalizationManager localizationManager, + ILogger<CollectionManager> logger, + IIdLookup lookup, + ShokoAPIManager apiManager + ) { + ApplicationPaths = applicationPaths; LibraryManager = libraryManager; + FileSystem = fileSystem; Collection = collectionManager; + LocalizationManager = localizationManager; Logger = logger; Lookup = lookup; ApiManager = apiManager; } + // TODO: Replace this temp. impl. with the native impl on 10.9 after the migration. + public async Task<Folder?> GetCollectionsFolder(bool createIfNeeded) + { + var path = Path.Combine(ApplicationPaths.DataPath, "collections"); + var collectionRoot = LibraryManager + .RootFolder + .Children + .OfType<Folder>() + .Where(i => FileSystem.AreEqual(path, i.Path) || FileSystem.ContainsSubPath(i.Path, path)) + .FirstOrDefault(); + if (collectionRoot is not null) + return collectionRoot; + + if (!createIfNeeded) + return null; + + Directory.CreateDirectory(path); + + var libraryOptions = new LibraryOptions { + PathInfos = new[] { new MediaPathInfo(path) }, + EnableRealtimeMonitor = false, + SaveLocalMetadata = true + }; + + var name = LocalizationManager.GetLocalizedString("Collections"); + + await LibraryManager.AddVirtualFolder(name, CollectionTypeOptions.BoxSets, libraryOptions, true) + .ConfigureAwait(false); + + return LibraryManager + .RootFolder + .Children + .OfType<Folder>() + .Where(i => FileSystem.AreEqual(path, i.Path) || FileSystem.ContainsSubPath(i.Path, path)) + .FirstOrDefault(); + } + public async Task ReconstructCollections(IProgress<double> progress, CancellationToken cancellationToken) { try { switch (Plugin.Instance.Configuration.CollectionGrouping) { default: + await CleanupAll(progress, cancellationToken); break; case Ordering.CollectionCreationType.Movies: await ReconstructMovieSeriesCollections(progress, cancellationToken); @@ -54,16 +119,25 @@ public async Task ReconstructCollections(IProgress<double> progress, Cancellatio break; } } - catch (Exception ex) { + catch (Exception ex) when (ex is not OperationCanceledException) { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); } } + #region Movie Collections + private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, CancellationToken cancellationToken) { + Logger.LogTrace("Ensuring collection root exists…"); + var collectionRoot = (await GetCollectionsFolder(true).ConfigureAwait(false))!; + + var timeStarted = DateTime.Now; + + Logger.LogTrace("Cleaning up movies and invalid collections…"); + // Clean up movies and unneeded group collections. - await CleanupMovies(); - await CleanupGroupCollections(); + await CleanupMovies().ConfigureAwait(false); + CleanupGroupCollections(); cancellationToken.ThrowIfCancellationRequested(); progress.Report(10); @@ -78,7 +152,7 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, if (!Lookup.TryGetEpisodeIdsFor(movie, out var episodeIds)) continue; - var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(movie.Path); + var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(movie.Path).ConfigureAwait(false); if (fileInfo == null || seasonInfo == null || showInfo == null) continue; @@ -95,17 +169,25 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, progress.Report(30); // Find out what to add, what to remove and what to check. + var addedChildren = 0; + var removedChildren = 0; + var totalChildren = 0; var existingCollections = GetSeriesCollections(); + var childDict = existingCollections + .Values + .SelectMany(collectionList => collectionList) + .ToDictionary(collection => collection.Id, collection => collection.Children.Concat(collection.GetLinkedChildren()).ToList()); + var parentDict = childDict + .SelectMany(pair => pair.Value.Select(child => (childId: child.Id, parent: pair.Key))) + .GroupBy(tuple => tuple.childId) + .ToDictionary(groupBy => groupBy.Key, groupBy => groupBy.Select(tuple => tuple.parent).ToList()); var toCheck = new Dictionary<string, BoxSet>(); var toRemove = new Dictionary<Guid, BoxSet>(); var toAdd = seasonDict.Keys .Where(groupId => !existingCollections.ContainsKey(groupId)) .ToHashSet(); - var idToGuidDict = new Dictionary<string, Guid>(); - foreach (var (seriesId, collectionList) in existingCollections) { if (seasonDict.ContainsKey(seriesId)) { - idToGuidDict.Add(seriesId, collectionList[0].Id); toCheck.Add(seriesId, collectionList[0]); foreach (var collection in collectionList.Skip(1)) toRemove.Add(collection.Id, collection); @@ -120,20 +202,20 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, progress.Report(50); // Remove unknown collections. - foreach (var (id, boxSet) in toRemove) { + foreach (var (id, collection) in toRemove) { // Remove the item from all parents. - foreach (var parent in boxSet.GetParents().OfType<BoxSet>()) { - if (toRemove.ContainsKey(parent.Id)) - continue; - await Collection.RemoveFromCollectionAsync(parent.Id, new[] { id }); + 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); + } } - // Remove all children - var children = boxSet.GetLinkedChildren().Select(x => x.Id); - await Collection.RemoveFromCollectionAsync(id, children); + // Log how many children we will be removing. + removedChildren += childDict[collection.Id].Count; // Remove the item. - LibraryManager.DeleteItem(boxSet, new() { DeleteFileLocation = false, DeleteFromExternalProvider = false }); + LibraryManager.DeleteItem(collection, new() { DeleteFileLocation = true, DeleteFromExternalProvider = false }); } cancellationToken.ThrowIfCancellationRequested(); @@ -142,11 +224,12 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, // Add the missing collections. foreach (var missingId in toAdd) { var seasonInfo = seasonDict[missingId]; - var (displayName, _) = Text.GetSeasonTitles(seasonInfo, "en"); var collection = await Collection.CreateCollectionAsync(new() { - Name = displayName, - ProviderIds = new() { { ShokoSeriesId.Name, missingId } }, - }); + Name = $"{seasonInfo.Shoko.Name.ForceASCII()} [{ShokoCollectionSeriesId.Name}={missingId}]", + ProviderIds = new() { { ShokoCollectionSeriesId.Name, missingId } }, + }).ConfigureAwait(false); + + childDict.Add(collection.Id, new()); toCheck.Add(missingId, collection); } @@ -155,9 +238,28 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, // Check if the collection have the correct children, and add any // missing and remove any extras. + var fixedCollections = 0; foreach (var (seriesId, collection) in toCheck) { - var actualChildren = collection.Children.ToList(); + // Edit the metadata to if needed. + var updated = false; + var seasonInfo = seasonDict[seriesId]; + var metadataLanguage = LibraryManager.GetLibraryOptions(collection)?.PreferredMetadataLanguage; + var (displayName, alternateTitle) = Text.GetSeasonTitles(seasonInfo, metadataLanguage); + if (!string.Equals(collection.Name, displayName)) { + collection.Name = displayName; + updated = true; + } + if (!string.Equals(collection.OriginalTitle, alternateTitle)) { + collection.OriginalTitle = alternateTitle; + updated = true; + } + if (updated) { + await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + fixedCollections++; + } + + var actualChildren = childDict[collection.Id]; var actualChildMovies = new List<Movie>(); foreach (var child in actualChildren) switch (child) { case Movie movie: @@ -165,7 +267,6 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, break; } - var seasonInfo = seasonDict[seriesId]; var expectedMovies = seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList) .Select(episodeInfo => (episodeInfo, seasonInfo)) .SelectMany(tuple => movieDict.Where(pair => pair.Value.seasonInfo.Id == tuple.seasonInfo.Id && pair.Value.fileInfo.EpisodeList.Any(episodeInfo => episodeInfo.Id == tuple.episodeInfo.Id))) @@ -175,24 +276,56 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, .Select(movie => movie.Id) .Except(actualChildMovies.Select(a => a.Id).ToHashSet()) .ToList(); - var childrenToRemove = actualChildren + var unwantedMovies = actualChildren .Except(actualChildMovies) .Select(movie => movie.Id) .ToList(); - await Collection.AddToCollectionAsync(collection.Id, missingMovies); - await Collection.RemoveFromCollectionAsync(collection.Id, childrenToRemove); + if (missingMovies.Count > 0) + await Collection.AddToCollectionAsync(collection.Id, missingMovies).ConfigureAwait(false); + if (unwantedMovies.Count > 0) + await Collection.RemoveFromCollectionAsync(collection.Id, unwantedMovies).ConfigureAwait(false); + + totalChildren += expectedMovies.Count; + addedChildren += missingMovies.Count; + removedChildren += unwantedMovies.Count; } progress.Report(100); + + Logger.LogInformation( + "Created {AddedCount} ({AddedCollectionCount},{AddedChildCount}), fixed {FixedCount}, skipped {SkippedCount} ({SkippedCollectionCount},{SkippedChildCount}), and removed {RemovedCount} ({RemovedCollectionCount},{RemovedChildCount}) collections for {MovieCount} movies and using Shoko Series in {TimeSpent}. (Total={TotalCount})", + toAdd.Count + addedChildren, + toAdd.Count, + addedChildren, + fixedCollections - toAdd.Count, + toCheck.Count + totalChildren - toAdd.Count - addedChildren - (fixedCollections - toAdd.Count), + toCheck.Count - toAdd.Count - (fixedCollections - toAdd.Count), + totalChildren - addedChildren, + toRemove.Count + removedChildren, + toRemove.Count, + removedChildren, + movies.Count, + DateTime.Now - timeStarted, + toCheck.Count + totalChildren + ); } + #endregion + + #region Shared Collections + private async Task ReconstructSharedCollections(IProgress<double> progress, CancellationToken cancellationToken) { - // Get all movies + Logger.LogTrace("Ensuring collection root exists…"); + var collectionRoot = (await GetCollectionsFolder(true).ConfigureAwait(false))!; + + var timeStarted = DateTime.Now; + + Logger.LogTrace("Cleaning up movies and invalid collections…"); // Clean up movies and unneeded series collections. - await CleanupMovies(); - await CleanupSeriesCollections(); + await CleanupMovies().ConfigureAwait(false); + CleanupSeriesCollections(); cancellationToken.ThrowIfCancellationRequested(); progress.Report(10); @@ -200,7 +333,7 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc // Get all shows/movies to include in the collection. var movies = GetMovies(); var shows = GetShows(); - Logger.LogInformation("Reconstructing collections for {MovieCount} movies and {ShowCount} shows using Shoko Groups.", movies.Count, shows.Count); + Logger.LogInformation("Checking collections for {MovieCount} movies and {ShowCount} shows using Shoko Groups.", movies.Count, shows.Count); // Create a tree-map of how it's supposed to be. var movieDict = new Dictionary<Movie, (FileInfo fileInfo, SeasonInfo seasonInfo, ShowInfo showInfo)>(); @@ -208,7 +341,7 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc if (!Lookup.TryGetEpisodeIdsFor(movie, out var episodeIds)) continue; - var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(movie.Path); + var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(movie.Path).ConfigureAwait(false); if (fileInfo == null || seasonInfo == null || showInfo == null) continue; @@ -223,7 +356,7 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc if (!Lookup.TryGetSeriesIdFor(show, out var seriesId)) continue; - var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false); if (showInfo == null) continue; @@ -245,11 +378,18 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc .Where(collectionId => !string.IsNullOrEmpty(collectionId)) .OfType<string>() ) - .GroupBy(collectionId => collectionId) + .Distinct() + .Select(collectionId => ApiManager.GetCollectionInfoForGroup(collectionId)) + ) + .ContinueWith(task => + task.Result + .OfType<CollectionInfo>() + .GroupBy(collectionInfo => collectionInfo.TopLevelId) .Where(groupBy => groupBy.Count() > MinCollectionSize) - .Select(groupBy => ApiManager.GetCollectionInfoForGroup(groupBy.Key)) + .SelectMany(groupBy => groupBy) + .ToDictionary(c => c.Id) ) - .ContinueWith(task => task.Result.ToDictionary(x => x!.Id, x => x!)); + .ConfigureAwait(false); var finalGroups = new Dictionary<string, CollectionInfo>(); foreach (var initialGroup in groupsDict.Values) { var currentGroup = initialGroup; @@ -262,7 +402,7 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc while (!currentGroup.IsTopLevel && !finalGroups.ContainsKey(currentGroup.ParentId!)) { - currentGroup = await ApiManager.GetCollectionInfoForGroup(currentGroup.ParentId!); + currentGroup = await ApiManager.GetCollectionInfoForGroup(currentGroup.ParentId!).ConfigureAwait(false); if (currentGroup == null) break; finalGroups.Add(currentGroup.Id, currentGroup); @@ -273,17 +413,25 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc progress.Report(40); // Find out what to add, what to remove and what to check. + var addedChildren = 0; + var removedChildren = 0; + var totalChildren = 0; var existingCollections = GetGroupCollections(); + var childDict = existingCollections + .Values + .SelectMany(collectionList => collectionList) + .ToDictionary(collection => collection.Id, collection => collection.Children.Concat(collection.GetLinkedChildren()).ToList()); + var parentDict = childDict + .SelectMany(pair => pair.Value.Select(child => (childId: child.Id, parent: pair.Key))) + .GroupBy(tuple => tuple.childId) + .ToDictionary(groupBy => groupBy.Key, groupBy => groupBy.Select(tuple => tuple.parent).ToList()); var toCheck = new Dictionary<string, BoxSet>(); var toRemove = new Dictionary<Guid, BoxSet>(); var toAdd = finalGroups.Keys .Where(groupId => !existingCollections.ContainsKey(groupId)) - .ToHashSet(); - var idToGuidDict = new Dictionary<string, Guid>(); - + .ToList(); foreach (var (groupId, collectionList) in existingCollections) { if (finalGroups.ContainsKey(groupId)) { - idToGuidDict.Add(groupId, collectionList[0].Id); toCheck.Add(groupId, collectionList[0]); foreach (var collection in collectionList.Skip(1)) toRemove.Add(collection.Id, collection); @@ -298,33 +446,45 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc progress.Report(50); // Remove unknown collections. - foreach (var (id, boxSet) in toRemove) { + foreach (var (id, collection) in toRemove) { // Remove the item from all parents. - foreach (var parent in boxSet.GetParents().OfType<BoxSet>()) { - if (toRemove.ContainsKey(parent.Id)) - continue; - await Collection.RemoveFromCollectionAsync(parent.Id, new[] { id }); + 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); + } } - // Remove all children - var children = boxSet.GetLinkedChildren().Select(x => x.Id); - await Collection.RemoveFromCollectionAsync(id, children); + // Log how many children we will be removing. + removedChildren += childDict[collection.Id].Count; // Remove the item. - LibraryManager.DeleteItem(boxSet, new() { DeleteFileLocation = false, DeleteFromExternalProvider = false }); + LibraryManager.DeleteItem(collection, new() { DeleteFileLocation = true, DeleteFromExternalProvider = false }); } cancellationToken.ThrowIfCancellationRequested(); progress.Report(70); // Add the missing collections. - foreach (var missingId in toAdd) { + var addedCollections = toAdd.Count; + while (toAdd.Count > 0) { + // First add any top level ids, then gradually move down until all groups are added. + var index = toAdd.FindIndex(id => finalGroups[id].IsTopLevel); + if (index == -1) + index = toAdd.FindIndex(id => toCheck.ContainsKey(finalGroups[id].ParentId!)); + if (index == -1) + throw new IndexOutOfRangeException("Unable to find the parent to add."); + + var missingId = toAdd[index]; var collectionInfo = finalGroups[missingId]; var collection = await Collection.CreateCollectionAsync(new() { - Name = collectionInfo.Name, - ProviderIds = new() { { ShokoGroupId.Name, missingId } }, - }); + Name = $"{collectionInfo.Name.ForceASCII()} [{ShokoCollectionGroupId.Name}={missingId}]", + ProviderIds = new() { { ShokoCollectionGroupId.Name, missingId } }, + }).ConfigureAwait(false); + + childDict.Add(collection.Id, new()); toCheck.Add(missingId, collection); + toAdd.RemoveAt(index); } cancellationToken.ThrowIfCancellationRequested(); @@ -332,9 +492,27 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc // Check if the collection have the correct children, and add any // missing and remove any extras. + var fixedCollections = 0; foreach (var (groupId, collection) in toCheck) { - var actualChildren = collection.Children.ToList(); + // Edit the metadata to place the collection under the right parent and with the correct name. + var collectionInfo = finalGroups[groupId]; + var updated = false; + var parent = collectionInfo.IsTopLevel ? collectionRoot : toCheck[collectionInfo.ParentId!]; + if (collection.ParentId != parent.Id) { + collection.SetParent(parent); + updated = true; + } + if (!string.Equals(collection.Name, collectionInfo.Name)) { + collection.Name = collectionInfo.Name; + updated = true; + } + if (updated) { + await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + fixedCollections++; + } + + var actualChildren = childDict[collection.Id]; var actualChildCollections = new List<BoxSet>(); var actualChildSeries = new List<Series>(); var actualChildMovies = new List<Movie>(); @@ -350,45 +528,88 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc break; } - var collectionInfo = finalGroups[groupId]; var expectedCollections = collectionInfo.SubCollections .Select(subCollectionInfo => toCheck.TryGetValue(subCollectionInfo.Id, out var boxSet) ? boxSet : null) .OfType<BoxSet>() .ToList(); - var missingCollections = expectedCollections - .Select(show => show.Id) - .Except(actualChildCollections.Select(a => a.Id).ToHashSet()) - .ToList(); var expectedShows = collectionInfo.Shows .Where(showInfo => !showInfo.IsMovieCollection) .SelectMany(showInfo => showDict.Where(pair => pair.Value.Id == showInfo.Id)) .Select(pair => pair.Key) .ToList(); - var missingShows = expectedShows - .Select(show => show.Id) - .Except(actualChildSeries.Select(a => a.Id).ToHashSet()) - .ToList(); var expectedMovies = collectionInfo.Shows .Where(showInfo => showInfo.IsMovieCollection) .SelectMany(showInfo => showInfo.DefaultSeason.EpisodeList.Concat(showInfo.DefaultSeason.AlternateEpisodesList).Select(episodeInfo => (episodeInfo, seasonInfo: showInfo.DefaultSeason))) .SelectMany(tuple => movieDict.Where(pair => pair.Value.seasonInfo.Id == tuple.seasonInfo.Id && pair.Value.fileInfo.EpisodeList.Any(episodeInfo => episodeInfo.Id == tuple.episodeInfo.Id))) .Select(pair => pair.Key) .ToList(); + var missingCollections = expectedCollections + .Select(show => show.Id) + .Except(actualChildCollections.Select(a => a.Id).ToHashSet()) + .ToList(); + var missingShows = expectedShows + .Select(show => show.Id) + .Except(actualChildSeries.Select(a => a.Id).ToHashSet()) + .ToList(); var missingMovies = expectedMovies .Select(movie => movie.Id) .Except(actualChildMovies.Select(a => a.Id).ToHashSet()) .ToList(); - var childrenToRemove = actualChildren + var missingChildren = missingCollections + .Concat(missingShows) + .Concat(missingMovies) + .ToList(); + var unwantedChildren = actualChildren .Except(actualChildCollections) .Except(actualChildSeries) .Except(actualChildMovies) .Select(movie => movie.Id) .ToList(); - await Collection.AddToCollectionAsync(collection.Id, missingCollections.Concat(missingShows).Concat(missingMovies)); - await Collection.RemoveFromCollectionAsync(collection.Id, childrenToRemove); + if (missingChildren.Count > 0) + await Collection.AddToCollectionAsync(collection.Id, missingChildren).ConfigureAwait(false); + if (unwantedChildren.Count > 0) + await Collection.RemoveFromCollectionAsync(collection.Id, unwantedChildren).ConfigureAwait(false); + + totalChildren += expectedCollections.Count + expectedShows.Count + expectedMovies.Count; + addedChildren += missingChildren.Count; + removedChildren += unwantedChildren.Count; } progress.Report(100); + + Logger.LogInformation( + "Created {AddedCount} ({AddedCollectionCount},{AddedChildCount}), fixed {FixedCount}, skipped {SkippedCount} ({SkippedCollectionCount},{SkippedChildCount}), and removed {RemovedCount} ({RemovedCollectionCount},{RemovedChildCount}) entities for {MovieCount} movies and {ShowCount} shows using Shoko Groups in {TimeSpent}. (Total={TotalCount})", + addedCollections + addedChildren, + addedCollections, + addedChildren, + fixedCollections - addedCollections, + toCheck.Count + totalChildren - addedCollections - addedChildren - (fixedCollections - addedCollections), + toCheck.Count - addedCollections - (fixedCollections - addedCollections), + totalChildren - addedChildren, + toRemove.Count + removedChildren, + toRemove.Count, + removedChildren, + movies.Count, + shows.Count, + DateTime.Now - timeStarted, + toCheck.Count + totalChildren + ); + } + + #endregion + + #region Cleanup Helpers + + private async Task CleanupAll(IProgress<double> progress, CancellationToken cancellationToken) + { + await CleanupMovies(); + cancellationToken.ThrowIfCancellationRequested(); + + CleanupSeriesCollections(); + cancellationToken.ThrowIfCancellationRequested(); + + CleanupGroupCollections(); + progress.Report(100d); } /// <summary> @@ -412,61 +633,53 @@ private async Task CleanupMovies() } } - private async Task CleanupSeriesCollections() + private void CleanupSeriesCollections() { var collectionDict = GetSeriesCollections(); - var collectionSet = collectionDict.Values - .SelectMany(x => x.Select(y => y.Id)) - .ToHashSet(); - if (collectionDict.Count == 0) return; - Logger.LogInformation("Going to remove {CollectionCount} collection items for {SeriesCount} Shoko Series", collectionSet.Count, collectionDict.Count); + var collectionSet = collectionDict.Values + .SelectMany(x => x.Select(y => y.Id)) + .Distinct() + .Count(); + Logger.LogInformation("Going to remove {CollectionCount} collection items for {SeriesCount} Shoko Series", collectionSet, collectionDict.Count); foreach (var (seriesId, collectionList) in collectionDict) foreach (var collection in collectionList) - await RemoveCollection(collection, collectionSet, seriesId: seriesId); + RemoveCollection(collection, seriesId: seriesId); } - private async Task CleanupGroupCollections() + private void CleanupGroupCollections() { - var collectionDict = GetGroupCollections(); - var collectionSet = collectionDict.Values - .SelectMany(x => x.Select(y => y.Id)) - .ToHashSet(); - if (collectionDict.Count == 0) return; - Logger.LogInformation("Going to remove {CollectionCount} collection items for {GroupCount} Shoko Groups", collectionSet.Count, collectionDict.Count); + var collectionSet = collectionDict.Values + .SelectMany(x => x.Select(y => y.Id)) + .Distinct() + .Count(); + Logger.LogInformation("Going to remove {CollectionCount} collection items for {GroupCount} Shoko Groups", collectionSet, collectionDict.Count); foreach (var (groupId, collectionList) in collectionDict) foreach (var collection in collectionList) - await RemoveCollection(collection, collectionSet, groupId: groupId); + RemoveCollection(collection, groupId: groupId); } - private async Task RemoveCollection(BoxSet boxSet, HashSet<Guid> allBoxSets, string? seriesId = null, string? groupId = null) + private void RemoveCollection(BoxSet collection, string? seriesId = null, string? groupId = null) { - var parents = boxSet.GetParents().OfType<BoxSet>().ToList(); - var children = boxSet.GetLinkedChildren().Select(x => x.Id).ToList(); - Logger.LogTrace("Removing collection {CollectionName} with {ParentCount} parents and {ChildCount} children. (Collection={CollectionId},Series={SeriesId},Group={GroupId})", boxSet.Name, parents.Count, children.Count, boxSet.Id, seriesId, groupId); - - // Remove the item from all parents. - foreach (var parent in parents) { - if (allBoxSets.Contains(parent.Id)) - continue; - await Collection.RemoveFromCollectionAsync(parent.Id, new[] { boxSet.Id }); - } - - // Remove all children - await Collection.RemoveFromCollectionAsync(boxSet.Id, children); + var children = collection.Children.Concat(collection.GetLinkedChildren()).Select(x => x.Id).Distinct().Count(); + Logger.LogTrace("Removing collection {CollectionName} with {ChildCount} children. (Collection={CollectionId},Series={SeriesId},Group={GroupId})", collection.Name, children, collection.Id, seriesId, groupId); // Remove the item. - LibraryManager.DeleteItem(boxSet, new() { DeleteFileLocation = false, DeleteFromExternalProvider = false }); + LibraryManager.DeleteItem(collection, new() { DeleteFileLocation = true, DeleteFromExternalProvider = false }); } + #endregion + + #region Getter Helpers + private List<Movie> GetMovies() { return LibraryManager.GetItemList(new() @@ -500,12 +713,12 @@ private Dictionary<string, IReadOnlyList<BoxSet>> GetSeriesCollections() return LibraryManager.GetItemList(new() { IncludeItemTypes = new[] { BaseItemKind.BoxSet }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, string.Empty } }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoCollectionSeriesId.Name, string.Empty } }, IsVirtualItem = false, Recursive = true, }) .Cast<BoxSet>() - .Select(x => x.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId) && !string.IsNullOrEmpty(seriesId) ? new { SeriesId = seriesId, BoxSet = x } : null) + .Select(x => x.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId) && !string.IsNullOrEmpty(seriesId) ? new { SeriesId = seriesId, BoxSet = x } : null) .Where(x => x != null) .GroupBy(x => x!.SeriesId, x => x!.BoxSet) .ToDictionary(x => x.Key, x => x.ToList() as IReadOnlyList<BoxSet>); @@ -517,14 +730,16 @@ private Dictionary<string, IReadOnlyList<BoxSet>> GetGroupCollections() { IncludeItemTypes = new[] { BaseItemKind.BoxSet }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoGroupId.Name, string.Empty } }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoCollectionGroupId.Name, string.Empty } }, IsVirtualItem = false, Recursive = true, }) .Cast<BoxSet>() - .Select(x => x.ProviderIds.TryGetValue(ShokoGroupId.Name, out var groupId) && !string.IsNullOrEmpty(groupId) ? new { GroupId = groupId, BoxSet = x } : null) + .Select(x => x.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var groupId) && !string.IsNullOrEmpty(groupId) ? new { GroupId = groupId, BoxSet = x } : null) .Where(x => x != null) .GroupBy(x => x!.GroupId, x => x!.BoxSet) .ToDictionary(x => x.Key, x => x.ToList() as IReadOnlyList<BoxSet>); } + + #endregion } \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoCollectionGroupId.cs b/Shokofin/ExternalIds/ShokoCollectionGroupId.cs new file mode 100644 index 00000000..b0ce2096 --- /dev/null +++ b/Shokofin/ExternalIds/ShokoCollectionGroupId.cs @@ -0,0 +1,26 @@ +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace Shokofin.ExternalIds; + +public class ShokoCollectionGroupId : IExternalId +{ + public const string Name = "ShokoCollectionGroup"; + + public bool Supports(IHasProviderIds item) + => item is BoxSet; + + public string ProviderName + => "Shoko Group"; + + public string Key + => Name; + + public ExternalIdMediaType? Type + => null; + + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyUrl}/webui/collection/group/{{0}}"; +} \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoCollectionSeriesId.cs b/Shokofin/ExternalIds/ShokoCollectionSeriesId.cs new file mode 100644 index 00000000..1a964a7a --- /dev/null +++ b/Shokofin/ExternalIds/ShokoCollectionSeriesId.cs @@ -0,0 +1,26 @@ +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace Shokofin.ExternalIds; + +public class ShokoCollectionSeriesId : IExternalId +{ + public const string Name = "ShokoCollectionSeries"; + + public bool Supports(IHasProviderIds item) + => item is BoxSet; + + public string ProviderName + => "Shoko Series"; + + public string Key + => Name; + + public ExternalIdMediaType? Type + => null; + + public virtual string UrlFormatString + => $"{Plugin.Instance.Configuration.PrettyUrl}/webui/collection/series/{{0}}"; +} \ No newline at end of file diff --git a/Shokofin/ExternalIds/ShokoEpisodeId.cs b/Shokofin/ExternalIds/ShokoEpisodeId.cs index 78cb06a5..6ab4cffa 100644 --- a/Shokofin/ExternalIds/ShokoEpisodeId.cs +++ b/Shokofin/ExternalIds/ShokoEpisodeId.cs @@ -4,7 +4,6 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -#nullable enable namespace Shokofin.ExternalIds; public class ShokoEpisodeId : IExternalId diff --git a/Shokofin/ExternalIds/ShokoFileId.cs b/Shokofin/ExternalIds/ShokoFileId.cs index 358273ed..6f821d3f 100644 --- a/Shokofin/ExternalIds/ShokoFileId.cs +++ b/Shokofin/ExternalIds/ShokoFileId.cs @@ -4,10 +4,8 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -#nullable enable namespace Shokofin.ExternalIds; - public class ShokoFileId : IExternalId { public const string Name = "Shoko File"; diff --git a/Shokofin/ExternalIds/ShokoGroupId.cs b/Shokofin/ExternalIds/ShokoGroupId.cs index 164e5889..3603e8b5 100644 --- a/Shokofin/ExternalIds/ShokoGroupId.cs +++ b/Shokofin/ExternalIds/ShokoGroupId.cs @@ -1,10 +1,8 @@ using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -#nullable enable namespace Shokofin.ExternalIds; public class ShokoGroupId : IExternalId @@ -12,7 +10,7 @@ public class ShokoGroupId : IExternalId public const string Name = "Shoko Group"; public bool Supports(IHasProviderIds item) - => item is Series or BoxSet; + => item is Series; public string ProviderName => Name; diff --git a/Shokofin/ExternalIds/ShokoSeriesId.cs b/Shokofin/ExternalIds/ShokoSeriesId.cs index 1f468202..0e3c4091 100644 --- a/Shokofin/ExternalIds/ShokoSeriesId.cs +++ b/Shokofin/ExternalIds/ShokoSeriesId.cs @@ -4,7 +4,6 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -#nullable enable namespace Shokofin.ExternalIds; public class ShokoSeriesId : IExternalId diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index 0504f0e8..e22da65b 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -53,14 +53,6 @@ public interface IIdLookup /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> bool TryGetSeriesIdFor(Season season, [NotNullWhen(true)] out string? seriesId); - /// <summary> - /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. - /// </summary> - /// <param name="season">The <see cref="MediaBrowser.Controller.Entities.TV.Season" /> to check for.</param> - /// <param name="seriesId">The variable to put the id in.</param> - /// <returns>True if it successfully retrieved the id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />.</returns> - bool TryGetSeriesIdFor(BoxSet boxSet, [NotNullWhen(true)] out string? seriesId); - /// <summary> /// Try to get the Shoko Series Id for the <see cref="MediaBrowser.Controller.Entities.TV.Season" />. /// </summary> @@ -215,22 +207,6 @@ public bool TryGetSeriesIdFor(Movie movie, [NotNullWhen(true)] out string? serie return false; } - public bool TryGetSeriesIdFor(BoxSet boxSet, [NotNullWhen(true)] out string? seriesId) - { - if (boxSet.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId!) && !string.IsNullOrEmpty(seriesId)) { - return true; - } - - if (TryGetSeriesIdFor(boxSet.Path, out seriesId)) { - if (ApiManager.TryGetDefaultSeriesIdForSeriesId(seriesId, out var defaultSeriesId)) { - seriesId = defaultSeriesId!; - } - return true; - } - - return false; - } - #endregion #region Series Path diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 95c0f96d..fbd1e250 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -54,7 +54,7 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info) var result = new MetadataResult<BoxSet>(); // First try to re-use any existing series id. - if (!info.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId)) + if (!info.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId)) return result; var season = await ApiManager.GetSeasonInfoForSeries(seriesId); @@ -70,6 +70,8 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info) var (displayTitle, alternateTitle) = Text.GetSeasonTitles(season, info.MetadataLanguage); + Logger.LogInformation("Found collection {CollectionName} (Series={SeriesId})", displayTitle, season.Id); + result.Item = new BoxSet { Name = displayTitle, OriginalTitle = alternateTitle, @@ -80,7 +82,7 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info) Tags = season.Tags.ToArray(), CommunityRating = season.AniDB.Rating.ToFloat(10), }; - result.Item.SetProviderId(ShokoSeriesId.Name, season.Id); + result.Item.SetProviderId(ShokoCollectionSeriesId.Name, season.Id); if (Plugin.Instance.Configuration.AddAniDBId) result.Item.SetProviderId("AniDB", season.AniDB.Id.ToString()); @@ -93,7 +95,7 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in { // Filter out all manually created collections. We don't help those. var result = new MetadataResult<BoxSet>(); - if (!info.ProviderIds.TryGetValue(ShokoGroupId.Name, out var groupId)) + if (!info.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var groupId)) return result; var collection = await ApiManager.GetCollectionInfoForGroup(groupId); @@ -102,11 +104,13 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo in return result; } + Logger.LogInformation("Found collection {CollectionName} (Series={SeriesId})", collection.Name, collection.Id); + result.Item = new BoxSet { Name = collection.Name, Overview = collection.Shoko.Description, }; - result.Item.SetProviderId(ShokoGroupId.Name, collection.Id); + result.Item.SetProviderId(ShokoCollectionGroupId.Name, collection.Id); result.HasMetadata = true; return result; diff --git a/Shokofin/Providers/CustomBoxSetProvider.cs b/Shokofin/Providers/CustomBoxSetProvider.cs new file mode 100644 index 00000000..7abee8da --- /dev/null +++ b/Shokofin/Providers/CustomBoxSetProvider.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Info; +using Shokofin.Collections; +using Shokofin.ExternalIds; +using Shokofin.Utils; + +namespace Shokofin.Providers; + +/// <summary> +/// The custom episode provider. Responsible for de-duplicating 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 CustomBoxSetProvider : ICustomMetadataProvider<BoxSet> +{ + public string Name => Plugin.MetadataProviderName; + + private readonly ILogger<CustomBoxSetProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + private readonly ILibraryManager LibraryManager; + + private readonly CollectionManager CollectionManager; + + public CustomBoxSetProvider(ILogger<CustomBoxSetProvider> logger, ShokoAPIManager apiManager, ILibraryManager libraryManager, CollectionManager collectionManager) + { + Logger = logger; + ApiManager = apiManager; + LibraryManager = libraryManager; + CollectionManager = collectionManager; + } + + public async Task<ItemUpdateType> FetchAsync(BoxSet collection, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + // Abort if the collection root is not made yet (which should never happen). + var collectionRoot = await CollectionManager.GetCollectionsFolder(false); + if (collectionRoot is null) + return ItemUpdateType.None; + + // Try to read the shoko group id + if (( + collection.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var collectionId) || + collection.Path.TryGetAttributeValue(ShokoCollectionGroupId.Name, out collectionId) + ) && await EnsureGroupCollectionIsCorrect(collectionRoot, collection, collectionId, cancellationToken)) + return ItemUpdateType.MetadataEdit; + + // Try to read the shoko series id + if (( + collection.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId) || + collection.Path.TryGetAttributeValue(ShokoCollectionSeriesId.Name, out seriesId) + ) && await EnsureSeriesCollectionIsCorrect(collection, seriesId, cancellationToken)) + return ItemUpdateType.MetadataEdit; + + return ItemUpdateType.None; + } + + private async Task<bool> EnsureSeriesCollectionIsCorrect(BoxSet collection, string seriesId, CancellationToken cancellationToken) + { + var seasonInfo = await ApiManager.GetSeasonInfoForSeries(seriesId); + if (seasonInfo is null) + return false; + + var updated = false; + var metadataLanguage = LibraryManager.GetLibraryOptions(collection)?.PreferredMetadataLanguage; + var (displayName, alternateTitle) = Text.GetSeasonTitles(seasonInfo, metadataLanguage); + if (!string.Equals(collection.Name, displayName)) { + collection.Name = displayName; + updated = true; + } + if (!string.Equals(collection.OriginalTitle, alternateTitle)) { + collection.OriginalTitle = alternateTitle; + updated = true; + } + + if (updated) { + await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken); + Logger.LogDebug("Fixed collection {CollectionName} (Series={SeriesId})", collection.Name, seriesId); + } + + return updated; + } + + private async Task<bool> EnsureGroupCollectionIsCorrect(Folder collectionRoot, BoxSet collection, string collectionId, CancellationToken cancellationToken) + { + var collectionInfo = await ApiManager.GetCollectionInfoForGroup(collectionId); + if (collectionInfo is null) + return false; + + var updated = false; + var parent = collectionInfo.IsTopLevel ? collectionRoot : await GetCollectionByGroupId(collectionRoot, collectionInfo.ParentId); + if (collection.ParentId != parent.Id) { + collection.SetParent(parent); + updated = true; + } + if (!string.Equals(collection.Name, collectionInfo.Name)) { + collection.Name = collectionInfo.Name; + updated = true; + } + if (updated) { + await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken); + Logger.LogDebug("Fixed collection {CollectionName} (Group={GroupId})", collection.Name, collectionId); + } + + return updated; + } + + private async Task<BoxSet> GetCollectionByGroupId(Folder collectionRoot, string? collectionId) + { + if (string.IsNullOrEmpty(collectionId)) + throw new ArgumentNullException(nameof(collectionId)); + + var collectionInfo = await ApiManager.GetCollectionInfoForGroup(collectionId) ?? + throw new Exception($"Unable to find collection info for the parent collection with id \"{collectionId}\""); + + var collection = GetCollectionByPath(collectionRoot, collectionInfo); + if (collection is not null) + return collection; + + var list = LibraryManager.GetItemList(new() + { + IncludeItemTypes = new[] { BaseItemKind.BoxSet }, + + HasAnyProviderId = new() { { ShokoCollectionGroupId.Name, collectionId } }, + IsVirtualItem = false, + Recursive = true, + }) + .OfType<BoxSet>() + .ToList(); + if (list.Count == 0) { + throw new NullReferenceException("Unable to a find collection with the given group id."); + } + if (list.Count > 1) { + throw new Exception("Found multiple collections with the same group id."); + } + return list[0]!; + } + + private BoxSet? GetCollectionByPath(Folder collectionRoot, CollectionInfo collectionInfo) + { + var baseName = $"{collectionInfo.Name.ForceASCII()} [{ShokoCollectionGroupId.Name}={collectionInfo.Id}]"; + var folderName = BaseItem.FileSystem.GetValidFilename(baseName) + " [boxset]"; + var path = Path.Combine(collectionRoot.Path, folderName); + return LibraryManager.FindByPath(path, true) as BoxSet; + } + +} diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 1003d4f3..024bc001 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -11,7 +12,7 @@ using MediaBrowser.Model.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; -using System.Linq; +using Shokofin.ExternalIds; namespace Shokofin.Providers; @@ -95,7 +96,11 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell break; } case BoxSet boxSet: { - if (Lookup.TryGetSeriesIdFor(boxSet, out var seriesId)) { + if (!boxSet.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId)) { + if (boxSet.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var collectionId)) + seriesId = (await ApiManager.GetCollectionInfoForGroup(collectionId))?.Shoko.IDs.MainSeries.ToString(); + } + if (!string.IsNullOrEmpty(seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages != null) { AddImagesForSeries(ref list, seriesImages); diff --git a/Shokofin/StringExtensions.cs b/Shokofin/StringExtensions.cs index d352562a..6be7d5d0 100644 --- a/Shokofin/StringExtensions.cs +++ b/Shokofin/StringExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.RegularExpressions; using MediaBrowser.Common.Providers; @@ -133,6 +134,6 @@ public static string ReplaceInvalidPathCharacters(this string path) return null; } - public static bool TryGetAttributeValue(this string text, string attribute, out string? value) + public static bool TryGetAttributeValue(this string text, string attribute, [NotNullWhen(true)] out string? value) => !string.IsNullOrEmpty(value = GetAttributeValue(text, attribute)); } \ No newline at end of file diff --git a/Shokofin/Tasks/ReconstructCollectionsTask.cs b/Shokofin/Tasks/ReconstructCollectionsTask.cs new file mode 100644 index 00000000..fd4c334b --- /dev/null +++ b/Shokofin/Tasks/ReconstructCollectionsTask.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.Tasks; +using Shokofin.Collections; +using Shokofin.Utils; + +namespace Shokofin.Tasks; + +/// <summary> +/// Reconstruct all Shoko collections outside a Library Scan. +/// </summary> +public class ReconstructCollectionsTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Reconstruct Collections"; + + /// <inheritdoc /> + public string Description => "Reconstruct all Shoko collections outside a Library Scan."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoReconstructCollections"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + + private readonly CollectionManager CollectionManager; + + private readonly LibraryScanWatcher LibraryScanWatcher; + + public ReconstructCollectionsTask(CollectionManager collectionManager, LibraryScanWatcher libraryScanWatcher) + { + CollectionManager = collectionManager; + LibraryScanWatcher = libraryScanWatcher; + } + + 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> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + if (LibraryScanWatcher.IsScanRunning) + return; + + await CollectionManager.ReconstructCollections(progress, cancellationToken); + } +} diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index d9914956..458d7442 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -252,16 +252,16 @@ public static string SanitizeAnidbDescription(string summary) return outputText; } - public static (string?, string?) GetEpisodeTitles(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, string metadataLanguage) + public static (string?, string?) GetEpisodeTitles(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, string? metadataLanguage) => ( GetEpisodeTitleByType(episodeInfo, seasonInfo, TitleProviderType.Main, metadataLanguage), GetEpisodeTitleByType(episodeInfo, seasonInfo, TitleProviderType.Alternate, metadataLanguage) ); - public static (string?, string?) GetSeasonTitles(SeasonInfo seasonInfo, string metadataLanguage) + public static (string?, string?) GetSeasonTitles(SeasonInfo seasonInfo, string? metadataLanguage) => GetSeasonTitles(seasonInfo, 0, metadataLanguage); - public static (string?, string?) GetSeasonTitles(SeasonInfo seasonInfo, int baseSeasonOffset, string metadataLanguage) + public static (string?, string?) GetSeasonTitles(SeasonInfo seasonInfo, int baseSeasonOffset, string? metadataLanguage) { var displayTitle = GetSeriesTitleByType(seasonInfo, seasonInfo.Shoko.Name, TitleProviderType.Main, metadataLanguage); var alternateTitle = GetSeriesTitleByType(seasonInfo, seasonInfo.Shoko.Name, TitleProviderType.Alternate, metadataLanguage); @@ -282,13 +282,13 @@ public static (string?, string?) GetSeasonTitles(SeasonInfo seasonInfo, int base return (displayTitle, alternateTitle); } - public static (string?, string?) GetShowTitles(ShowInfo showInfo, string metadataLanguage) + public static (string?, string?) GetShowTitles(ShowInfo showInfo, string? metadataLanguage) => ( GetSeriesTitleByType(showInfo.DefaultSeason, showInfo.Name, TitleProviderType.Main, metadataLanguage), GetSeriesTitleByType(showInfo.DefaultSeason, showInfo.Name, TitleProviderType.Alternate, metadataLanguage) ); - public static (string?, string?) GetMovieTitles(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, string metadataLanguage) + public static (string?, string?) GetMovieTitles(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, string? metadataLanguage) => ( GetMovieTitleByType(episodeInfo, seasonInfo, TitleProviderType.Main, metadataLanguage), GetMovieTitleByType(episodeInfo, seasonInfo, TitleProviderType.Alternate, metadataLanguage) @@ -310,7 +310,7 @@ private static TitleProvider[] GetOrderedTitleProvidersByType(TitleProviderType _ => Array.Empty<TitleProvider>(), }; - private static string? GetMovieTitleByType(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, TitleProviderType type, string metadataLanguage) + private static string? GetMovieTitleByType(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, TitleProviderType type, string? metadataLanguage) { var mainTitle = GetSeriesTitleByType(seasonInfo, seasonInfo.Shoko.Name, type, metadataLanguage); var subTitle = GetEpisodeTitleByType(episodeInfo, seasonInfo, type, metadataLanguage); @@ -320,7 +320,7 @@ private static TitleProvider[] GetOrderedTitleProvidersByType(TitleProviderType return mainTitle?.Trim(); } - private static string? GetEpisodeTitleByType(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, TitleProviderType type, string metadataLanguage) + private static string? GetEpisodeTitleByType(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, TitleProviderType type, string? metadataLanguage) { foreach (var provider in GetOrderedTitleProvidersByType(type)) { var title = provider switch { @@ -340,7 +340,7 @@ private static TitleProvider[] GetOrderedTitleProvidersByType(TitleProviderType return null; } - private static string? GetSeriesTitleByType(SeasonInfo seasonInfo, string defaultName, TitleProviderType type, string metadataLanguage) + private static string? GetSeriesTitleByType(SeasonInfo seasonInfo, string defaultName, TitleProviderType type, string? metadataLanguage) { foreach (var provider in GetOrderedTitleProvidersByType(type)) { var title = provider switch { @@ -376,9 +376,12 @@ private static TitleProvider[] GetOrderedTitleProvidersByType(TitleProviderType /// <param name="usingTypes">Search using titles</param> /// <param name="metadataLanguages">The metadata languages to search for.</param> /// <returns>The first found title in any of the provided metadata languages, or null.</returns> - public static string? GetTitlesForLanguage(List<Title> titles, bool usingTypes, params string[] metadataLanguages) + public static string? GetTitlesForLanguage(List<Title> titles, bool usingTypes, params string?[] metadataLanguages) { - foreach (string lang in metadataLanguages) { + foreach (var lang in metadataLanguages) { + if (string.IsNullOrEmpty(lang)) + continue; + var titleList = titles.Where(t => t.LanguageCode == lang).ToList(); if (titleList.Count == 0) continue; From c1facd279013b2096dc21bff8403503dff736667 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 20 May 2024 19:57:17 +0000 Subject: [PATCH 1004/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 95cb7176..ee5ae528 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.179", + "changelog": "refactor: make collections great again!\n\n- Fixed collections. But for real this time, by extensively testing the different paths to both add and remove collections.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.179/shoko_3.0.1.179.zip", + "checksum": "c071f804862d18f9a8d78511db62ce94", + "timestamp": "2024-05-20T19:57:15Z" + }, { "version": "3.0.1.178", "changelog": "fix: fix retrieving children of a collection", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.175/shoko_3.0.1.175.zip", "checksum": "f8e454c4ba217808e8cc389d6a996fbc", "timestamp": "2024-05-19T01:00:25Z" - }, - { - "version": "3.0.1.174", - "changelog": "fix: fix incompatibly with stable shoko server", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.174/shoko_3.0.1.174.zip", - "checksum": "9f6939a66682869cdaeef5625643a02b", - "timestamp": "2024-05-18T22:48:58Z" } ] } From 2ef11fa0c3750458eedf0467645ac5ff6c24be6b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 20 May 2024 23:20:34 +0200 Subject: [PATCH 1005/1103] fix: fix VFS resolution for non-tv-show libraries --- Shokofin/Resolvers/ShokoResolveManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 797a9f3e..3e4195de 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -1220,7 +1220,7 @@ private static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string public async Task<BaseItem?> ResolveSingle(Folder? parent, string? collectionType, FileSystemMetadata fileInfo) { - if (collectionType is not CollectionType.TvShows or CollectionType.Movies or null || parent is null || fileInfo is null) + if (!(collectionType is CollectionType.TvShows or CollectionType.Movies or null) || parent is null || fileInfo is null) return null; var root = LibraryManager.RootFolder; @@ -1261,7 +1261,7 @@ private static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string public async Task<MultiItemResolverResult?> ResolveMultiple(Folder? parent, string? collectionType, List<FileSystemMetadata> fileInfoList) { - if (collectionType is not CollectionType.TvShows or CollectionType.Movies or null || parent is null) + if (!(collectionType is CollectionType.TvShows or CollectionType.Movies or null) || parent is null) return null; var root = LibraryManager.RootFolder; From 0dbb69fde818243f1e769209653db9fdabf8cd70 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 20 May 2024 21:21:25 +0000 Subject: [PATCH 1006/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index ee5ae528..ddb756ff 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.180", + "changelog": "fix: fix VFS resolution for non-tv-show libraries", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.180/shoko_3.0.1.180.zip", + "checksum": "1460c4f4772e28c75f3405cb84bc7fa7", + "timestamp": "2024-05-20T21:21:23Z" + }, { "version": "3.0.1.179", "changelog": "refactor: make collections great again!\n\n- Fixed collections. But for real this time, by extensively testing the different paths to both add and remove collections.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.176/shoko_3.0.1.176.zip", "checksum": "f270ec760c88156cb9ce8dd0739705b5", "timestamp": "2024-05-19T01:30:14Z" - }, - { - "version": "3.0.1.175", - "changelog": "fix: fix multi-series file emits in VFS\nwhen emitting for the media folder", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.175/shoko_3.0.1.175.zip", - "checksum": "f8e454c4ba217808e8cc389d6a996fbc", - "timestamp": "2024-05-19T01:00:25Z" } ] } From 14c7dc07b1d48d5197ec18bee1805d982499cf95 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 21 May 2024 01:26:28 +0200 Subject: [PATCH 1007/1103] fix: fix collection creation criteria --- Shokofin/API/Models/Series.cs | 15 +++++++++++++++ Shokofin/API/ShokoAPIManager.cs | 4 ++-- Shokofin/Collections/CollectionManager.cs | 16 +++++++++------- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index 1592bf0d..179799dd 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -224,6 +224,21 @@ public class SeriesIDs : IDs /// </summary> public class SeriesSizes { + /// <summary> + /// Combined count of all files across all file sources within the series or group. + /// </summary> + public int Files => + FileSources.Unknown + + FileSources.Other + + FileSources.TV + + FileSources.DVD + + FileSources.BluRay + + FileSources.Web + + FileSources.VHS + + FileSources.VCD + + FileSources.LaserDisc + + FileSources.Camera; + /// <summary> /// Counts of each file source type available within the local collection /// </summary> diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index fb84e102..65c917e7 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -894,8 +894,8 @@ private Task<CollectionInfo> CreateCollectionInfo(Group group, string groupId) if (showGroupIds.Contains(subGroup.IDs.Shoko.ToString()) && !collectionIds.Contains(subGroup.IDs.Shoko.ToString())) continue; var subCollectionInfo = await CreateCollectionInfo(subGroup, subGroup.IDs.Shoko.ToString()).ConfigureAwait(false); - - groupList.Add(subCollectionInfo); + if (subCollectionInfo.Shoko.Sizes.Files > 0) + groupList.Add(subCollectionInfo); } } diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index ff41379f..de8eb40b 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -376,18 +376,20 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc showDict.Values .Select(showInfo => showInfo.CollectionId) .Where(collectionId => !string.IsNullOrEmpty(collectionId)) - .OfType<string>() ) - .Distinct() - .Select(collectionId => ApiManager.GetCollectionInfoForGroup(collectionId)) + .GroupBy(collectionId => collectionId) + .Select(groupBy => + ApiManager.GetCollectionInfoForGroup(groupBy.Key!) + .ContinueWith(task => (collectionInfo: task.Result, count: groupBy.Count())) + ) ) .ContinueWith(task => task.Result - .OfType<CollectionInfo>() - .GroupBy(collectionInfo => collectionInfo.TopLevelId) - .Where(groupBy => groupBy.Count() > MinCollectionSize) + .Where(tuple => tuple.collectionInfo != null) + .GroupBy(tuple => tuple.collectionInfo!.TopLevelId) + .Where(groupBy => groupBy.Sum(tuple => tuple.count) > MinCollectionSize) .SelectMany(groupBy => groupBy) - .ToDictionary(c => c.Id) + .ToDictionary(c => c.collectionInfo!.Id, c => c.collectionInfo!) ) .ConfigureAwait(false); var finalGroups = new Dictionary<string, CollectionInfo>(); From a8daf9e46ec3b9a49e882beb3bb95b79dd78b9c4 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Mon, 20 May 2024 23:27:15 +0000 Subject: [PATCH 1008/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index ddb756ff..1e30787d 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.181", + "changelog": "fix: fix collection creation criteria", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.181/shoko_3.0.1.181.zip", + "checksum": "0af3f33d0e9f94e1bd653d40ea87cb32", + "timestamp": "2024-05-20T23:27:13Z" + }, { "version": "3.0.1.180", "changelog": "fix: fix VFS resolution for non-tv-show libraries", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.177/shoko_3.0.1.177.zip", "checksum": "551621544d7fd5230575d540f4be9460", "timestamp": "2024-05-19T05:20:46Z" - }, - { - "version": "3.0.1.176", - "changelog": "fix: fix multi-series file emits in VFS (take 2)\n\u2026this time for sure it will work\u2026", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.176/shoko_3.0.1.176.zip", - "checksum": "f270ec760c88156cb9ce8dd0739705b5", - "timestamp": "2024-05-19T01:30:14Z" } ] } From 7118205301b82581c7bf7d839a06710107a4cab1 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 21 May 2024 03:35:51 +0200 Subject: [PATCH 1009/1103] misc: add custom css for collections to settings page --- Shokofin/Configuration/configPage.html | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 8d1d107d..45cc8e75 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -275,7 +275,31 @@ <h3>Library Settings</h3> <option value="Movies">Create collections for movies based upon Shoko's series</option> <option value="Shared">Create collections for movies and shows based upon Shoko's groups and series</option> </select> - <div class="fieldDescription">Determines what entities to group into collections.</div> + <div class="fieldDescription"> + <div>Determines what entities to group into 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 + better align with what you'd expect them to be named. If you want it in another language then + you need to translate it yourself and replace it in the CSS before setting it in your server. + <pre> +.collectionItems .verticalSection:has(div[data-type="Movie"]) .sectionTitle.sectionTitle-cards > span { + visibility: hidden; +} +.collectionItems .verticalSection:has(div[data-type="Movie"]) .sectionTitle.sectionTitle-cards > span::before { + visibility: initial; + content: "Movies"; +} +.collectionItems .verticalSection:has(div[data-type="BoxSet"]) .sectionTitle.sectionTitle-cards > span { + visibility: hidden; +} +.collectionItems .verticalSection:has(div[data-type="BoxSet"]) .sectionTitle.sectionTitle-cards > span::before { + visibility: initial; + content: "Collections"; +} +</pre> + </details> + </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> From 5201553c3f5b668fdee06f71340d7b149acafd98 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 21 May 2024 03:36:07 +0200 Subject: [PATCH 1010/1103] fix: fix shared collections for movies --- Shokofin/Collections/CollectionManager.cs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index de8eb40b..5d4e3f6c 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -338,9 +338,6 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc // Create a tree-map of how it's supposed to be. var movieDict = new Dictionary<Movie, (FileInfo fileInfo, SeasonInfo seasonInfo, ShowInfo showInfo)>(); foreach (var movie in movies) { - if (!Lookup.TryGetEpisodeIdsFor(movie, out var episodeIds)) - continue; - var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(movie.Path).ConfigureAwait(false); if (fileInfo == null || seasonInfo == null || showInfo == null) continue; @@ -367,16 +364,17 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc progress.Report(30); // Filter to only collections with at least (`MinCollectionSize` + 1) entries in them. + var movieCollections = movieDict.Values + .Select(tuple => tuple.showInfo.CollectionId) + .Where(collectionId => !string.IsNullOrEmpty(collectionId)) + .ToList(); + var showCollections = showDict.Values + .Select(showInfo => showInfo.CollectionId) + .Where(collectionId => !string.IsNullOrEmpty(collectionId)) + .ToList(); var groupsDict = await Task .WhenAll( - movieDict.Values - .Select(tuple => tuple.seasonInfo) - .Select(seasonInfo => seasonInfo.Shoko.IDs.ParentGroup.ToString()) - .Concat( - showDict.Values - .Select(showInfo => showInfo.CollectionId) - .Where(collectionId => !string.IsNullOrEmpty(collectionId)) - ) + movieCollections.Concat(showCollections) .GroupBy(collectionId => collectionId) .Select(groupBy => ApiManager.GetCollectionInfoForGroup(groupBy.Key!) From 60941fd24974dd9ec5bb158577153833ffb0b9ad Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 21 May 2024 03:36:42 +0200 Subject: [PATCH 1011/1103] fix: fix logging for images for collections --- Shokofin/Providers/ImageProvider.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 024bc001..ed57c27f 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -95,17 +95,18 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell } break; } - case BoxSet boxSet: { - if (!boxSet.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId)) { - if (boxSet.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var collectionId)) - seriesId = (await ApiManager.GetCollectionInfoForGroup(collectionId))?.Shoko.IDs.MainSeries.ToString(); + case BoxSet collection: { + string? groupId = null; + if (!collection.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId)) { + if (collection.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out groupId)) + seriesId = (await ApiManager.GetCollectionInfoForGroup(groupId))?.Shoko.IDs.MainSeries.ToString(); } if (!string.IsNullOrEmpty(seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages != null) { AddImagesForSeries(ref list, seriesImages); } - Logger.LogInformation("Getting {Count} images for box-set {BoxSetName} (Series={SeriesId})", list.Count, boxSet.Name, seriesId); + Logger.LogInformation("Getting {Count} images for collection {CollectionName} (Group={GroupId},Series={SeriesId})", list.Count, collection.Name, groupId, groupId == null ? seriesId : null); } break; } From 2f3722edc3be2d34aba5c69bd77d7119fa4504a6 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 21 May 2024 03:37:49 +0200 Subject: [PATCH 1012/1103] refactor: update collection provider --- Shokofin/Providers/BoxSetProvider.cs | 36 ++++++++++++---------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index fbd1e250..b3883acf 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -37,11 +37,17 @@ public BoxSetProvider(IHttpClientFactory httpClientFactory, ILogger<BoxSetProvid public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) { try { - return Plugin.Instance.Configuration.CollectionGrouping switch - { - Ordering.CollectionCreationType.Shared => await GetShokoGroupedMetadata(info), - _ => await GetDefaultMetadata(info), - }; + // Try to read the shoko group id + if (info.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var collectionId) || + info.Path.TryGetAttributeValue(ShokoCollectionGroupId.Name, out collectionId)) + return await GetShokoGroupedMetadata(info, collectionId); + + // Try to read the shoko series id + if (info.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId) || + info.Path.TryGetAttributeValue(ShokoCollectionSeriesId.Name, out seriesId)) + return await GetDefaultMetadata(info, seriesId); + + return new(); } catch (Exception ex) { Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); @@ -49,22 +55,13 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat } } - public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info) + public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, string seriesId) { - var result = new MetadataResult<BoxSet>(); - // First try to re-use any existing series id. - if (!info.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId)) - return result; - + var result = new MetadataResult<BoxSet>(); var season = await ApiManager.GetSeasonInfoForSeries(seriesId); if (season == null) { - Logger.LogWarning("Unable to find movie box-set info for name {Name} and path {Path}", info.Name, info.Path); - return result; - } - - if (season.EpisodeList.Count <= 1) { - Logger.LogWarning("Series did not contain multiple movies! Skipping path {Path} (Series={SeriesId})", info.Path, season.Id); + Logger.LogWarning("Unable to find movie box-set info for name {Name} and path {Path}", info.Name, info.Path); return result; } @@ -91,13 +88,10 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info) return result; } - private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo info) + private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo info, string groupId) { // Filter out all manually created collections. We don't help those. var result = new MetadataResult<BoxSet>(); - if (!info.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var groupId)) - return result; - var collection = await ApiManager.GetCollectionInfoForGroup(groupId); if (collection == null) { Logger.LogWarning("Unable to find collection info for name {Name} and path {Path}", info.Name, info.Path); From 7c7290ca664fcb218b038ce739bcc297ab9f397c Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Tue, 21 May 2024 01:38:37 +0000 Subject: [PATCH 1013/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 1e30787d..8200d86e 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.182", + "changelog": "refactor: update collection provider\n\nfix: fix logging for images for collections\n\nfix: fix shared collections for movies\n\nmisc: add custom css for collections to settings page", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.182/shoko_3.0.1.182.zip", + "checksum": "8bd2f8b999d41f2449f0c6568d2de13a", + "timestamp": "2024-05-21T01:38:36Z" + }, { "version": "3.0.1.181", "changelog": "fix: fix collection creation criteria", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.178/shoko_3.0.1.178.zip", "checksum": "b5083a5d0c5b0a2e456a35363f5f4194", "timestamp": "2024-05-20T00:01:12Z" - }, - { - "version": "3.0.1.177", - "changelog": "fix: add work-around for signalr file events\n\n- Added a work-around for signalr file events for when both\n real time monitoring _and_ the VFS is enabled for the library,\n to make it function properly and not ignore the event.\n\nmisc: == \u2192 is / != \u2192 is not\n\nmisc: cleanup cleanup function + more [skip ci]\n\n- Clean-up clean-up function.\n\n- Fix style in try move subtitle file.\n\n- Simplify get ids for path.\n\n- Add when to catch in get new relative path.\n\nmisc: add more guards for library scan [skip ci]\n\nmisc: emit how many updates are scheduled [skip ci]\n\nmisc: fix log point [skip ci]\n\nmisc: remove unused code\u00a0[skip ci]", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.177/shoko_3.0.1.177.zip", - "checksum": "551621544d7fd5230575d540f4be9460", - "timestamp": "2024-05-19T05:20:46Z" } ] } From c1b290e5a2cef0d563a56b513f6bf2c25f907010 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 22 May 2024 04:40:27 +0200 Subject: [PATCH 1014/1103] feat: add trailer & video providers - Added a trailer and video provider responsible for fetching metadata for theme videos, special featurettes, and trailers __for entities in the VFS__. --- Shokofin/Providers/TrailerProvider.cs | 81 +++++++++++++++++++++++++++ Shokofin/Providers/VideoProvider.cs | 81 +++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 Shokofin/Providers/TrailerProvider.cs create mode 100644 Shokofin/Providers/VideoProvider.cs diff --git a/Shokofin/Providers/TrailerProvider.cs b/Shokofin/Providers/TrailerProvider.cs new file mode 100644 index 00000000..a99cd78d --- /dev/null +++ b/Shokofin/Providers/TrailerProvider.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.Utils; + +namespace Shokofin.Providers; + +public class TrailerProvider: IRemoteMetadataProvider<Trailer, TrailerInfo>, IHasOrder +{ + public string Name => Plugin.MetadataProviderName; + + // Always run first, so we can react to the VFS entries. + public int Order => -1; + + private readonly IHttpClientFactory HttpClientFactory; + + private readonly ILogger<TrailerProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + public TrailerProvider(IHttpClientFactory httpClientFactory, ILogger<TrailerProvider> logger, ShokoAPIManager apiManager) + { + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiManager = apiManager; + } + + public async Task<MetadataResult<Trailer>> GetMetadata(TrailerInfo info, CancellationToken cancellationToken) + { + try { + var result = new MetadataResult<Trailer>(); + var config = Plugin.Instance.Configuration; + if (string.IsNullOrEmpty(info.Path) || !info.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { + return result; + } + + var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(info.Path); + var episodeInfo = fileInfo?.EpisodeList.FirstOrDefault().Episode; + if (fileInfo == null || episodeInfo == null || seasonInfo == null || showInfo == null) { + Logger.LogWarning("Unable to find episode info for path {Path}", info.Path); + return result; + } + + var (displayTitle, alternateTitle) = Text.GetEpisodeTitles(episodeInfo, seasonInfo, info.MetadataLanguage); + var description = Text.GetDescription(episodeInfo); + result.Item = new() + { + Name = displayTitle, + OriginalTitle = alternateTitle, + PremiereDate = episodeInfo.AniDB.AirDate, + ProductionYear = episodeInfo.AniDB.AirDate?.Year ?? seasonInfo.AniDB.AirDate?.Year, + Overview = description, + CommunityRating = episodeInfo.AniDB.Rating.Value > 0 ? episodeInfo.AniDB.Rating.ToFloat(10) : 0, + }; + Logger.LogInformation("Found trailer {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},Group={GroupId})", result.Item.Name, fileInfo.Id, episodeInfo.Id, seasonInfo.Id, showInfo?.GroupId); + + result.HasMetadata = true; + + return result; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<Trailer>(); + } + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(TrailerInfo searchInfo, CancellationToken cancellationToken) + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<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 new file mode 100644 index 00000000..bf22b9da --- /dev/null +++ b/Shokofin/Providers/VideoProvider.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.Utils; + +namespace Shokofin.Providers; + +public class VideoProvider: IRemoteMetadataProvider<Video, ItemLookupInfo>, IHasOrder +{ + public string Name => Plugin.MetadataProviderName; + + // Always run first, so we can react to the VFS entries. + public int Order => -1; + + private readonly IHttpClientFactory HttpClientFactory; + + private readonly ILogger<VideoProvider> Logger; + + private readonly ShokoAPIManager ApiManager; + + public VideoProvider(IHttpClientFactory httpClientFactory, ILogger<VideoProvider> logger, ShokoAPIManager apiManager) + { + HttpClientFactory = httpClientFactory; + Logger = logger; + ApiManager = apiManager; + } + + public async Task<MetadataResult<Video>> GetMetadata(ItemLookupInfo info, CancellationToken cancellationToken) + { + try { + var result = new MetadataResult<Video>(); + var config = Plugin.Instance.Configuration; + if (string.IsNullOrEmpty(info.Path) || !info.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { + return result; + } + + var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(info.Path); + var episodeInfo = fileInfo?.EpisodeList.FirstOrDefault().Episode; + if (fileInfo == null || episodeInfo == null || seasonInfo == null || showInfo == null) { + Logger.LogWarning("Unable to find episode info for path {Path}", info.Path); + return result; + } + + var (displayTitle, alternateTitle) = Text.GetEpisodeTitles(episodeInfo, seasonInfo, info.MetadataLanguage); + var description = Text.GetDescription(episodeInfo); + result.Item = new() + { + Name = displayTitle, + OriginalTitle = alternateTitle, + PremiereDate = episodeInfo.AniDB.AirDate, + ProductionYear = episodeInfo.AniDB.AirDate?.Year ?? seasonInfo.AniDB.AirDate?.Year, + Overview = description, + CommunityRating = episodeInfo.AniDB.Rating.Value > 0 ? episodeInfo.AniDB.Rating.ToFloat(10) : 0, + }; + Logger.LogInformation("Found video {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},Group={GroupId})", result.Item.Name, fileInfo.Id, episodeInfo.Id, seasonInfo.Id, showInfo?.GroupId); + + result.HasMetadata = true; + + return result; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + return new MetadataResult<Video>(); + } + } + + public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ItemLookupInfo searchInfo, CancellationToken cancellationToken) + => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); +} From 8a4dbaf22eb8108010f4ef238aaf098e718396cd Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 22 May 2024 05:06:27 +0200 Subject: [PATCH 1015/1103] feat: make trailers/credits optional to include - Made trailers and credits optional to include, and allowed credits to be added as either theme videos (on by default), or as special features (off by default), or both. --- Shokofin/Configuration/PluginConfiguration.cs | 25 +++++++- Shokofin/Configuration/configController.js | 9 +++ Shokofin/Configuration/configPage.html | 27 +++++++- Shokofin/Resolvers/ShokoResolveManager.cs | 63 ++++++++++--------- 4 files changed, 92 insertions(+), 32 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 3b84d564..34ed9fd0 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -205,11 +205,31 @@ public virtual string PrettyUrl public bool SeparateMovies { get; set; } /// <summary> - /// Append all specials in AniDB movie series as special featurettes for + /// Append all specials in AniDB movie series as special features for /// the movies. /// </summary> public bool MovieSpecialsAsExtraFeaturettes { get; set; } + /// <summary> + /// Add trailers to entities within the VFS. Trailers within the trailers + /// directory when not using the VFS are not affected by this option. + /// </summary> + public bool AddTrailers { get; set; } + + /// <summary> + /// 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. + /// </summary> + public bool AddCreditsAsThemeVideos { get; set; } + + /// <summary> + /// 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. + /// </summary> + public bool AddCreditsAsSpecialFeatures { get; set; } + /// <summary> /// Determines how collections are made. /// </summary> @@ -379,6 +399,9 @@ public PluginConfiguration() UseGroupsForShows = false; SeparateMovies = false; MovieSpecialsAsExtraFeaturettes = false; + AddTrailers = true; + AddCreditsAsThemeVideos = true; + AddCreditsAsSpecialFeatures = false; SeasonOrdering = OrderType.Default; SpecialsPlacement = SpecialOrderType.AfterSeason; AddMissingMetadata = true; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 3c1da095..5f53e75e 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -296,6 +296,9 @@ async function defaultSubmit(form) { config.SeparateMovies = form.querySelector("#SeparateMovies").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 @@ -486,6 +489,9 @@ async function syncSettings(form) { 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; // Tag settings @@ -801,6 +807,9 @@ export default function (page) { form.querySelector("#SeparateMovies").checked = config.SeparateMovies != null ? config.SeparateMovies : true; form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" ? "AfterSeason" : config.SpecialsPlacement; form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked = config.MovieSpecialsAsExtraFeaturettes || false; + form.querySelector("#AddTrailers").checked = config.AddTrailers || false; + form.querySelector("#AddCreditsAsThemeVideos").checked = config.AddCreditsAsThemeVideos || false; + form.querySelector("#AddCreditsAsSpecialFeatures").checked = config.AddCreditsAsSpecialFeatures || false; form.querySelector("#AddMissingMetadata").checked = config.AddMissingMetadata || false; // Media Folder settings diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 45cc8e75..bae05888 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -311,10 +311,33 @@ <h3>Library Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="MovieSpecialsAsExtraFeaturettes" /> - <span>Force movie special featurettes</span> + <span>Force movie special features</span> </label> - <div class="fieldDescription checkboxFieldDescription">Append all specials in AniDB movie series as special featurettes for the movies. By default only some specials will be automatically recognized as special featurettes, but by enabling this option you will force all specials to be used as special featurettes. 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">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> + <!-- Insert 'include/exclude' credits as theme videos or special features here. --> + <!-- Insert 'include/exclude' trailers. --> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="AddMissingMetadata" /> diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 3e4195de..4b6ac819 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -806,8 +806,9 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { return (string.Empty, Array.Empty<string>(), null); var isMovieSeason = season.Type is SeriesType.Movie; + var config = Plugin.Instance.Configuration; var shouldAbort = collectionType switch { - CollectionType.TvShows => isMovieSeason && Plugin.Instance.Configuration.SeparateMovies, + CollectionType.TvShows => isMovieSeason && config.SeparateMovies, CollectionType.Movies => !isMovieSeason, _ => false, }; @@ -838,33 +839,35 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { var isExtra = file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode)); var folders = new List<string>(); - var extrasFolder = file.ExtraType switch { - null => isExtra ? "extras" : null, - ExtraType.ThemeSong => "theme-music", - ExtraType.ThemeVideo => "backdrops", - ExtraType.Trailer => "trailers", - _ => "extras", - }; - var fileNameSuffix = file.ExtraType switch { - ExtraType.BehindTheScenes => "-behindTheScenes", - ExtraType.Clip => "-clip", - ExtraType.DeletedScene => "-deletedScene", - ExtraType.Interview => "-interview", - ExtraType.Scene => "-scene", - ExtraType.Sample => "-other", - ExtraType.Unknown => "-other", - ExtraType.ThemeSong => string.Empty, - ExtraType.ThemeVideo => string.Empty, - ExtraType.Trailer => string.Empty, - _ => isExtra ? "-other" : string.Empty, + var extrasFolders = file.ExtraType switch { + null => isExtra ? new string[] { "extras" } : null, + ExtraType.ThemeSong => new string[] { "theme-music" }, + ExtraType.ThemeVideo => config.AddCreditsAsThemeVideos && config.AddCreditsAsSpecialFeatures + ? new string[] { "backdrops", "extras" } + : config.AddCreditsAsThemeVideos + ? new string[] { "backdrops" } + : config.AddCreditsAsSpecialFeatures + ? new string[] { "extras" } + : Array.Empty<string>(), + ExtraType.Trailer => config.AddTrailers + ? new string[] { "trailers" } + : Array.Empty<string>(), + ExtraType.BehindTheScenes => new string[] { "behind the scenes" }, + ExtraType.DeletedScene => new string[] { "deleted scenes" }, + ExtraType.Clip => new string[] { "clips" }, + ExtraType.Interview => new string[] { "interviews" }, + ExtraType.Scene => new string[] { "scenes" }, + ExtraType.Sample => new string[] { "samples" }, + _ => new string[] { "extras" }, }; var filePartSuffix = (episodeXref.Percentage?.Size ?? 100) is not 100 ? $".pt{episode.Shoko.CrossReferences.Where(xref => xref.ReleaseGroup == episodeXref.ReleaseGroup && xref.Percentage!.Size == episodeXref.Percentage!.Size).ToList().FindIndex(xref => xref.Percentage!.Start == episodeXref.Percentage!.Start && xref.Percentage!.End == episodeXref.Percentage!.End) + 1}" : ""; if (isMovieSeason && collectionType is not CollectionType.TvShows) { - if (!string.IsNullOrEmpty(extrasFolder)) { - foreach (var episodeInfo in season.EpisodeList) - folders.Add(Path.Join(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episodeInfo.Id}]", extrasFolder)); + if (extrasFolders != null) { + foreach (var extrasFolder in extrasFolders) + foreach (var episodeInfo in season.EpisodeList) + folders.Add(Path.Join(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episodeInfo.Id}]", extrasFolder)); } else { folders.Add(Path.Join(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episode.Id}]")); @@ -876,12 +879,14 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); var seasonFolder = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}"; var showFolder = $"{showName} [{ShokoSeriesId.Name}={show.Id}]"; - if (!string.IsNullOrEmpty(extrasFolder)) { - folders.Add(Path.Join(vfsPath, showFolder, extrasFolder)); + if (extrasFolders != null) { + foreach (var extrasFolder in extrasFolders) { + folders.Add(Path.Join(vfsPath, showFolder, extrasFolder)); - // Only place the extra within the season if we have a season number assigned to the episode. - if (seasonNumber is not 0) - folders.Add(Path.Join(vfsPath, showFolder, seasonFolder, extrasFolder)); + // Only place the extra within the season if we have a season number assigned to the episode. + if (seasonNumber is not 0) + folders.Add(Path.Join(vfsPath, showFolder, seasonFolder, extrasFolder)); + } } else { folders.Add(Path.Join(vfsPath, showFolder, seasonFolder)); @@ -889,7 +894,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { } } - var fileName = $"{episodeName} [{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{fileNameSuffix}{Path.GetExtension(sourceLocation)}"; + var fileName = $"{episodeName} [{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{Path.GetExtension(sourceLocation)}"; var symbolicLinks = folders .Select(folderPath => Path.Join(folderPath, fileName)) .ToArray(); From 3501a82fddcf888af62a68997035ea654b257941 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 22 May 2024 05:49:58 +0200 Subject: [PATCH 1016/1103] fix: fix up show descriptions + more - Fixed up show descriptions. - Fixed up the anidb sanitization in general to convert better to **MarkDown**. --- Shokofin/StringExtensions.cs | 18 ++++++++++++++++++ Shokofin/Utils/Text.cs | 26 +++++++++++++++++++++----- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/Shokofin/StringExtensions.cs b/Shokofin/StringExtensions.cs index 6be7d5d0..5494e58f 100644 --- a/Shokofin/StringExtensions.cs +++ b/Shokofin/StringExtensions.cs @@ -9,6 +9,24 @@ namespace Shokofin; public static class StringExtensions { + public static string Replace(this string input, Regex regex, string replacement, int count, int startAt) + => regex.Replace(input, replacement, count, startAt); + + public static string Replace(this string input, Regex regex, MatchEvaluator evaluator, int count, int startAt) + => regex.Replace(input, evaluator, count, startAt); + + public static string Replace(this string input, Regex regex, MatchEvaluator evaluator, int count) + => regex.Replace(input, evaluator, count); + + public static string Replace(this string input, Regex regex, MatchEvaluator evaluator) + => regex.Replace(input, evaluator); + + public static string Replace(this string input, Regex regex, string replacement) + => regex.Replace(input, replacement); + + public static string Replace(this string input, Regex regex, string replacement, int count) + => regex.Replace(input, replacement, count); + public static void Deconstruct(this IList<string> list, out string first) { first = list.Count > 0 ? list[0] : string.Empty; diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index 458d7442..c29dd443 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -184,7 +184,7 @@ private static string GetDescriptionByDict(Dictionary<DescriptionProvider, strin var overview = provider switch { DescriptionProvider.Shoko => - descriptions.TryGetValue(DescriptionProvider.Shoko, out var desc) ? desc : null, + descriptions.TryGetValue(DescriptionProvider.Shoko, out var desc) ? SanitizeAnidbDescription(desc ?? string.Empty) : null, DescriptionProvider.AniDB => descriptions.TryGetValue(DescriptionProvider.AniDB, out var desc) ? SanitizeAnidbDescription(desc ?? string.Empty) : null, DescriptionProvider.TvDB => @@ -212,20 +212,36 @@ public static string SanitizeAnidbDescription(string summary) var config = Plugin.Instance.Configuration; if (config.SynopsisCleanLinks) - summary = Regex.Replace(summary, @"https?:\/\/\w+.\w+(?:\/?\w+)? \[([^\]]+)\]", match => match.Groups[1].Value); + summary = summary.Replace(SynopsisCleanLinks, match => $"[{match.Groups[2].Value}]({match.Groups[1].Value})"); if (config.SynopsisCleanMiscLines) - summary = Regex.Replace(summary, @"^(\*|--|~) .*", string.Empty, RegexOptions.Multiline); + summary = summary.Replace(SynopsisCleanMiscLines, string.Empty); if (config.SynopsisRemoveSummary) - summary = Regex.Replace(summary, @"\n(Source|Note|Summary):.*", string.Empty, RegexOptions.Singleline); + summary = summary + .Replace(SynopsisRemoveSummary1, match => $"**{match.Groups[1].Value}**: ") + .Replace(SynopsisRemoveSummary2, string.Empty); if (config.SynopsisCleanMultiEmptyLines) - summary = Regex.Replace(summary, @"\n{2,}", "\n", RegexOptions.Singleline); + summary = summary + .Replace(SynopsisConvertNewLines, "\n") + .Replace(SynopsisCleanMultiEmptyLines, "\n"); return summary.Trim(); } + private static readonly Regex SynopsisCleanLinks = new(@"(https?:\/\/\w+.\w+(?:\/?\w+)?) \[([^\]]+)\]", RegexOptions.Compiled); + + private static readonly Regex SynopsisCleanMiscLines = new(@"^(\*|--|~)\s*", RegexOptions.Multiline | RegexOptions.Compiled); + + private static readonly Regex SynopsisRemoveSummary1 = new(@"\b(Note|Summary):\s*", RegexOptions.Singleline | RegexOptions.Compiled); + + private static readonly Regex SynopsisRemoveSummary2 = new(@"\bSource: [^ ]+", RegexOptions.Singleline | RegexOptions.Compiled); + + private static readonly Regex SynopsisConvertNewLines = new(@"\r\n|\r", RegexOptions.Singleline | RegexOptions.Compiled); + + private static readonly Regex SynopsisCleanMultiEmptyLines = new(@"\n{2,}", RegexOptions.Singleline | RegexOptions.Compiled); + public static string? JoinText(IEnumerable<string?> textList) { var filteredList = textList From bc893d002b02b93309da66a1b73005ddcfc30ad7 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Wed, 22 May 2024 03:51:44 +0000 Subject: [PATCH 1017/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 8200d86e..68ec9e30 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.183", + "changelog": "fix: fix up show descriptions + more\n\n- Fixed up show descriptions.\n\n- Fixed up the anidb sanitization in general to convert better to **MarkDown**.\n\nfeat: make trailers/credits optional to include\n\n- Made trailers and credits optional to include, and allowed credits to be added as either theme videos (on by default), or as special features (off by default), or both.\n\nfeat: add trailer & video providers\n\n- Added a trailer and video provider responsible for fetching metadata for theme videos, special featurettes, and trailers __for entities in the VFS__.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.183/shoko_3.0.1.183.zip", + "checksum": "77f8b7f416bff24696b4176d3e57ca0f", + "timestamp": "2024-05-22T03:51:43Z" + }, { "version": "3.0.1.182", "changelog": "refactor: update collection provider\n\nfix: fix logging for images for collections\n\nfix: fix shared collections for movies\n\nmisc: add custom css for collections to settings page", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.179/shoko_3.0.1.179.zip", "checksum": "c071f804862d18f9a8d78511db62ce94", "timestamp": "2024-05-20T19:57:15Z" - }, - { - "version": "3.0.1.178", - "changelog": "fix: fix retrieving children of a collection", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.178/shoko_3.0.1.178.zip", - "checksum": "b5083a5d0c5b0a2e456a35363f5f4194", - "timestamp": "2024-05-20T00:01:12Z" } ] } From a4438dbe5311de477718d0a3cbe1f2da85c0b4a0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 23 May 2024 02:00:35 +0200 Subject: [PATCH 1018/1103] fix: add xref group from latest daily server --- Shokofin/API/Models/CrossReference.cs | 6 ++++++ Shokofin/API/ShokoAPIManager.cs | 4 ++-- Shokofin/Resolvers/ShokoResolveManager.cs | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Shokofin/API/Models/CrossReference.cs b/Shokofin/API/Models/CrossReference.cs index 31168f77..babc6bf4 100644 --- a/Shokofin/API/Models/CrossReference.cs +++ b/Shokofin/API/Models/CrossReference.cs @@ -57,6 +57,12 @@ public class CrossReferencePercentage /// The raw percentage to "group" the cross-references by. /// </summary> public int Size { get; set; } + + /// <summary> + /// The assumed number of groups in the release, to group the + /// cross-references by. + /// </summary> + public int? Group { get; set; } } /// <summary> diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 65c917e7..0c245c00 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -457,9 +457,9 @@ private Task<FileInfo> CreateFileInfo(File file, string fileId, string seriesId) // Group and order the episodes. var groupedEpisodeLists = episodeList - .GroupBy(tuple => (type: tuple.Episode.AniDB.Type, percentage: tuple.CrossReference.Percentage?.Size ?? 100)) + .GroupBy(tuple => (type: tuple.Episode.AniDB.Type, group: tuple.CrossReference.Percentage?.Group ?? 1)) .OrderByDescending(a => Array.IndexOf(EpisodePickOrder, a.Key.type)) - .ThenByDescending(a => a.Key.percentage) + .ThenBy(a => a.Key.group) .Select(epList => epList.OrderBy(tuple => tuple.Episode.AniDB.EpisodeNumber).ToList()) .ToList(); diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 4b6ac819..62586640 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -860,8 +860,8 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { ExtraType.Sample => new string[] { "samples" }, _ => new string[] { "extras" }, }; - var filePartSuffix = (episodeXref.Percentage?.Size ?? 100) is not 100 - ? $".pt{episode.Shoko.CrossReferences.Where(xref => xref.ReleaseGroup == episodeXref.ReleaseGroup && xref.Percentage!.Size == episodeXref.Percentage!.Size).ToList().FindIndex(xref => xref.Percentage!.Start == episodeXref.Percentage!.Start && xref.Percentage!.End == episodeXref.Percentage!.End) + 1}" + 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 (extrasFolders != null) { From 5c8728900636ae857e5532f959ec014d31535171 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Thu, 23 May 2024 00:01:24 +0000 Subject: [PATCH 1019/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 68ec9e30..1e5abf02 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.184", + "changelog": "fix: add xref group from latest daily server", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.184/shoko_3.0.1.184.zip", + "checksum": "5fc632851adc8ff1c4be945778f72904", + "timestamp": "2024-05-23T00:01:22Z" + }, { "version": "3.0.1.183", "changelog": "fix: fix up show descriptions + more\n\n- Fixed up show descriptions.\n\n- Fixed up the anidb sanitization in general to convert better to **MarkDown**.\n\nfeat: make trailers/credits optional to include\n\n- Made trailers and credits optional to include, and allowed credits to be added as either theme videos (on by default), or as special features (off by default), or both.\n\nfeat: add trailer & video providers\n\n- Added a trailer and video provider responsible for fetching metadata for theme videos, special featurettes, and trailers __for entities in the VFS__.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.180/shoko_3.0.1.180.zip", "checksum": "1460c4f4772e28c75f3405cb84bc7fa7", "timestamp": "2024-05-20T21:21:23Z" - }, - { - "version": "3.0.1.179", - "changelog": "refactor: make collections great again!\n\n- Fixed collections. But for real this time, by extensively testing the different paths to both add and remove collections.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.179/shoko_3.0.1.179.zip", - "checksum": "c071f804862d18f9a8d78511db62ce94", - "timestamp": "2024-05-20T19:57:15Z" } ] } From 88acc1643ff47c18f1ded76f4fc4538440eb268b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 24 May 2024 14:33:13 +0200 Subject: [PATCH 1020/1103] =?UTF-8?q?revert:=20"fix:=20extras=20=E2=86=92?= =?UTF-8?q?=20episodes"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 2a3eed4dd910386ee373bdf3f8aadd2511f97ab3. --- Shokofin/Resolvers/ShokoResolveManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs index 62586640..3e2c912f 100644 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ b/Shokofin/Resolvers/ShokoResolveManager.cs @@ -508,7 +508,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold importFolderSubPath ); - var episodeIds = seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList).Concat(seasonInfo.ExtrasList).Select(episode => episode.Id).Append(episodeId).ToHashSet(); + var episodeIds = seasonInfo.ExtrasList.Select(episode => episode.Id).Append(episodeId).ToHashSet(); var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); var fileLocations = files .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) From 5314aee6f1ccba8b0872aaa48b304c982f095920 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 24 May 2024 12:48:43 +0000 Subject: [PATCH 1021/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 1e5abf02..72054f0c 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.185", + "changelog": "revert: \"fix: extras \u2192 episodes\"\n\nThis reverts commit 2a3eed4dd910386ee373bdf3f8aadd2511f97ab3.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.185/shoko_3.0.1.185.zip", + "checksum": "bbf406685377d2d4ab11406d1d17c82c", + "timestamp": "2024-05-24T12:48:41Z" + }, { "version": "3.0.1.184", "changelog": "fix: add xref group from latest daily server", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.181/shoko_3.0.1.181.zip", "checksum": "0af3f33d0e9f94e1bd653d40ea87cb32", "timestamp": "2024-05-20T23:27:13Z" - }, - { - "version": "3.0.1.180", - "changelog": "fix: fix VFS resolution for non-tv-show libraries", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.180/shoko_3.0.1.180.zip", - "checksum": "1460c4f4772e28c75f3405cb84bc7fa7", - "timestamp": "2024-05-20T21:21:23Z" } ] } From 21354d4d7334c8edfa68247e7da18c6ee03102d7 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 25 May 2024 00:15:10 +0200 Subject: [PATCH 1022/1103] fix: refresh signalr state upon connect/disconnect --- Shokofin/Configuration/configController.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 5f53e75e..45a4d575 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -431,6 +431,9 @@ async function defaultSubmit(form) { config.ApiKey = response.apikey; let result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + + await getSignalrStatus().then(refreshSignalr); + Dashboard.processPluginConfigurationUpdateResult(result); } catch (err) { @@ -452,6 +455,9 @@ async function resetConnectionSettings(form) { config.ServerVersion = null; const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + + await getSignalrStatus().then(refreshSignalr); + Dashboard.processPluginConfigurationUpdateResult(result); return config; From 2c81333bad34acfa79b6ac6b6120debe9d9fe1fd Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Fri, 24 May 2024 22:16:14 +0000 Subject: [PATCH 1023/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index 72054f0c..ef9444de 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.186", + "changelog": "fix: refresh signalr state upon connect/disconnect", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.186/shoko_3.0.1.186.zip", + "checksum": "602baee1a7d05c5ef8f47fb0ea24a417", + "timestamp": "2024-05-24T22:16:13Z" + }, { "version": "3.0.1.185", "changelog": "revert: \"fix: extras \u2192 episodes\"\n\nThis reverts commit 2a3eed4dd910386ee373bdf3f8aadd2511f97ab3.", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.182/shoko_3.0.1.182.zip", "checksum": "8bd2f8b999d41f2449f0c6568d2de13a", "timestamp": "2024-05-21T01:38:36Z" - }, - { - "version": "3.0.1.181", - "changelog": "fix: fix collection creation criteria", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.181/shoko_3.0.1.181.zip", - "checksum": "0af3f33d0e9f94e1bd653d40ea87cb32", - "timestamp": "2024-05-20T23:27:13Z" } ] } From 4252dec70fe70e0b75ed0aa64c073ade4403938c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 26 May 2024 20:47:20 +0200 Subject: [PATCH 1024/1103] refactor: update collection metadata - Make sure the provider runs first, since these providers aren't configurable and we need them to run before the built in TMDB provider so it doesn't try to set it's metadata before us. - Remove any TMDB ids if found, since they shouldn't be needed on the collections managed by the plugin. - Removed the AniDB Series id for the collection, since in hindsight it doesn't make sense to have it on there. - General maintenance on the the collection provider. Renamed the methods and changed a public method that should had been private to a private method. --- Shokofin/Providers/BoxSetProvider.cs | 13 +++++-------- Shokofin/Providers/CustomBoxSetProvider.cs | 12 ++++++++++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index b3883acf..01b65983 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -19,7 +19,7 @@ public class BoxSetProvider : IRemoteMetadataProvider<BoxSet, BoxSetInfo>, IHasO { public string Name => Plugin.MetadataProviderName; - public int Order => 0; + public int Order => -1; private readonly IHttpClientFactory HttpClientFactory; @@ -40,12 +40,12 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat // Try to read the shoko group id if (info.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var collectionId) || info.Path.TryGetAttributeValue(ShokoCollectionGroupId.Name, out collectionId)) - return await GetShokoGroupedMetadata(info, collectionId); + return await GetShokoGroupMetadata(info, collectionId); // Try to read the shoko series id if (info.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId) || info.Path.TryGetAttributeValue(ShokoCollectionSeriesId.Name, out seriesId)) - return await GetDefaultMetadata(info, seriesId); + return await GetShokoSeriesMetadata(info, seriesId); return new(); } @@ -55,7 +55,7 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat } } - public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, string seriesId) + private async Task<MetadataResult<BoxSet>> GetShokoSeriesMetadata(BoxSetInfo info, string seriesId) { // First try to re-use any existing series id. var result = new MetadataResult<BoxSet>(); @@ -80,15 +80,12 @@ public async Task<MetadataResult<BoxSet>> GetDefaultMetadata(BoxSetInfo info, st CommunityRating = season.AniDB.Rating.ToFloat(10), }; result.Item.SetProviderId(ShokoCollectionSeriesId.Name, season.Id); - if (Plugin.Instance.Configuration.AddAniDBId) - result.Item.SetProviderId("AniDB", season.AniDB.Id.ToString()); - result.HasMetadata = true; return result; } - private async Task<MetadataResult<BoxSet>> GetShokoGroupedMetadata(BoxSetInfo info, string groupId) + private async Task<MetadataResult<BoxSet>> GetShokoGroupMetadata(BoxSetInfo info, string groupId) { // Filter out all manually created collections. We don't help those. var result = new MetadataResult<BoxSet>(); diff --git a/Shokofin/Providers/CustomBoxSetProvider.cs b/Shokofin/Providers/CustomBoxSetProvider.cs index 7abee8da..7050d55b 100644 --- a/Shokofin/Providers/CustomBoxSetProvider.cs +++ b/Shokofin/Providers/CustomBoxSetProvider.cs @@ -8,6 +8,7 @@ using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.API.Info; @@ -75,7 +76,7 @@ private async Task<bool> EnsureSeriesCollectionIsCorrect(BoxSet collection, stri if (seasonInfo is null) return false; - var updated = false; + var updated = EnsureNoTmdbIdIsSet(collection); var metadataLanguage = LibraryManager.GetLibraryOptions(collection)?.PreferredMetadataLanguage; var (displayName, alternateTitle) = Text.GetSeasonTitles(seasonInfo, metadataLanguage); if (!string.Equals(collection.Name, displayName)) { @@ -101,7 +102,7 @@ private async Task<bool> EnsureGroupCollectionIsCorrect(Folder collectionRoot, B if (collectionInfo is null) return false; - var updated = false; + var updated = EnsureNoTmdbIdIsSet(collection); var parent = collectionInfo.IsTopLevel ? collectionRoot : await GetCollectionByGroupId(collectionRoot, collectionInfo.ParentId); if (collection.ParentId != parent.Id) { collection.SetParent(parent); @@ -119,6 +120,13 @@ private async Task<bool> EnsureGroupCollectionIsCorrect(Folder collectionRoot, B return updated; } + private bool EnsureNoTmdbIdIsSet(BoxSet collection) + { + var willRemove = collection.ProviderIds.ContainsKey(MetadataProvider.TmdbCollection.ToString()); + collection.ProviderIds.Remove(MetadataProvider.TmdbCollection.ToString()); + return willRemove; + } + private async Task<BoxSet> GetCollectionByGroupId(Folder collectionRoot, string? collectionId) { if (string.IsNullOrEmpty(collectionId)) From e343c54bb71a8a918b4aa4b35f7f2a76fc52f387 Mon Sep 17 00:00:00 2001 From: revam <revam@users.noreply.github.com> Date: Sun, 26 May 2024 18:48:18 +0000 Subject: [PATCH 1025/1103] misc: update unstable manifest --- manifest-unstable.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/manifest-unstable.json b/manifest-unstable.json index ef9444de..37bd3113 100644 --- a/manifest-unstable.json +++ b/manifest-unstable.json @@ -8,6 +8,14 @@ "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", "versions": [ + { + "version": "3.0.1.187", + "changelog": "refactor: update collection metadata\n\n- Make sure the provider runs first, since these providers aren't configurable and we need them to run before the built in TMDB provider so it doesn't try to set it's metadata before us.\n\n- Remove any TMDB ids if found, since they shouldn't be needed on the collections managed by the plugin.\n\n- Removed the AniDB Series id for the collection, since in hindsight it doesn't make sense to have it on there.\n\n- General maintenance on the the collection provider. Renamed the methods and changed a public method that should had been private to a private method.", + "targetAbi": "10.8.0.0", + "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.187/shoko_3.0.1.187.zip", + "checksum": "c6ec4cb9b4ad206b3ce2f304c9fd1f62", + "timestamp": "2024-05-26T18:48:16Z" + }, { "version": "3.0.1.186", "changelog": "fix: refresh signalr state upon connect/disconnect", @@ -39,14 +47,6 @@ "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.183/shoko_3.0.1.183.zip", "checksum": "77f8b7f416bff24696b4176d3e57ca0f", "timestamp": "2024-05-22T03:51:43Z" - }, - { - "version": "3.0.1.182", - "changelog": "refactor: update collection provider\n\nfix: fix logging for images for collections\n\nfix: fix shared collections for movies\n\nmisc: add custom css for collections to settings page", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.182/shoko_3.0.1.182.zip", - "checksum": "8bd2f8b999d41f2449f0c6568d2de13a", - "timestamp": "2024-05-21T01:38:36Z" } ] } From 10a5775668e7642cafe94ec62dcf7fe6cf83529f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 26 May 2024 22:51:48 +0200 Subject: [PATCH 1026/1103] chore: restructure repository [skip ci] --- .github/workflows/release-daily.yml | 63 +++++--- .github/workflows/release.yml | 65 ++++++-- LogoWide.png | Bin 142390 -> 0 bytes README.md | 153 ++++++++++--------- build.yaml | 2 +- build_plugin.py | 10 +- manifest-unstable.json | 53 ------- manifest.json | 223 +--------------------------- thoughts.md | 181 ---------------------- 9 files changed, 185 insertions(+), 565 deletions(-) delete mode 100644 LogoWide.png delete mode 100644 manifest-unstable.json delete mode 100644 thoughts.md diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 1c336dd4..6b0262eb 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -1,8 +1,9 @@ -name: Unstable Release +name: Build & Publish Dev Release on: push: - branches: [ master ] + branches: + - dev jobs: current_info: @@ -12,6 +13,7 @@ jobs: outputs: version: ${{ steps.release_info.outputs.version }} + tag: ${{ steps.release_info.outputs.tag }} date: ${{ steps.commit_date_iso8601.outputs.date }} sha: ${{ github.sha }} sha_short: ${{ steps.commit_info.outputs.sha }} @@ -28,18 +30,22 @@ jobs: id: previous_release_info uses: revam/gh-action-get-tag-and-version@v1 with: - branch: true - prefix: v + branch: false + prefix: "v" prefixRegex: "[vV]?" + suffixRegex: "dev" + suffix: "dev" - name: Get Current Version id: release_info uses: revam/gh-action-get-tag-and-version@v1 with: - branch: true - increment: build - prefix: v + branch: false + increment: suffix + prefix: "v" prefixRegex: "[vV]?" + suffixRegex: "dev" + suffix: "dev" - name: Get Commit Date (as ISO8601) id: commit_date_iso8601 @@ -63,7 +69,7 @@ jobs: run: | EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) echo "CHANGELOG<<$EOF" >> "$GITHUB_OUTPUT" - git log $PREVIOUS_COMMIT..$NEXT_COMMIT --pretty=format:"%B" | grep -v "misc: update unstable manifest" | head -c -2 >> "$GITHUB_OUTPUT" + git log $PREVIOUS_COMMIT..$NEXT_COMMIT --pretty=format:"%B" | head -c -2 >> "$GITHUB_OUTPUT" echo -e "\n$EOF" >> "$GITHUB_OUTPUT" build_plugin: @@ -72,7 +78,7 @@ jobs: needs: - current_info - name: Build & Release (Unstable) + name: Build & Release (Dev) steps: - name: Checkout @@ -80,6 +86,13 @@ jobs: with: ref: ${{ github.ref }} + - name: Fetch Dev Manifest from Metadata Branch + run: | + git checkout manifest -- dev/manifest.json; + rm manifest.json; + mv dev/manifest.json manifest.json; + rmdir dev; + - name: Setup .Net uses: actions/setup-dotnet@v1 with: @@ -99,16 +112,28 @@ jobs: - name: Run JPRM env: CHANGELOG: ${{ needs.current_info.outputs.changelog }} - run: python build_plugin.py --repo ${{ github.repository }} --version=${{ needs.current_info.outputs.version }} --prerelease=True + run: python build_plugin.py --repo ${{ github.repository }} --version=${{ needs.current_info.outputs.version }} --tag=${{ needs.current_info.outputs.tag }} --prerelease=True + + - name: Change to Metadata Branch + run: | + mkdir dev; + mv manifest.json dev + git add ./dev/manifest.json; + git add ./artifacts/shoko_${{ needs.current_info.outputs.version }}.zip; + git stash push -m "Temp release details"; + git reset --hard; + git checkout metadata; + git stash pop || git stash apply; + git reset; - name: Create Pre-Release uses: softprops/action-gh-release@v1 with: files: ./artifacts/shoko_*.zip - name: "Shokofin Unstable ${{ needs.current_info.outputs.version }}" - tag_name: ${{ needs.current_info.outputs.version }} + name: "Shokofin Dev ${{ needs.current_info.outputs.version }}" + tag_name: ${{ needs.current_info.outputs.tag }} body: | - Update your plugin using the [unstable manifest](https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest-unstable.json) or by downloading the release from [GitHub Releases](https://github.com/ShokoAnime/Shokofin/releases/tag/${{ needs.current_info.outputs.version }}) and installing it manually! + Update your plugin using the [dev manifest](https://raw.githubusercontent.com/ShokoAnime/Shokofin/metadata/dev/manifest.json) or by downloading the release from [GitHub Releases](https://github.com/ShokoAnime/Shokofin/releases/tag/${{ needs.current_info.outputs.tag }}) and installing it manually! **Changes since last build**: ${{ needs.current_info.outputs.changelog }} @@ -118,12 +143,12 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Update Unstable Manifest + - name: Update Dev Manifest uses: stefanzweifel/git-auto-commit-action@v4 with: - branch: master - commit_message: "misc: update unstable manifest" - file_pattern: manifest-unstable.json + branch: metadata + commit_message: "misc: update dev manifest" + file_pattern: dev/manifest.json skip_fetch: true discord-notify: @@ -142,13 +167,13 @@ jobs: webhook-url: ${{ secrets.DISCORD_WEBHOOK }} embed-color: 9985983 embed-timestamp: ${{ needs.current_info.outputs.date }} - embed-author-name: Shokofin | New Unstable Build + embed-author-name: Shokofin | New Dev Build embed-author-icon-url: https://raw.githubusercontent.com/${{ github.repository }}/master/.github/images/jellyfin.png embed-author-url: https://github.com/${{ github.repository }} embed-description: | **Version**: `${{ needs.current_info.outputs.version }}` (`${{ needs.current_info.outputs.sha_short }}`) - Update your plugin using the [unstable manifest](https://raw.githubusercontent.com/${{ github.repository }}/master/manifest-unstable.json) or by downloading the release from [GitHub Releases](https://github.com/${{ github.repository }}/releases/tag/${{ needs.current_info.outputs.version }}) and installing it manually! + Update your plugin using the [dev manifest](https://raw.githubusercontent.com/${{ github.repository }}/metadata/dev/manifest.json) or by downloading the release from [GitHub Releases](https://github.com/${{ github.repository }}/releases/tag/${{ needs.current_info.outputs.tag }}) and installing it manually! **Changes since last build**: ${{ needs.current_info.outputs.changelog }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b5925fc..82dc7048 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,30 +1,57 @@ -name: Stable Release +name: Build Stable Release on: release: types: - released - branches: master jobs: - build_plugin: + current_info: runs-on: ubuntu-latest - name: Build Release + + name: Current Information + + outputs: + version: ${{ steps.release_info.outputs.version }} + tag: ${{ steps.release_info.outputs.tag }} steps: - - name: Checkout + - name: Checkout master uses: actions/checkout@master with: - ref: ${{ github.ref }} + ref: "${{ github.ref }}" fetch-depth: 0 # This is set to download the full git history for the repo - name: Get Current Version - id: release_info + id: previous_release_info uses: revam/gh-action-get-tag-and-version@v1 with: - branch: true - prefix: v + branch: false + prefix: "v" prefixRegex: "[vV]?" + suffixRegex: "dev" + suffix: "dev" + + build_plugin: + runs-on: ubuntu-latest + + needs: + - current_info + + name: Build Release + + steps: + - name: Checkout + uses: actions/checkout@master + with: + ref: ${{ github.ref }} + + - name: Fetch Stable Manifest from Metadata Branch + run: | + git checkout manifest -- stable/manifest.json; + rm manifest.json; + mv stable/manifest.json manifest.json; + rmdir stable; - name: Setup .Net uses: actions/setup-dotnet@v1 @@ -43,9 +70,19 @@ jobs: run: python -m pip install jprm - name: Run JPRM - env: - CHANGELOG: "" # Add the release's change-log here maybe. - run: python build_plugin.py --repo ${{ github.repository }} --version=${{ steps.current_info.outputs.version }} + run: python build_plugin.py --repo ${{ github.repository }} --version=${{ needs.current_info.outputs.version }} --tag=${{ needs.current_info.outputs.tag }} + + - name: Change to Metadata Branch + run: | + mkdir stable; + mv manifest.json stable + git add ./stable/manifest.json; + git add ./artifacts/shoko_${{ needs.current_info.outputs.version }}.zip; + git stash push -m "Temp release details"; + git reset --hard; + git checkout metadata; + git stash pop || git stash apply; + git reset; - name: Update Release uses: svenstaro/upload-release-action@v2 @@ -58,7 +95,7 @@ jobs: - name: Update Stable Manifest uses: stefanzweifel/git-auto-commit-action@v4 with: - branch: master + branch: metadata commit_message: "misc: update stable manifest" - file_pattern: manifest.json + file_pattern: stable/manifest.json skip_fetch: true diff --git a/LogoWide.png b/LogoWide.png deleted file mode 100644 index 3d4c26dadc070b129dd9066389c92d25f23a1d0a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 142390 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�Ii7kB1QjvkpJub6ZuU*O09ClBXav&;=9>H9l zElk;lhR%H#^_D>SjY!0=-UfKBH;Fn#OIsspX&(prv>|rjE)hhLmV5r-bXKRDcz!$R zCtMW11QZCf5aSxQ$|_6Px>O-_-oIpzmqY@bQ7_DQ!4aV;BD_1QxrnR!kd?*gs3F{u z+C#Ck)s`muOkcSleLh!2(FC3K1ZdS-aHY9XskBMuO4rLv)JOxT&)e@(1^og<^^K%# zrDPj>msDL=GQ%KY$!=D?<-Ppz-GrPTl=wK_!>)i1&->Fj*h#&f>?**Ly!IaKMhQ7b zWm1K$z5G%<x}be}AYVY=)N535)8$qMulctjbQr>*F4lP)qd;~YbLKeQ%)R5KmGP|} zAc+aX*#InOKa>R0paRk9WeD&>6}?Nlv1IcjYB4PJ#lQ=>9{haAO~=>P%2MwTJT$-L z@nPK6NGGcnm8yRlr6_>@iVOT;9wFUbXl9~R*ZBqd(Wu^9?lto$C{V>-_Y+=u_jEO@ z_71e&`PqQ#wAit?(gnRo-^$+jdfkh*pPdZ=zwnN|C4P0_0f-3f_K2ioXqCV^->1=T zfsLP}ImneJb%WBkvzp58(Ai&S`d$&4w~|pG))q!@)?Fwz^fSv!@cOyNLn=*12dFu3 z+I2bWYV<4%>`oHVJXY7lFf?nX8nlfpaObR_A46B<F;NW5D%|$BqquSI9mzfdC%*>( zpNLB*Xd01`*2wIYGnlsg2%mjBP)4EVsD>@a|L2|ldiq8W=MGBc=(k*f-wi~5Pw3$5 zx)q^JPez%Vg@=dhyhP9Uvs&~~9?fC!SUm3d`>X;+%d|HSD7h8;aGgKNW<4{{_j0?~ zo}P9=vG3{=_@v6q_A1JC?@fPkya#x&0tPuR1q^aRSzkvzsi||$maW#*7<Tc%SmaAT zVp>eBu>VMX?--TL#kkov{5=k6X^z-|<RNz}uD+ZbmB-2O{H}{e_t)djP6<eCA4ZDa zVSW87yz!}D<j9%+$m6dEu#?F9wG?tM6?=bKa~`r@Uik|wV&;$F%7-2W87Qc0$$q!S zEfziy8~Hgu2d_O|zRo$iLL9_C@z~$<hB@Iuz1@VZHS~D|+fQR-*m;*vm*}}~;nh!8 z7klRm+}T2Cb03xgtGzMhn<5q=DqFMA@8_jXBcsJ`hKm!Q;8l^woBqpVeuaF=d)lYh zK_Vh<3^ZR7loI0X>C7PRMc-Kp6us6e#}mv@LGRviZFDNqW6!oo(tOJTb#s~{$xb># zN=49_Uc|&x8ty40N=EhI<R_#cmJ?>{0kM5IqmGj{*f*#MeX9rJwnI^SC)%zUis{~5 zMA^XeBOKEJ@vaeCBK*5@Ao!3}5u{>Efo#xwM~=|GfK85U^3?K)j$+Nh$a~HPMGBud zB#77u7|KQK3HbnYPv9`?@cjT+rnxNb7m<t%_sPXWPNC|mCt~sU&5+P=uZ3n%hvn6& zh_@75$rm@2SWYr(X+G7-n5@_=<}bMk>Vg-9S4_omL2;Q%1nbpNiO^A1&mIe+{hZ{s zqi~q%gw7fZ8khh{0u1$xcgFuv*y2ZkmQsAW!9&Yy`KGzdI(FwRu41^n#KL(VOXV;R zwk%ctygEYNa((3wlhln~C*Sq;p(9L7B`z85Q*{u{@<(!wm&#oM+D5O5fpY)qK<`JB zFQ{wAvX&<a@D)$bcjD_kMUs(1Bb5Aqg-z7cU#0n;Hb{M<@2~(uIG1+&(2p0-rH;#l zipNWLcHag)bY8X_smD$Dm73VSWNUM@wQfTX8-9In&O(zh>QR9L({=a=uw(*&_T~nH zZoc9DAOp`04IM}&SLdV3O<}u(D1o+=+%CJFJWh5uqaoX{iC7UEw<y=6E&-1o#A^}< zF5YZ`dpkjr5yFaX6x_v=NBuwE2K~%5PQn~41zHKo4aab?I$57@<?piDJO8ToL%r=X zo9;GH-!cycCp8E8{m^Ah8vxB)Tvs8gRSU8=@-|$DBACc1>v*#e!eBC4%l+;L+@78* z?Tcdfv+NI*Z;B?4=QO!1NJxo>L-(h~#hRY+$Fr>3Y6&cL>Bo;0Rr`gWtA5}lpqcIG z&_RygL{?h&qjwY{MpyR7tpsJ-7=NkJ1695bw5jN_^(X5hvJv@XYGRcTcO<CS+Wtpn z^}eu0I>#?`pqZIaQA6EMx9#(ST_^hOmzd4EL6!{vDK4RVImu@p5^j3coyw|KVSxZ8 z!klE*OuyS0R_ZzdK_|VJVoST777+%0r|gcd!q$BKX>{RiA2>W={x;;>D6?w~sEDtF zPZ>Fsvrm8|@*jBPReS<=?Q<_^7KKctUbP^9!&WIDZp+CL*4?G?mi<$V)%aFBaa{D~ zm|gsPfWUvAa`PG;>={|v58};!ti?BM-r+y`ssm#cD!`X^9@tz56XhtF#!9}J;misU zoxZtCe}1uo@7s|o<cOFl>Mgn^3%HU;DHVt{Zb(8MvtI2x3PLmzdq8@J#h?qEd%|Uh z|Kqls$R}vk<UFkY-4mU#+M`v8QB)fL*Lgj8_MW;4v1yNm@M^t$mNA4^JS#z6p=a9x z;;s4U7n9ZQMPIRJWM!I1B4UNk3KA#`DpXE!U2vxmwWQdyJiIxLADq59stJSaFTa9~ zQf~!m$<T6N#N6zDY6}?iAIq;Y-5k(?Po3^Ma}~{CqGI~%JlqZPU+n`&1V$BzP04_X zR0Q;**;%@Z@>rvw_bem_0-$tgVS^CVSo)s8!|K727_vKXOv6^MK3YCV`<kS?2706| zDPnwy(%v!`H{e<PG(~-h!2vry(X*<bOipy3ZWtELOsDln%NMrlvi<1Oi?9BkF4l@q zPE!#u&y8IC6yA9DOR+td8t?Gm7&{v`cp5-(@dPAD(&!tu*I4YxKxgLlEo`1tGvGJ+ zUg5e<qUT|!NkfghV<srMjWP9&Pb3#SB}!9sB~9E$4*e(7m~$52@d4)_UU(2FLV(W` z6VBf=>tfw=xJyTLROjx)Kvchz{e5(re5`qRVb{eo9<!WaLVttNM~ny)H3<Ahu`ED_ ze-L5wkX*JCW}dw0*95)_qQ!bB>Cvhnu<{v^5qW&~IMs{+Dn$xaZ-J`zvW(Xpq{2dF zJ1O1ILsCp&O_)rZ{-sk-l&?if;vj1u(S*&<uC^1%6X4ehES5$)MG@z$U!^6l`$Y8D zT`dPKBJpSUyzwNj0@RWwnmGvM!bUB}WuFu9f-5o6Z20HXMHfOlMYqZBLvZrlPZz_B z6IE)VrxIcX{9mCV0-Hb48zBn4XVdr%)}xw-$g?FV9JfA~_o?gD?xxX!z~T&S<m|Zq zFm6+<X*XfD0kObb%uhMMH5my;v;H*g{BDCf@R>L$MYWXmz6}C^z%*2Fp~wF;Nhm9Z z;a4@`*3Ayal3}R*bnOQRi0${Z8!t_f8(mC#lbBgQy=wHJ4##BOT81};!Y?zig@%|B zzf5m79M1ThaSe@~3-UQRet1-2;yFO$kX7=pbkk{CfYy;oV-fq&0m7y#IuPaf>gOYc z9DEN>lT4;%H*KTc<!8hbrq8MP4!p804i-5YSL=h4b#6MApbY6;j)^b$kzSllE2aGI z4(HLxf+oV6kBZIG$-IXO$^Dcca2*BE=1p~*tqL#>LSw4-G(b58DSD4ZVW$JQ2SVT; zB*~xhcoU4iEnxeFT-|q@*SVi@*q>qCE5AOW+W3e}GP58$!ZTZ*ulRg<Lbg)s*k2HI z>fAz;dFHcEA7ArY@2ZT6dE98{-kvRXRab>Xv3B=M1``A|i*LRd=rq2nDI)kDd1`ZH zc!YQf=g%~*x_3FhmllRaMeq)|g!{3bE5CY?vrREJflf?BwC6Kt@fAeJu>@iXvbYbt zg?k;xM~JU;3L=$tJmTN0APxi%$DQAE>+m<|Bv)H}|EYSV@jk4digzWMQx&mCxZFSS zZ0^N&PTZpB>CXncA>%v1ZV0a8^ojoh?KTp#dcEEhT55<Q;?+KNwQGvU_kgeA)Ln~r zL|#+BdxJ9*coF<q=|z9tv}n!Z+2^2vW}5eb^3V2BwGf#U<ev4)qT_W=(Pk}8>aidY zg><(@b^wU>jjs6CWGv_LS+NCZRYq!p+KD^oy{@FGm>BSQk-+1<^P!{?_uD#lZ#fpS zC}1<phx1c~Cwypc=SNwTyfM<23=VL>{2=W&Jq^l|w`rI{z7^_vn!P+mT3mO$+^)H- zV603ghB)s~kVg@_v^0`laGw>+;5LZP5aOq;Q4C@pT4GkCI8Bg!ueCT_Z9!R|GR(no zPwPKd3}WFALU6?;ntG9&UQzIiJ{|gJZUf|kz7ma(ctVbNLvRjTrmD{7l&n)$cWZ9i zhcGPH!Jc{oLw%+Ebh7DWJ_DVDQ$8Cnqrbwpn)H^{RN^<R>2afZea7Mk?pIepV@_Zl z0R~{?^zH)twFa`T0e4K&3Yz!w!3P29j|K9cvbv3}-#idFloSYhu8gyya<to^*_7b1 zdkbDfF<po3r_j|XwePHJvB4|XRa05bbZ&fQisXpDul+i?8oE79%Ia(rRbQr<(`h-# z!p9-|=VWCdQT{;+PPR4Yz%#!-H7}w<bBEIY)zr)i_o-sdi@uflT>6~=hX>)yyG5RM zBTAgz>Xt1BzA{PY^ND!nQh_b=!dYz2HkiGUyhn)_(e-8DO*?*B_wrr?vp%4mjQ8X2 zrPzLN*Bq{m%{U1FXTeR`<GEUNg9*k;@Z)<pgQrxy8Hxo=cg0Q%S)JowH{4j{yb$xw zLG2p4oRLWaXP(a1!-6c++vyNYZd7Qa1AWi=%l3kyB(QifVDV`^OS^X#*K^@Tkr_3- z_l`qk^ep*J<7rybl<U*`Eb08uGgefc;1_aV7xtv*j%^-yt|9fmE*LHp7M#y7$~$>6 zovY>yqIIg*Z<CX9x*7qS767&Et&c=h;xF0QdI}!1MZVv&S5xU=aTcChudvsqHQ(e; z2z<%!KHedZYO+hBNp!uL-(Bk4CerlAhKHqm>Yl8v+@uAoSQC0RdHQiw>WJ9au2_&R z7wJZU^33nb6;lQufK?DkIq5)9#R_(ry>1z{Q@g>FG*Fd-nP!t&O~0>RDFft)ss>(H zzw%<~uPyixkJD?K$owvCJ9{B2(FUW{Ft`}FPt;q>(Y&~R!`ON*kS>C8(~kk5Tg%}0 zjP{@&Lft^O!YND{6K}UT2VWq1ro7WZfM1`!#VLjM(s4L`W8zVC#o}&JKdC+>p}!n{ z#iG4%`_;~~Wc{8-+h!RXSv}<2Cso$dp{^kB5T65k6M&*h{M22~=C_`fw3yB;ssHr$ z%frDodA+;-FEXiTUnrl=B~vt}*vIwGX}qmnYitXJdX}=VE>4Fsmz;d5EV*9Y@Vj%I z-}=ljZ9WuCZ+#V@?T%$`X9GpV%THKT0hb6CPjTjx>iqgQJ7qqK{0Di}f+_9^>GrV7 zTM75}^4uAVT((B0r#&UURw|w~357SEF&d4ic#p1YI~`Ugih3yRbVx_lLY7c&4HAEJ zJQ7&%eOi!^SmhVwJsvAp*eT0ij#n;+t#h5dYG-@ynIOB(I~cH2Z#~MyuV3b|pn^NX zrCoT6XS=8Rmfh$G)(~?OS>@DU5UI)cm3`a_k-)C;hhZGh6A*s@G%#V-ZerKTdgtYN z)CFF-(bhiqsZyidYrD<zY%@mL;a3lyrPoFNaL!55sx-;DVqtVh{mk>iAcGfYNuFjl z`rz4`KFVwE^P|m|jj6n>j2Vvj#<oBSiGgiy@q_KP8G;&pL?#y~&xQA5@lTZMB>Pq{ z)zS0vI4`+FvAUJ(x5GchCH=1A|J4}D5K^<--#Ewl#p52XE2O8_ShvAw93OB_)omqo z$VC|vsR=>{0UkDww-<nnQ|ktPh{P;N4U7z}>|f16JzGwg%woSzql;6YUa~TH7>jcK zbmjU=XtwP2##fsoG9N3+z-&_7G(RNFqYuybX@S*J?((z$NFxM`>YD`6>es;yX`(ix zjQvYdCD{xF4Q+~ao=X{S8L~ghUaR4}s7q^E+AnlJsuDv%vtpP}N3)w}RNF+4s}$j4 zJ85anR^=XibNN<mqF?IE`VAF3Evczt4==6K`x~&zKOi&VuG>_t5MK>M(X7*n)^^0K zeDQ1g`%Lx%=2=bC@Ph9LJuMk>)y0}`i*7j!PaM|ElfQW>YYlDoALJO@9b|211G&=I zL6(2qJ2p^|50~SU(f5K3`ccVXoMs@_sy<-cKF#$Ow_m{%H-WV5C!6cRN(G!TF2+4g zTC=1%^_BP29HF%ogs;Bot7twykEG`8I{$6Kf;fhQ7J78Zh#=(6It_@NV6K?z+ZQm= z`C43E%GT^%3bUMm`-4jRUi%a`CJr{49|?WAPvjh*cV=g8f|Hx~FU3I9HZod6Q$+L$ z;1V>R#N<Zw(@(NydSMUYj|n8foS8BDh8u@G$Sl>Qu!vJ@L1y`@4Q;zQRW`z8_L)36 z(sQHUc@RPW=$*p)eNW;C_YPXfTHy2ubTw`-YspZ&jNZE7tR?OpLdD=F@2Z_hXO6p& z2Ip!TWPDfbS{0=GPQjm)Dgo5G;+^0^Ba(WTkO*inQiJK8f^Uo9c9s$sbU&_yk&M*f zS|N?(D$9pSt4)ZlQ~!znf*v!U2>OS!h8Ah_hYDMW?C*58HjPCvNLce{l0i<cJ2C_0 zsNZgZm`;ELtnwO|SBWEAlzY?nwu-3X_+#VT#e;X<uPIbqvxOy(SO`9j$X8pxaaec* zb^J7SeP0y1lgFJx^24^+b|u!49{D7z$`nr1f-4cABquN1$w#SRHw2{qjOdCf7B+b2 zz;F%v+yfNO@HX~$-S08n>zQa{LZ7}pYl2K?LW18W_xyCinY}nX&pXEl7xbiTcv23} z+QvQbI~pQb2}jTCKde&1GQH?GdJU_LE3E0sX_-_#fG&c`c{~y_V4UAcr1Uoqh!tgg z{diAPoL3^KO{lwK&tt!o)bHF_Yji-nK^@hldm9%iRWe^@wX15AwQw3ks(;T_&+9RZ zBG#jE*#I_X$C()fnf9pN3+qhUuiRn(5|PxS0r1S!hu|2Os47W((11^w0gD$(xKbW| zjnmySP)Tr2IpNi2*h?}tC#&;?P*))hj}6-~RiVSAt=ZdGDegqcnWm6wtLkeX^*qY3 zAl#kTTdt9}p>*$t*um2Y`<X6Ew?5gx8b{|GepLsxP$h%GEo#_a6O0T**NB_gJXr1E z@=b3+pF>bR3hB&RI}}j~uHP+SZAO(}@>88$kj1>a^m>S8auY~5M$~0I$Lps_b&N6g ztmaF@!$W<K59cp{4{#)JXcu1DUoe9}K_>B9G)oG0xHJmW6S0Y%y591WG1H`)JK-?^ zpg^KWfYXPS`2vqP^=mzylIO-9=<^0OTp*tJbZ)il-|l@Be$%WK(~CwP47W{-I&Nae zF`~K73c2ZB)r*Q#9rIHL3YNN0R)%Zs;E@xxZO5FP+){178X^Q=Y<K!wYgZrfUeiw( zVop}1AdQQTL`@hn0*nF64x}jzBH$`z)U+#d$_n4)C%q!^`|T_|VzNdikrS7hDm`>( z9U@|L5M~owx9%0*et<C+BEBnPx2XDC@Tz78&l&5d5r9ad5IcnfR@8<NDG~=Jr9{WB z`2r(|loW@~d87_!&-uG75NnBA!T4d#PqOkyL2006ByI;zJP#h&%)HiBJl(KDKb#sL zwVD^m5!*6|UtpZnaNk)tA6y@f!FR7e)Qb9yI*VXo)vnd}rPnW+r8C;#(as#+%z<Wg z%P{NkfvXBS-kkrQ_CaIx&ieV=lj8*;K9=$%4E(i)gRY94wA!mAj(WW=5Ng5W0z3vz zaAf=~HxXAMe!(pH><OZ0D@i%1r}0Jz8rZCaIz;yS&Z*5W!1nU)#T=<mpG6mL4r<>I z^j7Pb{O%t2uIf*_EhaJ<|7ws!d$uXLc*@3~FE?N0?>KSPm>yj;Pm?n&JB;K~2J^NW zR`E1`eeCA@v`g<dATM`GUI>gzofa^&Gqa(f^K%JIq6p;I@cIPQI&&UjzWm;?o!8k3 zx@{x33wg2G>m=wpw0ydlnEpP-hDhcPp^$mZyd&a{zOx>RbGoB0h=G7f39%V3t9iWH zQP=n7*U`dsz2)}K_$a~%rRd2ln5+orU%q@Fu!ezse|v@=dAsM;g^|U0fg$EBegI&y z0?3IOyx#KG_bn=Ba4r}{f5}c_2uZBYMSgoXpr)G095gM$eOg_;1Wm%1O_;br<JNl4 z`Gzzy#->m!K>xwS_olN2q3U`8*Idm-8?3I<?I?W=YpDW0OR)K>`<)hbrRmeoM5O{` z+ee4cJs(9I9x$zoZme#l9lUT3$i&9_c5%r8c&ZU#n9jUtt-tBOLv!3`OyW>ST3oY! zl*cct!v030O<mnps43pj>SBWa=E-=bQ!=N^!gYCF$&ya-I|*`ZSqw-U)4739oyHKh z6Zc4U;rO+TFj`EPKgiWb)YayzCS#|?Ja#F9hpjWUa3r35Wgp2Mi?4ZsN&GXlz7L%8 zYwV{yBwsHJ4?_E_{E>Hk0l>Z#TKjtmCw2!%r@NB1syyZ-#HgcU9eYS^avaw)K4|9O zA0Q^B^Y_@U2!wi_DsFuGj6C|*PYREKl9La|kkvbG2b}EOuB0}@mdKIsw2X>~?l}YA z?LG}~GLE2E^`&*8V##`XMrbDA?W3G2(sQc*@Vq)wP4x@&37=P$>G9P;5ydu%n@{4q zGEBs#jm9WHQAfSWY=v(Xuuq&$9Q#751OY&-ozdgIJbb`J6z3SzlxHiqbWyX@oX7}; zD3Xudh-M%F$>!W`l_6^|*_8{r6y6B|X&(vh{YkFCpy0#2Y;MY4FIzL?%?m*K$GtLE zkgT3A(I2g^FP-=HrqQ{r(uN-joc_>QqxZ|Mc;X4cY$xhZi7BLLTre*0S%r(#HmBZd z2Yr}FS(HWn(iUoJ20@rPJI<o`%@hQtO_N>|5ggl(Cf2eNlxpLWAi(ne1p>IZ$Im|T zv4VVN4)&=BLANcb$Q<h4bJl@2y)$S=Kmbs>gKs@nWf$0M|4W|B>Dl&BIh`w*J!KuI z9J)1<__KjyNrqo({JfdaD3&|E3k+!Wm<~{fY>%&prF?MBiBh~CPFa_h@4gbkc5rqU zIuTkCGJbow*q*ca6#<s`Phg9w<bZ=3FpnZsefE2wj?38UOI2Hnd(V{fbCIaZQ8iEZ z-A~6}XCLC<Q*jC(?JUI-)cR?@X#>({8QWcyYNn|i?%-r!-V;%RJvoaN2-3@XU8Pzl z=yhm8k>rw_CXxW~!RL}H2@?>I);fYLu8)Mvl;JEO{Y`vzLvglBq<Nr~U<$YMI|D_0 z+5EgL_oc<&&GV+nZVOOJKjQdZD1-VX;1*#3!HyJoaZu;k?&IP6Y{<3pUcsUF)59j3 z#FV0r)q#nlafJSq7xJB_i=;1jy>rUpvFGMHaS3LFM-5l|Xbemll7>b#$k)ah`?rYC zwZiLBE1Akg2?95@q7uyu4mVD=vSrB;)wCBS_Xdp*VJ@yNM8w`aJg%bE0LnbxYL!KJ z9my5pX&=%)TfERrR~;BO7iZP0hPJO$Z0a_=T9?*l%z1ve9Pd(z07O|qTA8X0BufdL zEY#V`Y0N584ei)^oYS4eB+F8%wy>_d=e0C+XNmrzq!4wLv9Uxghsgt_w0tSqsq*uE zSti%vQzp?2nWpVsQ68zb{NZo<KBBNrXDe6CL}^u!3QL{xOLJ*OJMCv{;vnf^ztpBd zpwtl>DkX^g^cRsTS{`^Iu)~l2w;##!s*@F;F6=_##S;khmVb*;MdQo@r-2Ih#kTUS zX5Mjq3T)bit5UT$%vVVjK~ZbETR2{^hcqh3DD52&nWuJpNA<WJ5v!_&XcNCQI^~CP z)UF#;B1}K}WmbfYq=|@w?lTT}Y2`fvlHGe9mlmzR8Fk;d?B}DT<Cy|0yqAXx!L#kU z3g1(tMw4}tn@+d<SN1-?;};z}oRW`TVU1kZ-W_hMe$~Dz^7e<ld;*5qO1dsoZTa!H zM<O>)XIDsfFmE1M@w40Q`LTU|mB>!eowS&td@7xw8*w@(2fR!1QRWb+vWGXo%cfi~ zks@viGAGGk=97bsiK({*QJE7F=uNcOg?d`zo#B`}jn4a8GE=8(qxO4#s+;iz{0U^s ztXS_zn}S3)(NTDsYbf&yX$XRgGgVa{q4fD+m~6xK{f=xgp4sPJ901^bo)*sO9Sa^Z zpZS&G><Rqb<C>%B&#QJ{5nrT1PhQ)Y`mY|4$bnmVt*oxBqVmBH+1MRD3}&=;#rju> zBh{`;zYTovcu}mhSyN$%u6sat7u4(9KX^y{=I_AM9PUu<jH3_WVi3F27KKAj$eD$t zlMpW=@TJAHmupK#b^e$)3#;2%eK=R$p=v3<?AgV}u6}QW>N-clBZO7l*wMrPap49^ zg;09|+T&WCv+D10WsDEQy%w8?6h(YFhq-P`6<Q<Ko2h%JPfrsbXbe$@JlJqaUs?_v zcpTEKEx5bG-YI5U=Y_JDw>wErwHA}>8!NyARdP~X(*Zs?^w%=Vc`7QnyL6lNfJ9iv zYB<MP0lC}BT1hQ%r2*AfEKi@I;Z+Z!|F0l$GcQ4EUQuYe8J56m=K1i<FRz+LZ2yj| z97`|S&eN$^eNuzZ;^&8)DCy+mKD3V_le6Y6^QNQWxe_sFH)qY~9)k!I(Jk|(wD&_R ztYzx+*3i>|TdX%`w}cLUpz-y2pSR#$sh(zmlsO`G;)p1uhKtc-H0@^Nd3OcOok4m# z1Rj?#_<@Ecm0|Vp`gpB!q>5EIE!W~l&90}m!1>4bq9OZ3A=A?yX#)6dCRGB5?NWhO zh6v%fR;Q+XwD%X>2b=EsqjxEC$CMtP?ys9@iioJT;O$8d)-X-mAo=I3zrhon58*aq z8BP@bhNaTg>qDv}E+4^rAOc{d4D*2W#P5AKpqOhV@+DcyCQlSytYtMevA@SfwtTsd zoSsf@a=SRE^KTGnkwzUG!Us7`$;&jgi0-8`bENQD_x5`AvOQZ^lsQSU4-8^A`$Pp- z0SWmbTuc)wL8H}x7vgnt)ptDK;O2LWTvo589p&NM3aGf)ajN9;I^PzmBBuu+`{V`L z=MPYXrQ)hDv^@G$8yUUo*qZspS(HVVWr5u0#N&62iA&<)p^95|W0c+nz#SGg2t7f` zILb(DH>82te88m7kB;Ak8%Nb>gNhq!u}T0NEzr2?!;s{;c^%>(u3mthR|q-l2x{Zu zFZpE|XpSr{=r`0r+yb=?BdBeD`w~&Tj%&8=oNj2Dv&GkEeVWsp8P`PAAM6{mIkmUJ zYXT;<66{NOMOS>{Vjop(FgR<<kUZT+kC4-Q3C<0ii*YUQOycXgXK0_Ns=v28*$Ha1 zv3Qj4bVvG8*2Pj0Z*0wrN~#FINmR*RN~g1DHSp3G>4Rv}s6o{MdmPUy(6k5uvl<Lj zpw=;G87nU;&gME21i+KS$<^~#1>(l&9q_~khJ-F`&};DQ9$`npMOIe7UQAaMLVkOR zCO>sV2ZbMCetpACJzFlD9A0IL{uO%@LA6Q&;jePprMF}6BYIv8p11fFs9B2NmZJ88 zxD4MFh08>mUo-_t=`&F94Xy0;Kiv+@yE~HmX21jstMAf4YQXF}8RPFt{7GUOr^RzP z)pPr|%RBzrMVR>_<Bf$^D`W4crVcZr2In^j6+q3{8@91}y2N-6_?tWgfWwO}MqBl0 ztLd!~qX!MvIoiYz4Xc~xk@U?otgfM)rG;m2B315=uM<nFLKL!^t?g@Ry02v@7|Vm< zMt6fCmY*i(z*NmHi*%EpecoJ`p#M6xE{Duk7EZGD4jbD*-QEo#8$NdE=B}0|Kjvs# ziNl(E#_;&$i^`jya2-wey_K%3&LfAg4+{}!<AQ<kF3B~EgE@<EFU+hjE8m9>;+y*- zO9Kz+bF8X@2KYJe8W%8MYgBusE9o>@te_JwrMy>@Y#lc^=L8<3!g}X+kCmP%Z((AJ z2B|}&Gukcrbj}Zb^eF(GN)PntT^P_KaU(5dvwk{RIY&CK#jaC*GjboXN=T#qZaTWh zESJ?{aog$y#$&y4>ccVbtou0vzBskFBm<}2`rf#t=LQ(!hl>YD)ddlr)Y=XW*F(GG zcOZ#b%ZwynMZ&)xbuj_BzX&h0%<aNMjMIhnNDu(hjtCCrr}qScLEGAt5P)g{;zY#1 z+Cc1EpLpC3<Farch}P6>&8`Nr`XQ`%+ExvTKiwVe7Dkbg+kX%4jGA=(QpuM0$^GnP z;OM<M;Z66f%-*l6(o-icC0WleYWw|u?29!yBj3Q~FotM_MHYA=QKeW6zOttF5z5g- zb7}KNqnfUy9q0{z9|eBx7U&6y<AB#;^ASx@c@IeC!>q8T`9)HW2K7i4u4lSGYi746 zM?AOWxMjpnX+2O2&*4q_LB$pK$Yw_`DcKLNU@lzX{Y&BX&wbX@u?r>;_Jd`IbudAW zJgc8f9_cl98|z+=J++GVmFiYEu%j+)^qI^n8A7}DIW8<mV!n^w!by8y?TMGcas>V( zk3XH8fM-B~{HQD)K@M~~k+z;r+5mlHsRu#^8)rOHA!Hk`v;M7a;*xY=Oh+GwI+<cZ z2}w>+&K}3%dF<|WO`|+Ers+2Y%xBG<MT^|U6hN{WoSm#wX9el$X9|O!i9jgB`>a3< zA-72T!+3y~jhd#k^r2@|3g$kU3>NLTIlFq#-?GEFw=I3ZgwQL1C%V&ug0CHvTQBZ& znTp>Qh4DL|;%S!qjy6+!?q<j&d-UsX`YE2R7Q#K2I+V$4ClfFcX`|C#dq<rM7S}M0 zC-v7kXqD?c&YQ%B=PGukk^3QMv@~bUKrUtrQ<LY;d(yj7aJXF83Nw@`p?Z7wQzu=G zMpnxwHc?%QsK~T|Rh5a0ZYJ222pB=~Da;sLnRH&U;?a7z0;YLiTzbVX!IV@4DpF?e zv}{uzX3dsQTzaw-k~4`-_1%pN$KU1MVxo+^y91xZsJm{(F6N#GNjgewpebPUwlwoC zk#sp>^t>6k`V!Y%k;N2khvn+gd<{azBl3{XDOqw%3HMLj>5-yFqX`T7g`x*vs)cC8 zW`DKidC1X!>78@MwAw;3N1{h6vUyr&_m0Y0K%fx1l$Ib!pr0uEi-<V~Pc9HOMk~VU zUE2mlq{D6k<=*i;KZyFON?WPkEv(nKA~VI4bNE&D;TxUZ<G5@~$aa6up{`S(lB1a8 z)bd^YyWMHtk?a-}x(Ci9t~<krH1-#~`3~vVPLl_Wg!nk>okBV_a@;AH^gTA8lEO7S zAp(|=<9?Gv2za*PQ}~|etG%EDf9W+a2!sQiB^b?rewAIl#6$=9vWHn*b^FOjQzmZj z<+G>F89d278gW&XSdfir$2Vm+2bRu`Vg9qyo3E=uK&UP}+De73KD9f`MGTE&HyrqC zY6x#1s`H=1oEIW62&EWB9@Ca7jL9ZPYWhSYo;SCYvop&WvM7M?eyTw*vy(7F2ZmfP z|DyKdMGF@0+K5ToeYrS?wkKUvHW7zQ&kbtqH*2}Cu1&k4*^>oKVCx_58clPb79M|4 zAj-Mr^dZT-!M$mDH0z2##Li-u=pnQ{jr8(8IMy%kE8yoDzIh+kjHX%y1^qd>EHhQI zb@lP9>jj@jU1wsJe&?7t&CvH^qQb`PM(iT4I<oM-RYNJirI^2%8%xk>OLV7B+~z}v zsn)n7H!NX?>!{(5>8c7ktLl}CH(X!Djn)jDM?VS#_TSeVt+8(^^@poNBYrIeTHIK0 z9`N*lZgM+Q9zSys%+Tr#3z2qz$#}_EE)Fgz+>fqCr(x$(#Od6jKM#fI20ToV9lbc1 z<m_N(z8A>I^?xi~1zS~J6UCrI=>{n&2@#}AxFFqK(%s!kH`3kRNH@~m-NL22`*Obn z@An7JvuF0~nKf(TsM+!}j?{OpdH9aiG{-^Kc5*@2)NwzhHZaJy4R>pmF!$Z+%8t5q z%_BotGQ@GU?iG34CQEnzO}I0KQemQZxmtQPt&`aOfUPZ>&dfGu0iP41Gl>(UfVH%l z$+w0y&a+r+u4*0rud1LIRZYJh9EU;popwgg#)+fES^#Ow=Pi{5J$Pd!==k1zLTo=` zn2rTx#CJXxmE#lmDM}4XjI}Uobv`~52ug~aJ{hX!pa#e46QY2X_zLHmhPo95eYXE0 zExVi!a;(}1<Uz5rhfrM|;PiEEH1+pmVlMac*5s+5Ld96n(ngtUO{iC<(PUwtb%Z4Q z6AtDlmmogCFw9ed5I>_AwsAXr;^xR`Wdh|qD+oR7RYx;!+#iWkna}(4#>*22(|OZB zgW6Jl1s?ni+qHJ&U0StGzN%_{8%@5~RePZ;I;-p{JTvambR#igOg<pr)^?%6Q-#u_ zTW~AIXD?F#5;dE9zn=CwtgpGv$!@qr_B?Bv)xiN}xW5mG3NQi+Z=SF1{ae7C6k~{> z6in2ZVl-SaTa_baYNT%=Vjx8Dna4KT1NPY~C{}*ha^f2=xFdJjI1K+Of=8g6O$QP8 z<JCXgp?!k!0n^z`(6`HTk*(S7q^Nh+z`>+Jo~t7W&j)icG-PL&4E6f;50CdTpp0*_ z*D0GuC3kU*+S$r7ZNU4pSg$UNT7VIQ;TG0!gJ*61%zuLfR2OCWX@JU6R&E3Hb)T`~ z#O0C`q4(j(v<^yA@z@kIg|?PfR__K`3APh+^L7Y%S(fFc6_K_(v8$W1tY_naA1q|Y z5=V^>dvKnLr2LItv|cgacVp&wzmJ$u0Ri83HQDCFZki`^r8)d)MVfZX?U4KEOf0Bd zbFHDsW$44E5N%N*VWMhuB!GEfetv{~KG9Fy*epTJKCm4)yK{l6=X>^q$tAkRm^w<O zD29{+^I`9Ok=)q6)wG6hP0PkEMCEeHA&-$(w!H3~K0sFmhx?-^#`IivU#y|0QE3)k z370{y*G)28gOQx0uIwiXW$o^P?`?s7?2qnOVg2#dM7zv-L!40zD%{icm>8@4R~lal z#Asr+Ew?tYqx_yXVF_TqdbvY1;8-znX-UcVI{{KGDBPD$%X?%wg<*7=CRjU0jWS0n zv1t+B;_mRcB|Xi%k~ofPa(U|~g<IZv%LP|VquA4|`6s1UE7VLlYU0>!KmvE9u6l8H z$KF0fSArfo0FIBt*~Uy~9I(UC=P_2Z4M7G~r|LdvgCmyTkEoM(we#@gzv~Zy3i8Fa zWLIUGN&wvIGj|UIlZ-$k0HEpdOgPl!57M;UeE0P5D{?<5>e2z}e$o+8nbEiBLv9aw zmk7E}<Bd!mno4$%l{kPNo{d%Ba~(eAQ@jJihed8lY$?9D*X(1rXEc@DVEadYdXwGv zp?9-42Dii+MYtprcwzfvY>)1U6)kAI-0c#q=0z#0lC7tHv%5&8YhAAaUl$5Jl7M-G zDM5wpc>Y{Ug&7wXcm)It1RoYw6Zz;3uTA7-5jrevUQCq=-0w~Np{kp{g!Of!o&0UB z5XFP7hr%IvgzJC1_cwE+NF-nMstZs2E0Hgx!_BnJ6~IXeYqSS==*aGd!TKA0xD<7% z^%gd1mNqn_Bj>4{g&!zDZ_R1<@T48x$r%PjkI;+B>^To5ychByx58eY_-d{c7s|r{ zw;jOP0x+-q1}pOw%ug&DA>aH=!2FMmIifx7<=dP2-)T3AUb;(1-D2&c1HN#y!>*Vh z<_S@G;!8d&)t{(asoEBn(Z*mcBo;43lI@zYvD^cgiCjgpYsdN88B8-zv!*%v&WEkm z9Tc(f$NAAw*ZvS+%BmR6vC$K^EywR~kq1i5qye=-A@n}A!iOs}3X#%Q-!R05mINdc z{*7QfpN^I7*_Xk*%|t;Z_#D<38hS79Q~pp~{A(fB{++5F>UnPMSUu0liB5#Q4RwK< z@6)27Dd1dn&`&(KQA?Z?FZSMB4qcP?7fgZ`G}i9Z3$JKdU`@u<l=t|%*d#TiISECK zk?zi32e+sFRZ&*A)3v+t8Fo9S`AyAG!8&sk939Wcw%&86j+<L>%V;|+6-+@IB3;04 zzK}$A7zZ2-c&vyoj}>k@Q&2xH?T(&}7DxB5S2ep+it5mrIqJ9R>P$jH&C@W&8Fpg( z@eCVoSb<ggF`+TW=`}W&2=4};raGDuACfQdM#A1@S4H@%)`-?gH;EJc6d!$PDR%6h z(4prP>7=#aTgRdI8J;eBZ;BQ7JAcO~xvl?s+aAC%4Pj}&c-WaJV)%|DCoeb0UY9vx z^8I=H2uvor?{g5(d~0Yj-fttm00e@(Lp{Y;aWz+cBq$~gf#-(Z!Y(5u!}Q>3ZoBc$ zv^0^C-)2>L_=xjz?v7jYx6YAoBeN#(Lwzy#*kq1!B(3a9m!?;FtklZJM$aB{GqdOo z`qwJh0vcsbGVpN+pm~t^3mk~(?_mWN>>VT5VLaKyIFUU%Rszp`|Gxc?INm&TuM$`> zVDV{p3NHU;bI8Su<lraQ$;!%8kpj>Y1}2>F*%)DX0b>*atLQnPO{&YtBy6>6shmM! zeVFrUY{f%(j8TTgBCvTRwWZ9r^PDA3)uJe8x@WX;H?eTjiDrsd=~7Cg_a6z@X#0z$ z#~2TB#`|w8G~_1FL*91k@svMYKB=AY-?dw@>ka-kd<ZU3eRv%NM!!A_(Ab?|Q><Xw zvPg_~B`j7!Pgq*o@$j}G7Ng;GrJ}fqcWBzRT7N!eCYjH(US(ki#{m`4GKN9WBP!3k ze~67;x)x++`O;;odLTKx<E)YY3j-I!rgzb3<Y`ef2GR@hiQe)2dk5b)JHstG^Vgzj zM?w=_u4SIbDrxLd*Ob4@+%q1ET6Js1^GEA~hjGqR1bXOq{<<bCdvLjA{m~@XE>7{# zM{{gJ*|jJ*wwz^8__x)NbSxv8$O59=>P0|24(^BG{PMhlGGiag7oSK#|LhZC`~jax zz(^9AfQKokm>9^M58~&3D6>L%BcGH0P2W`R*TbdPo#}uhD)Z6Te%nB5-+^yws^D*y zXGF-VJ46?BxvRErWzEt&PAQX$#e-rYXQ`Gww6w)nY9OiBg*MV4(395B<Ak*LN@q)A z)V>ZHtc&-1_Vv6>t!VO1-M3xrsEb$ct)#meN&U`TUL8>Pw7LGIBuMLEt~MEqju*rL zM#_P^$9Qo>cCrC*sB%_LZiCPeweQTih9A=!N@hwwdlW;KJloyj<L4Gve^Jr%yWLRv zUh^IxPS}_!p~{JA*FA(ixp2X<ALxT`>zfhei%+R`<xktF9C=Ih7j)10Z-PMEwnJVg zKc7zAo&GK}X!VX#;DmfY*Pb*yjJ6-u3ay$d($E`jE9p(Um&4ULBqw>hyzm)Ui4~Sg zNoWekhJft(ko&J+VjwzTpm4c0HI!ucgz!DIN^!$(C?t(tDI!p<ER)-<IhlGMQX-8Z z$OY>+f;8RcYZr+UIn??ANAUXxuEWK}U>A|P`V6yH112A;Gp)%4+{o`w=lSKvDOUvh z4I?r0_fYIiOenoTiW4EL+nk|Z7J;9Nc3`^JvXs{21UFNXQ++mL+x#Eo^VhRdl27+I zN&;F!_DYVHiWARg9PfXLHNr@Mc?t@6U4IA{sn>EocQ&kx0uYoJU-QG{g@DsHtONyd zV`jyxk*2VewOJ$baA{m;r|{Ro%~)BYD&8%w=wtoqqeJd88XylU-Azac|H&jK*!F;6 z-0F6dYzV<AzEJsRyqsb>_#ugQ$|SO5SVHO_)b+s3=vz8??Rdm(DKm`5)p><)*#Vh5 z;E+Sq6kknfDt<EjCi)rVz~H|)b~#@H*55XV(Hrxm8O$h#t-|$&)CIh4EFp9V`r<xS zlhMnY4FLE<N^UZPGy22{Ymh}%p*bUa+D=oDs-A#@yMfQ+a<+<gtxzOg)cJ6*Lqep( z^g|$WK;oeR;}e2j%efFjwHN6()ef%<*HABtAdpa`&%xcmf~9piktpd+Jv!m2I+{%; zO@kkhwxkox#fKiRQHs;;!+ne|{LhDe{`LIUgWzMcei>|>HJr)mNStDyoDw!Y$}X+P z_$9(@sR$@ehyEiZ)UrV%sqI=S4P?u^dxVc^sv7=$b|LXf2yPV8Ts=kPv;KM5(+FC6 z+dn9(j$%`@WD}rT14nkE;<KYk|8>$=o*q@hHg}{FQBgRa<_7H(>brA6yz#%?@?s!j zU4dtnv7)``%oYF<zgaoeOxrPXCVCA91_8H2U4Wv5_)~hNo`xh<_2%i`HB~o16>W+I zx2GfV&QuIH{|c*R$zk`r%hJ-Ow!A#<VfZP@c8%V<B-?ny?*7%F)*S|1mrH&9ScC`B zcaTkqe=|2%Lw0B&I6nE*d8L1?K;q$Uz_~~WzuC_r7h0)0**mgWnQJHu`@Ez=01Wf; zDxM)UVh62ZpQty;{rZDWN)|Y<j4R};kpet-_kq)Dp1YMe(P+m7gu)<hhGwLovpTd{ z$CVU=r`PKOHnh#jFX1$imziQ)W6Rpq;XW5E$c00U052REeNM0AHmp+NI*S)6YEUHr zF|yp}a^gF$j1GWK-j(6SVLc(~N@BAy@a7mHym`i|ndmP$+4r}Ff^WorW8o%cr1*c$ z2XOLc%*_nSh#Z{%Ienqf;{&ydN_YpSs<N2nQ!Rq{#T_cxPP}#J-AGWGahWZAIbq#F zFuO^(Xv?-*fvCTVXYS3k67NT*t`ws#tcIDZo$BWPE~kvj!m&$7zXH)#gu$?2lU3)2 zra!qi!cQ0;d#k_l5Z+9hurl!KJ4*5TeSQwg<Ku$QNRSc%Fep51T<mU9PeDLg8K&$d za!akI4qS_snWiNdbgj-m@SoD_0*>6mx!=or%TBWBwcR}$c5NZtTf7PvvFC#bOw~}W zD|z6cNI}ku_9^yzaTtwBCU;RfKU3)d2APsFK}xYOI=kSu=cQ*aac<wTt-e)2O>ee; zmG&oVD^t#76{e_B;Li2Fj3_MyBb51L%m7QRNuPljMHo;?gZT-E`iyKo*aFx~II<7i z3wi8hr2R(XdfTMH03ZI11Xwg!oFyo<rZ!4Td!G6Q!5bPrnq%}X@rFH*R>@_{e)q12 z*Qbx!a5F#Mv$WJ)*d<N^+0xt7Tj!`_fcnIo-CC3*|7{U?zuQGKa^X!ihF7LmnOcP| zn#OYQ$cTg#U<*Gl9*$x~03N5VZt2$ld$rJE(o*?cLS$w*ZAGDl!ECgMuSO(rR2Ulg zX9N5#3&f7>*rfQ>CwdZB)z(=p(UZ$4IaxshrEifJuk{ihuSVhwBW}50YcQ?Ojanh) z(TYJBLo=L@)l*eu><Kxs!;6ebhl<RmpI9T1FV8WWW35k$dFRas2LVrLOSSj|#D~_> zs*kgnxHz&T#b=u_ujwRM-go#4HmRMCbdS|e8LE~7q&(S&?}wxxU2To{X{SlOCnnh@ z$G-{?0_%c-rb?{mC?{Yih)NIy*PKi9d7E4Ghc}EK9K-y=ge}*3CCv0bptlN-ea_vA zV!+v_IN*O*<AMfzIYN8$?}wHL7XyH6{<DdNz_i&sOm;VPVD@gmevj|zpH$~(Lcfic zPZh4d$Sm^jdt(*kGTgW5|1{zSz?P=Gzd6Yn6x~HJs-UHB3fK6qFS~%|Z}SSjtL2G6 zzU&u=E6T^AzYUEo<coyApATyCI}-+o%h70q<3G)U|EwXOS49QpeNnUb;2)wZB9BBF zFD%nk>Sbgc9FUzQTSfS$>7seuIFN=^J!KP7Fomxm&94YxQC9l4f1aUj0h&e5iA{g< zhV}vtl3^dph~jSdK(+99uL_#mm1n<F64eR1*yybUrr`b#$YnqZGpsZSl6rbNy-!=Q zhlYs~ayV;H_a!07=fFTQP*>X#TvdT^@@43HJm@8uybvA%uYjYH-!-a<*#dN!6`M=g z$Uaz6SOkX4YWgGjFm$13C4_(r{B8xqnekhwH?-S(MD-E8rNpK+$`h?nl(Pj(o(H-! zrgq+jwy#FPS-OXD+xXs!C&oe-ZO=|f=RER5r<R|p>)uB$zsK4#XlT5qVPF)-vk&Og z!0>k;Hja`q+w3cTH?bS`%l_$jm&4b?s{f2hZmiMwd=eu{R9Sgu{EHbJ8(^p~nZYl} zoRtzVSd+7g!iGYoRQb!1=Q$SeHSBKPVHgif=Icm}>$`}#7<+Gf@enSqQ9M@fz=kgv zi&Ga%YntdA0f7O*c^3oBTrp}p2w2Xvd~i+vX{w>d1{bS!*D1^WrP!6a@`p>6$&uzs z_318@K^@T_F*VOuR=)6Wbg=ooq9$n*+9v+Q)Bo_azQ0nRhaG#!p2~hq8IjSx-<*Cz ztbV?=YPdNBxG$|Y_SlXLn^7<B>*ou&Aix7WtKX#jM)hP&QBdhp)rFtXFIKKNRGC-g zcmb`gc8?1^052|TTy?9%K|B<JY8BJam-;Ko7X7v_<pyuRVaB(~<Cd9Z&N)d)T=>fO zmHEuGr#kau8Csd&z<``<kM!N?ObzrMn<m$i1ILcD{-s0TjdSa^VBIp_a%0muH6*@Z z^~G)WP?Di6lXd-{C91xJ-2z!)PYYsm+XDWboS%o}$D#2W|I!xn^Zpl$rg^bwR1APS zbcJ<Y7&zm1<zd9`D8o5EX4WIXj*9yJczZYmp*LD7{RGc3%a@%Mr-Rt#(&8Q%?}gkN z0%^l=&~PB}KK+;`chmMG_>%q{k_W2$Cbh@qB-oBmNIO{Y&24y2BGjVccD3AHQghOM zp8$|&kq_KAwd4rrOopv_u8TM3+$c0yUtjn-+IoTL?sofV#oj#LK?!xZ_xM_6dYS^0 zsBtp;@gOBN#w=Ao*smrm)%2pyL454TbEy6K_CLe<0T_;-E*{R!mzKAtL(}5o>i7CL zu!XxxkByYr5{Tg%y>}Rg7t@y1%^&8f+I?4=zHE$sPaPCrsAzfDa>gBT=7Jr0nNiyq zI>mEl1p3?M{k5gtR=SY3YcAF7vkm1Lm96F&lP6l+>2m0o)!$U`i)bm5JfyTm+|An_ z-kR>XrXW<_Ib^29$tr^lWHFSr)2K-6)dgN$p_;(@q20zuYj_s)TWglp(15M{?ir84 z+#!L7s~uH4B!dl#0I}9)r~R|WGAw@;rMcP9%>G6|oPiOTdZBhrPJwuX&_8+`mrNuf zcgiSzaX+?93CfYf%3u!bb6;;fx!R(@Q`WY&L<+1%{Oiq@?tbj4@MQL=QmHUGNm>uh zuR?3{oudRtye3Uh*suLdN55VmTW#d-ttdN}MML#e(zR7N#^ua7I@HOjd$<bI<-|Mi z3BTr~!dmyqk(b*nrQTG9p5=^+w3&GfSQGV#!fNUN>3%+3m1$WkJbCq&*nPjQy3W{S zrI&H{pefzJqXu~1W6e$l*~p@>T5rB6q3*ZsJ=@!d#us}Fe>I)?OIADtX(Olo{HcBq zn=@f5l2uY&7Av*#TpJS}*BkkwV*W@XXR7b;jNM``)Py6pF_<JkmrPW}qtO|^WYRb9 zW}1!n<EQQRuax>R!&0h6{Cry4W_{rJ^^ELXOXewJwTb}sxO$y=HhAc5V?x)KB5QxC z%gMh<rkevE;qth+n2|v)G*gRzxb{jij27<hSCrAR>f4s7k%7NaI@>?3>|BnCMHF4B zmE1K`mMKN8?N0<}=l}-<_5~2KsVFMv(C|G=M2YhpyfV*zvatU3?E~j^Lx=lXKP#vL zpZZApE^ERc=;l#_1u&G3%Zax}-ke`{z;Ud?J40+GT1{-K*a|f0jEnu&yhMhOQpuRZ zNAtyn0=lMN1YC@8c9jTz$0f39tQf{OqSo!ga(vjwhshc$LSe~?EOy8hpr}V+{A(gE z!?$JMO<7vaGhoSlVE<0w*JBOk4JU--Q13h|cPnmNCpdFrpf1eiz<DEh=~_J_T1lC( zc1EHeWE?H=0qczUgUQ-!MN3IDJ+YxgR`V_cF$FB3ategG7ZzuHUI3o}AwDiDI-W>I zt8vb#XF}PxFDpX)>k_+HUq<6m@otM;M^%O=rX@_cZUrN$*Lq|7eo2S%s%H>b`JF?_ zhfu{aF1+op)hcC{POvoAC)_cL?m8Xfq32_g$3NRqinGmGAe|D?p#f={4`;KWS8w1( zmL?{=xCWFc*N(KRDKg%YBeES0W3qK^Lm>gXR3nW2ygQ4vHHDe8#!6QsA4ys$N+@Za zI|r8%hec<5bCu7L!~=}Vc5~?;p2aW`_|N1p0gkqeBXu(I1ZYjZZq3dEN>*D2i8U?c zptE4tJ<oqvTRF>B{B5*=%eP)N=1`Ilw~_Is-l>PULnLbhuqE*u5#e9g4lf1K(|1O< zx1DLNe>nV<g59UC7M?|(fyC5u-FnFx<lQ2;L;}9nrf%Hrnj(bF=q{XUf!zAm<4NK~ z?P7R{sbJnbP1K@d{`RZVZm38L|8mDcYORC9-B?#{JktCQ|Iaazklt6=KqAbVI1)r9 zRAORJh2+h&7oY?a@?W4~C=~QHV-8Cof_~voDBdRYII>7B!#&BNWX-zA4bbIJUqM=J zFD$Le-3KS<m!&#sN2$yR$7sCyb>^ZY<RK(ex<2b-&%8hGe0eS50H^RNIEu`osNUz| z?EQZ6(X#w?o}ts;gwCnVpF?x&`uIby$G)AqHe?Q}A27aDU(Ws=1-PUL(rg9FGZK9| z%!;iw<M-JA2irW*M>{Beqt2SkOkZ363Sw+^%wooP=l?s%%k^a2Y0fO;w?L>!f%zUm zk@>z{{j=wWp+tIlJW;+Ztc9^DX;D7eGzz!`X?$)EhTsTDp~g2=v2~^j%T(aCVSa-q zr8I@5Q^&`4@Age;IA4$M#Mzqh8CGw&(9=^-<oQ@}m)&e+ney`^v-wj|u&*&iH<`IF zj>#m%BRbRQQ3ocy*|DH#JXWYDwl1Hw9sUP(a7<paz88r`7Hd0oPdFx36XA;xJU9kl zuUl?1+!lLn)E$+1rxj4J<Qn!KHWaTFmoP8L<x%oK?BD<SwjxOO6JYAt%_*pLNfVx7 z01W8G(4<}ih9+jKt9R%uUhy_&^vw}_y7552LcO1?d|PX4tGl$9+m#V8fqs{SyEaHy z5=>PB4Qk2isE2>jrJjq&*_!hZZ&#X&(r7iriUp-%n(lqpX~j{OKy>|~ezDqhG?!68 zL)8XfFzb!b@?kpGtDLYYLQTMKWrxIF`;G*MCxDiLyXy#*${k8oBr0mFo!{pp2wK>q z1k;%9Hl}(6ZEE-`$UE3dulWqeHo?)D;!E<_NC;{rJgRTDw%J`K^)VK!=rSjYY?m1j zK6E@Ul7jjD!tJrr$_7|fF{4NX-6<>n7Tu%Ug2-E<UrJEzj@_%+TIH`Teg?1%8+=`L znjU0M8{Lo!|5@O+89-&tv&Qhy+WOWbEc60D;Z`YXGqxYIv!fKo3Q<6r4|8W!=46x+ z>)3t7$Td8Dx^vE6=l0Nh&pvZi_c63RP0IJt4w>rqUv`DA7o{(=>f~tOw;B2|GRmFC zxmA^fcZhi7X1}M#djfa6lN~VSc0lpy7bEsrwFt*_{q!_@_!^az`B1iJNoZcUDiifg zxD{5zeCmRNZiS||X&tFxh?=rk+@z8Xoj$J48W+_%xH?(p?27{*PH}D?(|u_-6+Pei zVDdyyV^S^OP;^|z(Uq9`Yj(0(m4}V^I5#GOXnayyi+{H6uTd5+DHD}E+juYEEkYA2 zt9y^nfdH1WIayIb<hm{A-L-LEhv9BpA7@fog+b1XgVO0@K#0Fo#;L#H{MN^hxv2B7 z2KkG-@`0#M_)Loyaexe{GL_lvLAi@@bT1`_h{9)P3p*}+SiR$yA0Utb816aEGYQE= zS;^hpF{0{&{`Ip^`0#1S>)Oe@KRr9(EdZHMhw~%rm(4uuV!)-ps-ZZx;@^aBY6qo5 zkd`^CXVd;+52n0s{@S1a*K}e%As)4&Hm?@OZg8KGnsl(lOfGijs!Ty|)enK&{Jv2I zh)$FVUd~4ueYmHZORUs?F3oUVBO?yHo8RB6D^7J-EoL@yM{Rc9TrUn3SS`V#{1~8< z<Z-~ANjGPCYW2E5yDQ69lK@Fc%#8y*L!m~`e<y$mB6)EHdp;}<3V#`C#X}zC)WKMM zHM^4WQ`bYA^)}>ZPrVBT$J|5nwH9Au6((#Jk4p?^MK0BXVGD9;eq7|{GF9voe8|b< z*|VuEPA@`;`L;bW1D0Vre(w}7eSwDgZZ&@bAIrcGd&@vGaUlj;h0GS7F|4EF&5~#6 zr*j&)*7}YIdWur+j<~%Nz+vH#Q@%<~c9cm$qK(Y;;nM2TEVdR^YpgAIVxmR)4u618 zB4Fmq)(`2vteMm59Lrtix}`?=1T@?EHHIuCzsxu{zX<*T00uBWdy$?ggYjuW{3i4+ z=1<4QE@HN1GsvdNHOUT?iDXT)d5+?t_a3f22v)=@j`e?#9~w$6$=T4JLf<>s=x)ZO zyzXT^F?@L7;G<*;`d$)&7wN34T+uT9R+qZ2K8?lYiG)sm!`=1nAMy#Lb5wqU&6fAU z=W##d#4R9QGu)U`LfPv)>Y@1FXcITiz%Y+$C*QHC<ciZv0qR;BU?pKU&u`AZVvEa@ zzmtp2OZ`cxmRu0e$#Tl?Hd02onjl<cZh~EIW*mLQ?)jeHe}3W4e-b!=yTNDj7&+(m zZ!K$u^6D@4NigGsevoxk#biTf!{~;kORm)7OCK-onP?AMKP*aRT$S~L<9XXoZyr*1 z-i84sl!%n&ktWqFNl8T}VZGa-5_3_^#NCF0uF@2?bC29X`X`Hwyt>y-Y14VG*3IbV zW6?7Ih9D9*k&Jzu4Hx~K5Mzsv?KCXYn(U;n$9Q>~gAINQ+B-Q_rUa0u*&X^UhuS~j z>>#=sZ?~(VsyXymRqz&rg71h9vEB6I`r~sZOaMMJWYCOU1892$#vWdZ@i5R|EDriU zx~j@<cJ~#+=+kfVwy=7SoAOs$c=!Ky8fvR6+MED;2gj`E*fMh<mxA{D$8B_ET5rrg z#Lqnt8fUm3Vt6bk38s*sr7Rn6_B@Jt>!rIMdVrlZ=he2T8Qr`L2ITEF^wb?1tXG;( zV0S-zi7^?!HEgROQ#mH?a3}pPqHVfgR4{$j8UMbq=xjAKFcxPk22pQePWQH0p%A!e z58Z?IIT>V<oML0Hv%gySAZ)(_BzkJj?ZM<I5a%AbM$~`-exmx|0DZ6p=vlHLxaX$@ z^9V2j;X+Q^6*Ze2#qUT;e@ygQ-G;<CnB80dgTCS6YrleoSL5{px8|zEu%k~F-nOhv z#;{*~BL3h;pCyDtYNpSt%}PtQ)A&Qy=+Rl%7{Bg-*Qig4?tI;Sz4avSgzPnDAS2(j z8?z5ncRjpUM<5QEbgj=&tNycr2BicSN^_&9`en+4fnI-!$>{lQP^F`cwpnLSqo>O< zOGlSFsslmOEpzx;vrZ$&@$a)WX`zUUM+`f$*idnaA%euZ-%)XxTiMS7WfHsy^xRy6 zsxGMQB(IrkQat8yeK5*?U`U@_yMKPsv$E7|G^6Ahf3I_6ESFEo2m~gTxkzk}LHrD$ za3opp{0ZN6!oXkoyq;Y}nHwW<-h6TpFktdA(c;IJzE1n8<|<@6#9`v<skKT=rgKhV z9*pB*UJz=lX+b)RA;Gu2$nF%i{m94!l?B}vt~H`lo8?W@SwrR|gqfDpBB&EAP+!n) zXdn59h%kq(y!;UOO5<a&i(Gt7m{|2!$@rR}xtFdK#}~wq=_KoGl^KG35wk%)G{}*y zCao@QoBz=M2n{uAR5zpsJyeit{7#aegK<@oDmd~Y_{i{`fue|)tC((Z1cB>R<uS1D zD&p|R4wuO&8hWm<|9DIZAG|AiBFFv7P%km<U4ivw$-fkCO+?AuFKP-A{zdq#6d7GN zeRC(r8BEi$PhaYsoDxA^rieTBPKuLrDhA{=#9Rw*9ej#+r&~`US87H>jHNpf{>AA@ zU2g1-6cT<C$a!H}9<#NW&CD|02ZOXU_;?xur~roxW5W6(lfZi#A@)yHJ^ldklqz4> zuWT{l9eWglNv=`;k+I<NdX;mc!Ur~N@9TEOs=_S!<J;EmrfO&D%_)s5oFU-RQIj@S zWSf&GmyU15V!1L9q(0C~&j+J?G%p`xk3WQd^`vOcdGFzJ23XqLkAp=f4Mgt!Q2slj zHh%Obn6hHyuXm>qsJ9eBQAJkUO+@tK-Kcj~YGeT5?B(*h+aG-*|1}wfabJ_RUa_SA z#6sk1Vcy8%SDH8g>iuRvECefd1)e$cpKxf;&KqVQ-f$N#D^?4G^a^C)o^glwDe3It zb?wMfh(%8_N!rTg`mF0KhzQ;%)46IU>={Q;QXJlo`6YAej3)5nw8ezG-^%_MMPuQp z?o-?+!d@hkdijL-H)$5}Bst`HlN&x4fW3tV9bzc4*Vyn8041n_f-{Ki-<3DWerbl) zhb}{}at10Gjb0B`80PgHUp#CXQ(~gY<b3HpY0CoY?lykO0L93IbJ=IVfRfr+1`gUo zNWc+SpQc`8i+C$NM=2vMH~^4ez>p<m5)cR2=JX=gsSisHCB0z+We=!IB1E{c(5c!9 zd;`bQeM7q!8{`a}G(IjK0Z)HIl(I+JmVNg-ino{j!}R1vr<>^Hu0wNNLCQ@Xrl7K- zi;ELDP-6#CvPSDnCe%xqOLXqQ{<rZie@XBN+aAY{X=T>Hmbjard^DF?KKlVUnD3U$ z3^8jOm`rP)T5a$DEGe^~yGdzI=<{sAZDc`90XMC7!mLJB`+2FtEV)LwnM+y|?O`%x z9p4sRPfbfrNoejb(e=xJ$54r1yaB>1v+?L)1pv~7e(I3(5H8v22BH}wJv5HPOsU6J zEkOlkOe!3g`57JSLWym$t%UQL4^;lfi*e8kH^rxkSe^l_cor8uosD?NXhY3TrGMK* zXS7nH9jl~TAHvDo<pfrsg=jAF<FZ~uYk26)@=Eu%GUi!7|L*uV!1FyiW`@^t$TVU8 z@)^b&>+uav!%!0xQ}Dh|Qen+)wynnSc3~v)+xe?{aV#WS$|Yo66~rX6p><!>7g7I; zI(Up)R7p#t?{6vXrR=le5&Rct-xI`UQ6Uw?5|cudVm&kH`}^s`mpp}pBx|+8`cOIl z>`Q#-)er^J##)`7*#>%Cyo7^xQ%${F<Ww;H*xHg+qP?f!fDtI_oDmb=|Cpttz<k2? zF<qc$rVD97Om1~3Ans3SuoNk)yJ+D0)Nh0HqxG48U7?7Qic{<vcC2BFwhHIE@;4C- z5CSKENt6OhFA+=koWa7*oQssTH7=DC=BK<R{ln;V%=jEF;sPE1)rT_Y4Le~-y(AUT z=Hsa!+6$3>?x<n4da2S0ya&V)q;00;d<xvX;gg8FL9eS;bNZ$UVQ=%l^H0+rLZ7$u z6EQ~7Ig12kppy@Yzc&+a!i&5)8JUt<u9sM!7pA>(m$<#GuGmznku8g)LDP1A_jCoZ zh4~e%B{Fir?)c{1<CbsP$=xO1WfRf;jhrLS6NcU0ldTV|cu8aP;!*<wyQut${4^EC z@lqakohF9Xc{9i85xXTtXY4Hi$eGFrI%tcayqY3{hLgWOU=!=tApYy{E<j!8<pm~g zCz!Ovv?OFC`hr5-zdygE_pe?6(FdUKL60)NZ7j|rAp>xAl&cfp&6N&2mqRb^v33j* zNsPwx3&3#iOgTZZ=1{>$?1o$>AhAvFXXtvcG?`c$wNet|_4+-en~}8mODk69$8l`V zJJv7;Iirh*&`90HJ{?GPOipl5=9ffdM(7XbiRGR<tV47sGloBn#cugA@0RrkXg>@H z>#fXxX4|$zSl=slGn$g-10~9EqBPf9UytEBpN6rStk_Q*k>=N+Y!E#S36IBQ_$+sg zmgX2}Ef1QHH}$@3@q;l5dhy^tKNSjw!UE+OvX9?1nfL%0dEFCPOQb2n%NPhWqNeV$ z-y9H|*6-JVtOw?wB*PysyAPH$S-|aJBAr!qck9f2Q}egxp*h!2>(~B)J3)v1Gc1;0 z?#6jH@4m-nKyEkodq4XXl;E>wxILm58}Kc2K~En)c22>MEUxM{&=(s}Djc@IE4MDG zfQAWBu)8EX)qRVT@n4>H8KY9>Xl}T)Ul=#s<N>ZnE;h!?N*R|Vf&M<PME<3ufc9TW zq2r}c4a1s=!XVxQf(6)Z;kBDa`3vnX(NaG8_&f>{*k$gj#J-XOH1dk<J-2OG2@w=3 zJ0HJKdwO^{9xl4wF$Wwe)~9>*`+P<zq-h;kT<nNH&l;Xm`oPwXT1v)&3lJ@K3Yp-O zKTEUT));GYR*QzG{iq~`(XP&rY0Mi-zXJL~Q*P2Grh@EXI#R@2KLJNg>7ttuT{Xlg zKZ=@N_DQEbL_b<31g<KYjVS)~s1Smt>yohp){`KfK)1Vcd&yuI`g_L`xw+}7X|`-3 zrswCBDfpjUv4C8!L9ng^i2Oab@>9EEpO?z<YW9LzXxh}3wAFo}`L+Ov1fT(@S5gDo zAx3!NTt~gr*k2o!jJ(0I=B0%WCpt%3IXZT>QS;#1f8}eA__YgtxnP)0S~1;u|Hjta zIm&gIv_oT5n-w3rv^kYXo|^@UkKe}5hVIrl3eTe?(IX#yBR-MyXbDOg4@NPoft37c z&+<e(n`$O;c7C+tX!V`6FjAds{Y8%~hb2~{*#Md6l@}G{!M6hYCSZOBynTs7;{gBT zcy7nyO(k;X!t;4MQ+HW!@~IgGht2alEYds|?XeJq0eSGojs!7IK^|97TrUb8cfH;V ztC{&76ibP>`p*&sd)l#ZX#ZWIb9-B}ppAP)Y4l>L{Yhbirguw&r~8@(3wr6!kvX>k zMzKe_`-$t3cdX=Fd|2!R3SFfArb78>fyWbq3d7mEJuyFu`HIHz(j{i#S{j@^7UtmF zIN)zG>Kdzgi_9ZooOH`~COcr0XvwwfmBot*?#Dx)>un|+|GgRvSzi&fuF96fu<6Zt zw`LjH@TLGbA5eQEX@fggK+f&ztXuY#4}|=?SniX`B!fOKJh9s41y#Ai5S=4wb>UpM z5^9Zh7ub#m_uZ&;1UYj(gc-Mlv-x*HH#7ic`OBAm$M;Ei`gK}t7E{PdB9Tl_5WqQ7 z`qmJ#ESMdWplea<?N6NjTfUEUX2M9fnlSfUL8_{-molBms3;{>Q&#set^`ywZ#l;r z_B*{KxNAk|qMg5$Yy)(VU7VFuW-e*W+?|F12n2whXJ!Eg3uvwQhF=t%WGeQpRx|T; z+9+N%q}Z$E7?7Wad&M|UN^rZvO_U|^Q$aiAQo{+r1A1r2y+sA-+@Hx#gc^uE3$089 z<-`@~`46lyJF1^VEVFZ=BDzk=JJEtvcbo=3!)>R6+8|^lams^YY*m(X%$rs8Yk6JV z?Wg;*4gh|`DI^=bCYSx4WT)%J1S~b0cOXdnd~r#*N&4|G^Xk>ns0-h^Tu1gz(7J@o zX^db>z>lc0*C#|+UFboMa&l9XY|J@={Li8YY`pXtz-(orNMS_wNl8Qu4TxhplW$3K zq1NblM$j@)4}w0UxZ4;V75tFl@zp@JA*lKXlQxjeMgON?D*Q&>Y=o8;%Pih&s2uDh zKG@a+MX;x)x|}W^yJ<!Il%1K+yLI|zli2;P&F^lNU$9uKDmuR-Y^p+TtIHX>8N!s6 z(BHT29T};)L#rqk+0N+g$S^hQL_b!%pB$~KDa<~9OrkDeM1AsePlJ15<Q7bZ6<pBg zr=$MGr;nlQOXBE{Ye#MB4G|(xW0DGc<cK(>R|tjOInrQ2KEn@Epd|<9!}@>dTNsc~ zQVpM)mx_^{TWC4>(RjR+nAoBq*kvNonM^S7Ne_1mjL+kCK?Oz&zsO)WJlxQDFzS0E z{5buIAl$0?($A0-KBOv}04NuIZC4RghNP^SvmEFhH5`*e*#uHB0?x`yN#o8T3`rjt z?zt8jd4uVwil8CIa|3-@6DDW}%OhMG>lqkVTpTtsu7b)1u6=TZJce=8{RM)i3|#j| zkd$UEL>YFgHS5cdZ)=~-ZoM}MxwTHced!C$WVmc(v_Y7zA00a@3s##~wWz&TCihS_ zwQ{lIlpIlqlS<(#AAsKtz?fA1C#^#!9K$EZ`cGu{8e)HJmb7Svb4GYBMC62IV$Jn< z(&tzXhHp<?;ONZ>8`C|cbJ#{@3<B3~5II}L#rwa0qT}ivg%?t-Lc%fx)duxF35w#N zr*L0Z_?E$k3#CLV-bv3et<v|C2(kZlG%rB^vhSgOdwwvYHI=>Q|68mmx*Sx1*!=n| zl4&im-GVt#YZ&TC-JJABv;OzkugTVNppo&R)63Nqu{(bXe|QhtlDkpU+wksPg|%}p z|7umF@5qLJwng_D{khJXWA};C06op01uD&Kh|E4pNnxqAMTbQ$smC+HOz<Dpc+W;7 z1Xff`Ou&G~P>3-l08bHI2Os1zF*g^%d$SsDP80ssq2t%AZ?8gXluItiyY0_Z9<#=- z^rsaOhFx$=d`K+KW&1uwFIanpg>mnI{`F@AY$f9~&%Pf6nUaU~)Fex5D?A{Ups9Fz z0PoFRefo8NFe9ZO-PT4KBd-L}{0d}?zV^($z}_}|jXOg-%^h93VK}otw-`>Go0~|T zQ`}2qd+d%m?=eq3L7!=djeEw!8mu{TCQUANLnG>Tx#fc-`#YEqH+-~R_ksbnn6SRV z9E$CxvBEExrnnUY^8MucO(?0QDj+n>hnE(6n2lG4CLKVGm{Qo_B&ut`Eb<W{<bn#A z(?`WSS4VqktL>=D|2!yUH)QA%d+;qXxz+G@LHpIs{K@9#a<~4*KdnDH%6W04#C$<a zG8pL9#{gGPip;0aVJBZz{?TzQKd&#;h6(Gt&}3oe=1i7+pI=jYktK^wj43P@*IZ-f zqJBxOI1$Tw)pydAmzf`K@8Qy)H?BZW?^QNc+LdVeBv~x+YqKpIpaxgz11gd2gQez} z%|7dn5r2fOXZLDpz;-Zr6&YGD;~#HI*jIb3Xwg?b^yNg$x+3EPkCsB;&yF8?dENSW z?{t+tCRyz6dq)hn2=)tj0R{XNL3xRi-H3zZwSxnaQc@8;eup&zY`}&c+Q6>O-ww__ zh%rca#)cfn_A^`yBY4c7=2}?<H4X0~UpsLiF99Pz*$HVQ8{n|D3`y&tijHPND9_ch zpvCdOM)yT^8IhECI)@MN>`szo_`+gCKL)dy<vrz|9_{q-#>|V9lqY4PgOz3+r+xTh zPLI=eY#Wb}9~7;^j=1auGX^l)PDqfKg*Nl0VYz%cb>}P@s=u1pZ-C=%J?0hw=LOD~ z^hzAcOyZ6*mQP4MU-~9Q&sF~ZuR)b0!_oybdFVS;L%2E&j)eLc&}ies@?TrRhnM*i zm~Iraaiz0{<r<x{$Bf~gwC@b+m&B&cn+-H7VIH;ztAD5fKGOwGVu(I`#Z5_ow~#De zhaSrS($vyh&K%mWwBE4VRTGoTE>wtIklSzRRk6_``P$ypp*KvS0(;s)mfC-1o{iyp zO*UFCZ1IY?b&n<5kM2~5zQR!1kC0+IL?kDC#Yw9IgqsRp)8;=Lc30`CP^&uEhqmP# z&f@6u^;3zqTrY7XpI#nIhq_Ct27gMSJCW5P)o%2*!+$DzEb;6d6|lUx?vF-C=yG|| z=%e=Z6y)$J&uR=wRu=!JsEu}i;kf4zkS_NHNSFR=CK14U$!EX6r4f>dv-Y5u5Ch26 zv8bBXF>DJQQu76C#VzY1>8QS~`|@g;(9R|KOha$#gS!X(@Z<M%Pd%s0#$!t`<ayql zhT(e!+UrMtA6#*@rc0XZugA#0&gFuCMjf<HY<H<7rtP<Se7E(&8Q>re9K-vk-e=_r ztoCwcsHGbOX^wt5V43&20uh7<7~6QcW%ot@igOMNTfDYaJ&<;#Kzp^{oclR!r`inf zDPU-Z#*{#k_uj7~_TyJAn5Vh3duijWuYw1)yIgi@2{ozY=LTj<PZMvgeMqH#)A4_i z?ddOtjqH;EQgyn1p+r?!uC%Ni>+I5e|BJ~NT5RS&x9-Eb#o}OUDkgvE?1b3yj!i)d z)Ms#5XO6Vre-ksi*Eh7}d=2J(6K;KJq?6<18B{^-x;2LstfRfW$es3_L|GX>ECB9` zOh(MWiZt=!V#`v`-ZCMz@(>*Km9*_d=Cw}*BPFTv?fDcA?-4L;srWnTuyMM$rU6EG z*p4-MBpb|N(;gkkX!T^X<FRG^z*TXw!Tiov{Cg`R$MxeRN#|5rohF;b3Z^b!T&GZr z&{!#s+2G&3CGL629P*@O7EE%_xg|{VQO2u>L*X}=$RI;DU<T*UBU2lB%^pPCCqJvK zqDUY3LEW>TLwIS=gbAK{J2Vb7Y!wasiGGB~wH2MqPIbso`s%;nk|JHW$DU$$s|#K_ z3^;2@P<|&93HkP;Jj~~O6GA4QeBWP<5e`)KLXjWr5YxJ7OA7Zrm?Z_VPhPok=;ArK zj-1vcj!}Zg_rex%`G)#)EEH1GFpg@6L;4tm@Tz^H1D^a=oHvzN&rn9i?+3htIS!%w z!R=#zFQ-grU%4t5Zst*GYEHw0(RolSJg6U7W4RaT0Rn;P)2>`9b+@)VEL)n~fhTL2 zx@?pT_7?jA(EJARjtB8Hig1KvS0!hex)u{jn|e&+O4CwN9~T#z?(L=kOaAG6Ui#Je zUlQrx!qJ)`Ampyf$JAk+fzOW_J3VQkAN1?BrmPpV42I&o+m?`TcA8CV1gGlj>yyL{ zMK;jSC_miplIty1OL4z*QauvMnTj%`Sd`irV>)uhv<aKn1Uo@QtWNVM#vu#d5|OAk zYL*iJX!^|*uZHtUTzx3>#EaxenJPeyc}>N~Ik;EJl}KaX!}?m=?8P4o`r;yY)tvBa zI|8R|L$U5ohJse+w=f>oU;V)&5Q!gs5V|`N{4QU#?SJwF#9svLLeQGrSLDcO3Al>> zonOw8!_+)!(4&P?CF<>yd@&#i1qe|rNNYuDeo>imr-j2x(zA;Xco}blp;XCX64J+u zPl}I?N~UZ?{ooa&GL!6jPdq4}F94)+Pa3`3#+2(s6@^(l9v>7%UTt|zss}wvC?=+D zSG%doNpt<#>%t+iT!_4VWxmm)i+fi}lzc%SKY!U#eN$7L9>$tuXJEFWq;^?F+IC5L zZd)AUifd;-##-~4eqt}|jN95A%q>^>Xwc3etu%O9b1p)3ZGVtXw(Z;wr2DUL#y)T) ziKJBVE3S;cHWh)=jt*U_a|#za!vKKX)tdP#C3DDD97$%(VvOo+t|mW%4B8%|gS)-+ zu}4(ifkIhXx?>*rFZuJLWSDM*m!L+erOzOA;XNrG0+mb5pg0!ZpPRAu${08huG2jI z1N!D!)m#lp^-}mMpAgx=<Ft1Ebj_(}Un{v32K=``Sq#-uoLoU$dgVaeqT)|!_WrQK z3D7Ti6^6<3HQ<bqaX%#iN9IGG**%x_ZS{xH%-4h@`xF*KKeF&@_Bo9%uxUNCtX|(V ziL@gZJu-w#lQzx{HF)w5R#EtCCGOOAHhrM?pTCrLs=im4HH8H4Tzgxm;h2T!rf5H^ zq=e+4n>V=|cx{FXbWY(*#wQs*47LvRfbI|{(0NFx*h~Pj|5tEcY3k-tH?Ri?CK>L< zz)kQ7)bNuP2A|@vv=TGu0fGBsqf#m_;!WzN&j4Q6^%JpbZ?8KZg@m&1W$#cEP8!XK z_@%tJS3{$*x||i<pS@P<(*3V)=!>!Yfo9b70W0%hjED3eDsMxzDFT=TTiO#NGsePK z6rvu<M9XfzT1DRK{HuUOwlq|cS`HVRur|$<YFWa|bbjI3UK=-C`ok8k!D-n>UQ#G7 zW=E+j-$9LoX=Pk$xoY)Tm*N12X(^9Aso$=-#Fv#T5cCiWF!0m&5^!a1z#C^nTpuex zvHLjXsySAe1cbCL>kB&;g&4gCtQwY`nE0rv33eq}`7HA1Tp#AYF1?>{ITO>F!0aZ# z#Q<}-8`5ua)3%BGtlMdmp9t{+R`?q)mvTyO3u+G8&H5jOu#~+0d~2cv{_)KLAP!MF ze?l{oPhs}!x1`?)hTWbQ+H7H`$Y#~ME}?u8CJGzPfR2XSv%Y^D;d_viRwEWG6u*J0 zJzcG1*L*ICQkh7up+*>+h`{S@uCm>%$T%tCGg(;bFk1h-8QvK6{r-A?fA(|tHjCkA z>WzP)31N;@(Q9a&$7b7|{x~Sh=uF3K2}E7kUy#g#wq$%&dALc_iN5(#j9232|H?!S zjAyY%?*94hGqLCJCI(0L#n6S{y%;({Vt{r4Bjd14Sm<WFEs$bsds0^%Yy^RGX|cBE zVo6UP>%+F<5znYds%O?R4QE=tFjk}p3(gsvQu^LMRpZqjal3)3YG{KeeUS<IV4E>h z=z(H4c+>o?=qluHaVL-3t%5*aT~pv|X>4qr+EQ@yadf1@$kr8wd=4M^JB@JA7<yf( z%%lzL#`ni_o*#X=#Yb2s4)_1m+?P-YWDI?}UCW4~limZUaQrS%{fb((y<A0FJgl$k z7v4u<!FVbPevbj#DQVv)DB0jDZidz(<oe^89y?GVX16NOJ@11gcKR%L&!6D2v6(#0 zl;jzgmKTYFY@V-v`akbRkPD|CunpBr!+=(Y-S2-%lioRFK39T|FYTN!Q>m4-CFT>s z=mq<yYM)(Ykv`GV0l!!eBoXhvHg*`C?|H?Y7DJ(pnIfI@MdTB|{;DI*VrhS;Cslkr zRs++X%<XOu+Sl?o1rRBVa+vk3)hy$=4~R1}wt^YKCf}4gnOBSU$|OROCj!cXMRFol zZAV*%e!GbR^^IFgTk+5*#VbL8yeg1VVGY-ii~nG|<v&gM7P)A|eSU#~)BkHm@ne0s z#N++9LfXfQC`br0)|LV`G;M!zG}s`5Tv1+*o%v7c^$%@n5i|KupjM7uhk&N|^nkio z=^DVQ27q~Z{htw31}FtE()CM3K*@aezxWO)Dou?_%8=>&w3;31bs7-sg`x##D|ROA z)L%>C76*Mbxxc>d%9l^T5N~M5;@4SgwKqW%*?r{YW8=LLbN5M_tm@7WU0EWe2JQo7 z!3empW;TJb>@SP4%wao?%OR2(zlsl4wErsn#TJ*7UR-F@8t*L)P4=oDa6kU6I(el% zIdO6fdz>M6B$#GI+-r%N`9=6Fo;+5Ef9=7_;ltO2QG*fA3o&(RbW8EL=(g6TI(;A6 zviQ3-WJY)rfVGf|mCvi1)O%9le7eBwL^okoznIm+V>_nU-Z9MU7L{}ONS5&&2KYl> zf_x^ye?D-YKpeFRD5u?gNzI06Dgi0)9-&6=$DVU<H)_%mqlrH_o+8naz6Fm2mHN@L zN+?uPz60(jL;dvVV@RZofoTcpx%tK)K@cZSCpi+R<A*7UtcTs6*4{FK#Ask163KIR zaXYvBeM-6MvR!9{21-22V(A7yFWNjOz8Fn>Sj$D*dqalqqeca<mgXwNo*P;|hKC3e zu9NraZKre2GQkhGm5aVOyoVMzBndU|7=PexSI>T=IHv!zM-iW4KI@3L7)y8NvTVN8 zOATtlFpF0+K0qHD-SA8kTP}S(?`Pj+ExI8>wS2>{qTU8HvmjyUk<;9}#{m6eiK=vT zC35J`f2M<(dFkd8(EqS*X!0~RCiZ776zQT!`@0xOOJmG$Ko9bG&vZw+h%dQ^Prdem zpP@`SDL9I+BoMK-ry{MkdPdSm!|;rLSQ?eHn(hmk-eJ7Ccru(K)6HJ`1H}!)w^lSI zu_0P6%gdRs){$T7<!-g0A&}dD+uAB&yXk$$6u8#}?MQ~o?fz*R51iA;Vl+xx&t^fk zmO*kyetIlN6uyYQzH?H!pDmZdcNGx^IsMnWS|smnwNvm#b+-COevUP%^2T|T@0xPH zJ#Xq%4N_on4<^gCOyCc6y{Hh%@?lY9(f@jPz<ThLC2|pKJ6B6}I!1J(ix((>CxM#P z`OOp*f2Tyh@5$P(aB+n-6spE?;PMUm_pF=3m#!HYf_K3TLdN-2c~J38H|*z{5_p@H zpp}`Qnk?X-sk#z={Q<XGE6r#X(2}kJZwzX04fIAz>u^|f0JVeI_p3JO`z71z?CqQ; zWu7XikI*JrEQQwfR9#EpZ}K4a3Sfl3%EKcLizi*NC5@1^nKFI9m2I`6N3Vh4IlDmu zAfnibkBywD%l{6iRKGaxI*Lyh!6+_fpNr(ZSqbGSg%KyXA9WYXh4py)=bVStRe8{y z2Il84zorBlC9TYV)|qnC*<-0IPrl2j{rZjAM_p_v_bOMbqhFy|F*RYY-P6Np!#T^S z7N4WT?V1nkU{2xodhIjW*^oU8@-9A`iriG`RnbQ0<K``?;&i4dYjU}6b<^xij`Z!N zqBcJNT^;}VC6PknCQEPmK_%vU=KFZcL`AW{SnKZ$A+C=rx^XF{)bv=0vlN^gwa~h= zZ<Y_WyPSISJ`^bVtpB6wD#NO3o31{nfC@-=m$Y<)ba%IO9=cNj=}zhH?gr`ZJainI zLw9~#eZN2a<bv6APpq|O#z_OHIac|27+4SdW=`7t#~!+sYr~7QA{cl^C@+sJn2<%w zu9&R(U#aksBz$_lT#N|zFVMPnuGA4f{lxit^=-QQK@E%wO9^(%B7(Chdf8N)PlC6H zJ-VBE=i<lYZ@eYXn<kngrX6%SLmWpIktGMo5nxC~X0eQnv+(JfuO~aWpw}Pu*Raot z6X$8rOQ2xd%*zAbCc3kJn!Hl(b{w8+D!bV%YUlZXQWJI=1gi_|2wP`xfr)DJY)m$T zFCGPRrkr@r1|u%c63833P72DZ%q*ZEJhXqDsQ52U@?BIs+hRgvf6$!jY^Jwh(oEC6 z&jN=Foxl0w<!CaR&`rA<mOb7@ebqruR2N$$!uJ~%1LQKKBJ$BcnbiQCWU^vKB^ey4 z)z{=@t2z=`8FKvky#<%j_0OTth4Kvs5&{t?qJgP;Z<Yuz2B|DZIG*3T!5T%w^Hp(d z?z47Hi_!8fm8Kwc0g@&LKSjU}iaGqmKn|MLiENdhnh--NqwXlniS6hhjzI|*y=|0w zj%V{c<KIa;<dl!@r)%*tQ@!jUX3bzaWLa>Ylt>7}t9ny-dUSr=z%GZ9vgEN`Y)$%t zV&EIa;A!WbuoSb3M;uz2(1vIA!bQI<*+oM~b*W)0v@?wh9Nk%45d11;0`#Ed8<gj= zs<a7?HL5|8bblP`tIR)#GLsO;=3>0|5Fkh%Kbh}&$<mph(AKzm;TK3vN$Y%}O7n!M zKJ|Z0a|!ZHbtm`naWFiYnIa(`dS4x4QS<C3R#B$^83n7)W25C}H2h^vw#0POczNyh zqYsBdEy>!vnA&lMrYwb^8>Z&2%vEHp_)l=ha>C^X%2T?E{H!zYef1ncwNY+=fZnQ^ zTqC&)tHu4O_BH?GlT<GBM_%sO2o!iY65lpM+S{)Ay#!o!08LKtlFe|*%ycIaz+*YY zvo`cmm{H-B^%wT?rX?WDzG_;oeRE^{bbIpZ%??iO5u?c#y5t(U;CLCL50SPO8Gu?r zPnkuWctaG6F9!@S`oFO=A;-ndB1qG{{kWcB2Z-FFU%VEpc|-6w|2WmBe~k+cXH{aB zAql_2S*Jc9Fn9$_8@v7FXQRp~a+x+mq*)o^LZHBf4b*})5p6>+cd-~}9QFv)6;$B! z%n;-u&|Ox)+JdA(O>R<JqnpveKbxMp`wIHKiSYz9b<NMqJ%%&`;A*m^>9*$|3KG1$ zq+5e~GWWR`wd9DS6`@`9r4M+%JGTNwFYc<!`!ivM?}3!KUZ4AX@ygO*VxJQ=^&PM4 zIomeo#_T9JQk9l{^J+byg@k^eQ|X^bxq`xwJf$}{Nenf!4`?^e>vs4(A<i}X)# zS@HjKu-o)0V<@XJ#@vQyEoo(aY$auJhpy1cO9XqmfOcKGZlZF%Px{$yz`?yJGwSf& zl>pVXT*}hLePj7oDg27)zkkj|7F6{SAN4L;M=^}2G^{kWz3OLJspl*#m|Wy+a;?!A zFQ$_<OfyXPgeLNyry=>AIff?zsSr%4`btA&R)X6vjs@YfBo*<a`NW_IAZ}A3#B1Nn z6_N7@QH4A>u5Cs4n=o;MWEO8+w4n-WuqyBXs<l%ymj9XEZ^2RH?W{zLZ&XA5=doc& zt;Iwo#USeS^(2o)M3PlVGd=QnIc$cu@VD%$X3e800i+KfKz@Q?W8n&3obZ=j3+Br7 z_e?L`X)oj^m^*WxlCm1z-gj;*J26x5HuBbhRzDIYNg{`{{PN5G_i}iTa1@$=Gi&^0 z^otX(tgcEUNWXPcx%mu=hC$s%WvDe_I{M2Ud=e4P;=N|`Rqf(#2OM^h1Q?r&2ZgSH zhB2vhr>rIF@1mPmoqw^f%%V?f2wZ&kmI^;?apw-mD+&$18FUs_9eyEYjea4#yIm2Z zKVY>czvH;-1UwlY=>pk8_JW`riji@D;cz@q-xZI6=~`AjHZEO$pBdJA?hpM@+-y>) zT7(#TuE|Tre8!!G1?2EL>@Nf=q9oT;JzGDP-gQmWxrbr32Yp-9ea#p6-fJi28xZD- z_Swa-D!U9LM+xET`1UMK?i)Saz%HxvCk6bdYhDaFnM`*5EAB39J|Ejp2Mndk<fzPX z!j|lX1fb(E@W?k*oiv>1qq7}pL8DMJ=A4cTliEAwi;K|}Zm}IW?vif#`PcMXMFR!J z3`h)t=~^Nb|M?BF)ExcpnUtdBXQ$zOhQ0He2$3ufFqui%`NM+{z5Po}%7pI?PwZ2j zCG8JhMRo&vVsROzNZqB~A_ZuDdMmg3bR+s5hp#b(XGiN~R;hB3Kd{}VJ0vt8A6C`p zKasNH+T8WI&LnufbTyCV+ML-Sa#O7T__1U~M;<Cu9txs^GxOK1MgnNgsWpR`P~Nzy zRMvhT_quy!Ko^$h-O!j;%PhVkFMp_R;K|OJE9&(XT}GvUX8SiO70&Uy&PF}67yK=t zU?z)-F?6{2PLF@rG0;;<kcE`P9Ncg`$z%?#?Ud7$mH7Q@YV*G}nQJhWLLFZTXw@4+ zEvM1THJ*uzO%Qq5UMpAEBF}Mlkzx0U;3qNb@pI-Rk@57qrD6|FmdPM>F<1Hd6r+ut zf-3sLPq*Ed1I<1vVpL(VHDwtk%<+MkY+s0fMxNE?@FXy?DCB_7s?h52M_wd@_ZMVl z5y_?%!bIO8@AKR}YlH|f{*smf2>~-Fnjqs+MQ6Lqn@EV7!W>ho^Ij2LGAB<<>jNm$ z7FX>`%h5r7gMzd|`$e8uAM0c^e2bxips*>gykMH?KvOo+Zs9dIC52c!IcjdomR?(5 zIa+ojl|)y!XV+K-G0#fw)7O3#f<{Dd)O_b>nghSJIR&f`o2B<@(4EV)Mgo;uNj&H1 z8=S*dN0JPWUlx-iWe$NMdayf?2n09vA@bYZ?G92WC-n)3ks-~sWNI&uO+tGUHyyFZ zI)X2&7Jb%SX#%vc{^<|zpXQX!mlF<W>QOs$y9jUnbw1r|1-zVVjxUReh^nk`v5;yG zAQI0G4ANjHH56YVTFL;5MW9R&d0SWO+$p3hFgQ_5o<{sE^+$DKcKPpV?xSd6b5T8u zc_Vfrq{rtPLoUOT2M1~9Sc&M!>1lX>LZ}rxN|M_8qwld;37X99)EdHR22kiXI+>jt zs*;68_!MmXPSUhT^ylrhAp@+sebB=D8%6sbOTc5k*pEt6p0ro!&t#_y7+9FeGh{fi zVV~*2%Mn&xrn)6&!Ce&D-Yh&4R#+2%ylvq={p$ak29nq7QC3AyLtYK<=yF0(683zU zHwLUhX!Y`WGG*D2He<OD+TI=Ide75@7W;mImuh2py4k>yu&EIET&)d?pIdT)@}s@k zuoNxp8WH$fM?00D+$2?i5R&>(Qd6nt_)g0GK6>6>@&L_Pg?N8y*s_H(%1sE+P(OC; z$EmQ|MeJ6@$6&=%KCV?h{tO$@QsM3emVg4<fqw-Ug$xVp9^g;fGnn*~hV8fflqKeS z76&-+3ut|GTf$=ABtDOswW*sxWjv%QO13p~xX~M)aoqA}kf-F!)-A4gfbblux+L|8 z#Fd7rR;t=Cv8&@d%j<o)sp|K_7`?=|JzXaZ)=|NkpgHd@wzxb+h5P#j2FQqg*L2<J zV)&YSY=HZ?mA6Ut_+r)(iBZ`SFn*Wj5;#<n{gC_eQoj2ODeDKUc5v@0-hxf_*h@j2 zSb@v?_rZf>#Hn$<$zuyLuZNO-lLZQTz1yq7S><y66WAjH)6TWnLC=MvS*IPBtCpo7 zs(p}uSx!_3PP_9%s8lWn47bFHfm^_fh{;CV&7o6~lD3?A?OJ#P%Zf4gZH(YX4cx4J zh*4f%L63u7#+GEx<dsY^4V98s#2>WqEBW6}`BnC3p~Q?|ai_7r+rI!?st`b4<md>U z8KB*MxsXt@=yjh<Ek_s46RGZtXd{#*B&pB~2Kqy_m<;lnQq`xvk4X+ux5ZBDm7%_} zv|&YChFgm!PztM-aL>v2`&AqBH_}vNl<nN<J}Mv6n;M+DdHzVy^j6p?Sd+i4Vf_&T zoC;qYI}f1TuaQ4-&Rleg#6g)HQ(SOb7{3Qplzf-hUE{bvKLB<qoFLWVl;&$*MN45P zjb%j+SkbbXv;Vd}!_E)WP9yztl+~XvN9=?lmuHL~z`$p?Tu&HGJJ4-SU>pAQN%R|q zl&JOnWCPQ=@1lNeV*KUS5Juosj9``t(7=YT;S}auM%Zw4e%RQq^Q)*JBxY;)?*%kE zQtek8M*B>K6@ZSp^CB5?$A5kNA8qlG7vAkMRrA}<uU)d0E$H&XimzLAbey2a$C@o4 za^J#;C@@x`i^%Us9pf07Jo_D0i?4F-YF|D)bY%3Ks+k_Ujkhqn1YEW4wigsu9?QIS z^-Ow)kn;5qSOWYilkcB^<Q8_?c(3#_F84&?GDSeisy<m%XmBldpGD+}-~wTT08)I< zS<Xnnh{`VuU0vn6>oE^i7HaeaSEX2+eW{#Z4&>hbj~-N5v^dr6o&~`?lNgSphpA{n z5ev*tzHY>KF-B9iRk}k*;Mx>S_kh}dZP(H(i_Yra(bD!Ft&=4q(I4#1gQ-?BfbTnE zvUetYP<aEBK(q4W3z{*kU@(VRizq+47lzVI?ReI%pYJ4Ub|If(f(v7Y(lD_bwuG#u zv?bVCQMc*G%DpAEX7iqTrLEk4<TECrwz?miGjK8h^suBaqeoe_;Bf-k%M6@~I5c<` z%V6B2o8K1?MQ~cN(xElBYehk4l}XDFn2JDoFNLy2Vqb01Qd^XpT|Qfg9ZBgI4J>qh z^(y!8bkeJJ%CLm24{z@V;ThdHot7bEJGL_{wJI~Lcb=M3zu&xdt3oEeJmD)3PK{|^ zac58gd5v(?(a`aiF;PX?-_=+@c;_VQk+icK92HPDAOcK*+7p2)9>JRa1>IF2q-3>} zaQOE){hoqyR48h0cNDxIg$;SUq8Cc22!Ym7yN}mTJpJ3!r?Q8|Ptrxww=`=hfhN)6 zoYoII;S^4+`w8hp>=!fIq_yw)Qe@!q`fWpL*r5`bp&qLqSPAJr(lJEBQ(cxTOU&s4 z->kIUo^CE`BFaxr)#<vaNaQfO<G4yb(2gJa&S@|GcKvnNHQl3BOm;L&H|UaBn*&k7 zQKxe;*tDXh#DG7^(89)B_eJ#|H37)c-xbDGUNSYIy?1V4&Lr~~!q8rkpTN(U3><BG zIZ;_zT>%jV4VmJfvwti}^bbS*Y8(cvdL)U4ob-V;ihLhiCCLrhQb<W@3NmwxbF+!> z5T1Tf1y`95p4~Rp9UJmG_7M!~&7BF&RzIG0uUx#woR`40Ii10W!XmGFSLTYZ*Mqsp z50qqJ3t6n#tV{GjPj9}&&8^EPswe0>G-626KOUI84cZif;jIrBS=yD!7MPy9PO+|T z;;Gh<Smxs4KH8@9eI1X>l2m&m0i|=C=y}I~wHSPxwU<@!&?*K@eV&hv;2aXl`!Cj{ zU&-Aqh)Cop$iTu$g#f=$;-iO_R8&6b;n$O!vZ_K|f+5y+`|R;UVj>(&JPS_b(oWz) zS$e(i5le;67IHJ43m%vGdXG$Ahv89s;9lhGRSyb-cat;L#aTbS-wK{@sgT`~qe$_& zTMZ(q(MJZ46a6uq*}s-Vo;RP{J}wRo6Z5@fqFJziOb6D*9TN|>27O}WX(Hq_s-c&x zx=!6wciilYcd8~DB-g_VxF`HVG5h&Sl&(~*X`qCKC^__YGG6AyCZlLONVTm@`uVqo ziDqoqqt)(gKDfw^L%@;ylKth7C2^I>O}nwKcxcN8FBSQ$4~a8%f4M{J8tZ4GA}bRr zDMJMgMc3*`P4+bm{yVu9fiK^h66X=$xsy1H=uMSf(8IJPr|X>2dCqI~tS%eZB7~Q! za$FceEV+n?kHVKI4j29QoN$b)_VAQac4nO<J6fHICM@{{zl9hqm0iuSv4{>z;t)QL zz10R2K|c?&_r-aQL*iJl2KERIqEDAM`I`3OIRtK*s+$9#jUTm*{*;7YpZ;!9cqO0W zzL4=r$=ljl+8@}MQydemmi~N$i`bpUsHew&z6?rKA9AW_J4$e_`6b2{yRcrs=RpB4 zW#QoyD7`XSZUtuDKexP1;buqLyj~>WGL&2L{@9RgENTxCo0mx-naO8d3=pli<XON& zk-GA6KDnG4kJKLQGb&W2S7=sPg^de8CzHK8WG#!9l^NeroXPxNV1t69phRQL;;H#! zXG!j}Qn>$JOpUGmhFi<y(;zmV>yR_sE~YZE6z0B-*z#&k!%B7ej>F)Nl8U$ULDNs( zpHq4aj7&*$YYGQT^10_zyYiX+x+6r%T+KMitmQd>vhDAePyf=b(*tOD!k~(S{y-{C zCU4)2{o7I0e#_a`jG(fjr@ll-YVC<)c=kf7ga-i?yV?Ckw>r7LfOY;&dawj?UD+9A za3FGJFK4yz?U?XvOsi!B;y9v@7cM1SV1I`99_tS%>|vTZ**Tc~t^P|blNI8;Jvg2M z<c=a!tn96N5I@^(3C2VE0Xa%@KFw=o0{1@T^%zX<yXoXY^K_5z_M&->EL#>Yn+h9m z=sJ$N1Z!`N1ICxvn&rZWQ^^_Xrnzcb!*Un>E~|AGXFaW-Zsa~;V5UD#V}Y1?!zs%8 zKuz5<i_rJh1j*RXI}x=xx78uQQ!ZwkDcRx(vjMk`KxhDP`MlDb?abKdO3T@I4mf-Y z*yi=$X8x7g9k1nt$j`UYYkb*IK~>~HN@nM$x_8Z>U?Y{-r~(2PIocR!<yf}1QZ-%j z6p7z?SA@qpg>A3xY9e4pF;k5UBwP&^xKDUd-(NV``eHdVVmGam6~)12>Xb+ZBRT^p zoAA?D*26E~X0jcu*kD1fbiRa5w;39uZ23R}NRVQP4cDNc+VmS1{ZLzV?)hAR+<j?u zz|nw2K}P3!i3SQ`VtP+qU$SfgKBwncFMa`U*TH6ezWyzt#q|*<Fc$tj<Kc0UNQ@0# z?IfzpyBVF0-UO_(Rj(8}!RcS+q`Q~(C0p_Rr0rKXsg?7M(3upRS_S#XwU&{;D=j7e z*8Z==H(Y3h3ds8l$oe+>C)rr)b+*QY<^1e_-htKTlT69Qq*y~nchqV5DwcjgUdoL; zksLG)RXr{cg4?f4xFUavuotpOy(QGvzL<Q8L4zrdYE_z}NyV)@ia>>^o$=o9Hek+A z_MIC+pzN#i(~M*(C?$?st=Tjs0mc*}(Foi|2V>4KM}vK|7c<5WDO&xLeQat<PiAb= zqMu8*I6|sD9y+%yu@-5KXK)*8b@r)C3RSx=HZ5EF``_>v@xmoCSaLm73hZuhBoW=- zZ{9-<eX_&xdE7i6e)!+Wlu44&+AUecqMhk3{s^&HoEuJ4sYCcRP%M4B#0|J|O#Ox; zx&3>|=64`{<UeZD(~f`RQQv30W0`8jKZhYA(W61O0uKy3ia$IV(8Nqo5_nVQ={}cE z=Egq-9KJ~;#>B*Mg*pO*L~6Uy*SI!j`%XlxHk|r)8%Z7RaQ4i$zGf`uppG-Hz+uXG zl{SfpkIl`m+lZ^H_QSjpE#Y>Q=r_yO!u^;B`r=X>w(6G^X6IgZ#~agnUPv)Qm)W$) zCH0(XnWY={({xw%@RB~GxHNgO6H{V>vYE=<BM8_GZDhPsm9qJptm}&}d_U>Q4!S4B zCU}dbO6SHWp*b0k+76mx=vxgFVC4JYQKT!XoSdY(L0aMUzx8*b!pbg(vAUEMIO==X zk3SE>>)TZ3e^VI!xCG3<ZM^-1O#Ei}SMR)<WMBYj3rfm9k<G+9KwSx)b7iV3uGN-M z__2$qX}l)su1R-b-Sqt%1tFofo2|YRbd;UU2wmCq@hQO|b6jXYc_=5yl6u&Qkhi1Y z%7El<Ce}yKrX$wpC*Or&C~-wLr}yDX^8A!RlM^JeQmqmCAGNu>d)hh$Dmw<mm`q2P zxAqG^F6fqNOO{)6>Zz4~Y!uhKzoYVvUH<@68#KWMpR*v}R8NQhaoJEVO@NH@`{;X2 z(T*Z&WDHCl4a=A$tRG)g1?o0xpBRD*lvSK1OCOw<mOmD`gaIK=4tx=og`}J$hm>}# zryh(gEFz;+VE)whcf-rK|NcS?uN*t--{IpEXh#LkiZx2NyuS9Mv@y(H&^+FH8h)KP z7cA_wa{j`s@m6JN_V^$?7zFbx4dt-T5-*L7XtX69>ui@%@gm_~;RkvGT9ZwkM99ak z;GWp%2A%D4a4CLL&w+xd;IC;e=Gx=&m<xxVWy1g+=^#^bg{W9>Z;6x5uK%j_!vo|F zPZ}evMwk=?kUXztL<zhNdrpxhLmVUX3uiZ?m-O01(j%@68E*&NC@;A_7GHz2een`U zbC^-O4#RQ)mz`Clq+oL@9ZZY;(9Y8lz~(%XY`GZFXLM2YRr~pb(s~Klp8FL}--@X% zgO{`cEe;uCoTf8VlM8nk+q+>41d!->C8BtAz%gDO5dJfZc1P|<9c8j0IFZ)Z9VjU( z<>6XaL@33YKwcg?*w|moJ0@H5&fLnB(4t~%320O_wLiIx8JAl=;ETu}*-bOb-mp?M zidw>;iT-5CXdZgJLs5e`Gc&Q?K#mdW75RvKy@&~Nop$yTV`%;@pK^{lf8EjO{M}YQ z#D2k6srm3tt=zuYSjy@{nt-^Z@34ufTxB9UTrO6(36jV46hGHHP#@Fz5JF$+nd00` z*)_N@6P6uOrr=_70*H0mp1}<L!g2u?tKTO~)4dCFkMWiJTyJZEmwBd`P0q^JW4;3C ze<Ivon*9`j8)lVoYeVlM$AIV-&=_sVsp3QjrTAE1@+X|Wn*J9~qZG!C_j+yzMrz3( zIe=CKh|BWI2qL;DU2DrtZmtwvUzb#l!!@T4l%>S(Z){{OW%iXDA#pSPHf@vzr2-ZQ z_HsIt>=88V2FF!9E25tU3;6PUkXo8M@j!IBrM|`86nf-WqCGVRPi8f?PC0IxkC@f^ zv$Y76RvmI|CfHT#jyo0Bci<wkZjS>tFVTwEjv{t~(a|R>m#sXt62oS!mNqq0;gqCm z&>G+$fp<HQrHuG-225vupOjG=Mn{LFr<&5DV33B*P91)6E@RJGD}O=ZE@PGo{~&?| z?zK`S<X)!7t`iS^IjDAH*wOPOw!F=Y$vTyp$lhTot6l^tZWa@fs{g<sCKqQpPz zh=p1pV0BbYPYmALStybXIIXbY=-)AehZ-vz2TWfe3*&P8c+0smHiz4?F)uPCOir8v z)&WbZxLjWEEIMOVetEC6%88~ct6t?h5|g!^8>qlgTJ3YV{s;CPA{T9SSThN2qHQal zdd3|o^_KbQv)kPJ-tD&3xh%5ICiKe$Kf6oZ8jJv=Bvrlk!)ru8N>Igh(emY+0t8aC z<MBJvF}6*@DT^kn`mdHL-HA##9bh>Hoj_Mf6$w6VmvLGV^*3di+2&0%K%tMH77^8e z^C?th)q`^4@wn_~enfcy1!-dDpMPE+>7NsPQbl6jyPWZdoH(&FmB(f`k2Q|E7@;Yj z3e7^KGH=qhE|A|{yJ(xTc;d`0qf1gCpB%69@Ca$oulgCgYcTAySrE+4%)%quw)^PE ztJ8MDPZcNT4UYKbDjKW;H(?|lD=z|~&4`ohp_WY$5{F@NLS0Q<#}|~d%7jpc{rVFz zrp*q@lMg2_F?s{~V@OBrRaq26;vt&XtXAVoNCQfV*c{#tGKr||ZLM|>5YMWhIn&%w zaOo=BFcwHxkK(s^cc91aWM71)t!T$|lto6@3h%WkP!NnAN;^z-kcNTOE>vEV2xho1 z^ZK=zQzs5j+sp<lF$}N%$rxY%A+fJSzo-RZ1p6~?WW0T=e$$n>&*C`r-46G3YbF<H z((0CoiKP#q!+<!mAJjxsP$)_kzS-q#>Cs2--<IX@MWhO()_hR=+vZ83sU6ZZ%t#Nx zmKA0TG;LUH%krj7CF<x&L;cYH#+~$IoQiUd-fm7QIL@}|SP$_1-Gpl(=*J+?lw>l2 z)}#>S0<0F8%C$;SNLPuGkgD)k-q*$dh#IZ;yxTExB|ASVozh`JFHO+3Hj^Jc_9W4- zx@nhS;is@-E2wzV6t!9ZOv>r>BuoDlcgb!b_w^Q=>ctzF-HYyFOFq8FurE==iD|7D zRH!n6LqLn=26gT^vCo<A`6{9lDlYlQD+*2jRhw8n!q{9iyqt`DF*^pUnx{sPvP3=O z>SU_t+jaZj@d$9E2#c3>_-r7gBA(SX-iO-*)v6%Xew0xYXjy`8G<h2}4+Hc5^5!au zJLinBQp3uxx_OQl%O#}z9#3a5lcuuoMDq_ECO40k=WB?=y;{`E&p@>~R3xoMpYHbh zXWW5|)#b|*%!Df;zG&sR_$kq(DyfNw@SYDMPsgyrdD9kg3aCJAnH$ytn%|;(WmWsQ z;ho8!cN&hH@@O8XxlqtUc&|jhtOgY$rW(<6xP+{P;lV2A)pYs&!+hfWbp&d*=Y%)f z5I&o@uKcD(yqm`^L}?!MO8^3iPsXX$pRc8(`(qDp!~fYstp4NQe46M-Sm0!Hn5E>x zsD_OUFGXKLkeq}@4*eQ3irsd~Q*`~dD`r!Q2G+QsZ<1$q!N|R%$}&h=XA*a9OK!#a z+q-?ndxk*<prXL75{Ee0T3q%A!WpKxu;McAVyy&!qWD&|+@L{PI#GcyQdO3~$!<OC z2ROu^#dnSMgl@NQ`WZ2kS&c=$(dm0fC-TpX^H;!?U^~Xnwb4CusX<B38e)(_xmS=r zq>N(oUC4({fjF^`V7>I+^zPS);}<GgvcRRhV9#O|McPwdE=p=R<3p)*;J5`V-+_~p zuV_L>yuG)EP3@*DXGnH!3RYnuF>BPn(&``UlgSrVEP(m`oU|j`Do`bU^!<DvT?;C_ zO4;9(gh~9-YAXMx@UB1tT643%5&wXxHqGxn^T>QJI(uU!qDnj9u=s_92D=2rp2%R@ zFeIeZa1Y)3WiBu9)m}O3flW(4cxJaFtTkjSe@W45o#ILA)bK$7ccgq-g{p|%e)*_B zQH2aQ;mVMoU80NQiM>3Pe<!oNuX!^h9##ZjR$BA)6khccMhR$*(U0;Zx45*Kjtax| z7%v4)q5;ezCpzYMjocRD$9PfHn^S_zzswuRR^PD|AS*<X6BhRz_k7>AJ4O|k{(yr2 zP6ms^nt4&3%bGCb0eU4fLR5Vo;y!wqlZ^DoFaC`_y_z9>WODSpY)CC-d6b@UOpaW$ zZ?M=8L<17Sh3G|Ut*d{PP{Faql1rGfkOlP&@a8xx2%{5|gqaajMw7hv(!yG+m22jy z_*f<^8%RyErYl2}221_$=sS{ky_e3Jr97V|Y73DR83v6U8uM!+v7FiTNeb-S`*-}? z+C_M*N_17b3vw6~%SBWrYcJeuHoH7)H|*`JPxN@q%E8|?>jQo^cqfS{#PWGIqxsIx zs-4^6ve;?WA+RVK@Od27(qK$L9mltalSlc6v=O+Du4LA>947nf_t=IU=k%-ZW`ymn zQ+1Zx#G^vLsHzG8Lnsc5vsXJ5B&^ppOU{QeB(F{I+IFDE+k<*5f869%&cCEG!wz9` z%*rI9KFU$|Z}}FRz0jS1V!1M0onZJyDp}xp7pC0mE7rjFpzP912t$QGyN??VCQNy0 zj!$cXZeZ16`O5{Yy~@g~0)!aMH_SmmI-&CjqllJ$3jW}AT(|UEKU3cnwT82wjFS2+ zZfM(tjH`kj88UgmN{e1XE-SNmNkO2xx#c&E#E^3nXg*!soAT8Mn=ixu?Ayv&zcfY4 z=&{p{fH?KsRppd3xl}4n!-2+CktspT>McPYVaRYI@ETofu3>D?izG}G{iofP{C_^8 zgdA`wIrAFU3W<x{FqFf2JEDV1^-YxqEydX~><y|0Jl1a2Tf^1K(c+Yo_v%cJ5B_Qo zfBoG={c0WaYl#3QWlwH_XMcu{%F=KBCz<jv?QpU6S>1~oB_0>BDVaZzt!Ag~Z^K~J zr(a6GFbo^cFVr^5-wOfl%CQMw<iOGPd;6*7dax0>*v6X;+q-F`*6rb^$g+nEFhAA3 z()ZdNwAotd{925XB4v_fywd%aP^p2nj53Q8rPYUTg<Lzet7LQpUXy2GMW_*kt2*~B zC-5`*_>G~N8l?=qy=`76#%O5kZxrM~W(`A95^V~3{XfZj3Amv#Bx}=1y|F4^(ykhG z0|hwlluH`FteLNz9<lVWO&}h6;j(~)NRaL7I?6ut5|0>%ibx<2rF5A%;IXeNOtT3O zm09cQYF}`-ePRxRbmv(v$Rc9=-5>Fy^v_`)nH1x*LPOshlJH4A2lLw?4U4RZ5nsdS zZ8goO&$CAm#Z@)&U@xhpCpSZba?g&(ih$)-`|R!Ows(f}D-&kRF@SM=VNwaTb;Fyi zSALr<47e3c^uL4<;7<|~>hiOQ&?dMa>rY?T9UG4dOK$u3{sbVuQQ2^Ef~M&Sswe0? zDiTdJ-qWCQ4X3RCLjy1D<wF&V7H$4Dp}yCBSVp|1dmqQkE8^AtGH_|z>?RG<IdwdN z+()0oD$6i&bwU0(2)EJgB}_n?jNN%r0>mgna8XK0@Z4*u`@e=$$yi89pJj#N<2<HW z_Z4s`{jA?EfUN)>VywHwPE5XoYJ&H-K7S{y_zw*HH;i%`I!P;3K+&5|kyWc)E(}lx zP)QebI^IY5S!xuBj`|#KS<R1nebXiMW3-g7TW_nxUEM|}MsmgPg@8|v&%Rm&(!Z5M zoj^Ej^AVIjHM}=h7OCP)_`twsJ`GDL&WnnH*?dwd6Y=~R`W0qtgLF6Dh$JthYwdQG z>lzj{x-3z*tUl{a`)|t!-4zahp4R9HXDjuevpss<g*ve63s0V8b0VTPZg=mP#Se8= zg(#p#4zBS8K0qpEj>K2g>5F=U&1UQpdYm&*Z|k<<u)cleXJ(jEdthGIQ?%M_>*;B` zpB0N%7D#O%)egeoW^CXGP54Q@E^Lb5vTc&NS{fLl`XmAWC#+n(`YYi5ZIgA)RI5&l zh~%cF=<6}uDm;(u;I7F)K0%Th5F(#BpAMW|6#W3UIpWIESDI8RCc2*%%7BT@!<lLw zhO6Pv-Q$!J)AycB64CkTYLjXq#2a|k(B!G1s4?pobmL-!pF|FA54D%XxwfpJQQhzR zx_l+1l;fMbic=*kc@R!v{o$M0RpInIrPOC!)c$Zzm-B*q*1DT7G#qcZ8B~cwt9>3; zA8;jy(?Z<UA7{O0d?k?*&_1+Yb5Mx3#!{mN4j+PQy39T&F~l+jZ`iCjFJy+ttIgpx zoJEvo<8*pjM5p2GRt?j$lOls$_n$C)Qok`yi{^jfe>j>tChu)*IR8!ZXOMc`=fmH2 z_WPO$9qV3cSe(E5@;%~Vzvg1F7U5^hgU!HVRZ#=~LyS-o0s1a=H*}e$?PF@q(bH)F zk#s-8iAB2;C{}ptD`&-FL-4r{;p4YE<L|&@7B3)Hy665fIV+$&oM9V>hR$KbHZC0& z<-DE=zqEKG2CkK-j;Wb3_X}1I$2U7LZmTT;<8A7b0~F7Gl?Sb?Kh16gII`+~KMN_N z(9fv6F*m69=@F(qKX2;|KxYV1o><S;obME32X7p6TYa)F-eWr-PtU>J%2ke5C*!$G zL|#n9{ZmZ4njPXsOXedQoEbLIot&hJ;Erw&BWBdEP5ko2jb*xFD0}aH6ow5DIk@%5 zBO6TiRexaQS3Uou2iuhCghGY83M&XhL*CqW0uPQc!{r^nEJDv~wK-Vc#vr!Ft@U6S zJT?>!XUvzYpYztRMG0%wU_9U<As~dEnLL|^eu=JJ0HruF>2^;OKC#oYny-9dsB%|n z?pTYjn~DJXP1~7A2Wg1X;7U!XP=*Hz((uN4UpeubP;1!9S(CnvH8E0R^hW<r*rJjm zRrl1z`apDwD<@xzHX2$y`z3y*?^mq7i7J)akQw<3!@PceKH(#($?}s0Z^QWuv+LQS z(Ej79wT?Dn+@&>duY%6l;9LPQMTo~dRMm-44F&{bxU^015t^42kK*8T5kw+DV1kEG z)|b2}6P){Zw`czUY=N&rD`aY#?-h9$Ko~n5Tcxp)HCxK-U1;I;a(~{pvH$Ze*sL3r z*aED)5F|r=`7q4SZx?S7WHMc!5Lz)ntz15_As+t032Z=&VsN!KVZJ(R(8<|eN>Wt% zivlG<Ta)=SH>+&1f)(PpT{nmo)OS)eJ}m9L_>E*>n2bQ9#=M2Gq?~cmhm@atJA`a^ z`6C2w3;&2dl}~#VRj*y41qo&U4c$g+0^{*2%m~r@V$J&D<#^mjWp%V*$wyg+$Lsrm z%d<kD#*F;s6(;@=+D{LlC#1Th=5yYeB5m4wRHPWe^kulI@p$2Qod(Z8O`yN-JhIh2 z4|`HiT^bm>D>?C=64G3G_@Z<Fc*y^E{2diBn<Q20c(~Zf8KKn$dgRTZ2aQLk^rJp) z^gFy%yeFzxpwep5*x)6YAV|XVQ_8$<|KpKzk!T&^I)~Y#>LZxO-Rp4Ap<?Bi!2Lq` zkrh*{IG?&1`<|=Y*7}DG7)8;7cN|uG9A?>ZIr5&T{4WE>q^Qvz8ua^I2K1=WLNIN| z={o+X8_oUO$oWDgZi1g_p>Oc-(J60zK3k}`LL$^`7`x4I9qS(tN#w2z=yuMTVpp8i zcXM;BdKNP!4mYNeELiT{?3yomO$9SNmU%ng5WiO4|Ih6?%<IIJ>D;UvxwXS(r)miU zvT+cuxj=Vgv^S+T(+|rYG4?6c|Cb$0U5bSnD@6>-AFE*ftL@ht64GO$KH*YeY_aX- zeXbuI5u$63Ad68^k%%VLZcwpamDIC2G}ak;CW;vZBD{@tMwIi+dNB9mRrf)aR&ZtR zfI~xDZAyWPr~)e6T?yL^WgObpd5uyUdy<6KlTG3Lq<hha#Qs})fNFh<sdkmf#>)lJ z@>JXzu{r{JqO=$id>Vt3gwdpC=4^28_?h6ZIrjymIa0WGaCTBZjNelYJ>Jp0>|u>? z+~WuS8s+KQ7*zKBa<KKJ)o+>u+6N{s*$n2Y`sh5?jpA`0z-}Mz)e1*Z5=H5nx5$X% zA5)bJ!YSTz?u9o6N-u-!$FY2=;%Vqd5?kXLGYAd)HiicdaO$`0-Gr<EB;i*M|Eo;q zNZMP+$3?6x6cln2R>VfRyC>}HV@Q7NT0i7|-e-HFp)4ufX?(8t4Z--K+_ULGa}O~g z6XzcDhRTfQgW$<dD*(jE8ZS-3cfLt0$4;vM%0)a+8R@^ao3>^Rosz(0J69${H8)EZ z>t1<D^q)Kq9WLHfsL=>v0g;bF2g3x8WB_QiWFC&%**CR(HMFmZm?@iP$}46C(pKRL zot@_=Vqry~gcDKd>o&Qp($LsHD*#pq+@}eL4sH)#<FzfgE;vF;{mFYNUk=n2!)~*V zEn9F*or{7s8sx@>4z$jZ!u!g~FgXXQKkex%sw^yNwNt_`GfrgcPrpO>AJfqCkDWYy zpdJ{8N9xrQd|-&h4%BqU6(LhrS<_u>bg<w%X99JP<&&BalEz4A?=e?+ZRO(kZV!)f zVfE}pX^Y<M9rPjHj2ph&XE!OEoG2oj(qD*l;UlG5cAJLNm)O=}v786>WbleHn2?oF zYrCkEi?o}PoSzmwXkC5nfXT!_N}Kci*yxM8Q6D*6NepAbP4diL=gs}0g;21G{jG1O z_cN!j;0OoI5xc6zh$8L4#p1~?G<1KQ7>ns)(eo;RNiE;{jnh8TiX;<q_Xr2F9%Qc^ ztQ9gdoD^=oX>s#plWd4)LYp16vX=W$#Fy^48{?^vsyEBP&;6FTBBYV0UEh*)M^oxg zoAdqeUB6eaI;emc@&q-sM*Oid1&PO%2H&LyLnq1Jf2gj2;zmxAd}pGAA{azu1gn!Z zd%IdVR?c`8)**yJRb4kJ<%!YHdRhId6(?L?#YRP|5j3pI3Dgl6<^FO$d5nqLB~V0A z02SJF+>1W3^u?!Q@dZ*aBPUVjd~w=!Xir5s8|d|Ib)b1b+sSG8ZlhmFh;!LG*8Le1 zX%629qEUQ4ID+GFqK~X=io_PkZZSr+;>@oyK8@Pm1g)l`9uPoW%9Oy3HHV#b(j*G= za@f6Pu1GVxC^p-dS@y4#Aa#Es7tr0Q^FviM@Qo%N7@(3{ycR!~gTU3fLOm{<ZU`PZ z7G*vv&q^%+Ip+21Ro~xXnpd|n+uJ50UI<+$&$N?1N6oTA`Rn~fbD@W@Km|#Q5jqi2 za#&lbc|=?B=-{WB`?5Cz`Mf)70;j{!{A=VG)P`p~gp*6@*;1{~Ve)Ck`AKY?dp!Fy z2^Aw9@Hcxkl!+%lx8l*SS!|%E?dT*87pDSr?&9vJmvHr*;E=Gu(LKwS;_{F3BpuC8 z922I5Ljyi^dOS8u`Lj-hiAfS=Ykfn5y-6h`$RcG3k;@Tn#95`jA+v%7?_?H%K(g1_ z`zjH^Re$!~LWK=@RB-XfWM`x&BCwz#F1wIwDP#76JdeF0lD8$G4&9<aAoe~|W4!p1 zS9pCFMJg(BqZUldK!eSU7)hBkUkwwOL{s|5Fhu`G&wkrbvyqU|z}#FL!5fEDAX?fm zR`DnaMy1%<L5Wh)lUGJ}Z?Nj3#F<W>#`&CA!oEz{Z*1q6J$xF>8ey)`v9aD_L&sjP ztlIh=>_m8j`w2d@0{<7*3#)vw0_Y(Oo5vegg#rB<vKM*W0?Gp+3Bsmy*J<q(&6dId zej;*5zeLM~(rxG4>g_(i5Tt57<D{1`@08j^jqJeTT(Roz-94lyDA886t(T}=cS+5+ z=V5QXprw~u8N*)wFyN*mv#V%D)y!^`C(I3EqN5T$qGvLCL=33EDv>a;pUJxtvVGsD z-JZIlcq2LgO56?7yx;A2aclSOA5WR`zv#g|G&&Xp|3XVL86V$7&|*;a`M3E>zZeIC z1y%o511nBlkDMqO?b+h)E$>l$EzF&L$kyBgRq_7%u6(|Fl#6k!gsdiy>?druct)C5 zW!!^#*EhlFLAgBvJlA|Y8niJ6=&d37<y+0%+Uc|o8Y)EX4kjD(YOB)z))S3azv5T% z=m+0z8XE{@xD%!ZIUosVtgaBQTxEIGs!NxFyCkFBVa7F$6qQdrkQmv?;wCKSI_*32 zLc)oF<Xdjt@cV>l+43@`Jl|oDQ15<!)hZ_T86VVK-v$kk{(7|jL;74Wug9}1c%r%B zY9hGh*IrF3$_?0|!Ylff%YIvFs&Bb~7F(bY8O<NJ`1EhP)4B^9{xuaP4L*NFYjr-5 zs}>J_VWE(&c^qSh))Xu26!bP;xyUz5D^m^i2Jp4nIf?=lW^FX^jqv90kZFm$kvyPH zF0}S?F-&F6kpx_BNGURp`S#~dmO%MxegS8Wd5}ELnYU&KcV<)^kMaOEPI<e-tr(k* z5!xJn8~Zhr`eY<LXPT6MwI6=peY-Y1k7Z?6uPrYktuLvn*oK_E&GRIGSL&&=G}rua zb;m(gt<?Y-_r7QM#5`siiIO(3n>ocvxx){wAiJOM;iRxlo5g7<QfEWPc9uVF++o1M z<t233)@0*+TRI;<<{n0hl(S0wcED+?Uo3GFrR2NCLSd374Cv_Zi1N2L|LbaGvxr`_ zPAy5Ynv<Tu4wodV+s|olg8a$b3wA_XZ@S@`ey6J^28+?-^6@l&|5B;Z@*3k}40$k1 zI&*wgts&yuJH|9w6T?3D)BWgam;zQs1oO3V{iv<Ej4??HOvkm3R;Jsc52(?1)giGB zd<oHN!~~qoROjb?SscR|PPMr2+T07v%PD%XqJtK1*bf~(pXbgtrzi3Dz3A&pz?-Bx zd-WkYE@-zW6+?RAZNaCE>nJAb@$Z@qRqqD|rdLcEe!Jn@cPh(DblXAkIYpdmH_YC+ zC5>rGGiuRU?mJg%9ryN-?9JRpm1vEgb^Lehsqv(mc2JP_7g)^wUoCb+f)NUUA-hJK ztF>d)Ap4<|#=9Re?uL)@DnH-3-|A@w*F4Gtp!Ra}QjE_gC8^saed&JW+*S7UI@MEu zY43Oz084CEnOYe@xKoMn=xr9u)h=laW=j$Y@PU9Jn<8B^y}5^^plHM5oF~^dyAC)x zeL10jB`Z08S+f>mhHst3HyPbkuE))L?^HM?O3rjHYA<U>yP104Qj)IGYL~vAGEdoW zumiw@aMz@zddX2Ia|G*0<6!z?JAZ%bLwur~;Nf7IxLEBa2U-^VknFzF(@CpEL=Z=H z%Pyvun;Qai(EerjD0d`Tj2t<EM?aZAYU9pgqs9kv^#Azf|Gbj_(KFxIq`Y;VA&E#F zG=#%-M<)7A&{y86#XTe&K;)@bG_dup-DB!<>{}B)mk5S$Zw8?)F8n|QmPiqbks$>8 zpR)7$$aPwo7cb?X?A>8X<n<I%9&4@LWkj32F@Elce~J)Fk}M4Iz)bwf<6`>xwy?=G zxsI?$*r-6Jdm6PXD$s#5H3~@%;qoe%fC@utm!XD$Uo5>@lN^opaC+Av!<MEOpkETV z?YFX~hY2Mj5bc+Y!@+tY0wR{1J7ReB8DpJI*zmh*IVT!iR_Cj#D`ioEIl&*BT`q2= zM~Ff!xD3^_6O~9@_V?BRgmUe_HY7PTc)R(53qS^x?DAdq&kEe%Hvi_HexH!DF|p9# zAL{Nid1~}l(X^@5VeLOZuD*;I-jw|kkS;(zWI9^_T|7JlfmmpF^{f{28KPOpbeB19 z&vyh$KcTT2>?jD4q6%V52rPyYXaG&;k{Bk2(KO*yej+p~xFt7r7dDrM32bghza)x* z0m=MW9vUOMb$0k<u1c4Of_vWc@I}=U=K{IAZAHF9AF{1<SYOG+WQS5nEj5C3zqg;# zLpH*pojy`Y^?(pAJxmzwiO+ks+}G7&#%^&8Hi5pI;<;TON*}<d<3dQD8b9u*!@O#Y zEF@x>emK32TNwXk=N_RsGaDz|e~}UFPo55Ygi5-)88~HbQJ9?{O!&9cH~L?98xAos z?eX^ksJq)NO=o;#CWHzUO}`O{cp`p+h^q)M`ltz8s1BNP?db&-3Pcxd6q6GD{Y%1d zY@ME$P+8V%Zye)gmnDkzcm*`&I{f*2rb*RCrjh$Zb<dNjsfn+0_oQgbxQi970=fI| zWyUL@?prRYS?j?2I^nQIxob2y8%3=l@jKq=v321xWW~wR>Uw&0z;ss*uA)^P*Ybwv zRKJ0(luY?xN4KkIJ(KMLCj8m^Qx~?6T-3npIP9I=p=C|-PUotRTo{6zFI%)|jw)h_ z*JR(OtHbV|Lk2HEN5$iYrMpO+XM82g18^FLHXmbhZxvltS%LXXI$Ga9Y`!=9KZ?qm zRAG&8$Ha8Kf5X--;R8LZ#VS6(vdRrT9g^El0Ctb-LKxp1P0&%quTR&Dz%O6qy*=O> zEqk`7P(h)i9pI}erP&FG039Dw($GLWO!a0v&kY|-x_1Q@z+|57f+@--JTS#+xap3E z`t-YE1Sxzk{>+E28Dw|5Ti?4B(3yz*clm*5)G;NX)rXqYuZpFm(SNZ$5dG(@VSy&n z#X0O+V>qFhtU~wZ_{H{U9CK`rwsROY{fH3ag8cY^HPuQ9l^jofdk2HcZqf_D;@y`w zG~%!3CB~yTnRkDC{L}PBq&l7N_QREK12UVtM_ww<9a3j9$&%=DcNqM~xW6Ie)Blpl zPa=MHTL0g?11M$g>Im2!exuv7u5T=Qi8lQ*)|!5ji@XimdNFS4(OJr5Hi=?lus-r} zI{dPz-iGKWHp0`lGxI1YL3#u&TPm<Skt5{W{yJ=pA$+bMH}Cy$YolmIGhaTw@FPNt zDr8hyP({`}m0CaTlKUez=L1<Z&FA<xysf7LAW~WXSg~u_l(3b#M=g%J2cnWU4Rkgt zl(C9CeD2g}`c9w(c|}VW_fBIKnKt0~O^eGx(Y!Onj0gJ;+her`tjz@*$m4R+(Te9* zK4ru2wZ(;KkSWkV3nf+B5@Pg|M>B4l5b4kS5FJPT9{%#QqxB;{VkCp%#E^IlrT6DQ z=7{=xapTo`0})1OPKVdXDDk9AeD+a-JIf3+$)^KTK+8T0-PvF3FzAa`n@erxjS|WJ zN`wK=djqBvZWX{?HJ^ZZJ0G4--$sYr_EqgGWDu<P$Sc|y)%55nsH8|s{<5I|0VJg5 zmfARPGFIr$HI0qEMH7jjCZPV@4)<ep@he3Z(HLu)$yKr0`~k2DF|`g;TR@9%Y3b#9 zSNY@JwXB^1qthkY;i$(lhYCy$Sjmr12d=NoU$W3*UyHs```V2PO4_={$9Rr!&(cDk zj&Q{_$;m`@(6&T?3kePgTbj&I#6PzMLINgZ1t?Bo-dw+BA&sFVh_ILhP_{L(w!L+G z;|(WB`4>8M`FCkxy&)<k4H^9!-hr@;Y4JV{l-SHz&BnaGNt%doYZn$MePUSio<BlZ z(J^W~D_9Zgr>4N);*|DIru><cYAwV!m8F1=;%(EuSyCLZVB3Jd<*MUuu?>(SC*tQ+ zPaEtbRoxFa*@Y5}<SEC$Y;O~f+u`=k2m^3f6^-Yw+aQs5D=lsY6te#OrgGekm@a-3 zK1L=AmTWcW7lXnjU%x8k%fS%#X3v9%&G+jYo)4)L87S|(+`HHtc-aQRIqt!w`z&Vf z(@4qnI0_Qx>inBb`h-j`tF(Xc;A!YOhg_&K3lMlGQWj@GlVxEjw+fU8ms%*(-AF-- z_sj%(q7@>rh=FtN&VM{)2Iij#rXyXQHUb`LY_O|XCjp2c7(TjY%hi<5vhjoilHtxP z<{G~;eq#RuxUrUHF`1=L%(6-BF2Ke^a5&Gh=QULuqEL_#%FQ;oP6+kONor!NOEc&m zc`%UE<|#r-h3tUwyuzAvXPPhdH|wv`IIKi`3=UA7<@~%!-DGBwv~atVy|eIy4rD_z zBqU2<ycQQ_VX5Y$(-zzCbf9u=NVcT^(=l(@c-IqOwApg@?tlj65C(+rX7y_47`~PR z`oQ(Ec1`eNT?!Z0G*383CU@RD>h5UT%<#qUQlQDsKy;?yp;4(u9Z0r0ySnn0;yaeD zsgT{!i->45?jhBw%Bu9_z7so|zhuMza34gb>DaBD_KCIeGK_Go>)KS8Bm>lAfHDmw z(AB|x?7`+&7UCsc>OIb7YIQdATuw9{=*FL42K-NeEOttDrah>vaQM_1z2L-ibjRQb zUyV0r#i&LxyTXb<)+a{<#7jE-R;`go!<Tc0>bFxjP!VIjAKM<3UcMFAZF+q+05-2m zWJg2~o>-!n%V2u$S@VF)@1ds6owL@@%a0Dbt1xwQ>poc?XQ1yxn#~Tqi#lr|vk3=) zhURV_=mVxdk&M<2Ht!9|sNac>F2;Qqj6McD5N6H}A1C_^?jM)0+NUK+vOb1OkLKeO zqZDWF^0llj^F|N`GLX)XRD!(fpZ+Rj=>BI8cm0etxc8rIa9^;E*|rL^qTz$(`GZ}3 z`cJw@9@}8afuy}W>qzg7r)oYwzZomV6^{lVMLM#K+mopB6<edJkmj0a{gzicMCPv8 z0z7JPoBPCpeDGuK?x&vKv<?cOf8jC#vl@rzhvh3vSa=p5%6^CzACP~P^~=Ak(dZV2 zOw|+rIOafJeY<+HkVRMI`FyrP>a@oZef%|EVWuiKt_g_%8Gf$(W&yx9$U1`I-Hsd_ znFQakf`t0x+tE^lcGqu^#`w;Jt(>}cY)zqe1H?W>Mb@1AcE=C>5Mzps!0ZL9!?$Bx z?{v<DRN|u1Hryt%h$zwd;{sF-WuWd)=YP#1^FMPK0N0Ku^*e<HSuNRrJ1!!phk5?& zhl>mMCl>)G4oAd8ek1j64=&K!&sr@K@W%-QW%x~7{XqAfy(F_sEZC3^_(T;#TW^~7 zMpeL85!;V6;mIzfF-SrN9XwZ@Od`@P_1s2}V{E*WYeX#Gm>6DO+oSTfh>120Jv9Pb zY(1pAySZF8%-#FM$6xyH&zo}IDBX)mz}M}5I<&;P8z`N^xjd31rsVgcKq(!b(BKuL z%U?*<DtHOHU8jWFzD1|&S7|VYsO;h4-3<~JXu0eM1df`4T!YG+PrR$t9|u5B{gi4! zBbntZ%R3Be$rV^S26pSCUKLMpse^d*^Of<8DME)Ke}U5fhmX}z;D2sp*?f5WMioO! zeS}x8-LF#paq&cb{i;pacfTIWZ_vl6h=Bc{&5?%}?h}EnQyNA@i4E#tuA_%&LpTSr zGD`|)X{$zk;^IjWAiHXxXJh5_-#II5)<8@V->J=FpyRtdp#N}BZg0Mi<yfz48^1%M zQ52Uhc)V8-754FE|0$(U6dx}CGGoBZ;z3sx-rXij6V0yaiIC45TP_pIAfk{KIA$^5 zPkalW1<vQ?;*V_&bCx&f<W4sBky6L~A4^vmRb|&iRYH(%kW#w4Q>0tEySwAk(%s$N zaOn<_?(XjHxHRA8_50ysE%|kZ=j@r;vu7X2Y19twRYrLl%<o1+M>ee;25%vz28`g& z`;w0<M6oV&FRTTT+(A^bc_rLmG7TCw0Mf0N#dJaF3XcDMMfC&2|I5+@CV4dQ+lnZ# z1(nlNdb84@L@O_xq-VAL9gIS$Hexr88tZl+Qfk7)R8MLgSk!ChnZWZ0jS)^!5}fsE zhrjFVT_L~Lerz;ipdL7{n~{#n>{1~(o+i2uQ57?0C<*Jw3_K0wHvtcLyN(UHZfza5 zQF^*DVeRRy@ksutzVcWO_@rse;Pqj;%kR_G5-{KX?Sl>AwSFSs*+UKKi;Ka+#3H)Y z&FQ0S7rA~7Z#yw8vML-%t7$mj=CD06uB%QaSx+}n2K`x=X$aq8w&pvIZ^&efG2MLz z(!dE=RcoQ7bx+z%#`Gt3A^m5Dg7^OdG$nQ`cfdv<0P5jBXTkC;IHmt)38@cBDW)p_ z;BFBRBLEVEN^ko9L(Ol*t?r~wwaChtBjFO<D8g4Q|M-SceSdP4`QR!oDLTSoJz<Ca z+b|g{mD#CV4L6<)?N7pG-Pa`UezYb>eWRUt8X$SZC6SQSYHjW=T}BThBH%ojED$}z z1$S#4>HV+s?vBS&ba_;<3dK$uYQn<s0p77Xx?UiGh7avo+$h8*=@aJ+@nbQhy`R8` zkG1(;7vi{l(|GSUGD{DMl&%8B4}Kd}x<9+@_PGpQ_{t$BgJ8dA*;gz<Q>!*6F%k@O z?|<)&|Ig`%Nj#$@#Dl-1N@%PvxOFM91xb>qH)s2V$?`NtFO5s*IYu<l$NWN#A$+mP z6guysV$>ZeM11MnG>J_?bplKyr^A%0KhAk;C8(JDWWfu5(wz79@JQL~@}yD0MoN(b z*$Fk)qrrx)f}+GDVTo{VOgt-;aS&2Pc*?;|_ZlY?@5dLJ;K{whOuGF+BSk-M5CxX( z-rp<<vDB9XN(gs+2|b_F!2de(1<x#LeRO@ahp8}}VH?KD;eg2aCf!C)HX>ABx*>S{ z&LSsvN%_g46&LLX&Its5#Gv76R#WhXn+_=F{m8oT|LSWUK?<6O$IF7~3G-1sG*JB! zTdG*Gy)YDK5*q^5p9NK5>{?!u3qhz7Q{_SL=nK&L4Lraw|E;jS$9uv2aMN>`GpwEt zBjQ(hSFhfyuWLq&GFY9(a~Fk@h(5*H)O;Ddi3URJ#FQ_ZNYVz;(d*IiC6P0mZKHwt zl&u)QEKdHW7HIOne%#tGPj^HKvJqdt6i0BY4pPDgU@6QT1sCJfuQQ-$A?E##mEAJ` z`Z;lK@H}1T;&fHIE@UTx6<_6X2)urb&`*Mn*?LBhk$91y_yeqlZxzm@LJ*^4N%T0? z<mRa?W<cVPuPGHFUd6n(dq2I;KQG`vz!+@8l+<9?Cuh<K@Z-Up$0Rz+F=BO0L@^h? zuaA&X;lL&Ml!GcjQv78dp`!|m>k%&v73~TJz!5a;qUoejAizAr(xYDuih;G$$=Zgj ze!jMkOB}&Mj)4h-Ykwt3!hVPx>x!M9HqNnlq2I+!MAuq1%Zx!ETS3<f96e(-H?tD` zmN6w9&|KLnYEmflom9y@f04naG0YJ6+YH48hEr@;9|{yrV()d*E2G_rv>f;9oG0xo zhwSx6(Hi&KDt`nUc!OxWX-Y+SJE=bKk9dcAMXMU3-GaESbfYSXhliELL|)i^4oMc` zOCtUQQ|CD*5PbeWZ-D>b_1`}Qyj%yk<tu0<o)Vq$BpPxA951?Yi;P2IjRqbk$!Cna zlrVFc-mB3k?c?<D^8pk?j2S$Y)js-!J#gx>V#e6X0ix{>Q^~Bq7QQCIPv6|uN3Hh; zI7aSV?a~-$S<Iws9-Ax`-3%`WX+IK>u-Rb5zOg(N%q!L3goq*nHqXxM@BmF<*sp%O zrR&RpDOBV(2MH*{JdS164^yLvgbBY`uMZE9*B+PDff;~8%iPxat)ikaB&|ss&nk_B zwW<`9D_f~cJ^D)g46zH`%<fGDyxL}IBn|nGYWyv@oL21Lul(c%#Op(}%iLOI-sc=X zRR2%^pVkdJ3W`XOVGHIaChQ967qxRAI7|3M$&31eki!=pc5*Ap9K?mgzIfRl-w<-} zN+rzTa#yyDZf~<eM<BUxI+@~)g`Qz*iKIwz{TfB<Y`{H30;q}4M@nTk*EB`#N^A>E z$Lw`I%8m|8bhHe&ePhQb7duU@$Jgb%X1P34GD}LU5QMXziY87@L%6nY|6^PWYpfJh z1!KNU;CmSh8Gsb0pj}w>*~mC(e~%3A%XL6cY3@{>@u|mnoLsiU0q7zrGYCJ%gek+L zsW}D*90y@v%`7wpG~#vCa7th~r`|1ZDv&_B!(>13^Xhk$5cAS?bpi1Jq5rx{F@*o* zI6pjx$45gE;mD?5QbzYurMXMjKU#clD?Q@|g6qow-5+4$pmqbSvN211npKbU@C2D| zM6`2InjO^D>3G3)<_D+13RjDcH~(mO6VX<lQaI0ev6=%XIlVx)jT*B1MI|m#Hg+al zKC{jp|DJS|E;L}pIU&;m*3T<4wHE+Rqn4}F{&kvrf7bdB`n{o{fi{n=`ARG5oAT&i zSm~-CWheT+`2U?qPtP7X8DCuNQd`WbsWG^^^7fOYeY4ZtEUT0*)3Ft>UP@s-FcHq0 zq>jK@n2zNenFgw#x-BAAEsM$Z&;=Wlh&G)!(xL8b1<C)Cf2f9OApp+3gTaQ*zq@dx z;kZzgPQz5j>IC1SI_i+S-2PiTg`AUYz$&X=RjP(!W}s@hSj_~cGQ8E(OM9VJg99bC z?!<@_gz81cE;APXr6Y<*6FhzCzDk39atRaqxz6lB#Q(5N(^P6OfkCe5rk@PvxAkL+ z$B30!(iUkQ`++W!`~5hn;Q4~lxT#1|*zti&gW84!kEMY1W5OvJ$EXIGY!$ylTTA`5 zl;n6eo29|-)Dk{jYZHmUFXYsWYck$zYx%3?@Dcs5CNuPgeAn^X%K|J&d>wV3;?*Cl za-G}pp(7Wc*Rzh>;j<DiZjUNa3=O~nnd_9Iw1y*5hQiM2nhBN6dpn9y^95BMB`r99 z^!JebpS{l3#@N~Q51LvUkBFQ~Yl<jSMxLO?0jsmEpH7+Lh;3_1ON$=1D!X65VP9O$ z>7@+DaG<C=<FI;cd5y4KImoYq77dH~4f6FEJ+}DbV_4e;*NL@txS6y`Xc*JndaCkZ z?e%*D0NUPBl94Zp`!PH?fHbH|gs=4?nQ;9P3e35_gbZB06Lzf{F117ibLUgJKpT(f zpxVU~SsTJeb3HKzQc6uwL0jegjiI%8h`0Pzdwx6!Cx!9hA;kCZdfS1f9RINs{*!b* z;G%aF!u}WkYlOy4O}gO5S8S$gRS&b_A3M($xwCaI#cKOT7>eRhQXz01&W3m~!V^3l z;R4je2apKhaLoL3RsL`*N`eZWiVlqrlc83qrF|?^K)g1zZN*kWcXfEP=p+XF2jJ8$ zNJ=(Bl$iDgJdmqk8(3!<vDixJ49_N~2zgzBctPYc)JL0WcMT^@UzsD`f_q))XrJ%y z0(Tn8(2(j5EF<v_U%DM+E3FC~b~dV)m+rp$@t{OrWOK*PX9+vjdyz_*NAy>;fpt`q zgg?pC;uoSagYcrU#wBR9I9&QMZ}2Z{VFSQg(DDUu^Q2xPxnIh6t>Gi{hd8l1;CmDB z#*MKr!G#dLmDc6iDJ?(pxh@1<sh+$-^u+Mz5OY?>IYBxRe!|g$%}@R^r~JPmy}2gy zE*;*kfns%m5x27}Ur}NID-|FA;pa>iShcnQqtE>mk?D|QR>qf~TisK>037!iAEQOx zK@NNH3vU~`sQ|(_uSrF|?6l0IGK<k)7ia~J{e{1J?#pBcMG_+=sm#@B98~7dVKYbA z+pT}H$w<*^P@J$FLg|(Xrp})3@V?~Z<K*Wq7$aU*F(>{LZZ6p(?90>WnV1Z>3HEdI z5=|p$Ff<7DC*>nA?D2Mw#LM{|_}GdC!aX!a8<9|l8rSJNeR`61>wDQj2R5A_3JZ5K z;P~DwBj7N;K#uy^WcD5W5tOTSfpTc_Xz6FR%^)SF95Dh0RQX@Hi&k^qNm@@>7D$U) z_kqAk40SNBd-JIAWMFsFYw^Ec^53qWj~fwsqL3#7)d0CIVYR<;oU;L@J4*RcsPNDI z4C3jyzhY1{^M76Q2lM?PAp+ZMK`z?AaSVgE$Jkb_82nuEaR}dNkpZsYqxhXPRv){e z9nNQXj6b7XURp1ngTZwsA8KnYa7~oFXObeyob^<f6U5Xtw<iC9Y<o6XZx!A!ru56q zwDY8}oTu9OlM&zN79QI!b<a+Z%dBO#ith<gicCyQyzMrRkE=*u9?=QW$dUC|gD-I9 zXJ-f-ujs(^LTfoZU^9?szNh{pB#{3*GOXmvokpP-5-C^!X2)H|p8?~=$>XSj_MAQ! zSnM=4VP0Oc8ZY)=CYgZz&rB*ilZ3p7#5;-zHMDy-D%A<PxlI+kOoZ50e?GcukpC$$ zk7CGU@6gx^dIPdVQQu>u?TT4svMBr^Tw&DIhE<t1rg-P3l&&|Ru0g*n@o=X*Fk{5l zz8I`c+Z(#uj4fB?AB(P*^qBd|CK`Nwx{A;l<~<8Y_N_^r<uOTs9z`zvlazwGTVtXq zF8497){%gxcD~ri&;WBMzxkwoU|8a3q;B^5N%wHj)s?&>R2K;{Iv<>e)7$2Dzbu4K zJ3vMC-rKO+p&s1y+*qBv-lSGSuB}}65xx+>EJK|kq~Ux=0>V{TFoXS?JwO{QXwx8q z`W4rSo3B}kS3`2z&NVxJ3ejnSDHvrp$1(GYo%Q7_z-Ie*a{u}5zh~e-{P0AE4bb6h zM^DUVhpQ|$E9=0l9p9qyuDo%1ySMqx*I%@8!&Dgp9wK+!>-buT^%CU_XUdtu>M(9E z(AZATG53W;Kl1h%fhcx1EfHOg_OO`jsW>>}(L(&au=avJuTnnc1<FtlHzyfk%;ZPJ zWndmRj7vmksXghTr&)0wR(<Rv3rc>L(9oK1f+av<vL8a5hrTdMfB|m;w}YVw=FJwD ze4r%nsZhqABtf3m|EJzW(YVPtx0b#0@ZficV`ypgu!29kRF_Smnw3;h^S_jpl>)ta zzcWhyQbnqbdd(|r{Y-@=%@3LGy(L|y<W@gYh{t-s01t3A5@Uzx@pRvF&<|CW>M&_c z_f37cnO1MX7l-mVs(tbNqeheyuQ-FtZXr(cUlKa_*OmPu1lyv>$881s>(_FE8@@TK z)hIER5Z{%XWAdWH9xoz{DkAaBJmU;v?5c0<qky6m5|i|AU{?UIL+Sd!Kn?*u9<H|@ z3%Dy={1>{pYb{&U;sf^{>hnA_p}(;;{0(QuybF5d!g8#L#U09|9qvY#^Xz<1GV@mV zS*y}m&&6o3pC-Q}fia2n?{YwmOvWSHLSaqp0#Mc_JyrRY?fbz}@4+Q6Az#)aVuOD5 z{UdFs=jW()kCz65Ic`ZAVp_Gu$kHLLnVHmq;lrU}*4Vh|jq3ye4-{R{`0KvJ@@e{t zT;n-x%KBQ>@&#a3dT*NZ>KOODRYo$J{HtzlwdeU`Q{pc;0k1;PWk|_o4;y)Lv0klA z!}Y4T@UTQgi?icTYh|Jk-FWS=yDFIXw?m8nKS;tRrx*x>@susqo+~Ze&8}X~UuY?% z0|E8x87s8tuSqk4M#!)kh$&BwDr1uA3)6E0SAWX3ot5dI8^mpO@Cp*}|FL#>(niFF zRTL8@Y4g<6?h)<05`|v0vRbw%ue0;b((yd0FB!||aJ*?%AK~gRn9uWRA`+<V7tkO} zO;8c4=0qI!n5~1S;t5=~Z23mtz3pSAtPXDv$(MER(p|5Um6@C}L&VNLJ~(^55aJ+^ z#m9`2B`DW&bwwMGev7e#fPq2U$*8DkTKEmvvpa?3MMSw96%>^sR-S8I@su{sA-F$q z5FNxWGyWs9c)6+uj<a(CTt=>~6}6t{);((2988$t4GkRe*`5<-xF6LbFc&b{Uxi8| zEY&Rb+{uAM_2I|=X;B6`uuGr_RgADkK?PDDT}S0)F-D9j>Uy`1+Y4`GPR0raTmPf1 ziB<a;q(PrQUyGMLl`%3atsoc_chFg*iD9moz`XMAets?jxE1200Vyd?ipXT=ULbY2 zWF*3by`9SZkV8{`ptd-Wz{qFdKOTzIbE2aKXFj~zi&S3;!8%3iA3VEjyc{nlk1{8V z#}kFEr{MfQ*ynN3f$4x%A5$m@Y}PU?HcU)S-P;3r4o7;1h!5uL+fztnjL*l!S>=__ zD62KM2CB;<IAIVVk?%boEL6~IhxNc)^OfY!E$F4tXRu9#$l35BN_rL)X+Et*LRhj9 zAtk9J0M!m<e;bIg>VLgw3T3-qor!szR8i7Yw6#0_FgQ;iV;YglrlM@|<KMM3|0j9z zQEOg2WZe9opU98v$ikTB;V(up7iE>Pg0!$>Ocwn`HN-gXF3&_KI);3DuW6*8Jc$rs z{Rcj`VjVCu;^2XE^8}1D;Fr+m+UYCddeka^b*!uq4n*%6YaN+Jd$S0ws9gQ4cSu{Z zWB^u-cqL&?9ZK`qAHlXe`=Qj35b<879e*r)lQ<pOxG+4~Yqel8mu4v3sZVJ8YosBR zD<`M?cyXjRO~x^NSC$kP*Xp$YDk)<vBrzUa+u|2`#b8x&c63yckWgo2H1ptrn4-|G zh!V#7u-ady(;4$c6(gv-R>xjJNuhI$m^t=ux^vz+@zRQ^|KPpfK({ywY}k1Dxi*Pz z>1d-@YffPtV(nrbX7l`^L-sHPh*jw4;K0!F*SbyH>dN5vkIV=0-)Lqe^!|#EpFK^C z0*BM6H9(sRAr&Qr3^VN0%Df)kV7XS&M}^18%91d;txtXAYs-9)8*B-47VHa_S14>n zbG~8~uoBvhL1z{*Xb09zonW*W=%#+WS~||Wt_h8TuUeewXmmWr>Fl^|-mj}0b90;5 zb8t_7C3OnjS>fX5W?INPR7(Y$B-<~+rc3itQ?^ezOpvjo9kT^XA14B}JE9dOjACOI zA~Sfrw?+bX414OIQF#b}(#M#65sq9%`Ob0*r^ThYV!$lu8dSRZt;ONx7`YK?#u+== zva!5l9vZMVe5G)0pq9mK(*33!4jifbeTy;B-z*IG`{-U{2lq%-LmpeV09eT!t#}IX zLLXkWYzX-@k;s2soFjYKd$2xy{EwLW0KIEiAR2_SyaLpsfBNmrdg?~*#0vsPn0P$m zZ>;`Exw#(KgH3lN+1vB)3H<BEu@{twLjvRIIjxF7j+4J37k`{)O=kKQE!q0bE74-c zNFF#T>D+0loIimFq>ifg3d#ZzUeV<<rdeolwbRE7B(*UDDq$)6oIE%^n$@0wZBHbF zZuZm#yh^a&>nPdj5nucoB`w%}ii`f`?u<)DIS|_ITzf?&I;kI4UQS>BY7tIm*%_aP zf+E_PCT(<X(rlro`L|pV;>*(w9#e6ruP=`zP2rrCQLi_7g5K$r+RUG)k6H2)$9we_ zYl9<(e{;By&@ka5fhRsZl71pqCs+Vtq_@u@z_|h4w=}H1Dr^hOzuUu6I?fs&8xJS= z4+A=y_s(ljAE4tyDcF{)>KU>AtzyCdQ|bLdBe*|Cz#@eyO;gmN0@Oq#9NKn_Vv z$~b8oF0b+#nL0_8TEz~YHfiS4pZ8RzyzEMr<GRh7a=D?}*(A~nSr#~+xks+sTpA4* z9pxELQWBEMUV&Tp1fJuU-HRPtvk9KKh+*tJmXt8&I9Z;M0rNTc=w+d1zuXc-yI&~h zfs?V(+d*47Ng+AYfv!H=p^fiHKL{;?EhqR5dydAoj39WTYyMLJ;9=Q?kjLRwqUL6H zxi6xQJfe|Mk1O9QZH{V4A|dPbB81B|UM$wJCmzQA>A`zkFW(L_XgI6oEG0bMK0`E9 z)j&}*If^YTw{&c0_Tx>Y(cLxKz_{ZQGiq}QFMb4qdw8mIsN~j{#2u(kPk?~q0rB%M zRPd9N5l(~8y8ld@|8+AAq^y*5jC8(=b!TL7EeL7DM3WNrO%!i?DJfuf3A{-EQ%S*l z<;!1?BY0@jLRdFC7~CH|ZFS#vMs^(&RMZ3Io|UW!p04se+1@M;llHcC+8G=7zBF-Y zcJz)oM8LO9BgwavFEpPS^%kNb;X1Pj55`7M;Xi-EvBXjsGath!(f&i_MTK5S>T$`% z)lv1~QjyYtb7*9D$c!$?4^4=On|yyKMbev)8y}avm&*t-Xu7v)TC6rl@G}y)3Msy0 zDQ3n}lZ9Zs&OcAcw(V*EYz7FvU0+I(=D1l$z+-J~b8s~_XIVnm>{vOeIG^l`{r-Bc zGe15?qBK?Uk@b(gRsUL~kiKV>$ElqqPs}fLVb)n2TA-Lc@W1(x;y(?74~lSr?*LGs zSJ*I-|Jo3ptkR33EL(zPY59pfbA3gSM3Hvs)*jomRXp?~i}F2bzt;^XCyw`~bCw)+ zIJA@?!Rtf7F+^jko5uX8d8GsKOXWcK+jmS!?a&3AaVoyKw!Fn({k&kgm2r6Z4h%@J zLD)Ba35ofLzaR?g(btZrph);+zKD;!Xr|k+rQ%UdieHc_btmAK<0v}uqW(<KqOfFf zW4r8wv~)Q)>=#I1+{Wib;2Ju<f@ofXJs8i7-~^}j{EB38$R7|^Q<#`b5SG;zozmqX z0R<*Y>qr!sv0%%iTsEyYLU+)|IiUuq56R*`#SYqug^`6$+Ch=IVzqwXLiIgcT3RdH zPnR%oL*>ywLJ5F>M;#g(3>8h*4w}IdR`Z`p!L_}8Uia!S+#737ir=&Y|3C}k4C_eQ zsEd-6S#nruA6Z2vm(*od^xp!%j2*H@G)uRWz|!CP1QtweBV4S~hluGkh9j9)IuB4& z;ijsB=cq#+@APFhir9a0ZTXmlyUHe?iBtoGTY}p1;b=~=p9Q3=9UkB<)^uXK?R`jW zwGExjFyM|#5*-vBiT0yI)walS=2#O_C&2jjIH>3XpPIU8qxzLGgvK~hRaZF^#Fq(5 zTP-#&%P8uU!?n|KU<<>+J$PZf47@N_hn_=gmyy?C5p{OUGO&JxGXwo3k~`VYm|;Eh zr`j03V1@NJu_9b)9i8^%ih-$4s(fj7z8+Qy(Y&O3J~}cVx69Hq;clr=!lhE$T54Lj zU1lOrHK=@Se61p0Y<93&N4qXUBb3&j^}N+uEBfhLd$8@m-_ab>nq)StKU3E`axU5U zMf9~Of0}R15>v3W-4u6;gC3?Ea&?G!FiJH(3AJGzYVw>(N&}eCl1LExpZ0JA0`*;Z z_rWcE=?DbO%9RK%K1}AO<MA_0#H(#!a!k^qT*K|_6;=3^R`dKxOD;||xsQHhxb?RS z5fp7V#qXGD=M<N()cJy&0WyE@7WR#{G&QF3Pj#2of-dnjfGxQUmNwh+B<4%T3W-{O z7#PVf?Fe-)0rs~al87yjD5SNGZbn|9jaOme6N~4O1a16*)`5|({Dm1s{2??%n-uUW zg|EyvcZ<!`-#bz8rmDIi#zdiq<!uoUd&}l6*sZx;UUNAjSZ8MYBJdAQ`Mn0Ry%bBE z;tn4EHk`%)t`o%|Lu?r=7oT=k?G7x|;GI@XS3P4d*i?S1ko9irh)sCO_y`=^km`^; zNBqtvnC@(n5^<{QR589Xh?jdfWYyP;`tA@NAJ>9Wf7tsH*Rws~QK>_+yi|i0Vw;2e z9=HoASkw2PjC4t9ci(ez%T*l}7<IAPCcLy-*WyBgwyILtyXV^g9;vPrF^=j^j>Tzv zfsRvaehc_^^C8AIfWBrz#)hxK=c;UJo2{jAX8>Q~qSKlK4(RpjTY{c5%Ns6TY_t3L zQ>%O9*$2uoS%gK-$Wi?-Hf%k5eonIF>3Pot9hM5xN@fFRu)DpeGc<icpifHxJYiOo z&N>EO%Pa$gHL6V~u)PGqN@%^MLFD#=@88_^rzp$i-EQ4r*Rb8g8@decCJq_LjI*Cb z)3y<HA$5+H(`-1Hk2TnzF4sE9?Is6HO!JP?oYPqP3A_DCNza9B0AB3KE?9X*XEn%q zQ;yfJ9;X}K!%z9Ea4A1C4mOKymN0C5yuC}}F^mEUooVR@MVJ^^rM2)q(MbLquuZ^_ zWWTdRJwuXVQ?WY3-%?*KP1h{Kk3W{C6J7oSzh5^0SJ|FZ=vXB<PjKA~g6uhf=cK-B zeVr%d8-dAGn=WtLVbt@;e2)9cCB9^wN=C~`TU%^m(`S}sISqQ|-OGRv=OO`BvrZ!! zSb++LyFDT58VUqnw(5Ef!JMUJ$`F=XNc#DS>8RK)*9R|f@<5d_1ItD4wpRMsUn5JU z7lPB*wWzRti*;vF3BwmO%7E9uyS>A25-${6BA<D+G6JqmmLqVDtc@i^_%4~Lpu<81 z@Dz&mD@QiCK|037)E+(EPd}S!;8o_?(5Ch-XCaV)_Zw&W7yg|~lFpuTp#dB<{CxZo zrbhgmj^Rto&G@;)Xgae#Pe+r7$0qaNv8R)U+bh%?^M;A<t>D0M@)7y=@tteU?$N8| z_i|NI5^zjtb3@y^$4v#15s<TQ&qBQc5{v*nf*}UU!|Tdg<j4_!U(UQ;#~wyyyxoIL zRdG@VC39JBX)f_`z1c6unn&*=!LJG$<eGelaUXBi97}nLb4W!OI%9tH*7`h-$=v4` z)|wTXDpr|0Ii%-R)kc(4e-1-6CW)}f9MfBF71%*G#%Jum71rnO2R2X0x}b>HP5<uq zs1u5q;HXp=J5`fV#)gN))2m+%SudxcfY0xo{{p$JCkQ>U)6=5=TT}_7;ngOk&BLsB ze;>k+^I|CCB=n0vV=F@Ds>zQ2cZAG!geHeq!j(&R1kf4y=@`(am>s9(-sbHKc74L= zM2gcqY&Sm2jDmhGwgC63L>N<W(AN9I91PaPRYI)bP<^=Yg=BdToxFgIhzw@U@}FDj z*2VE{oelZi&M38!rD;AHsI1uNa0wEofTOmJ@IY<cofz5EOrM>i<mRi=nwIaCg3WTG zyi@C|-Saj;6u4F-;&@B9>zk9=D*mrlioYi9(y_|(3+ZM2<NeE$1eF<ZlzuK<taZiE zy(P0(z<*m+n)vGC%y{p8uHC~XC`*<I(KpWJPcl;$^qqaIk{nw|K;X4&bisKJRfo9R z#V#y#?=5ulu<nj+ZVb`+jJb%4l64qwylA}IPgHIdj;QcBL1~*_rwmoeRmh`85dc~T z4o)UOv5L8RJgWDouUxj?xSaAk<D7NJaPRZ;vmPp6->`ZWMwQI|fTbJoe4x;~jp`7W zZo&7|M+u_6$PK$^fS+b$P*R#FBlz+jFa&{5?+nS0z;5GCX;+$>%F1NJk}Je(U)I|S zn7&PWPWJIiNmnsPiNbrnb?wtla#59^1P=>+zs1PxTHl;hv=3XG+w}{6G%>Nx8j-t1 zcC$mncax3s=GaU@^;bK=Ce>_}N4H0^Dv?IqIpOb2y3C;3U2#Z>C`~vohsvWEeQ7~T zS$4ShkPpLLzfX3g=K7;?|20G`nzANRo4g)j83iff<r1*NC~I6%wbQ|Y_;hk%n?`jg z2O{Ld;aG{B&hLx--H{2>1lbvgHdHeR>X-{sSgwCh*{4sd6(v|dS!r&x(<a)o80eWj zg;M$HjqANh^m`WUjmZIDuS4Ck+S#mvQ=$zc5gOHu-6HRHiJLoE#xaT@KhH#p9psY8 zfmO%AddL<lCudkvLCdX&y5{j7wGW&BuI9J6jLRGbq(yTx@a=<lS^LQ|w!FU&^slrG z6w+?LYuAL9){NNHbdCf9>!XpL7hyq1-Uy>wAb$ykKX$qTcgxZ3h<KXxg^3-rJ&XNj zS5zF42_<Ex$klYe*T2S2CuS<lqqHmH2aUF2c05XJ#$Om8Emde?`pXpu&*hboi%*5C zY2Y~PO6Z+^?lIWt@2Ponork~{VOdvHOLTdhxS7h8^0f;@JR`*S`YCg@g&mdzTyohk zeNljbXoqdcJMD`gSm<OA`1zfqRHU@Dbef5&wx;I#>dN67?#q`i!$ZT=R8-)978d55 znuu`^0IpI^BT`msjYui^++T_%HB>b<srL46EUA1MAVRGunK+RG2)dBE`_Wd<%K_AN zu0Xc9{VdcvcFs#T#|fg9E?VwLf>Ym4-_Q0K1<eAj<a2ZJ?6NLoR*#V>H5pVv`<5#c zMN#9#FZ;~qlZrZW^HcM476JcAmZKm4KGeLH8lK%%I7P|l$sMH=UI^QH%F3jqTdv0@ z6(c~cvOd0k=bXrezrYH1Jw}i+=m+YM`?Vx0j;sV7CEbzz!udVd>5R>zkKKI{CK<VA zJ9>?8Z@F2WR)1ASm`gHPgx3)a{=anUIuc(ztwlQ-pFq0DD@s&=bN`#nNQ3b~uvDVc zCF1v2VS{RO17A#U63uK)6;bZ9E~T|jj>y&Or$79ychm#Zt<Gm04ym=L$taMY6@V>; zWZc~BoVy14Q?327TbVo&veJ?^T(j!(LpAZ0=H{z2vrA<Z%f(d3(}$^wRItJit5i5C zn=XWW13#TPC)QRQ6MRrN&A^5Mylhx5h6=;4&XrGggpqjXDyjorCCw?ow2J;Q&mx4c zVK!d?-3mCMu30}M;e-Y(w7{5f4UK7C)}Qa6E&jhM*)*TLla88F2%`ctAiN6gj;i@3 zWK<vWJX1A2s+_DoIlWyFoS}%3G?e2H-(2EKdt!-W2G-Jaw%m<ugl$4PT|Vh_HZb2c zG!TTIt0D&B*Ewj43#iBOE+m(5(=3I_uww2O2;paGDAN#;&fAQ<wIJGdvx_NyaahZ` zveT<gH8>S0lWEBNwPIomnmQ|De+lk~g}rjBBaAv%1=mzMzbIsjsHsphWYx)OdAA*f zZv28|QBmV+_>5Fk*gNijBg1yH6e~h-RkXG&VSBDJy0z5wn19=`Z|0?67o#`R1VU#i z@3oVSaXqiXK4@GqA8*%AcT_eJ<EA=0q<g~8=S)%c*d719GjZ|DM{lD<O<}Uz^ID^p zVnEC*+*&|fJ{r;|$&*)9W@TvbpM`m^Srq?&-+##`rzPBF4hu8Cq*P|}T{o&yNgv!$ z%MAvN-(nKY1(!Ga;DrcXs)M45Bt`L))6!Cqx3+)sAS=mqS*#NC5O3Iz<7HkRZad)h z>thdI1D9C<N_hQOJk54DuLY~pjb+8S(prup$}<QjZL-EaD4>ye^1o^IhYaRT-EKtZ z__3DK`ENX13L(8+?h1vjLFOoQwVC%cN9OlmJp$CergAUOn#?CAjsldFl-8S^KwAmN zpfi3MF)97pG@zrbpalBR2dmMT7OuP-#}N!W8KHJfWxmHPrA>v`UZI!*=@f4X!R7M9 z%9Zk-jh8q((K%Lg{zbTbR$5ANFNbZ-iLJepn9A%uj%8WFu<>E)n0&12q_OAT&1|)Z zUmEK|(t<&*|4qLJ{@vDZk;A)f-Y@ZTVa9IX0%HQDG!3S^e{W=K^TD^HG=Ch7Cdtj2 zc3VO%F>Ie+U*|vG7w3s=%Y8gia!o9wq8;JzG==VPBU5)vRKUvYvil~hclHzwF`W$0 zC%No&$ZqgwVK?`a?KWY03qYoaM~6z<t}m8C*odc>5Lv<%)r(c%>iTB3qo{iVwhR_} za8L{ebp5U4B0Hg=w;h+UlxhfadqY864h3{#WNUg~G7(&QbcBfmp+f?XTbjpORKNIx zRvLk`G(YHkY*54biQuy^V$1S}`PaNemR0TKB)lef)U$_y)E3NXtaK6ELy?nLVP_@f zR5W{AH{okcYbK^+KI<XGuUb>%Z!Y|7=Pq`J6I+TxNk9s8HRduI+E&c>g~^L)pZ`jh zABz7<7A7pWz6>$|0ePHJ2;{^4-B@olM*XZ&u?Q*Oaiudc9-9R5RV%o8g`_7Y5EWNi z_v|J0fCxrV`)$M7pDDO^LvR+c{ev#4`>AA|T+^&2jEH*w0HE1FA>!(v9<H(}%9{+Y zrzVUV!ZTzhnHHI68;%@74*<JYpXd@|DY8$(k&7y%wtQ0*9=&Kq5i73apgyUx5LP4T z>Y^AJUT^p65OlZjwX+oymtL+1{Tk^l!%0cOi>xc<LM{@`i<p^KTb!$jh=oMU#bILF zCg|4^w^DBv(MMNHeX4=`5_%NTfQ41~`J{Tk%5JPhX`U&g`2ap}bJ$*D_hri#EPwF^ ztFF_Dd9ef4obZ^JVh+wD+sfHDU$tkI3dBST3QNlJY<&OA2Nz*}|H39_CG8|J<-oRf zB1mjla{@-7(Iv#vbzcL0O(Q@TC1Fx%jV6g;><mQ_t#XD{0m?&fcQmeCT5!MIG*e3R z4PGP!n6rGj!OS$TXlr!3dR9nv^HsS+OBa=J{o3_ZvS?{Mt)-mN7ip_1O9b1hET^F4 zcGi<COPB>qn4{Fsn3v9`hn-W}3TfAzeog)c`hMwl?Ks~&9xu>99W12FXz@2{^dTNo z)`Q{6**b;2NKMjao(WrSvidi9ccpe`uVF5yCo2S12#d1L{P2opa6@A9Fvgy}k(*A( zT{WbDi2X)#f!33ZO<i^Qy~3YN@e#5zzff_dZqvP7TVEBt5Oe!iz}>fd<J$61kGz)o zFR9L2eDgp0d5P1hyFFjO?Lg;tUCV@2j4VxI#77IgQzc$T(LV13SeS?SyN=>-xM1PU z*_|NSvoMjIt!QxtWz<bpQUnoYAVdlcx0RiLYGE~tFMpH$#^kMF&$dU$zNa2XBt&=A zsQBB(hyLGGM}f?k0Sbm@u>=E#icjY?WfZ&Kv|U}>#W89wn<$mS)*cV{br)=<Un|H) zx$?`6za3soxZz^~I6b7VZwj)D4;yII6RDb(>akhp=>1>1T#Q@t)A7k5KYzQ|4J&DC zBH7!T?VPg`A0IGpkcq*~E@+-BwBo0g4#hS*2s7GkzyhMu_gp^AY~oKHa3P!@A9nWt zmecr6UszMRHWFa+)eGI*-{{=F7F8B6eeU^1p2Gf)87zo#j=N|&UsD4ouY+yP<e<F< z5-4E|2sBC5YEs5`!2j^C#qYeD$_<Za@4+H06C@jMp$n6fpY0{|9d5U?9u5ooF4pJg zmm4}3E{tN<a{xOD998gtzpltk5oKhv#a$?ekUB#aUwkzA>Xl>uS5HQQw)T6|2W3 zt28j?c#2}vA(2^O`|k^nuovIyDk4rA=#lO`pfa;Oc}Sv0mLPeGr<dIoI$V3HVm+KJ zjr4wkufy|s&4_U8I1*gywz}|&XlX_0;@Qp2&7CbcOw7|W5ML`WJCa+W!yo6BRM+Q8 z(?|3fF@Ei?;#c6a2H=UdDLkd{6=HgRwJxEdzgRz3Rfup1tk11!IQ~1WGnR5MR`TWZ zu*tS=htqz6wD79#-x;mcrHT;9!C?Tdn<HCZ18Q$D11&Ex0<9<ddrR-5gY~baPjAw+ zmYBLeqDn_wPd(yXs2#s}5F+Af_I&P2c<~r!WkG6J#@E_|mY{SUDcL>O)*>Siov&Km z0gwmOZzN4oQV-rOvwhWx^6R5JUok07jyP6mS;PU#JOQeb>pe6OvZ_iwLE=BR;*<9L z@tk*GsN1;Hz+iYX?+IAYzx7rtUxImiNGXB2^4t2QJe^hjEu5;zhx>2ZeR7z|>lSLJ zTv=|e$DyVv$T|bI4<)2a_Yo?6rTzRO$k1^1$Q!waWMp!{)~U7OuZ3WeLo<Z+s%MO( zqaH8w@GDx;xw-3Z0l2{M&N5IEM(Oam3jJCM*L`qR`eyye_{A=Z?<^tns!6zIZmK?+ z=_$Di7rn=Jf9O@TF^U)mDMgC0_C&0(&NStHfWN_y_>X#%=?&miBV5k`FZCO70oKGn zi8ETy_3xWRx@D(EM#w&$#RW<~hm9kO%TxK1%?3WX7qs^)e9S|6d5<EbnA^<Lyh@%O z?Qb!n7_Ghg<4|>~DipS~!DBkoL$>vH@SU7)7xec~=dheh7`O#y3B#QW3l`n(aDEh$ zuZFR09zXlM-sWQv$U%TDQscC^RJh8pg2FVt)%~(xeKGfJ#ejMeJ?@O8Vh5Dk=r*oB zU-PcJ@b)HYlZiDKrIkz+QnxWUHL@<Gp|0|h<MCtM_`4gb4jmJ@V;ai57gSt)G;32* zLJ3;mSCtE7u9r?yaneGbbF}Kl1fA#|++tEdyd)HiR!!bnkWy;Q+`CADiL9QGf-EL& zBAlq2(!xwR3#qo6ZE(Ckn11;eF9Io%(;)Wo1sX$#2vJT;%jHE(kWS+R=lR$@Kv^Xr z`zsQkpOyFViu+KbNig{p6k&Q|+(W7>o?slEOzjH^?~%k#pNi~-V&vRhqrEM+P@GE4 zL9vP~QKhiWCXDJz&HlHjj_y5J&gX~jXpL%aD17Z8sYHB!qt+B7?-1xfO=I&FoHxKI zn^hLbS?Z2QCT9TFn&`wqLopiDZ3Y75I3I{Pme#&~?gUm1Yp5!o)awFf9>Qqmyp2-? zh-ebx@0<@gG*vWy`DGlY3=<${%yK)PT#dD-A7?S)DD{Il6B2@(W`7`m)mpR?NcN2- zs}{(~V%5@&CaCiM64nbsgfHo4DBu6k_$t^Q3_196Lw0Z>wdF!J6Vd;tDI(Y2A%5r` zv%Dn!A^XL^m>78Zw838`?T%PPT3t2<BNiR4u$+XO%N1aa>AOQC5VwNpRY?{;mvr(m zX&83l_*Z;XwvP`OvisG7NV&lFW6%#ASi9amlUn=Qbb^-NaR*@U(K+w&eDIsh0dBh( zGa0;*r4}hLTy5aAwBePs^cAI7&kf%8l-rUoMCjGap?2*}M?uSZ$OSXDQ3(%QB$dNc zHt%c47B&4a_x5?fxxs{3)R`8YXb>fA#a98yw9z}HFKYG9lG<Ls<6G_-V;C$GOD+TN z=D1k+;!G9|ukM3n^%eL~4}XGF-<<FCtqvY$*yj1RrhI$|eJY;x;3&7EoLbmG^gR)W z8t7VWz{K884mx#GEK(}xj*zR-UFWC8_qC>`r^6RrlI@&9>aQv4F@yI>F#qit|34#= zD9b6QXT}cb5o@^HKKe3ze-PSSpfUD;YYU$#5`z|mRFbiKe2yzU5}AS-dt!V6am8m3 z1Y)Ow>}`3!O@22bUHtfjhg0vGxKrb_roZUrFBHVn?y{wPLMPzf3QA+togor%$Ei;! zigFK0r>ZP2=e4kd9HS}=wx7XcW9D+~4PtMc@JI6lEDNArltVw@#Hpd7)KNeP=a_r) zt_C&gBf#GXvG<Z5D~xUJmJtz+OH#f@$wtX=ujMT>;M2;-rNXM=$$%EhI5GUqL9HWP zmWERWXN*DJTxZ+ziqV1e=t?qri7vshp@=b;V&aMep)Mip>b4)SMeLKS`+LfRq+XsV zPIkIY2;=1NPuRKX_jzM4i!F}Kj&X_q6FEMc|7(N9XdXtH{VkBZ2FdCAT)<u#EvWUJ z-|0|ib%Q5ZE%!Ivenb}Dycn}AN|4o(=>qez#k)`$>kkb09kPUi@{4#lJuT@v&Uljg zsVL#fVZTiq=IWb?J5T(k1anBfORe;giqj0JH-FdKoi8?}QJ_R9&^ezDTQyRgu-2rO zGI`<K?esn;4Vx9dGFbMt#Vxg13Ds|3|FvHzKd9ngC)XBb{Bj~OlF|k7go@NVpJ35w zJgRn2$*m%B`9^IhPNEX0%%{aY9Q$=Yn_fvdlFvIt?4q(=D==JJ{q3eHN>V`kOUJJo z#YdWRWFw`^N)fSSFVcg{)ocwFu+laG1{}kw_|w}%FFg5meh%C#+%VzdI~V-5r1_p) zKfH*Eqj!bFA0X_1$mL@g3TIz46<xtwoMXggL^r0i(WH5nr_q0=!yTO8M`|~hVI{f@ zB=y*5_YPl@c=L?hEr6LaC&O&VHx^6u@p60Hk?vB64fv%zd;6Sah$d&Fv-Wf^lbzj8 zw%3jiX)+EzZWMyfux@a237CAy-`rOD0yfaUvolBLpnZ^YozO=tdN4<GIc9c3uB7JG zq}$)?<+VeSLGd;kFueNB>+RlzQcCp=CT>u()o!^VgU?dNVfV({6<<l|lbiE#_EOWQ zr%({(a%S%~Vp*v*gS(vT3p>Td#mI^*$GmGi7{{;p3UW)M+!?0gUcv6*_s_<3!_)Dh zrE9MJ5pIE}7x-hlwgfADUZ6e{62F{D4D}SXxB<gS>36)E`0%fKfMvjD9kj{JQ+JOR zB8p0_tF#K5yhnU%n^-AWg~9qsQv7Cno(Z8@fZkTvGUA=3Ak69O+W<a4p0Na5#re<s zqn>*>jz{xluN~DQ=`9o6FZOZWk>h2hJDq*_DKy7hXJsaa*XM=Mzb#6KUp&*Phpq!$ za!RcFo-Q#GNXj_F?n-|G0NNKQE-S|ko8N%hYz}MU!kUupylto6`=y4g8jyA_8E&TQ zY-Th($w@j4qgHG6xQ#KQufV)mv5wVNDP}fv)kr__oSSAZZaUmI@`>KlAJlP`PEoRt zqhk`@A@qm=KQnecC6q=#fW-2HTsjc$(iG&wE$bmxT=&Zk2}Ff4sk$K3%@!>S*MFDv z2ACeHSsde(S%{Z;y>H)L)c#|&zx|dyJP*67l@qp@Uv&0g%`&W^i8Pp&&lG~B)z53H zz{Y4DT&5}ie7)?a`$@~mXz<c=%$KPamC<ZfAs3Pbj01M2#@dLPX6M`KY#NL3Se-8c z!S%px1ISZWrqd%9hd0nLw3nyVqUB!m#5zAo$QVCYE54=ClG5|{@BO-p0r**2t8Es> zh<InKdFzfTA{f7WDNHX;E)Q|BTpSi)jLoC4*(jK3e#t~i@F)_GZ|O8kWo1ZNTD50k zLv^RC;}D$9sr|k>|6)&=T&|4;L|0PU4qNQFZklxVp5sMCxRH_UQVYm(dCg+wS49v$ z^bj~yS>ezed$_2#L9NV22tzt2wa=^;6&_J1mEJKXhk9~nHm44W@|ViN{;C0PaD>?3 z#S4Gf|E0kmC2jTOnAjdLIpO{*QNl$7xv;!6Y<r>nCJ99#%)(+QCL9uD{-KD)X{6VZ z<}iF|DD>|(-x#>njEyX%+@9l2ClUX#zwZUZ%Li;J6`G1Hw!gAo`#waq)zQ0K&@Ae_ zT)X(Gs4gm_Zmfm)?cr*iFhJ``Q&vp~x>T!((SR>W{2>=`Jm?iMysOF8_%K^*O4++F zDLQW7vVVDw2GW1HxVvkfE>j&*-!iDOH1HzbtNYFO7!n7b5;u>k#b-rr?D=GAxHz%C zWv(t!y`{0kQ&2dz209=}u{sH|1HY-MeLcTS*)SSBKidowB$8X%&r+N>4LG+obAM#m zS?cAb<qZYGhg<e70(-S8cg7-!K{kY<OsHSAx)xTTEoyp5!NK|f`TM^<`Wq%)($l82 zrd56LQH%axhAE#G%9PmQVJ|FzIlv6oclRE7>xHu8U_7lx(X3)jQBB)+q+fVo%43U} z?f7h`Md$er{2}R&SN6-9YA7sQ7S6+>mRL^}1a|{2>9dxfuNFB45N_bh@e9v$CKJ-m zhVJiam<$63utbHWz5CKXaCO3r?oDR+Xao<b1>1lDwu1Ia+)Ytr>H60jlvt^vg_-$E z8=jewnE;QXVB5Hqt!d8*K+?hjC4ayhD%F0bijViRxy39w;;IfgzN}aeN~jRg_zjP8 zW1*4Z0-+6>%?9pNBP5G2&csxcWqiWr73A8jN`ZhzzsHpnp&U8mE4`4FRqtXIKmS%n z*;;U7WlZ2*lq$#j_jJ$rZ$^SF`OeTNQ<a(q_GM&fdfK9|7nf7;*e%q<_o{oCQc_s} zUSxGoVYW(QLa&|;_~K%u9<7a*MulS`Z*IYZH7{)+8U0hPNCFN1Rn>Uk=lDO}l|RXr zBWs>&1(PnN8*7tU`|o7wC^0<+^hbI+Ex95v9C)Up@3-_VXUM)Qv=wwsxeb6}6`M?L zaknkqMNc5ClqxI)=|`jluQ!|Pz_$?Q4WiS<^VY4XTPfvh+MRo}X!5xE#zyVOQ~e*` zRSKtfkJU&~*Vq3rTh7E*^5O%K#zf2AUye{aE~CZJqX5CE6FNB~g3Kwb+>P-2_TJe> zv`)9>ctw^9zZCoJEamiC*1HZZ(J`D{i{h1yt#Xjx9c#Xk{o|B#XBAZn+amU>CehcU z^C*{O7p+ZK)TSVYB^|nD=|;z2d<?NoaZ(&JPn^ZvE{BJ&A|IM&a*H?245y50(jen( z1tDxPUGdqR)shGh_1QWwTdB|VinH6njWw`9PO;P@@6?hF473Vc!(l|*8ZOwJ=Ff!a zq3Oxn51)+4sJXA57D9UKSutP?LVKPnN0U#<%{={bed{K&g^TH~@m8Lqik{#}a*pgc z(rK6P%9rahA`bFz%2KA699@zk?RAv>+LV>8-FHPQb^soNd3V#so_vSBnaht`oMWX- zhD^N%Yp^7&*{~?jTKsOd<^r$%d$=Lk;o;niGO7hc&Q5uO1pF|~cDEm5<GHlJ@B8+| zf)G*v+ai9x`Byas|Jk)?R)~`byOx8(W3bc)zA}^0`e~}kp?mHDZxmS?k%;G;TVHIK z*YmMD%UM7RuY=+0kfguh!?~oKKp3r@^Crs&r4k|JVzFX7C1;}G+jQIrqeeu-Pftqn zM`OTaIrDu2z}I<rci&VjIjJFxD59LWx9KVIY(6u9fsvnak*1&(k?r3o)X`>rO~RF5 z$MK~2=Xoj?Z>v2{EPvfwqwW=m(Id#(!%ck3?_oK3)ya`gXsUhWJ@$cibb^o9$<&?+ zo9%el;;n}SPhgKW@|W^tNW|LXCmXfvaadCzn)y-SJ)&skA+R9XAx)=G$Mkm&OhLu8 z+tm&4)m7=IJ2;+2dybRoBISG%{+~?nN={Z<g3SM^1AMY@{}lr$p<ilhr};N1K8_@n zwrrLI$SG@3rG*mos<DeAM@Ps0DwNd$Jm`=o0qN1uKc|3O6ttt94c=e;<fKW&@U>nV zn0cc@7MpV0bl(v5_>;aZaqZDDdmda-ZLxG<x8DkD#%H%XwuGjsWXIMx=bhr1UB={e zs)T6cqu{c+*=bdLfXz;O(bUp%+geD^^>s8v>CG!KIywSK<%+Mj<z({k0lyJSS7!{$ zphe-YNft<Gnu&7ozr|_8dHXew_oz`&W*#n=wTIf120C@PZ3_y-<fA|%T^~)lUxjR6 z*D#@7MEsUY%s9pH=lU3NwHA=ygKIKgE`p7Wv4*uWxNC!<6oPfP<Jl$Xo0O2Wn}ZEy zY-nDPO)>`{ct@gy|07Z0sBt@(R_`_Z79vtG#4Wv^XJbe_DF;qnlP1dEQh;zn_$eQ~ z1xF-)UcZK^<J@wVew#N%XoxO&Q!9K2cHj?`hz?R)c|IN<PkYKpZ7ydVe)E+tc6-h9 zGx3Bgta1ZUbCoY*8c(bdY)m+_A4QEZF!Mvg*N2*Fr*E_jnUoAAy$ncG2TLpI;J6<; zF`J#4Ce7lEmIbyNfu%IC+|4ZHPqeXXqKJeZY&1V2sUD4z2I|vXPBOHpsgP%K&196n z46%h$afTnIFQty89(^5)ishZp8GLX)XOhvOFV7y>U6K|@+RvDZ2<I*&<NwQx`=asb z@oD!YzbgHkbph$3DB`Fg6N=`uV5AEZp$VucQpQ3YNlKyx!|suu#31{ZVZ^AFVM(l( z;(z@e>%W2ka?8FsYl<Ho%P{ZDLhbpCKzBPSSAJm&4V(FOYraNt-!}wBP5=l1ej(1$ zpOq+4o|1a>mfMHyV@2hpd4r4C^r?qJFFO$5!<8LIls?bW`*_CbQzvhPPVi?JKWi;C zRlYQ#Tn_1FJ^iJMO%cQ(Craa14wn~&lB-CC1{@q0RFjbbM)NYF@#loAO}%RuE4@YU z*lWzo<@p&!A^(@m*GtaWwdMNyHakvvK`jLFI=2$S=_f>jw6vgdyl7-}c!i{b(qF}d zr9&Y_f&iV$fIdu!S-$bDecNWr*#!C_$ylo4Df80MWO&5fC&`PPSg}@amm3Cl{1=Wh z_0j`sE>lnOAD>H0aDuHL0}K<=e(6?Yiz2mRVi>WxkL%0*Qq1DAYAj5&i<=ke38LB> zNzG?xj|mr0QxY5>!0S$Z8+|X@Uzz`*8H^&hS-$)JQRxz)TN(dE>$Py2v`HnYTQ2~b z_B9s$DaN>mHK)A%l<z!0J>3`E%I@`>5x8hVk`j#dQC6NzY2^5sp4isNH!+U3exju^ zZqVYENR+0eg!zP@s-`AKQT!W_)`5n{#!^arNyWGK^G4ZjZt{rxPyPBIHEfahPzw9M zT+$snUxOs`D4R$5N~xUXqq_Bipy(x~0lxVz+sY!1l|o-}LYG^UOUueWBQ}P)zoJ?# zh=^g&35(YQQ<W`Raxs<Yh}f?kt)c%%)mw%|{XgHs`cXmY?ogz=JEXh2rJJRjMOwPM zyFt1^q&t_TmhSF`{jXnt_k;T(*Ts`P*SzO7bLPyM_#t)-&n!<qM9-oS!it4s#YRvK ztxOHp?)KdL=Yx;%tBNN^=9{2uyR`_-3OvhGcXZ4&vLhbNfcFgxO0Of_n74cF&F<)M zViAlr#(?hmg~A9iNswB~hx<u7da?710cJVXK_yYbhm4Of5TE(~fp|V>&LRYqDy^oM z#E6nBmUF+uruwQ~Q(n$a=_Gpj1TO{mS)I2Hr#gGEh#ML~r>c?ceZ9_39)UKqqmIK6 zfnH+fs!mfgE7N214nZVODW$SSj531pm5>zImBl<G77mk`YaSd|>g_9JMiCPFVxY{( zY@x3xEDUi5GDj6t55xxlP8c?>PpjR7dfvc%!>b($n1>3^exyMKaUU_6@zmosY~s=7 zXXnAQQDI1@-%S|W%7Qvlo`F+$+?T&xrSQzFa`~0yVOGz@7{O${56|(~Ecg-e*_w6Q zrC!rX1uu}|0*tg77u=fe!jU?@{twL&R~1t?)Nw{$yhyA&Zsr=!ZZdg8o)gGQNgc6{ z&TY;|3t4<`83Yhu_Iv6fsCP|PLVEcHtDWtno^Hem+Oj@x$T5Z&RwSY+75V=%s^_Zz zTBF~FZW2yLB<XVRL*gD$9FDjwlkY>-G;sRG?5*RLK5%Pk-DYfesF&*}rtFXka91_q z2p{Moh}%Dqg8Qt}QZjtT!19@415x{gT9Yn!*d0(Ck;LK3Pbhl#G)GanZtUrse~R{o z68$h2>2bdPo=o6kjx+fjH?&1rS=WS5`TC3EGkekK#xDTm+}A@$i&j4(zhZg7?iQWJ z2Adflv^Qxd>`K*@pD*HaANKae>ucEjR9gKs<E(%(Bx)M%!&lcin2#@a>ELg{a>&x? zftBOparyT3v#fhlHd5H|^UtRCuqC+J8~GsiQserUI+atbz53YKUzetrmrZs^`!3yy zF)@e{?}O)6)P?}7k4tK_5a;Ob@bgD;28M(2s`K*!vwc5yypSmT`VP_1xDHjr!0|tV z;vGzLkaquOz|&X!H|o*mD@!4A8_vFeYu-2iGUk10S+yMS3Q4_fu&*IwE75LqEVQck zyBa*aM}ZDf6~4=fy^wLkJ>AWQr#jsTVl+{vvTC5Y$RogS_48Fm$l_2&kWC!Ajbq;# z$l`E4&sm()`{r^pc)yCM2eIyEe}~=WKH#wg#!9FhSMNE-ZARubdNRT9F|6$w6C}(e z-Yj#yoHnia442KiNBFV{a&3+s7IIl4RL)v9{EXfD8j31`QI*$l9cl1RVs8ic91>xa zI!oT}jY?`^4vc57OKKZ1gz8S2j4`Jtd(Cz#=J025j@Q>=^w`1Q?333iE;Cx!{h2r4 z&iZVSlyWNLtvh0zf7l#TJdl<5&Xe?>7RTFFWOJhVOj(>#N<pCq25DU#Om8UV@F<qt z!$d80b#-N0o8HL%356V^Op|F(IFMh;7?;?%*7^ljs!T$En!YI~;BA8|mJ<1!I)(86 zmC8}Ii<NWkOg81Tv0|z!GcK7O@{-`qWjJ(c|CyPUA+`#kjdH#f6*%Oy+4eK`GB;&y zWsmKt$iqWc@@X6<KA1Kl!q)9+ciP+iKMLe%qlGzqZ{5a2>N+!}-vN==xB<5#!!#}4 zBsLxZ;O3Fd9vK&cv=+Ev8Y_zmz{YG#wVT@iS%WoVbZ52Od-Ik-$Z8CPEqMnUImGBu zE6Czh-1=P4_kqE2*Rn+_s86;zETB))4I!!Y$yvSEvYPhikmZpfX7ZtCi#tYdR3iO4 zpQL*ukZIW!G3A8TrZ^akGjsggzd)DsEYY?vA8B?f&BBziZn0$PFpG4pNUeYeHG`VE zgNQ@zV)=NkceqS8ySvgT;+0mK@T}tSz%%pCCV$bZ&v7x#eNzCxgPv+na(D@`hwr7S zcHhL}4n9d{Z!Lc~jRq!m+wYqxKU5*_v^mNswR)bq`n!Al1Ly7kCU?2D&&Ss*illKt zn=d@PJ-{q=KIH_m?iXo2tyCQBTE@HXb9bQV*70k@0kevPHJi1WAefv+(8<zVUHwzM z<rPj)xT?#|=!~vAIWi+t%^?rhHv?gLOFDaHqk+74f9h_l#=UvkpY@^XI9P?j@0pnB z#<4As-odxtop6bcy)F0yGfmBw&}%&oEU^59F+$6GdfL?iHZ*O^>^IhHYvB`o{VyIU zO+Hg?N7%8~21^J-Z`ZaElb<KtQ_{*Dk6Y24>nGV5!M67nTN<^j{p3hT)p?zspi52^ z6ERLkTH4c9JG4N5TT0d#T5-1+bqk8{eLj)!)X2u+PiY*WKE55_sQ<iObF6LEx_1<S z{#!4So*W+!p%}s?bdwh_|CiEN(Hi#ABp&zIvlJ$##%u1rq8fmikYi(c_-TqozTtvo zOmB?=U(j}6KObRg)4^xiBJ_XVjNHGc%crC+pIzXABO}-xyAp+XFp{`gWDN9xfH0$w zrKMsp0hM?nT}(V^my>IgaSG}yOD->;`5J1zsf8L_qfH{|;=241&}$4L;p5G?E0!YA zQ(PMXox~1tz39yq2Wh4Rv;QJ)t}_{&c8zIeU3mk2&`zuA$N!O^9|G>c{EzC5%y`1! zhGz9KpzN3}=B3cpZW#ru;LDxfT(YK!q0hy|>b@UmjrQ@0G+CH8xck0NaL474rL;74 z(iK-VK_ex<S0^KF$cM3Q+-kN<^Kj6`(O7|#S-aLGP-2adbu%kvMQ=~P(@Ql#IKn8n zOkEKh*->Yg&04d|PkT&E=~*~R#+S#s^zF;qnYfB}D+B_6Haaa=Hy~+X;99*`%cLn^ z_+H-NX`^6PvxZm%qujH!41Xb7+uC?VI&+|--dFDUH09^SCnxp?jjF2tvdh1RO`duG z0s9R5GK<LtY9!QrL=C%7U=Wt7JruvN+hwL-vL7D9sj>NFV4h9d-Yo*t(<xO>ii(R@ z?xx1V!$ruai{&6j*0f|M*$G##+rvd1APmi{(;3%iOJ(fZh=}@Vl8f)m?-fqe)Ik|6 z&NcBfdn3O(Vs<*csq7)wm?iEQVXTguh8o5@M%~}kRBjjR_hnOVowzNlSJuDV33zPw zfygp^C)oRsrKDN{%GY*e_W(Li@M-jw^PGaVoXbX+mCh_a#aT_Ymc-7S3uALFrw2uJ zTmy2c^a^qaA^MBm%RbKz7ooN8lX-T_x8Z9BM80YqDQQ~ai}yxW8@F@KL|9>J?raHP zA>9?I*&6B`JFi>t!Qy>^TLq=LoP$?d1>xUj+LPWlC(gAblmKlGl%+ysiem6vhl(L+ za*^><NlE>oFZ{~-TWYgY)AL;DS4(U#WY6jTNo>w*o--p?D%HsIu(0M}2Z4_WwIXKk z#eK$-Mg@`JM*6$bqr$@{<TP{I)XJ`ztt(JqDt5Y2Q4G8M7Xld5<f?4Y+2M1#f<tr# zKNzh)UzSjH&Uts5)#7<o8Vj@5=0SaFiZt?ij96dav2D^aLmPvo2p&88-9AZ?KS}%h zemcxgas@OKx7_)cH_=bLKC9d~^_;hx*|<zh`G?@;QQyg1`O<t2r>%L2@>Zs{{2^9z zqqXzSvz0)n-_h98l44Y+i+pBkeI`}aa;<Q_Q6@L~<I{*M1~FMYy##{onySJv&(^X~ zSEpgX`aBv&2n27XyL;aKL&|8X+><wI&E76%OiW;W{(zpM5P|%`C>vINQjzL#L4Lji zSkR&@D4B9K?U!LrnXfmWs+l;r?>_dDoZg2az7Z!6@2{%*{{;L$YRYLxRn|JUou;tR z9Gq5djix0{*-%oPp{v7u&F^%WJte31!d6wAhNrQ*1tr-Fn$Rih=fU8{LUPIc+E1)b z+kfmKln4Olm&XlwR1xwhIkLEw6_mqIRcFeoy7+i4%uGz;QW@Iz@HaPA9;mbxG9n@x zrWW0>08P)_Or(LHug2|GQaxRQc2ZMMY~3P!e+X&nG(Xj6-JmHWx1z@pVq!kj?1uI} zx7IfSv?YtetIZ<c!7k&?TilNZ4MfE0G&gpj;oF*8Ds@wku?~1X+D5Nqrc~04i;4<R zQ7OvH-^}cM6cErCW_aNBWVPK1Z-hY{jl9cwf~DSPo2jkZ2<!4AHdP_w@;9eb{tagq zm6~-6)iF6C7N+PiOf=YLC$04{m+i`M_(=Un1BkVD;J)<qWO~g2)Q~tN<Rt~i6>>>c zwAJ|09fMZ4{f&f=<S+43rkD}8YCP0%e<lAuaQ}P6*4a2SlCq!M>N*lVsF8ZTZk+I0 zO`}Ug^j1l)z<~P!A)(2q_7sn9hW<bKF@e2atE9`N|6OLakD9ZgKDA+E=MAIXoN<|~ zf79Hs6A^`aKsWLV;~E>}N)(rAn3(A6Z=XHoj{6}*3R`JaH;IYtxQrkByE-uT@}a;7 znU%(FI8>*zSqlV82;Y78Q)QPz2Z=9`F9H-#*P52@=IXdaU${woyYt^b>lKt7wv|*q zTgHge1@AVsy{+`Yc3V!Y;p%!ZiW4|2n8V6CSF@&~rZ;2ryL3A-CSbF)tnBpBqPMu) zy%QSCx@tf6`m#(xJlyjRn89@xdrcj;f-E;^eGp-PH-1q$xBslz_e5c&<#2YYAQ2MP zq*<qBAG0;V?v0`&uRdvWtBer5H1={yb&VTmxc;Y75s+>}{j;p%AcnNPGAS?W+#Ccl zEBVR=l#7s@IoLCvGPfHxu5AA6iTeMMb3VxlX)3W9VdROapB6_(@@Gpl&CFgjq1iRy zvRS<4+ELh=OG+=#HebarrAo7TvBMe9mDFk3pyoxt@79jeO@(oM4Wul<6@)x-z1CNM zM7Cf?s)beaRLK3BCqu&OssLt}##l8rHfYcL!|-MMte(uL1NXDD&>YB*?P%APeHSyu zyWWwJrDr#wd<hF42TLs<rh%a(s0>!ms``g2i*Jp(-Uj5Z&muQ#!NS*VBeLnXyU`vt zOuSqJpDs~3kT2<^qvyTKgd;#6qrbDJ!K0qkH4@VtNbPzsb~(Hq-?T+W22b6vS~CEj z^d!b-nlC`|2=VLRD?jJJ#HIfgUC^ZC`aJfQpr?(e2hPtiUtV$t7~KRuI5J6OcelW9 zNChX8d;qG^`VPGTljsj3GJVq5O7&du6HL$!EL4RtdBpEj{{`<q>1S+NS-F&gSF|ZX zU7Yfh^sNmRHYk`&cG3eIyRo%_J;fu2p&F1=76{+EC7=@ke%`Nb<>5KIaJ$apgbhC_ ztG*(3N&Pkjy>^Z#k0!N{j1FzA3bY~8ohmD(0*Bm_&rz3Sdq{k}6@f_(43usqsh(&U zVaLpljYVeNaLa|dink~2-WCO%Y`QL|Y?G<V5$$d`8jcw-ejvD}hb2uP&0_GAB<gW| zN=X625cv0ooBNqix!=-!xxDRMSx6F>hcS~r;Tf~O(gm`BK$h&@pCxTw5<voHz%;^Y zvc=??rInQwd*sYaoxMK%{9LYI3LkA*wG~9+`F3S8DK~*6F;_d}`Wslmd3-$G-ok+! zF}ssHKGK}xjpM#`^KGW9lEfh*Dj7Fnhg^%-?K9`5qYhe#(R}hdTS<n(VHyN09O&Up z>jH#(0_x*EH^(s(Yg#At_bL0=@BT0(j_8;3B{bAkmFG_k2zaE|%sJB(u?c_LpHAbu z3wVv_XMZ5z@d3u1-i}XAhpn5Hk>-5;R8(TaVt2_&p#L3(jEd{wXU-5Atxt=>njQHu zBf$PTnD~N8j6R?w¸JT{(yYWGhdtD(wa^zD(9am}H`?>#vUr^|%%Qu)Tlq?t2? zRL<SbOv);3r)FV!JG1*m<l!>MQm;&Adzaib;D--18pl<$-s}5=irv5^yUMDG9DzmZ zCp~5D#e%h=<}<-_ft9hj`<1lZCXF1MxR_tA9d27FAx2gQpW-L__&v#NcSagLfNzwq zclHJC+NE5itX+5NnlGfxKe{WadkQ*HuYm)4HlA-clO~_A6zbI^SUIiFc#{nE6G;J- zE<oX>ye$I}Yxoz<^9g7{$e5KG`MKEmj?+Z;+AV@|r^l*QtNM`}d)rThIT*0NCz1a* zum`#>;tvOVemrf}h}Cp;T+Oecvb||7Kdd9<ddU_LQtW6a4Sfj`TGmFzY3A^+97(G` zS|x|{`1!mGN3gCpe=IHC|7d+qyJ2m5ts&hTFIBiBn_sHt?`$F~<LvXY`+0JC#!5z2 zzX<_){3S6i4UOp0f}x<?vkg<sz`*QQ&Is&XUNJf$W+&!49bnOMQhKgBm`*Xu#OA#^ zWAe5K3&HGu%3m1x1e#MLJl%0>y~3^G_hJ8WR?EAjgF6}QW(gSg*3(j}oE>SiA9!hR zRy>F=oWMsK*jGk5d%xZCDV|DIq2Y`M=pBzF@x^F#wM(j;UX=C6c~>VT!DUX$$c7V` zykjx>I@;e-Ugrz_xOc0Z4%qZ6CI$F<8O*!RRgz!VQUo9HCQYoCxHUKM=DYBR^wALn zg-nAa&DfAaK5cCtlPfo@Mbd&9E*Pp)82_tj*Hbi95>t|Hqh+~Azrkih%P)&<uI^v# zV6$l^y_JNF47p!5q~mHSRp!NzkMGyRd8pVP|4i&L8GmxQW<u)a*SkA9(D`VM=IrRc za)l?|Yi&i~JuXOF*wZ^`!DuLI$mg-}J!Vya=mDEkJZC^=(udgTu_k>fIj36)I0of) zT{oOgy$dThQ_K0NpB4tYs@&V>2n=<nqh+6E9|#EeGYYoXY2ZAXtL?0`-MYbEBkw#~ z^iwm#&aru8T~4dX6y$z*@9JjZGFJ3Sf$fOIFy>>^{<QQn%X6n|O3Wm>UN2N8S>(XP z#LT`lMN`e`WO20oOx82V^;i@gAi-IKj^9g-M?=PRWoeEO_XR|lSK;jV-S7*tLXp{+ z57A(}6H1#&dmg4;ifm&9+{a9`mK-K;B81eO46x6QbYb|NZmwNMmq~~x4edEo+6~Pf zBrcai3c>N5x~%l5nHARn_;-WrKJ0(9f!{R|<HlWzktQr*jCm4YMtOPcEg85!!775B z?w_4*TOR}cbEgJl26|onuEsh$ogjBT1+)3fVS}(awL0PJ0TpA)mbZ_AeRiK`wF0q3 z6luNMl)-8{DOv|Tp)&rF7wyQ>l{x87`|W3))JQoE*ap)%2R63&%&Balo5njL%4Zr& zfTy^T)IDqfc&6lPVZldxzVK>3Dj2c7(YWogURJz}jkPTr5FLrn(*PVLt>Aj;G%<v1 zU5zHY>ha5tF>Y=d^6<5)UyM3Nh$@Qu?#xLigd$I=sFy9PXsGY_Z7hZSm<pJwQB8fZ z^P)yf^;;TBIc6Jp;;JZKdbz>$5w#Xm*SH1k8^O64HFjurC@$X8lSsP<TpzOnubG+3 z-29a+w?nwjPMfc(l~k1H351-KZ4xGYDrQ%JXihKoXz@2+JmxrV8g8QEg8nY~zdHX5 zVch;5CsvjC5gCM!19`KF$F?cWwfhXE!qU<cAVNuu1bfWm{m@1S``q&K^E$BQiXWca zP4TaXehhpT7loSBg!|L-a#|jbx%xVolpr-46I$1F%w%~M5md3qc>GOQI0?2`)MRM# z#pO_d_Tj^Ue+lpj-Tt>`tY+3$lcBV7!rAI({ITkW%_Ta7Z_)(7#d+PQj3GTz(HF+R zc9c-2u8C6Os1g}u1b=6D<&ZJ&u(ckC30U)0y1SoI(jE1!7#{hPY*>+yW613IXRe_E z$>-~_$?WaQygI>%jJ&&mj1QObfuWgt4paX_(@`~zgz|qX`nTG<HYiJ5HwlT>g5tJV zSle`T6tXFjH)jG0hKf>w$kJ!v4i5@tIcelmF5mAtG|#tEnb)=<sQWt^z&nyq+3p<k zU?VMPwt}y>T>dae*PlK<efS)GsNfDwc~_)d73`k0c=P`>>Ae5r8_y$X$DR_igd3X5 z0y++Z9O-m2+_2}?$CvqV8BcoSMxIf%YsmvBsd$TEbcgLI47<3!X7tnjic|f8DEy6a zcTJ%1W1fjKH^!=$Vp5T3>r**GH61bDrLOf7;;;lwc%zvge7lesWIpQqqXE#!OKIqA z2Rpb=6kUugj!IF-#%MrjZ-UU`JPp)O!Bg6nak%LKll^#l8eN%YED9U?ol($b*h76E z@^30M+3A(X&Fewe^oKKbY8W(4qi;Au<8)?^g9dBL%F>QEU$1E<mKIOOBLT;dF|H@Q zJ<IEz#bqDUKG|;xrh-&iM;70%UJ)l0u68tod#8auKw!@}AI8|EzOhwpfvWcxWRyY+ zFCh<l;5Q=5NQ0Ty7l$y^UT7Aj3#Z2;8RugiYTQR5N~GQfb{(E5!{Mj9wJ5_^2KosE z-umKFazbfIQ`RDN8ylYVIZc+oj4qJ<AH;O!<<#<u3Z}KGVgb}c6$*|^r6r@?k78C< zr&aA#2IP9e>cW?UFg!FFVtUd|PP4|Iwk3NkGz!Aa&1cNouFSjISA9KPXnmpz--19$ z9V@8HL(j4ZSSh>5eEf(fp~AujX6du$`dRa?ibZ!MSlE8Np60Gb%EfbDvETM?dk155 z#<=#f<C5@j8q0|EgJWqX5`6B?a%YPSR>jC*l(?c~rcHz6{gkjp@AE;!S%}x(YOnBD z$~ggze5HK8JB!hwefsP&)?cBy>x*eQyB-X_ug?@+wZkgZV0XYVxJO4SV<jIWBW?9V zk=a{L0nY-AnL5FzXTX0taI&S;S$CAxRs^o$^)HVgx<?d(j0~nzUj|=xj#Ct3>cXC~ z=Raj&Ko~x9PMSi-SR@7Y7^F^RCL2De${I#CAgU*GwoE3HWDSm}Y!lckO8cQZcwv`| z5V?Gy5Z&Soz!CaO<N!7PAw9a`z&$KIX<gws&skMaSjqzNj7Y}G@rowmJQ6*e9ZDsJ zbtPhL_lmi*Nq)L`^ZdSMrW7Q6Uw-|mE1Ux%?Y5IrCOm9+p9+Pw_&_vgerVuy>K%g- z7KS?=Zq~P22Bbv;B#>_RXSZ~*(XU65f$ACt>!fx0rS}O9ys<$|#TS`rPeadj&j2BQ zq==<H^LnM59bQQP*_6H7G+N!SgS7ZZeSdV=213alj1Q#dvTpTynV0wTdqG#DuG@nl z?bu4hDs3}Qo|NY+)c{u?)2b(7<aqJ2*W>Nu-qmmDlAk4AVF7F}lh=^9{W+R+a9Df) z@RT@9{1p3vNo0ZgN$yc2pdhtB_4(3BJ7F|5`gTh+z(+CjqwAdx+3tuxj!ZaUFcNL% zbAC(T#-*#H$VSstucqqww{u{bZ>=K@{Rl5wOYCqZHso5UB1u^HX<Bwpy}M!@!^vKj ztZfr4LrVZR@L$UB|3&vd>GS#ML*i=}b<WgDgPL~wR*?FxxsL`hJ>zhf&(_fL9jf1H z*yPQenhWmujU<+wSUE-aXW#BMA~E0Lsco#ic2X6UIm@=A=N(_gEhZ@f2c<Quz(<9h z_KJ@D^WU|tAP#Jd7!_u0GX_A+*C_MDPa*Z$w%X#;2c_c&+F|h|jL3K)o-hWk6aeyo z*W4A>+iqNbD~D6pv0x0C96!<=9uBKkS{b9Ir3Ky4FCb%7l4Bg)f=X8f9_Gl?*cTZt z^<c~S>IgR?DKIew=a~>beqA*jWGOShswZh<9t%se$b8<^w7#PJECtIJdXIB=PKip0 z*5Q>lWG;&H@^CJ`IgHYIf7;(Q=Sw;Iy^wNouPx{Xc3e!goX{_WG7`1~e8_#)V|({7 zDAHH1YFkyA$gHFF5RqDU=~%1T_bC|l?CT47^P_OHycFg{hnu?V;>DDZ(=@gglx5l~ zJn%{G6tvj%st}QQjXM~p@G?Kmr95jJ-G~W``5&b3|0myg){zOntP|{_@L)RmC0ze_ z>(JMg(>`bl67+Vw*oAy<@Ejn`j7}~~S@z#OsK~Fgd;-4W8xS$$kJ*hhX@vM#e|R7C zoSn|-Si3uXTfs2?KsA@mFGTA6VW&cjQZ@7BwSoOCj56~FK$M|?_Ve*%rS0>9RFKCR z_-D_lZ=1YrLl%r)@5DUcU*wUtaYH=**qcnB+3pD;t*aB-6u4B}+Vn=jQ->ysk)5n| zIljV3Hr-2u*y_&EPPu!pi*Ipqt@Czh8KYIB!p^2$rujlt1>O8FZVHifbgPL0P_Mw| zmj&MlaXpGnR}kdN55FuewDLi15}NM&8vmm@=j{u`1nFsQ`Tj`UGYX}FSb*$M?(>=O zYRPeSXJ`@b&uYz8Y4x4Op*#_SzGThFP6usMqLuA~5xTvcL<1k9AkE7ShlMYDO<fP? zztSXTojms5Vu}*BZ(MQAU3)6#bhQx$H{5ebkm09m(NdRxj{F~iG4y}+iHfRz+O44f z`bn=Tt68i2M|p)jAY#g>{G^ZXNt2(~yv-kz=NK!q1^am{M_f2^t-{oCBfABo&~h}Y z$*EU1xNA47hT7udONVt4>6Fa)goGSElbXjAwSLXDkYQt%XHiXa9~m<C`7`abJEyc? z2$*_ZKUxmcFQ-b!%MQ40-hQ=MD(`g(y@?73sDTs32Tm0p&p)a&!6sw&G(u``;}C_s zSFO?|UuKoRhKPlH7`deLRMu0O={ov-oVn6gQipdSJeg;hFtK(y;aau_;IYJyM!K^z zdpJA0=ssYRM81HU)Sh!bP!w^pb%0Vc8E}XRP99a<wvMj%+Vt4Uq;J#iT-+bqO|;9R zq8o|X$PQh;L$)gRjO|K1nHOz0z8;j6IqF&>TUt&2n4h<7{B74=su7=aQK;Sg^|pBJ zrG=Wt?fmP;OWWCZjvorDK3INljXZOB)p*kL!m!T_8;t%Amq;=HML8{O-{~^Lf&-NF zerxJLh7KoaUTFL|(dFs2Y`bA^2+I4S_>1|7bKJ;xtLlE1lq`CsZ4!Uu{nb2WmIb%H zZ37G4p8ofR@^qbHK`Kg#lM)3T*`%EW8IPA+1&=YeAX3!y%s+A^@^Ee3GC#wg2Ig4H zJxwH17bv8(l}#qD^<@wak-2uUi-X1N7^z1@K#0L|s}GYQt@ZlDrh;@tub+Kh0y=OQ zYrI!RhSrB6qv`2BBSYtfMHX)RoWJrf*Ay=cvU`W{243yq8suKo_S@iLGb6-=?Ai;1 z-2+>r+XZ(j@qI)*ua^l}274^GV&rJ5T>CjZG*^;wWd3b*#wt9S^0MlhM&@$maH-t; zf>pf8wUHZUUG_3fE-Pq%JT&y{N86w+j!nqcSnMb`D8{WJ+kWhapa_9jO44G#0X9j{ zN7aJc`b<t~rY<G!wN3cRe5Muda5w61`v0j@CCXDU{)hMfWfcq<-U{Y_2mHZvwv}-E zmT4LqNrlDTLv&J5@ezS5(5iKAZ_8SGReL8eJ9r->`qE_wd$s3A@yO{76DA<UaN)}V z|Iw=ci3DoexH~xP-1ST)d!5t_Gtf(sWW^mp!bPO@8%$ov?X>kY$4%3pf^|manszR^ zd^VcV*WE?p{NiB8kXIx|m_KXP-fZXOo^=^W6^KG|ac9s`cj_oM<k;?5@S)M?;m|c@ zwU+=GZ_<u5l~7RMn%TxWl?hPI>y`^h?y+<t!bmn$VP#^eKEUuZj!#JH?6NQ+&1ecB zz)DPZ&FExjpaR54Z>V)nMzqX>S3ZM^vONvQG`yBf9_gCwqLhbj;Ok^ZWZm8yC2@2l z!vsH>2Al@y6BBM~Vx`b-;-X^MQZ!jbq1upT2_9kAet(IZTPK@%zk;Uy$Ct2A2UBi8 zwo;+eE<fjlvhpW<!bnNp=Pc8nC_nS6ak#i#%LC76|H{q35t+|6`msF=;luvOHA}+U zF)Plm^nY{ca>j1Ha_%`k51C$Hn_~RgJa?5^Ugy@!zy;gD1}birSKbz+xL6OCq}qy` zUEKDvDJM}SKd`=d4j&h|q?48Zh+<CV*kgR^@Nt%vxp3SELx9`gnjcFz8nHD`-wP!B zBuRZq#KMjtS=wQCB>I!8;&e3e@}=O|6BTD7r}HQWSwzR6&B<geXc)OU({io8Abts4 zN4TakNzP%`pUp`tz&B)Fdbz|*S8<nx#D_+ptQ^MH3NW+v@V%oHKyEB<x5gX8&v47` za*Vn2h;z*7v0fsVD`q^CdyEg+eUSuDGwbh4<X>s)*FUdYO@zv^hF~*Wdkfg}vv~e# zncny^NNCT~<_gY4_Cv%!G?u-}$|6X4Yemh>HF>kE{{{KuA=qxS^lh~<d!o5K@+Y$$ zAh(CgHE+T<AD99FrUQVfTK=Nj_Ag)WX{sy3uF?+~v+8(#7Rmz(P)6acQ?_N9zBkuZ z5Vr$fy}tdEojLGgZnD_Dk^FN3a#16`(z-V1qe)6Eza>HF357p8gkUz4gj@=|CtZda zTe=a*e*W=G4ku$gy36bHX%be%s*H^0s7uqR25pkT9Xs2TU0=VzgLvA<_Nke<pZvrN z74SJ9E`Z-U?kt0<63WYEe#iNSha-sSz`=#KjvFjEjIQAj5851YRX)khgQjk?<unoj zF18E7bckSRpm*#%r395G(~+tDl7oqX<8T8qUk!g$jd$B}d3|e#aqsa}$|fNz97zHe zpU!0jt!krCWX5CIvsrcNLDp52;0Wem43G1nRxd;3<z<gRD8Jk&8vBI&$?}W+cy|pi z{0wUcX?}-qUK~r{%9D##?I;P71V_ebath`xjdgi`xy$Gm&4b8&Tye65o2W7DqzAqF zzak4D!N2M|Ve8l8-|E|@To;CK$rqS3_V8rI7LwKNg?2Ir7W|tF+yE^*mP2P&Y4Hq{ z(s@dn3?IKF5HP*h)Yjj{{FR$um%2~gJq>2Lb6BL6&C5qvKuZ}7YiZf*&&c?g;z|Bf zj?7zb)w*<9Vpq2Acp{<xZYv*ej37uKM$q$Pdn^nrAsCI3_C-VBflpWMkH@PLyAhGg zJ{9-$y-%7(VoXKTFX<$s-&vPq9BCN_4FOpaMZ747d>xWh#2lc2_`3EQjNa-s270=h zWm&3(Ufye%pwEy>&`(;x?=?lv`lda!5+$d6euY3{{PvrQm6?9|(y_f9@?JX!+zHh} zw%}q5=S3i5N@!aP2th0AT&Z~?yGs7yw7j0>0CI@?3FCPI!wJ8=veV4ah@tR7zXSU0 z^PG`!ibA;ErN532fA;)qelWP-dIPZyFXV;Wf_9U-P7TwUaJ8F30PSxe>--C3i-8>n zR-rBih6%ud|8v6ttR;cCcJzgAj1#VSVY%I$e#(TuGRd{sIQ*48{~cw$skhn;TO8xV zG}!8d!%lPdgb4$X8MaeTObR3Lxy{R>C~{oBpu0kuU<RrBt%NRTR^&_T@3qt2V4Zgg z63lV!Kz@7Y-GlglC3Ty#_1-BzmHhOH?WX0Sfyvccor8nT8#t201%C9rzq$*vk0^yz z`Lt;jN$GlPUXNp~oH2w7rL1gUKn?Q^_IEr_&pdJW3{TG#$W`r@<(X+lv!dLjtXf?b z(uVW2m9pP7eY$^@Bm_dre)LAg7_$yWXL>wLt9JGN+y~nP_;<xRR=>u!v7ELB31z<L z&*CCHLaa=A5x5?nzv7A({Mo(EZLM6=^p1kpVejH0m|}5g@GH6*Xi}2~=70uFXJ^+p zfW4{VFv*_%-^}m~52DC5`wR~o0Irv)#b(4Mq%F-9{QlI-jLpGd#v#AjyBT1+YPiH8 zKqg2TsW$~OZ^8QSYLbTkNyyv%?ONZ<hd2E07lo&%64;gzSnlrbA%MtQmIXXQ!gri2 zufLG-MVsE5v|zgvDrKs;yDJvlDbgItP&~Hl>8#%Mwp}~j0c-5icMX7dK&Bd^?sK7_ z-gR!@`4$co&TLRlIK`hGC}DxUc!Aw0FwkqPtX`SK^laMfVpwsST&Cc`vxyYs_cpL+ zrVRu1`vMmL1s-j%_|okx=(K)#x{H+dNQ`|HAp<#RcML=la~!8s*+d~BPltd=O{gXx zpZ3d^be9M-NKp`;+~Hh6k^#}qp)NZvmfCv@na=^Sd6?qHeQQ?)hOJHeHb#M(RUNm0 zyFI@W5oceBoCQjaE+%dm#WKp4_6N`?_Q;uYt81~sTd&KYX>T{q^KCT8Bm3p9UP2$! zwS(!#&fv5Feq4$oTgyU^sJ=f|%koi4-B95f&!y>zWikVO*QTp9L1X;D9<_s1uC+nP zrA5;m?lyT5t>>Rp2Az~7EOQHTj*M5G;@$cA+wkJ;zx0(n?*ANm*>(GaTMvd=>LeJu z=ACU(Cg-O+i)qNeB!~-iGx;{J=c5iFJ^z71WQU}ErO`9)?<W(4@~gyePgomG&ZiES zc^=B3%Up_(kC-9!RvW9u69e~&7Jk`!!R3bUC3<%w4J`m${^UBudw|pSyZc17d2&Bh zc9_hYD?;eWZJ3M*Fv0Yr{RZFd>Xr|AR=@Sx<_iQ5@7Da0P5$<bH^{{uKBO7Z-bkci zi#1Ow-mv50dNGIORaC{;mS}BnZ9c5mTlGh^LC~*5bOY6p-A2T1l+ysm)uykXFT;Og za{XFJ<dV-{dmTEq&&KF%Z`vEP!fXcUudV+);k^dlUwgi=8(be980A_DOrmaL-U*P| zz%@KqKwq~4k=m@23^9n>udfO;HTStqv?h6~eA~-_>(jDA;vH==2FA2bx93}zNEF7( zZW8gpF5wEs2hl$>1Jh$F)0KY>w{pV*{(~tHe(Xw<S4R51Xra<I$qXwBBS*>+>-p|q zyL%x>3Ut?mUUg8Syethes9E%y?NC5(?`9B$KmAF~W~?ekf;K%iDycmO3Mssn&Tr6D z?j_Jb*;vZ?qAu?HtI(N124lZi461unLwo~ib&%8K+|Gv6oVjj4$5b{)Mk2v}DVO=S zQaR?AEI>u{G>c<nb1HLcvxv7=-2z9E#bWn(E{r>!F;&OBt7sBS#8U9S`BB*Cb1&ij zEP@XRABL*uR4ji3(@R+nSp+{C+2SN;i+X2?rJ#dAht?u#W3RhqyheMz>?T)jNyyPH z4j1kjP4g!cM#JXa$6(c0W#Mv24uALS=^fe^Owm!MVu0q==R`1dW^3)<X+r%aAqpo6 zX_S7^<HPz5{2=41zz=53al8zjCEhm8HO8^YiAjY7siE}x8n(;O)`WJ_l#+d4^uM^$ z{KM5;3W$@A_adjIE$m8WUBHINiE>J5uCdUrnTtp}vS}z(OiT9xhT+6)5w_JvML9!b zH+sNGT@F-FODQwT0qI@#1yurca1VbNu;$@#ou4jHJT7m$9STkxpiYtjuCO$!XhoBY z;v`&cv~XzbDoEvbUNl9z@@>uqFF%=`O_19StmtJyAAL2CH2l@5@)6yt-!Af;HFOVJ zE+nlp`iD?*u;rRHG;>%kwsTm15{~qBWU52j;S=E4<@FkKaOkxJ)o0s``&QF-sr&lO znu@;Gw(-9-iMpJ8{1kA>3DqmdysZ$w(qw!p??Fc;72vVIJmw`7KteLOJsK|8fv;6= z+DNH`>T{#>DDEZCHuz{tw>w?mNDeZsJY0>-ANNkvW!ZMB@t=DhcLwfpT61tBxWLVh z)5X35uFz_wQj?OE3^e5IG?q-5Qx#5u^qirGVla5$Bw8a(v6l+Na)c#`DXX@ZlboY4 z3Mi~J3Sen`c06qCe6*I7jHh0hVmN&rHwqzo=fbmjD{5G5K9C#5Q@&Qh;LAG-cy_hl zy4qj1CO+E2VYM2H=Or}kTj6nWdslw_UngJmLH8T=1_CgY$LS=eJMK<nUboZB<>4kp z9t8n6%SIuxYGrifsOv*rFPCYMItatBFN1TZu|x>7F?{-R8TYb&Pf|c)6>H^Z4-x>f znA+ph{N|<jQ4a#wrXjTdShQyi`xnZN7DJ0I)HNpUj`IXKczFeBR~zL!k*L%8<dk+d zYX_(`D_r2ko{?)8_l=9SK&fBJ@OHoEq4eB%FK&~@nw$GI^!q;8B{T0n*7|_wVi|>z zl8r;1r^N%zUY_6SDTTXZ0rQXMx+$CDfA`g!O0g|Gs`YRCSN!IG*SOL%#^yX89;iUt zrJ)nwwY2zjmbf`RYP85BBO<3J-N@<(p7(9R|D6PHgcI|&zlx9PH?Zg*`&vXBNTc;N z!9+46@+wCQ)pnA%D1?UI5zkbOTjyXa?OG`(6V`=U37;d|y@Hw5@)si0Ao|Unq%F<4 z&gbqjW0thp;>fiI`wtQQ`MkKqaYCuu5^k*85cs}O6cRfAXXx_D8te5k{uC5Q3)Nyw zK=SsYs?JBUGcK?r>UO^yTvN-b)p4~rhUBx|79&AYIi!`3;`0w}pVvZ2cPG+T7rq$0 zJ=w&Jw+5u`oVE#wO@j!riJE%40%?8Z2?%EFB-(!q9+H*}C)fp+cwUs%xDh{fJ#t2G z)Jp=-Z1<{U8Ui*m<^AZ<WJ>{`%#6*=w$?Q)3dHE+yOBz%nWmf7)j`IVw&rJ*hnX(0 zQ2Yk{-wDre^(v_;N!b{(HYz1q{9SQ@74CtUIThHuX2nzmW#(jeun#nvGut#;=}r<K z=nSp;nP55i+wn1F)}0yuc)WX(iAEK`QgQJP=h3^VrjtT|5BOwgn5Q$#)K@b&MN+?m zPZp+e@>+k+#&tWi8lw)6%#$gaFnF_gZ<5u-Ii4X$X+prs3|>wFxUe;Nznt`Sb69T5 z<3=A6T&HzJwwCfiGwnc&82~NyAHe0u2l?8EwL@olX`NbB0|D-=3}uV0v+fsu;Mn;9 z%7gLnC9*ECy3<V8VKj#SaGuddwqQ03<++L;cU&I-+6)o={c@8oNi(q(2z^dh6n$xn z*;j5>mJS^GKtTw-hpm$`0mqrOZ7op!Bs$anTuK709DxcFP6^ovc2--a_BUO6wXiVE zUHqSa{Eyv&%EHe)g7KET$aXs=<2anryBfC$GA5Up2iq;UW)$LWa&oR$l$FhmE})&C zciGu|zP?<SzXIFGuN@IrJD{p@Eh;w!sYvrH7dBO6MAm=~ho>0A7=>J!<E;yulS-_l zVKwEa4Oh0!xgGD&YoptIl!p5Hmrmc(_JpssPBfs_eS+TKO|M8w4`08OiwZfL_tKrA zL&w7?S651DZw$sJMUrO3boY`--g2mB+vqsV*(wJIeQpzM!%;VAe)K%;^B3$SD6P`u zbKQOZ#iD8h@C4OpB7PP9@D4&vd+PRfkvHFUsC&iJL~~iYEf&iV`;yAPNJz2TGa+V@ zBa_9ptSY#E<{bsb^nj2c6ZLdxwNh$SU`3cVgyVaKH2G#n<=p0<4zAh943dYyzKP-4 zsfEaYY4E)@ZmyM5AZ!<F%H<R7h{~p6n7BwergGSGUp&Cl_H1+9wh%+Dpre7B=QL?m zLBqHCO;tqLp7N)d+Z>%J{>q3f#hPCi?a@9J^U5gmB(tFmI}Z1-wfrQr?&lj<`!DH~ z%$JqsF*;#~gt{Qlua`@;qi5oK$V9!xPfzx!*S+Kz=|vxE&3Qoh2L#It!emytfJsCD z6Yo@QI>DyLw^Uz$ZjW37V*tAm=pFh-6Q)@JNPqTt|0@G8ctuIp+Dv>fRCDyGk-W|C z%E!`SbRb$rN)83-fUMN#={EjTf;O{H?5018*-m74o`IwXs5UKglG2`;rJ-~&fA>x} zc<(*7+XE0h7Cr*i(6%L=^0ow9l_PcUOl=}@v}4Eh{T`a@5m(-ey{y|@p?@fVb7aGZ zd}r3atcmX3p<Ba-eY{furN5qCQ$$il@%P{uaS@K)YL%bEn+x161Zi5Otb0sfDVjcW zslsbpnXkg63K~&kKO{7wfmBKwz!B)H-p`KKl4pzQHgWLJAscIA?87zZ`*z$aAurdP z`y^skEX-2}q8v`!vi894OMP|cy@N&PW*~T(iw%u+``4Wd0-?2ih^COj*!WLZx-tbB z@R)69xGaHX{CQEtBpEJ9C%oISfZ5_X;z^{chF*IIpBu7@?d{x_H%=qzVX#}60-amS zCc4t<K}bjvF;bFBY4KbB=HArMvf5d{>EC3r=i`!9`Cmh=|6;(WBOchi{zARsVLc_y zbC@G%<m$oeagshHzFB<7E$jXZ;|)bxS((f$|G@!azy^X>$m~ZE4OmV~NTRh1Am)Lc zdXLcjTz!uCcM^9^`mZkcWG!Q-*hpICEGtDGJSa}))Zm<OffG%7`4sRIcoFly|5`NO zhu=P`a~_#&PDRI{<sjxmfXwymBA!c#*mx2NynEKF;C_}_pBt$me##}0wBRM+IJ7{M z)gg(5Rz}~~<N7er1jA~!qPvuCY}MYv(#FuO>IP0eGAyd1bo3{|s{hH>Cgu^~%-Y(O zoLa6*iEm~zWuQCJ>Y{qmfi5(w>k9#?!7|mfB8n*`cyZ`E^T=g<s+@Q?{ny7RZpSNy zn-j%Oe$FNYHU6oTX~(1N{0IH&$$^!NSw|b{Guf`Y>m{(XqBQ`2#>`sldk);1U}e@3 z>0UE`OuM4J6Roqu1t&ABdRxXbla-NyhA>bA^Ke#O{uQ*y<0*m+(~f*gfcxLAGI>H_ zw8euk&qXU(36|*}7uMKGm|~Ip_yyaH;?uO^u&lJV^I)~puiMf_;a-fSLnh%qjEh|% zoNZhS8%{JGX9-64+9v?3Rcr2csG-G|v{!nrUss9I4DfSHxf^#HlnJZez>DkgPU`p> z8+zWCcZO$G{;!d|RaB9YKgai%oAz6y1>hj<&|j}^?|8o!i~1#}_g5khPus-jvsVZ= zuM=k3gN6%NWgH2p<9_;{hD01<qX`JC<Yz<FeU8%)oKuK+b$p-h+`eFDW?`KPZEkFB zK_^lrCYE_DA9MSc`oB3~4-M-+o$>R*s(`e(9Sal9dhIC2aNU#rMnkBltNPRXSY~$C zdiM(zM~hmm)Py({108A`6*Eg`I&VZ9_>r|*SWvxT*C=@-A4u@GA1N+kW}^Gbp)g6s zUb~>D3(xh=gw1u5WEP_!9O2q1uS8;;_$9C)it1sjL)H4>WIKjscyfqT?bCyz^Ww2U zBOM19nL_Z|1E#ct$-66_uiac6b#u(kegHJ~&81$<lv7d(bsVtnyhMoipQT$)mVqN& zX?giDWFe(EtfT>j^qad{Rpiwhi*N0MyPVeI3Fv*Rk%bhFlN8qTxdI+=g8{q?wMkg) z>Mrb5c5d4RkIRG61h;#uhFGLe*Q0>I=99{<mo`5pfT%bf+DI_O&qIhuy`DeGdT-6d z1AIXKK@)Ofv8M=GGW152=%u`aj!iOXUHDs^5w%=w)5E?(e+7#cuMe!L^gHcc!f3E_ zM?<3uObioL94tZ1NX>Q5oE(}noy33hm+rs(6$E=GS~O$Gy;eYR6W?_fdDBvLM<-)J z0vcpE{Sp%Uo|7Xn5>*a$>aK_Qw76$b*P9*|VpN@+*4i7~gyuOPS}aX_o_mV&vc)VN zWV|UyvSJ7#TYyAnYa?{^N>(M!t4cgQQ<$VWBk(^x!XsM~GwI4#gJyhXz~YE2oray| zdG)XxBVi|jevP7k#DjK(adr{n2inuMo<3vmi0`^$AdA*orc?b1z`CaG_(&0=P)5bQ z*5(taNf$VbjQ~NqXL<47LRdo~-*+h}C>aSht+M9(Z0nOU|1R<Dj+0Q`UG45+)Ae$8 zZKATGetM0;#i{!tJjaJoh#$DogU%uOHLf!vB`+?mP4W}1OvI%#Y*-r0MEebnsD#h^ zzLp2;4*9R80{gpvpJUQ<B#Qt~acQ1$kYFww-av8k{B1QsVs-^K?rssKSpGOdt9Ea< ztNXdPdxo{erB3FVB&EbLOqnv>`6k8<^T}7|%F6aI!cg&ZhB+T#ECvOc9jvj{P8s); zX+_BAY8YRQ=736+(_7kMRD2@3C1CXPv0WIptZQnb!Mz={Cyf+1zplT^-wWdr=1%@7 zSAC;@<UQ?ATS4;w1ETkId4$y(XONdPyt;u#SeJE+$|y@%j-Acj0hszBc+(6HVbHHV zKkjE>acmdSRdjXL=y2vtv1!L02O%M+^W4iaNm9s{s^{7*vR8bz+sdmsOf+n@?cx`Z z3?6?02qFtb?OUUg%Z*jq60z$=eIu_No0Mjvqe(1ph5K>J0;}5Gg+O~=f9aRUcmLXi za@Ye}n7Zf1?Fi${R1tM$2_+v|^En-7;`48sIt;b~g$*BD8fJ!-pf9=35v*Wn*Egy_ zTGoc6hTFHD)`9|Fu6u!+;8?NhbX@@})dFN)HIE_L!p#J(JHekF#i8AwKYmzRJVv&I z#P=%K<zygUjN|eSOU3AQ0^7kq;*eMS|6JJgKsp{|yp-4_USq>6s09*}9=**~ggYrz z((nZ52ON_F+fmm(o_O|Wg3%@G2=~K!Hh$#@w4837mhXSBeXrXl5}S6@R>(fpmzq~e zICC0Vnbw@ho-{w;J_}@BCrH3G6c;b@JVIr}38y=<J;PhDkSYm_0>*q?f~06hAE@ej z;$&<cZ`52YcQ$aP5WxZkd)rKk!b?Adlcl+-R`XuzZ-8B^y!^+GZ2k8NNP5s*5Ew>V zsaag0Aoa>KRLV2hf^@8Y31ftDe>n`3(rRYR1yJ5V$JRQdqG8K-6tc~J-|Tch5tU4m zUIkV?EW3^&$T<1&-1<6dzl~q-T#7t~@sb0fFx-TgY@_5NEeAjrlv%IRvC!u1Q(BPH zyXTK)FZk&>L4Di5V;KWRLUKft+a4y|XPP{tr6?olnZhclM{yC0-W!d5=XHJ%4xtRu znR`nZGK~?84I$NIuq)->Kis^2-asZ%G%7&#fAeJiq3V}Z|68Gmu;t6`R=BkCfYIHm z)ULq7b?dTHdR=!*M=Np1tP=_T1OO!<S!k5QZN0ZN!%rU~?&N0un|W-0x_78gZ<Jdl ztgw)~n_5U#tIRqFpLomRfie57n#JFtLg~M=(COsD;#MkSh%c?*lNBuR2r{%6dFs(z z`50)2q-Dd44}9M`+SasIb@Fk#pu#-4pSG@6^5s-QmBwN%E+6h2Vdn7e1l5w6yzXac z@k6$h7CkK-QA|Rm+l0Ex+HUtF(L;TMQt{J@i~|Ghyp2aadlg>a+Lesw;Z4m8V_;tF z|B1GfbJRs<b3|ZHSL2pkAQWW(-sc`Lo<d%T$QD?_ziR98g_*tkU9SUV{!2<!!_FNY zQ76PcaymM#a;eKUI;PLWiIixeE#6gZf>|!V<kegf9O?;qpBpQ}uMMF15O_mlWQA<_ z8Sx|K8SIE5%HnhkG}L3GIEg9y%gY`_+5QosLLhAz9&fDwwM3LOd;&CFbis%8<?AkO ztwmYSV=4VA^YGt@<@2E-D?|2dOZTqejUND;x89TXxlIFy-*+B|OR?4USyxRP4r(ay zJ%^>p+7~>0*`Jpso&eNpYRP`*mrS1ykiiG(p=rWzD>?<_Ko75Ioo%xN94nFeo`l>! zBKN^`QIPfuv-XYT+aNKS%Xabf(rUgfJsijn`Mr8l>>YRL5kUH@U6<c-t=UK)iwxQB zt~z#ZKW){4j{nx%JtnGhetjMN*XpX0d27-dWV!YnV&Sh%MEc3;30lx<oC)FCC%^dx zl^`Ze(leg!qMr8BKi`=Jd;+HjurxHVb}evmcQFuj#*UKn!dg2MV8q1#4-=M0ZBK1T zMTl!;|8A=G5`Km@i?kLx_2GlBbmwllm!(6=F!8&j$`|!_8+4J*cPRSaJ_P#wjluUW zB(o+OHz&t`B5&Yh8}UxDc`MyB&<TjNl;Yc;#@aIfAgQJ86I!SyM183WNlPpD-CjT! zJ0l@CEj;72eRznV7$t0P?ra<{@((oI5h?p7_K_k;OGb*Os%`^MV({XyjV&hlV(~JD zJBDFx1_14mt^I^d(zkcPieZR{N1cI^lNi8YSY=G6oRlPN!T7T%Ttj<KqU86gD4*PC znwB=VEC^cC%(r@U&V;Ln{iNL}y>RrbN8`P;8}gE3PTa{iD4IKnU)3}~ByH3+h0Kat z+W!14ZI!lJ<MeiA6Mx0DO1gm`ED7fRo`KnKEw#8K$4J_(`x0t|7XvW0HZrj_FIr?* zFQWah;~0l-Dkf14`}Lh7*e-OGHr8fh*C*Z8$GPX5`)`f5+L{QIe4iGg1nvwtc_-Bv zxI-rK+xkEgl8e!R?(_6PLmm5>Q?{d{J10AmeQv?2@tx-N<U??F1sdsiVer0rncPc! zX8i63SYrNud%VCT1l7qo;ggt(zt*d=22K2@z91<XsluSh&z&F8G&s6(Or{*+h_{#> z853Zftk5Pu&V-RMJ)Fegj`xYzxyQsseN;alf9~W(?^9yT%`rujI4Bu?5HVarudRG@ zf&g#ENN?|V*Z!u}jmhKH<?UeY9bt!!S_<mYgPYZP^?hV%j$--$<LVk4!|IxC+OUmn zHnwfswvEP4nlxr(+qTulw%Irh8#}otZQtj^{RL;A*|P_0*34*5E?@$2Cg8L#w$$R2 zP<Bj*`G&<k9R(TgrQiUFf-&s)P0N=4Y-Y=r@VLKAJbnB$^9x2vN(-5ZiZxhF@mGr` zu${(56v_P@@epO!_47a|Cw`*%gv_$NLj!-YrkEO%^ikLMg@V#T4NZmi)5)!{VMbzu z6B->;c->K>GBv#rX%DZR{>)g1T22W0kuN<<GaS~<EtrMBRDSn1g<H_dn%7xUYBrp_ zJON9DZyz+Y)*sC|vxW$a!3YzFDf=#b1LUcZC5;8|VMC><D^f9OVy1Vv2D@H0d4F}L zY-?Xf3k)PXrT2Ybgh?OkU4n^FQt|FrzbegTdfBM6a!{~Ov+3GPVB@@Wy1f}wB8ybv zy}H&~eECXQ*g{Td(IbS!^(1}5L^p|QUk4S5+x9$gl9YtqKC(bzF8Ugz6-aE_XUlW` zDaGBwzi+SA145r&_K@5#rn<bIo{voER*k~G*~<)`59ZVDpY(7L<Zql6OUtGFlYQ;` zVDI4U^xz^+QCz1fx&Xqtf*d8)BntBo+SZJP^{qxLz8W#2?aQN9>9P#{uTeR%s%qAT zJe9G&PRHNFaLO@UU{_f47O{tqBlt8E$apW#&Pn3b!U<n>F*kQ>7$!GjDn)*P_k?7z zPP1D(NwARU(de|eY&Z6wCUk9WFgBm9-sHU65Sa%^i;5eo#AS;h2@)rSmhq}4<!n5~ zAx@Y2*0U+)msJW9m^*L4m8UL06-owmRItwgFojV>DJixJn|eeO8-|-oAA;S_V<F-M zxYy^;Zcf@p_l_;;y~R*c(2fNgkjImV%OVM+EXeGqUM~(<7O>g7Xc5|b`kT_aUJdEK zZcm22B@ky@jxfAfSAiAoppId2b=yridJ+znmT`Qm+T5UvIGJ$$v3wU)!;%0S9tdHb z8}VwthBx`em@fFesVK_-oCC|bv%LUqiO^5>jImuzth5xZB|u*l2TEmtGER8Jx1r*e zZZ(}3+aF4gl)5tE<)Me3<2D`%R{{Ay!%{Fce7KLXKXp<N#+|@zpd>uOsO*Gx@LKLp z$u@X@z$64lf;`_<Z0xehlVtNz-?ulM2o@E~YbkfHN2#B~hDD_2V>g|;oRwuuz(tN_ z^Su}+Mi+?Wc_M%jl9<=BIh#p1|7vN4kWrQiCoSdeEYW&x^K~|Y#fjq!R`o8Y<Tp|w zF@<<>d2sx+yZ7MJ>~XXrwSam|?|WJvX@za$po86LicG_)n!uBnuekAvOl3*Qh^okT z*B(Y*%w{J=bqV?R7<!WIs*RKF-S@^kkNRUZUr$Th>3$<h?|tXd<@LGQRn4SN!@l5Q z(2i1J7nD@PDD`bqcxgS(*B^=KcmXl;m-hd!dN65raoJJdOCGBUatRZ$YgX+ggiIPP zV_6mnG1OHLL~xvV%0{|TFLe+st-!GS+o<vfCd8MYgS$|c^Mp=~H_0rY34{A0Wx}6M zo=gMkqLXBo%?-0Hhpu-r_|7_8DW~RkxOA|Ne|6FfZoC@;hwSh~Fk;b|t#L;PC*c(^ zkN`T;Zhq|?I@!#lTxb3YX?kUOfR1{8<dew~m=GI?yxTsLl*og(Lko9)HFcB!l69dD z2K#vDn983$+}u{@V7lI1;gNaB_!FZtxi+x<?$86n@%DRz9(LP8&$!uXw#ao;(SA`m z-vtlwbO$FFSJ#)~VsrX(hc9YlxBRsQL?|wPvPZ3Ox?^Ca5JtlQvN?eGi|j}+rciU7 znz&QcmgQ?$!)<&|wlnW$V>DsZDDJi-U}E9rBg^Opo82<pX0TgSlnnC0x}7*nQr1y{ zq0!q<#J|c2-oENUZ|o3v3!p3qAb|zs`Hs4hn8QqQB5voi$quyzdDsfR@<<pp$fk5d zBN-v)q~9Gne%1YZHe7tK7g~Lfqi2(*&tKM?$2aV(q@{Iznlc{iBvGA-4rg=Lb{)kq zU{Go0hDHWD<*zL)yJyfLRnn*`FzGxu;30ivb-kQBBz<m<?q6A4=)R7tGq^Ja4^`Ch z)Y03VY;44&r;;X9H`WF7ef^P74{1=q|FR|gQkB~dHn*~4n%Jn?_%IFZVg#~lkiWuE zF(C;Yvaf=YNrTtN=-01h*XB*KGdDKOAp<t~$f+XdWerVemdFaL%on=oMd%b*(<zz? znC%wX+&yYZw#wbL)IVfTw3l~B!1~gOu)}sq>1PodY{#Ff0@3Nt{7ca}ynJLC*#Z0d zz|wgW>kn0^uZ$SI7k1{X<U*_*p%Oy^HmwP4ol*EFV{E<~2ZY=HPtJ)6?>S6%J6vAR z8L7m6@Ull`aom_?>8>e<-93>sY80@Q89TMErCf559GF$lA7D*-ia#<~;^vNPq%6yc zebA_R<!TW90mt;{v$c<+Zf4|Suy^=rBi0EqmUYK>$p)7vLb~2|?W?~sB?ZMO6%|b> zO1u<_VdVCpUIBaS-GTmxH)6#X?tkb5>aJN4iAY&z*VAl#56e&Nl+W%#)^JIETL&wc z8S?U(7?;|)eJQ0;nCCKyGRx9Klc?RbEWX}4nhNEQIW}WBq}S<nw_9=?A}Pg&jF{!% zpAt3FmMPa|h!J1BENvuZ-<R572QVbHwHZC;pqa8Sla|h_s(_mpo_aJN5&9DZMP@oY ztcvut*{4Kw^+)6U6q0%L$1*Ib->>prl~tT&60Wq`ucq1@P>u-A=o04g(oW!+NJ_lr za~0^TPQ_d@5ESE^JKW=pNla>gS^Nmj1?E|wG3zaw5xFFiV)de1n6<VqGgeV?qEj-y zg{v5<PL2Ys08B*L4Go}d@GtqXd4ap8dEh|!M<y^I|1&PWfZp75{JMyCE;QU^&j852 zJFhj()PP|2su#6cgQ+%TRe-LM(Gf|xs2omc7JAHb7A6;Y#`-6+&Xb+eUTP$|M71vh zW|H~j8)TnrTwdJ<)!2g;y2|#zH3TfyJ4d?mcZi8_)bJy$Hao_<0&&;BFH}}kH}*zQ z9Q3fVymE&eEE;{AMx7pYTLm==zP)gokx{))_$Xoohtq<9T;(+3NCsER`K<ARUGK?v zGVZN%lYT?wU}qd3X28OfH!<)sen>|mwyeCJo?Ff$mL!3#cQc=CR_nHNtIb+<(_i}j z3Xz9~zm9`jt*TY2@j6&tStSfjIUblox7&n5$8K$HYGJZ1TvVU;Hk<1U*x%O#d7;F9 zkGYm6rOnWvHjXvh_+?gI)WKQ+uU4X=wZ>(SHt1qVBQjo2xww?vjl+R(Yv*2!EFswZ z<3k|a$(l#CKW=R}<Bi^WOiWg3UA)ux`S1EdkE}*x(&AxqUJr;g@^s6R@|7FkYS8KQ z)LPRB)0{uAhWM=G?U*U6#bi57ZzAvK#&;Dk8_RbY1>Y3oVLskKYNlTFKkQ<leSUFJ zL~KD<p~kt1%mXhO^O`GD*-zN1<a_zbKWiS02GfHEb3Z&ZU2xNXoI5%v*10h8zCJxk zueeiZeU`wV0Pr@H!%_hWsNIr&Pb?A2lnHkEGry==7gmSIzYoSA;=Coj%zt+PAH>`( z$bq2}H{z4@Tnwebwur{V;AUYqoXl!U28lxF8C(WYyEItTA0}y~KhOMX=-VAsU7LkO ze%36%y{_q&@cdz6DOs*1@X5%~8QJOjp#m7zR8mV8{6l@EYafVtjEj`D><d2sTnBZH zn~6WQFxUV5YT6v#G+ZGF_Wn4I&RkLCQo3xd3c-onQCHMd&j{`7^IWlsDTK#VPP3+h z#*acJtFU2c)MF+hYwLa2l_2HoFTAbVcF>b>mAnu^#vK(RcqPh2agtE}-+#eyX88RY z%guhf=Npv&Ri!|jM17Vq@2}we8X;-#JF)7fhlM5f4X@T`L%?#ws&2h=gP{#dM!E+N z<9_I2okw7|v+**Sp6#9#_><N#7bsTlnObRrN7d68#XQs<Er%wTdnpXu20Seo-zAq6 zjhD{;#OSHU%&C~aIx-mD{FaftdO3Pr0~sy4zgJd!sTRIhrG1ltHHFXH?{lw`fQ#No zX5)6H9yxf(w1-KD*Ag+XMX`|p2AjDsl%?L7zVuBgz2N6}{o6^3wRH>743+!^aC2lw zD{LMs(6`|c?fd^OE?iQ;UE3H8D4W}?|D;>9X;)rB_inJ?%^`6o;TY^WOzv_VvFPBn zt`@j2i#LO;Jfo<h{=@}InsI7qY(8w97)o+pP#_o@kgpKr*=%EnYA*e<0@0g$UyrtK z96cQ%EHcn|v|3d!JvR_rm9RUG2)e?>@hvGfp5Vpf#^E7mh6vS^e53*9!q@GQsB*mA z<#?i}o@ycCR8^oz3y*^WAyhfK{2i6iQI)8d7@0T4dlsJ-$^cZpKs(tnrTW7>?h_HA zr0SI8R2sXFlT=rYAY+F1+8v-ZL7InesNWZ)KUN*&N<_fcRP-Z@)S<=pS6zJYmc~NM zD{r+%zEEG`xr1V--zS`<Jprk5R=Pesavm3t4SFI-4&jFNY$G5(o4cnY+YBa*m{AiR zi1#Za;yZcjSXie=i;dBt9}2%(ys*yq4i`-N=CS%`)7-o4E=R5?^976;M|`-_;>%8= zNDAl98lA4y7#P<M2~8@!am$|h<yx5m3u{|4PrisBY_4WUxpwl|=Z89~hq2XA->}V( zpLCN0b6aaTIn5VI&|1i~s5DQ5rVhNvmo;e(Vq@YOA}M@$I+gdu#69$@b6ns0ru&|} zfxiVQe*XvL{L(2N8cj_xoW}{>5P8U6BNR@n9O<*WbUy<hrO3ib`Rorgfbvw2_diZG zy>>eC6OovZI>2&$>UqxSZpFg-`6??B6)!F*8>kl62PO7{-a0I6LQ@YPTF04%q)@KV z*}N#NCKVw^el|zzm25({)H%N4ov&=$H$Z!LqS1&gcH)y>9nPla+u%QfCs5+~Z{Ib_ zl$lN>=icb=(-{RvQ><BC#qEn3Zq33~7NWs*;@_k!{M1p-a0(^PJwf*k8;ekAUm3i~ zYrA_{fZ)s{@SZzQ0MG+eNOU{(AMn6`PU1^K|KrSna1z7~Z4u#z29zcmU5UN^B=kN+ zmBS8hJ{PnWy&8wsa;6tcR_<AjJjI%<+f(u<sN`-#c)3Gan%l=4mksV&PM%NoO_f71 zNU9n@BiD?P)`=1gTH&n64k&ghK|(e%ykB4j1zn3=bXx6rtp*_6gakon_#Ju-CPA%e zt6%8)Mtx>A%ov(PaeaHJI6mi=GZns)frljYh6mq49~+r4U>d|C<$8V8i`<glbFQ8K zwnyZ!F|&krf=gX;gj>pck}4q6su6DAo~E0^R0wmm+F$Y!+?Qa<D%>ro<Y<vBI~F&! zHL<hU7I#K_yJ9!!zgMh_5K6hN%M%Om;-PjqAD!_LSrbdAs6_m)$$SQBY?p1a#eyP_ zj#nBU8Y8}CgtL3!FS*w~mib^#TZXn-os`o1&6x4|-XE1MK#8T`?Q{*VD48K=Z5*zB z#o!Vw`ov1ncKgEdxE+{Uu`V%SZ-%_xD0|bvv-aTTu8#dX0gotg*p`$uD>SSnAsHzl zjSQhoXa8ifQ|7Kj$SYIS>UPL@ku2OC^*wl-SKPO&Z6Svw{POwXhU<D)waZV@VO<VL zDaQM|CovY1ny2g<?A{D+=V@sw@WA}Vc;}*d^CW_nyCBl2O5VTF>X)W})+(?zwmW|j zUD+%rR29ewTgQ6q%jhi~f1i$UWs>~Lbra5vF)%8Fo-{1E8|^_}(9<OmSFf%KdY9JF zsFTLgIA8B7_039grt5h>M!jM8XS@S5!_sQuHN6rt@zkuoIsa30ME^pu`n}ILabib4 zou{KrzWRu-M#*1mVuD*zzSiU<yxU0b*`rTp7e$sZF(FbA2qfspXRgVnXRXRs3?J&t z#IxNbgKuqRx59b1a)o@-SJr<@Q#7Ti+m5BDo^S6p2i_LwHF*t+3zN^8BHeg7Je~y9 zsUNB_kFVocXSpZH_K0oS>oT8?nFow(bhtqeoUV&T|5!4xf0j&8*F{rR^ZlHLdH2|| zx9o--yYyU=r=YfG$J23lN$|i_|7=Z;ji?j+0ZB~Qt<LL-#x%0xfCDS*I^uj|lfv2! zN)woWWzmEcD^(*L&cH)k>+uH*lS*%DSeR2e7?lpP(MPM^MR?hCxFSwy5}f0c%1pk! z)4g03lt7zoI$%xBhpv1TuY?ABJEoTK2OA)kn)Nbwg7?3>Uap#m&7z2G@S1gC0<Ak- zIbS+Y8@CbsMqu$7$My61CMJeL##`_NQd+F|rBL9lstiJ)<)P?g;KTa%JEPy9>IA|g z?jz-#NbG6t5|T%YZT4B?xFCigz~B#8b4N0*+VZ9q8oP=oHkK*dm6CaItLmOD3aa<} z%D~@?*nCv(&7Zk-IpXf0#wp6rg2|FA*A!LCp#vkQp~_<Qe5pY;E0)bykd3+luIS#) z(-NEf6ZE60`=?uj4t$<YoUGy#gi?$BC0oORP&>%^v2VkOZB@@V>VdZ%dp9qG3+qh} z-;zg@Y4)39Gg1=EPqd|Jtc(9ZmLMvB^;15YAS9r;$UjW66nP2^)Xa;+Iy5qrM&$#F z8p`u$r$AtTpUV4XXnS}X#Q0ug;W*A=F#Iz;%?Iq+wC!}_7`QI)GYeLK-em(g7tiG& zH_K{89HL%GWQ&aMVyuoK3c9M~^C`37==6Nf7>GMJPdtw5w!mogSo6i(GK(RK>n1+L zk6-1|MHmApIo$#y&*){%dP)EmdKZsE4hRpku{hmplT|^dWc1VhG#&AN0Sy2NVnec( z*1cT{KPox@o1)Dke*ph5H9>5MI2USeuKOz2Vcjvu@qRbo+@F9oT{kT~ErQ;ZsV9F% zt-+3IV=7bEPPG=Qr*)&%m5oPegm6*1|0(;+TqYhfW-8ir>Srf{PF-=M8gi*@iwDD@ z+q9;Wc%sASNMUpFGUVXfC3P*raJNgi#@mU9w0)7jr>kR<T7~#u$1@hvZBv}sr?4b) zjVd(km9IC-foM2C05IM)1*qR!3}0rb`OfijDB9l#ji37bGZU15-$=0<!1ai|OAX3> zrX?YD%|tq!Qmqi0>}+iQef;z*L`htT6<-u*{Dl@5G7W3N_X{FB@2m4@W6?wdPWSrY zZ~c8ZT-ZBh-XMmEyDg>En-i>cKE{;e!Sk8AJs(|7O;KlBl|pHpI+GswAPDD`c&mbH zD6Q65)$?&@BogYx?TrB`sz7o`#bT>)+Psyh$)(Sw-w<mv7F_t`6gt5wD?-N>gwVFt zRY0@ELJ_;x7R7vAG;?M!GkpmE_ZyUG|EN$PgK=>&>2aaf@9BaJs9DvhOXGi)emML2 z^GC<mhXd1c2$Fr$Dz1*iZm_Gw=$56*8yn8w?y&2g%w77&b7_hvcx2n&?X2MUT9(M~ zLr--=LXC=Lb3b^ZV)ES1(AllE$Qi(iiUv`2JzCyGP{2Uox_`Dlf?JMr-Mja}!)^Oj za?wpC$n(@1358pFe@@)q^(jHZReR-`@g!lIC$d>oD%h*fOpPaHHQKlO)diGk_&PG! zjvhcFINx@YgYdt5d*YLz6dz(p0^P+0hvXVFctD%EzlL2*=P&LvW@xK!{AlZGhTERw ziz>eKjg&B%4)mo)k?LE9u+-IDQqMZVF^yz-qNLTJJh8v`5LBq?KOb{ng~eT#VP(tn zx$qg`H$0Wb!u6iJ=pagvWV60ESu4x}wh}w@c^oDBwKdtn!Tct%`cjNxy_T3dQ2Im# z!|uu;)D+k0d4G$6#*JYkJtsSj^$eRZNW4;Y>Qm5tQDhBa_3K;T9)#(|3R|u%{=<IA zFbCzp-f!V<Nq_#Q!jiZ&x{2>lHqviDX#SUb3UY0z$AhB!@mm06Q*D#8Q&ne1CW=yW zRv>Y?A+HPX<%3uGS3i_RCtD?L7w!g*gM=wvysk#L(ykr-$j#`ijF7n}uKAHUceBq@ zX5Pj39_|QPKEm6di^ua)#3f~QT0FKhpCO%DiJ}K|It+i!N*LWoO3#UbYc=@YoEiDZ zsg7S^H%Z`Dc6Pq&2&%`+-WEVbmg?)$e!gz_NR@5KY(1162xoc&5P5aAdC%`)x|w%; z8^Lx9M8KgE1uMpe=a)ufYr!4f(mDCRRdSF%W5%VUB3CG3NAZ&k9vPbTOgr>FlLE2< zf)8cM4K;HMLP?YM84E;K-V?H->8*ELv6aY^Y8&+*bdL|qvy)wA2oCHiwd;POaj)0T z3)H0_V=CfayD9XJC8*OU^?z#me2*9Ac40m9+KA7Gs;i+Pu})5nW_8%h0I(^JPWJ@Y z$zNL>@mD(ZUVI5NPF*)81%3QzAu3Gx<+J<*yq&p=4T(bU_RHFG>kL(v+;u0GI$d}# zu!}O&p~W7&j<zrfUOy${1iJ}{3mGwan$gw&S=X5TZtlOR(HKEFzOTzCf-<#CjbLLk z|G~w={@umpJp`r$Oef|;!#lCBB0cq#Z3{jY7ev>)l~!#ej5_r=Q)b+xj_z%f;4Z8m zZzu>tRQKtM65~s;aR#b8X=S?_p&O=DJnc!WZ9+sYElB&7_ouA;$cUetT`mKq7J$9k zQ=+V_tcMG21GNR=+-qx-qN>8g{fX3hs3<R)8BDval|_6bu(oquzWj7+(PckVR0%3q zJ>10vD<#7bq<_&@c6b&)_2>9T=|%$#V2*TI*UOKF^cwb6_)@(@$_BgY)=_0@2=n6D zL~q)80P_c=00~3gGb<p=Nhy*pVp`RzHw1<hWaZM>P+_4L`7&KmS$(3yjhIXRXmFp( zgzX?TRX=T;8_}|=gS!kQt7@z9?n;{d`-fF5`<9j$jw|0XN&B6Kxp>v;YKc4V;!RB@ zo$<!>1OzrK+Fp;BeCeOMQA34uO_=H6n_3(T;jtzMmWJL*cpSFef~+(&J)b<j=9?Y( z{NDWW)#K6m6G?qxSG-C0g-nu<EZwNG$$kvsSI@=+%C4Hr{`CQh2^O4MJks!!L)3t6 zHy2Sw1H9oz1%Q-?hdEF9#@!=_{8OCyVhF{>;^-bktNYH0Gw(sEMjYdL3!gY9A$7*v zG&`@$w%>BJiVaxF=OJkWhf=$9Dd&7PtE$2D@m&U<%l$)Ly7t_swBOBHkB>fkzxv}2 z#=sn{%1S#Y=9N45&+&#w=$d^l$1~dCi88r8Z?;r4G+M$SHMKN?z?zO1J7pHeaRr4m z6*xXM)LtC>S3#W3=mZ}viw47;oaQ|YCDF!1NzUusN%S?HK0sdKOxI=!lRj@A_eT&J zCm7;w3=VZzo(~O&)$ZJ>6=NxO__cU|*K%k(y?c9(*&qJ;WaZc_i-C?Pgpa^5XeQP= z2dC?Mx@PH{!_X*NrZenCtIuVA*G*+Nhwo%sHkXgmg7I;LkV=QnnOQ!Zt?4A_La2p@ zLFlxRAcU%Xu<A8{cd%cdW&AEEXUe0-#6-fIC03_0YYO&FhB85PpqD4a4G9-FDJ_Za zRZT;K0x5KN!U6{_ZD3b3Ic;fq+0E14>2z`Y$9mME*9OwZz>~Hs&l{rH*r|ag+^IgQ zh}fJB^Pf%4+3n`ecdudKB&9fXaz0)wn?vp;R&+1KF_v8Dok1mmkqsHp6_z4k7KWpa zHR2(ZwdrrgAw+GmKUPS(;(yc=5KW{Sa#lt`N`(@v;`oG$xVf~MJFFPFv%&n<<c34D zjjip>I%oQC?<wv}tj29)5g(>9)q^=Xmd~JJT?`nNm+n8C&M&69CY%NtExL7~ZMb_j zGo&Qzs$>BiIl8@`RkQ3A=%gxG9998Q1?0;T1EZC;KzT+dBL}HVAND2<0#%EWAywIT zbzE|~o|{DhtHc2u0HHP|x#KxfRx0dmK|03j@_66u`}Oj-;x+kSmgE=71XL6IgJou} zX1BCtt%Y@@%L_m8Vnq`HDKv;ZsvzGyz#$Z)sL}E@6jrx~EHgWmhu3XSH4>Uq-ZF<I ziMMLErz&X9FTy$DWE~zc&CIpHW)(FjF@u*=hsy0u9$S};gE9kkjdH0fLSQIiKBb>V z1{pXPxaDU8_kI18K8}s7wQNI?%z-V=lqE5fzMQciLt{y1`pVv1d~BcCn7|Ewr~aA) z&I^)_@YhIC0yLmP(sohQv9ThOXgg(3DZ$y9Fo%T7ig~0c1FilllWCRI04?}h%Kh~f z$Wt<IK~$Mn*h?oqgw@yz(-b!=(fQoA*N{V*NVa`@NKP*A4aV=Gx%WAe$!UmL-qLkn zha&ielB(7Zn9N}UKa3u+(tPg(2M0xlT`f&i{MO0n6>eKsGBVai5<XVaN%Q(O0V%px zAPnG_ehrVlcz!!6Kk2{6FgzwbG%hZPS|K9u$@x{oQ_A6~Y&zFV`5g7p_BVPXX(T$N zGh@Py`*K-U)yaIHxQD*&w^vGhI?!wQcms9eKz&ci!==0Y^l3$8yr!ZyD}1Gy7gaP? zm>?gP)nWq+0qRt_I{G3`_{o4sjN2cfN}DL8zuhO6*5^-ANaZVTFxQUCn<Ld+6cdNB z*WcOD!&G(BAcg}j6`|43)Y0vY4^-*-HgyB!`agFARX`$&in>GaLBxz+6Kqarof6>e z(bGXocopn9Wh_rKe2Ew=?Yct<q2l9vs!%QrZ(Km>X0UGc4bKAg`lWbMNNJQD@#8xQ zQioN8LU0}rgB_f5mLIEbrw8tIPLp~>;by04;??uw+-giW*kQQ1d(HDaJ__mgKZF!3 zUJ@uGfcstF3}WC#2G55U{*{v8?R?CCYujHWfhsl-4~Po_!SpF_#i8}6tla3BqzIB; z$z}!Q)%j7S2naLF<lN?~)Tq#spz_9~{gaUI28|DIvJdMp|M*bM-PNbmP=-18FQgF! zAq;V*xI8|o%gl+e>iTwu6=@>^ShwT9o3`+deLqK(D(|pscZTnCooVmw=YM*t3*2n} z8qT%>lF9#eCdxk@C?MGp>eSL99$|-?MoNxWp6F+s*q604wbMx7deurhv<7FZYg#d- z{I=yLGJM?=4ynbji~z4Z9z|U`Gl@tCzqP%@49W|fr;vt_p+%+b)Wl?Wwy?(XH#!=G zY3XrE9aH7+L2R)s#ihjF$;Hw2rLvpEi|MUdVPmOJgG+DkTZ0hhPp<0x?>y4(777ll zQkc2!<u0;#rim`jtZ3*v`J|$Rp;htWE!#yrrWfkkYcLDR3n?{xIvby272R`qBrw=J ztDPsK@oXQ~89)<$s`idjFwZ|KT0`z=T%5pLv04{!LND(jO2IC2+vE{EpYS~#d%5kv z#v{%dn_5_z9BF%oD-(rA3b!Pdw<Kf~L?U;8d6SZS5@2Wka%4b=KVH^O#~I0sINn<H z?PAX_sq3ny<2))B#$^uTkUk#yTvkL<&jBqdKNI_fk)}$aSEj?AX74r4FxX$*N4~4* zo}P(ZV-8pt*o0DSTR3sfm3$$kV`5qvV^bzZ0y1`+f#d`yZ8^MYyDZTgK5l!a(|3XH zi;HckfL&X{czvi*k2u~pF*me5+X<z73;6%jD@G`6W}uc2-=$9)r(@QpPAnZFI=?Ga zBG)Q&+f=p!A*{39HGX%wd49xjTMw6~w_Y`lEd+r8`Y3pfNpQXv)<C6Z2c!~MphagG zb^eFchY!0qeqtqbC0Mw=GubI>RRWHhhRPZih@}eb+@R?2zRFW?pU-~x*HFYu$hhff zCx;WGgpZy7hK<P@UFd?|<P4UKwB@N*gND`BG|$Od`i@va*SPT@RAlk(!$7sWz&mU$ zw+{3&IG}8Kr%+$n>W$*JS61BIEHVPe!Qrk<Cb(V0t`$sIz!}~;uf2pCmj(dH(r7Dv zJz3-W^GlgoaWQFu)-)RKUUD1y|L;7Zp3lv-xiAvsoqjcCuJT`9fq)uq&x8n5q$KJz zM`<}~mNTM}hV)`2ypyX<fjs9W%=5$mgLR~;Z{l4ia7if>WTtX?_f6S=ZSwN!ylWfN za+S{zK}D{d3L?}yv}Q+@U4+6E@WSq7$s-Paa>62g9oapY|9;k%oy*K|irVeh{<Ok; z2SM{DB`2Y}|2f$&H@bWsR0)zm{=(8!<9crSyV8F(A|jrhIFEeScC)?tHaNaZ63Aia zk{Zc&2-En*asWA%dAB#4FW}C|B_{R`)R{-!DDS^Zjh?NKm_KPnM<KG>%Z8-_y_^H( z`{a$`oJmPZ(p|8rbxB43_yX|VG?rMaxwD0dWFvfn4$tz^fv`1A==^;PaIo)>Fn=IU z5SE>y`KqjS5!5lj!Pe%?xGkqSL+zSf6hkPE#+$XbznZOjR1`a(Nsg|4MJy^A7u5R7 zy%rr%Z`cX%nAO1(im7D2XZm)igb6g7=Z%TB;_)pSNaVC#?>)tgKCUn-sjd$jnzTsr z312$ul5*@pyRN|mi2HcaRf(iVfAp<<A4E|B2>bx7{ugrvVF5!mIj_Rc3)?%G?_Qj( z{n6HIsIXcl22~VG@jaWV^Nej{c~Y>A9?JA_F9Frd*~2?7lXZ1$=v?}db<FpaDQHUY zYp4aXMft5sphz~hTvi9nugP$Nt6jFio^A_?=oDvGMNioTmDJm@dmF`+_}D4?^wUGs zL1UJuAQPZ36i87@Yp889MrlsuMgD+-t^aVRynK2(5<(;u=3BIiX6_z{Ga@J=fSC)T zP-jtK8XRe}jZ>UfgF+ZmHDNnUS>UtFFcz@1@;pa7n<-Gli8zgz7c}*7l`Y_@`epfN zOx$>oUM^?&E7+nN?E!b_A@%w>C6~{0q_=Y9_%1eQ%e|p`&wfISMhQ?4F79V!WN31{ z5Jr8gDuEnMH~(vU0LhL_kJw2cH73r%kWOrqE9>8j88R^=-ZeceCUii*WsMUGJ}{;3 zgX89PB-z7z!~gof|Mc0wlfBy0^=4%z=1ms2!sK_x%f@^-IzvY0y_5GX<Qr+Qzk02H zCNHgX8`xd+eerULDxEsP0V@319omJ!P<STPr9*I_tG<sZyT<Tsx-7`xzlaQr;yi{# z#C&i60wzM6`~C0!qXZxY4rQ2#i71E<PkEkR$1kZZs#QZ;R*lLu>wR+C|DsOTA%;-< z^PKkj2(wpTJ_Bkf9Vdii#)5VJO{;RA{JSUNI^vWROJsL&Gjz1%@@~djJSIv!@iJOt zFa$&>nQ;aHKB#^B6rUCl4ocY5DZmcs*J^jX|8=$Z(*9OqKcM<+s!#}^h)$gY<mLT{ zQ$t%CfLPcc*I3bFXNF6>UolL??1dXx!NxMHAkY+t*r#|Nt>!|CZhGjXd{T{Z^_;;K z)yNE7W32PXPyG?u=gn`YE1Kwfvb}7?RW?&l!>_v8_J5EEbY4|w134pnPY}l$Gev!o z!<`&xbVDQQ>xlOiW=!AmnRyc!0ruaB@FUPiwx|{@pP{L>#HqE!LA6ds%b2SuA7#v5 z;B#;rTA|QGMRd4WgPt@Yyq*n|{H@QX?F%bd3gx^(5w@!>dLWV4+W2S8_+x>EFjv@& zs#U3W%Gif1CdGPcI|dCLB#+0(h5lhk#wEot1Ha&C*MYuX@J9qj4NXNScMXP80Ra96 z*eRvvZ-<f(XZ_FNjfIFL)Kn1}Gg8EwUog2NJCanuFe(~CJ2*3=-}T(&t*{eX9GBUA zprEBQnVVZ<e5MkaBlA^YX$-v05_qXE{8kY}O=HefgoJ1NCaH?p>1mgYN09-7Bix9| z=InLvj^)=|j=8P_!xZDab9{=DS!}GdB`!~jH5x9S8hSqLx$FzRcnHzL;@r1vg`egx z>PRlHp@4S#z))&#yDq>DYSj0Lg1VDWB63vN%uy|}kNA!U$Ws)FN)A@SyNr|elrlmb zbTV)z0va}1whtYT5_{wAnT3Q(FG0r81Q6m1CwT4_7)p^X79+m>$k5gVESB$dm}6&W zeMVRe16dPVSP`EZ)!@kJ5dS=3FF+MjUgBzalN7;+f7cg}hEANPr8~rxT`@hMXjnrI z7zA}<VV2_e0*$)(t{5ZcUd|K&@ttTK=-&Ik>%ut)g|SZyNsHQ?t_<pmWdm@mSs4uJ zE5z8`cA><bx18=4C7AjQd30Q_W~vz~PU7VrRX&8VmEz@ht-HKJ`SV+DVPkPM-{~h` z6nzECG^MvPhztN6R&W2$Hdy+-jb{Mi_v>yizuLhd&e<tC_o+~Mu*!@@&`?YcHkR*` zW)G6`9Hrk+D|S`SBSEdOq!IC-1~+3Hf=Xc(5bw*Lsk%Cv3!TX0M08kOLUJse!a06R zgd7=a&TTkvXlZ5?iacSx)=a{h0+Hu5p7wW+(!?mtW1@kax|)NMXYB(O6_QbA9@gUE zZ1=drRBiAdfkh|&r&_Q(#)Uh^#DpLE3>^VqSzJrjUD~7w%_Voy<gzfPE?Lpp$wtWr zm_)3GQlU0F+~eYLe;l4R#2$#LW}LUHOIM~mS@iz-RZOl}Ryanb!*nH2Jp*-UDJg=$ z$LI4?R=qgN51`OwZF_cHvNaA6)An{jm4z<vL}gDq|DQU2i$PNRmgYx{vN72^M<t)Y zBO(Y)i!@TE!lz`V%pL&Aiek>_ke6qdc(ClwdzKqlsGUz#6k1z@3vM(#FtOESU?C(0 zc4fik0@1>gM8UL%Bj3j3BO#;zjJ?;(K-~rO$J^)gq{N)Yn=HF-2L9NDn=q;owe{63 z71it!0Zi${JS%9{;IaoofM<;qiZOHmP(S9FKzi(n|3+D8|4f&7sIQ7*fT;;L6SNjr z0$DssUne<`7Bf~F65i14HQ-0mW~yE<#SUL2EZiC&HO}UKbXA&>;^AzYbe;k_FX$uC zQ20larMFpT3W@k9j@Lc)$J+xLF&|jDtXYqeUMtcV?MIh>OMMlSon~CN6Y4ac#uP`I z(bX1sxHx)*A|A_EF*P{?*g0kMghS_5Z+VQU8NJ5%X{R)r_P0N90{`QGkxna8Zl-E{ z62S+P{T&0-5=+qFzZFICewESw$){jl$3gXA`MM<BU;ryc_eQs;)NJD|)-%h*ihGv^ z!W*5;=r>RKZGN=$kY6uQubI*C7*a4MB;se8s#o3bndr|rug~8%l+4CwNKvwemUzwm zc(oA~PaL+kJLD$fvW9(uI2Cxi&8fa9jEdXxG&%(M%Tsf~BW%J*fBfgaH<E1?AdPm* zDX31tk2E&N6=J7femDt+2w(`n17@s$PtAE5tXo%cUuX=UpPQdMy1Q^A?lkQqECTJX zRdM?n$SLyuwUCTNC#gq)_fgaS<nN5#q|@v~!@wazn!v%nczwKnpVkLmUV(6R3BDZS zg$vB!F9y1(82#na7=0Edu*>Y|v|3#HN+Xwrf!C$GWP=sfKDmfz|KKt#l>r;P?SwAp zi>7sQyK><;?O3OXXyOMLNGz;0!mzhxnjkFypnn!%?ghd#iUcY~kG<Rm0-9E&RhT1V zoFijw1HNWSNyUS!E3_!OT$xl~L4fsK<c86O0xDxYs-cy4o}*Z$ZIv%sObvt04nnRF zSLr1|-){ybxy%wSwR*>ao22oVG5rH6vA|n-RTtSN-X254?I9>UtksLdz-{i+6@QEU z%&%vwGnOpv@?2;4Ho<w~u<t^pFXj?97)K`S{S-g;&`W8NF+pDgZi$MDLRu<JGy&*l zEh}kZ`R8+R|5{QVWMx39xsxPGmV*uHlkt2AFQQBFiO|B4^0LAL5|*PY)w3HObECEg z@kB`MhTG-p4B1xaTre4k-Kc?#K&K7kaKyoQ*>WQYgQvU{Vhsm1DPEo9L6X?nR}1#; zX+PSQor^<07L}Zq;)F%txOdyk7_6zTp~dl}JTA}sx`>3NKsAb{_Zkn>^4HmvslTr) z$`au>71ipyLpwuE|1N=2dX}cfTE`#sGUJ$m3I4sw`<k8aG<v$11zzAK$lvax7+m6A zDg?`$E9G}KApsw<j{)=3AEE{t!QMKgn>EFDO^QqkNX+?K4Y^78ll-zalRa8u#3ToK zVK+3DHAJ0>>KhDf_*@;nhZOQXFD-{*@TAW(R%3P{NlQq%Ufb#u@uiJ<oF5dnrG<|( zy=RUQ=eB&RXWC2}gc{#Im&IM$sE`^+r2%McXKcphi3Vg3u4ns*r6vf<<NZABzb{g4 z`?s)D7rETGQdt9&LPS0=Oc*ONuf_do?OXl!XUh5)&3`NaO$7m2O+%DedF{@eHaRNi z=vc{K0K#yh|L`z?$AlA!sC!m!(|7YdoNBY?l|VwoDAUw5=GkMqAx%bthc#`!MQE;f zf3I^fTbh)q=7zkgxVUa@czUZZ4;yW)a<k#3tQ<u)gX8SXs7O4l3#jtpPo(I2jx3D$ zXm_0YY6d-%yXixU@io8^?miuk#T~>RdB%lFSfuMfru8`9{2rL|3a@8Pk$#(%LoHf2 znC}-;0stJb@^2R*5B*Q4MkXXCq(x?$W(Rw_ht|}-f{sXr$Cg%FkuJn%uXn5p9oiPy zZrOQQtiw0GP$MQPC4fZtwN5vgX;otJv|Yypb1}G6kNwCz<aQ!1w|e!(<E^Y?AfG(j z%#d=Tp|s)$WlXVtNSE{F`^_dW4i1Jt_M#6=4Z3pOP_<YoIm;W6YlwJKSv&xdLZ|sk zpdhcodA8*e+v(Q;AV4m%nbM?EX#^MC$VQpr0p|0^yT4(yxu5=yy8rJIu)0k^#ww1j zae0`zSXnlm?G0#SnX!-!_pj{_<RL1>;*5R09Zo%vD7=kS&K16Fu=3$+&e(Js*owm7 z@gFTl6)JEokRD#rb*;49h-E~^^+zwaSy7SPw>Lrg-`&;qt+<|iqVQ2~n{*_D$HHo# zN#~*E1qPOgmCVx+s5)mHT3cW5k@j`HEW~D=d3g98E5JlZ+u*FuYG!3K00d_+)Pm)K z7^B)nEN!N@QUS<G4kiCg_Mgp3#pHiT-?$RwR-S4=FU?p~LWqb&4IL#ou`J|X9s7oB zilLEy;i=z8Jr#46&d_l=E%&1@Fe0F~&hDuxx+m*>nd{8^qqXY7>RQXi2K9x!-df%R zvgx=i1|AuaT5K<OQ^ywZJC{C4p~BFecQ;8oo)Teadxu`P%NkNr;hZbzv@f<Z^R0G# z#o;n$l}9grBvtEz8Jtxj;U=+T@NzukqK+SAztZEdu{PA#xQd|C(@?aQ=Oajv;|z2> ztq^?pvwgGSe>V31my-f->Py<qxNadDD=;MhSh|bx=>IK7Ju+j>gWs$O<YKE#iYTtK z`Pxj|Am}uBeWPJU*h*?GI{Zr!B!dsim_|<%XKHKimZ4|U_hCtcF*Jy;F-VWS%cT}C z%xMHE(l?v!bUrJ-{G#J=sKGs*-n-IeiOyLil$2mU40wzT8)z1=>h6!)y!F55zKo?c zcaQG6!iAcc&tH{A4;ikoH=n~ECM-96OTmi%ZHR04%6dq<@~1$2@)um2YygTM1tRz2 zf*hWA*jQyeUCoWTPsjE7=EOtH#d&!f!9PHU!mIp>G|rDs@u$wSZA}ZOygjEII=|^H zNXvxq@>r~K&0xTkD&<Tk+Hj%!G?W)UaCjdEWboN!vliS23sXLSUeDdQ!6l&J4esG> z_TB&a&iin~`SPYC^I1&1U|F@p=C1IlRi<Jt0%~g1%*5a@EAzzDnT9*ZR>+YUXlAJ? zDFFP!#f2>}`b9vOwA37rMw6-DO6rf-hW&RF4S?kZa!PT*mdE<73)lvj*c2?iZ)pP4 zsmk+SaV%>^H3y~6?^Rg9D-=o0d!NF&F2G2Z!4#l_3_M$1Gp!9O>ktw3>COFodS^e> zyJ~H9X6<z0LcVaiY|V1SsIL|%Ew;m((Wjg_i|CpSQ&AR9yj=aUQPJCXu6lG&rsw_; zXBqU)<2hY&=6qpAB`0A~+YQlqB!0rp2M<2Na^4tGM(i0s0Mb8BW=siqv|Rv^HRwg{ z%+4zS9$v#3JG%c1K}DecL#|6he4oLFVkOz5+M1wD9h)`#386GpH3fa(D0C<eFkW<u z&hZLejKLNbLz6v&^){xLKHHM^(>>hmu;w&C;^KxV^qBkbp3ND4<===`Q;x`_-|jB3 zBJSWJWivVq&}*z5IETR7ugWv}D%fV(_4M;~sSW;)b@&qg=d^{~h*r=11zwF18sP`R zZ}jg2_<Nx8XaW^Y7u${%SW(C)-@nq21SyJYm>>(fY_YF5w(oS1jHJ5yf;U-w<|bbA zj6Htp+=-K8U*kD5(&E&Z=(uecfOg_go6zdxg_Fu+IrXx?RXgrVXdSgq(f+Zo6i*Ox zNyXsJbCREU0?Vigy(?L1xeM41dc;3gJGa7R0xjAzz#At<-Exc$HI(>RXo`7lU2$P! z<_GVrUlenKHoa&1%F58AiHS+<hSbCAcpGc`I)}6=-a~vPSN@LQ>hVEDU!wMSIeTo= zg?T2JYl#r`_04tGeHwJv(*xIOkh=Tm1)H9?Yz~Gm?{Nd$`uSp<FA3wMeWK7f4d+GD z%8O%T*z!OTh)~s|rS}<>pH-H|eQ-!G*t)AA^W?(Z)G|M1>W}!5TD!NAFv5QT{x9Zb zl#fGam$lKKl!`|=>Y!x#>)7UDuAGswtdj|1_`FB^P_ph?<;pL-+AMoKS+FGv7(cud z@%HVmf!?(yD|L*T*iS%VD$44f5$P-^0YSWJjEb92ud6FhZzD8CMfJK~;)_LM+2-Y> zr%%@LGWX?6u~Obg!Re&5!i$kdTv^t5okU~IkiIa*9gg1WKokP)uo-K}&$WO>lX2iI zc?(Jqi2e!Ka33PI*JiCWfY?YC@rNKt4}FKu4P0BKSSy70KrGW`ER3;H?d<7s+kKnb zst^7GC=G+j%TYD>F#B0oO#EQFH)oX-TJ+U>Ly*nV(n_6%y>tldno4NM+T_R$uNR*n zgYJ2LH67n0WH}x&%Vw+niqxtxB2*iPS5Lmhv-oYeJG}K91r0VoyApZwVcPT&UfF|m zp=U6)VHt@Ds29RND3_oxKr<G4qp#^pdN%sX87J0|Qqc6oPVkOnME&S&+|$fj5Tf4; z)wmp|<u)AcUwoPV{yVwr^yzpdkw+WeX*+$IZ&$eF2IWJ}Hx65HyL(36_Yb*($Itcd zE6aMXIR$g$YX~1Kug&l~SbR^9LtV@pn#MGm+ncJgW<%iVcm|3kIrJT`XeDi|tDY~T z<U-od#7#arj#J;*3+_9>v9c-0h9xGK%djO&yrG0&5OP2OGlif6({sEeYkJ?7uiUXd zC}C#MHL+sQ1bgGk#4JOcEgR$ZxW&Yb1Ri7lXrLnZ3j?qD;RxM4YM->rduN)(0&+fc z3q<6+mU?u_>~};mJe2)rnVqkC-Hoe|5ZD8XoN&&!*X@d=M7p$N<Vo13)#u+va)>xr zyGC@0AC2afdA?7&zFz5y;HRW^QW6g750fBK3V)&%Q^KvG3bd4|T~8MJQ#%NNdaLMv zDbD@H-xmcdELdR4Gy{u9l+(Y9hiPk7>Bs>YDy+q}f7b_baoKRowq!?$jFNJ;^0Yo) zDM}l=G|=c0Ndfn0H)t|?HoiGVxfd!@BMYuy)zx)=+ILim?b6bWEl7aS{^aY+u{|3{ z_Dfq?RkucesD>B6W{>-0%uZk)-Rf=E)N6HpS<!tb4L%qKqZ)FYqO%1ja*RCTMMSf8 zgGqiag>tGi+$j!F@clCMPq;o^RhH-}4QJD^iOM#lG4jX;7^c-#Wj(e=U6xz#ZEjdD zRS}26vTZV8eb--V*naNYLY;A#K@FkSK_P^?Nx>PZW_?#GGtBt-U6)yg@3{%Rvg=+p zI_djid7RtRrYBw7-P6!5$96jO`u#vK8045dDNo~7dMN(>%^}Jd{ld#{kMO)C<?l`% z91J9-NAVs!pBhsc70b3oy8@wr2lkttod5FAbgkroiKCMBj!<#(4hAv`6*1!3ohKhX z$7j4G#}FBW2KAds#IC3&S{#_3)u#Jr9pd>tEVVGjCFgDi9B_E-tO+I)Mj)}u#75wI z@_7}C=5b!p0V<_fFA-7%2&*pxR<CY7Dsg8wL;HG}Zo^B)!L$-X?an`jjE#b99mSr7 zR$fIYv9c!Ek{uEbg%#fkZC*Fq03v-52qj^((xr%bi;@vI|5}6LBj<(Vb!lSd!U6(9 ziUjduiYce;wvLvzyg-?6jvwLpD6CF>U&w4z6|GN;J>6EsaKPziEk=@Px5(YxW-ZrK z=SI8lxnt<(OF*6``jYfsS0B{G$Lul&Nzh+TYaq|iK9YG}z{7H&;nmc(7DgRUikYHl zb6RmMB@+TYShv>q)wb1f%KSO<#-o%-Qu+DsgNW`5m(<(R;J`wJk;4lT+ovTws(}N8 zp#OmYLny1v@(aD7W8@2u4OIk@V&rr5F$&xA3d$-NwxBE=uH#0=Bb(%2Y`e}1VeJ+( zytToyGIBOL-3ZZRdI-?YPFn1zhdWrzzcg=t=A>)BJ8ar`{Bq<s&SQ4sIKeHcFuT*X z=J=Y|wDC-EJ|#Dy@!8|XU&3{>^zJ3cW?xFhI1vYfBxZPwqb}DA?$(_r(s}nCUy7(G zQB7~G7Uyo<8x}Gs_{Pa2DCM?QJISmN7SwQ%LZO5O4qC^idV4F%n{|3BQnX8Q_+jr- zubh${d)X3i<D8;I`6j7Iri|P01A2^5_x|O_i)0qEbLze1T@d=r_0AVZAJ_B!*O#Cf zg*&eoi!RPz3D<Jaf&N3ctBpnrIl=e?eZt2hrZjqq40ENf25{KJ12n-C-X}p#9;;Em z1?415WC73*5eDO9*2Idk+PDv{zk*=QVyc$pheEfvE>{o@ZIo>HKQl6=dwCeO)oLQe z$*o?jTZon&HGlDEm-tLn8xhp@s2QW_$$Jr_n#E;5JN=xt(>-+L3KdfoCyr8*-LYTQ z)%Pl)7+qF!ClTR}*CZH+fq|>g?r^zGJOQloMQK7+xvwT|l1wLd4}EwdZkPKJ9IZpM z)sG<&32klXv~EY#4ew=bSyofUQd$`^B5X%VpDeb!!tF~BKpQ@aB(^L1fn<aLr_KHN z*N73Re;KDu{**u!8t^fEgu}Ru!YElhF+eed*p)`xNSOqABCMdvlC48Ws*vp#BWj~u z#cgnO4LU)2SgRpcqcx<6pmXOFh!#!7P2BubYk9l>k)JH@ue$}^r!rxpOoxdyyS7_D zvCJLv$o<4;QVGu8P|j&9Wn>&Pe86}Qus!E5IM14@vq)GN8_9-Rztbc@(QuRkk8=Tq z1P;0NPi6hV57KkpkxJ6Vi$6CkQbInqe&TYGxp``6iE9+eEJb1ZafVrLih0|d(e!+! zyD*55MzH;Gb7usnCUV5-r`}6NVLHy4Q6v#E9Rjjh&B9ux-O<=s={KX`Uld}bigSO8 zO*d$)M3;0svu8sUrN_j$wMT^9BL6geahdltHHiJ2uk&#vGGnJ}sIZ(UMNowM_>VnW z!>dXfg+JJT?<Dctj_3_>BoO`sI10~=lJnQyQ#v_}2}zBA`?z&_`Fg~{*P$g>$aDL0 zszHBiDy3|zIWcZH&KYSo`qRLhCPWGlVV)d&<k6wyWat3?<t=)q0%cJrXm*#c{!6Tp zO{5HtiDGKH{bM^a$4A~6x)YU&<NHL?wfo>)u*7zx7;!<?<M<o2>?5l7Ohlq7u=hD6 zRmvf6a5le_z4Dj6oVT=+o)!@0>J~6@5~V|?MM<f1=_u4#5-V$<%XnTvOtytm%~(BN zdpQWQ^7Wj?^}85tT8F9COeedSJ!9W)l_=>a$JLMPPaNbtLaUb5EP!G*R<8G7^P8o6 zT!c^X5TePgxqmuXg`V%YSYE*)jbov^BimW~5FZijy^hV=v9O0_jBg23qi#7wwRxH6 z9sA+VTgDiAvV}xYL0s;wX@&s&uU1|}z$_q9@zWtl6?Xne2lkqzzGOxXM$J(%N7He{ z5$O%;YjwKk9*@|ql!oTlc9|v7+<SAfMJmO`xltuD3{da)Ewx60JRbcz;qtmKO<neo z72CYLFRIdbo_>86LD3A}Fw68jucflvVJ<k5isq$W8~%l0#I19_hF+1NEv#x4d)<#i zOC(48^Ol88*=_aDtx6mHz1)O>lhQSz!U-X3=*eduT;Oma@LkU9rArhkV3!|YmF6}G z(Ip~=^X>6%0VO|vTnLG_l$N*Sv=!l8$~I(hT&8FTcXU^<qK8E&8L4djm13s%(<A=U z$8$a|dYvpGX!eug(TxtEhufdoTSkJnF7bJ+BQ5;vV9`q<IqlXj-ktAZ=NZ<%CyWk% z&#d_w5SuXWpK7$i?sdd$yd5DDWh1zG^omK@`tGgRVzr@*<eqa~bfg0rz_Vw2?P>hF z$<Q^XRX?pxjbr!m=0uBkam|d37Z}2O8r}o{sm>!wvro$b!|_U?9gHQ$4J7^W44-~~ z!kIIe19!LZsH*v~5&J%8?W4Zk5&K1Q+>uRWdSO1|NT#||swB$1#)EIe_GdmB*!xxA z<$L!zezW9(z~NXWqL@DYxp#k@rYXqj$4TzlY{1yV?Ob}4y4n!|N9M`?YCYE$lo8{K z)nPb)@#(4s?DD?q-0Zalw9~Q6X9)ZErO&C5x5CtP9@9s-RU09(=k2zxi<x&@7SFBZ zuO(Z8sxt`*lI!h4$t-2=VT;tf9}xVCvb+t}RM_m%^@z|mTqi2jv^@ku9z8oc^(uI7 z<0d+HXbTiH4&#I2c}Og?LUc>ko@}>iqx>)L|BtG#j>_tLzBUj+DG5OVK{}+QK|(^1 zl<w}1hi;@nK)SoTyQRCN;Q{IH&fft(-?iTNUzgnHo^#Lanc1^vj`Th*Y1<{I!ow%; zd-C6xpI<AWxbhRsj>b%8->KXUiu6ThCSyRRzK#&V%218xvS#{HeRSk}7e^MtRc<aC z%15WmjE7qdnGwLa*->6LSCwdi5c505B3%iF_*PxQtPa^b#&JFup~Nhx+pRe%GDlY? z2&q4x4a-WjB*2({6_3!W?64B(>Uc{WbT~C{>AYK;nsF7_|4+Vxcdp9hY65P9QTi;T zCt`J5Gw7#ykoi+ZveMN^xJTTiehg74Q1<0^uR{d0?=jTilinXTpG#~b+cH1%`jYdr zivZ-=f%ZKV*QukOsMFk=s!zkZDpIA3%^XdJc`l{b*hrF-Giax^tYv$>C5Jp_Be9V^ zpH#b)YOxTR)nhMD?4#;WR~?o4#KeW2?Qf(ggOu4$3hz!p7(-pRNzybw$j_;ztXRB^ z#p`n3>7|l=3Ue)I>d@4WadAT?HA0kSg!p!MQT!)>elm20L!lQxe@EurlVOtoVSj8U zrOh=rk+jV}<s9Q}7^wc?Rec4<%(IY6_Yc;cvuHwLi0Q5-SV}D#SgW~7P!r4Jz^U6w z4Wqu?WjbtJ)w2lf`JE&4vC`8E!l=Z!$j|LoqW+5;){er1l#vH8&w@Tb_5CSK-E?bq zclP<9_`;qQul~p~lJQsWx}KVo^B;0Oy4n0;X4s@oHP`cLBKWllI0J9{GL25`O5FIM z)ZQ*3L7^2ZUq6_6&Dc%K`VTxW^dF*vf9arhj-nF(_P>|_2h3PK(-3h>khf{U$%&T{ z);N7{WXtYzImIU!LUG4(HTgWRSv&B&A<E1tt8)jZ%-N#$#D9^n{V$FGo0g=0uDBl6 z{EwsLE8L$;*3EoqI7xMW>b^&Br=-#iQfZ;bwqGPWA$__q5GKy$x?)}RJJ9~Rgi_V% zwtG3fZ3ua)ChvMuChP1tsNq%nn+D~ZK8-AIYe8+ic$ZC6m+EUHnwKV9XKGxPt* zTRR<IIrpD9)_-6Q(SHcc-fOnrH}?I9&iZ+iHSS0#WN)=s@Cc-`Cp$H?l0hw?Bv$A5 zC+nUah-@yuBmsG#QP-O4*%G@4v;?va<8*6HO!+o5rqkH=qM_;T_GDomXC(|jq8{?M zbR}0!OnoBr=q+wfUrhg~5$k(Q{L9!_C5S&V;%}CXgVRc0Och9bdll3wP?`e%hD=H8 z=rF=4HkNQt0k4p7q+kM_M(YiJdO$c*(%NMK-VB(&_l77ePh^Te9*GVfq>$20oogp3 zek5;&sqBfJI3K~%>%ljc%S7CuU-R{I1~C>%7mBFk`2@Pn%hebWUa_#%T&BDrrQ}Z$ zJx6rCJa@Czp6js1ZM0Y&(#et9O==Qq!*dik4XB8lH=}s%nEv}a-=96%4;*UL^E0#d z|DLTDzo-{^Zrl2_ZODIV!s%uQ_SB+$!=R|?x++5$>Gf82<!KPHK!LyR3F~@leQQu~ z`co?`eeW8ri8c1CX%<{!GIE)uOnOU>9=?0GetPbt{b~p`r!Q1VhzK2g4Ns&8U#Xu* zKF^b$kin`KDT&*GyN;7nG|Rd8erB5B*^Bo({1*XzJ@D_Pis{#J70yQMJfIe@hR~B- zEp8W|PrNe1dsl06MXExGM1A;9XN2gB^HD|T&8~6cm&kzc&&+oMtL`jLYcR|F@KNA` zxJ=u5U28c=XWoyyC#cLBjo+FsWfmBJ3vNzYOA60eTsCI%*NqmRF-*)MF~2brdbe7S z)i`ku-o4jPxdJhiTskv9x_=ea-dOZ4RQ?x`d{bGmVfOPcAg_~;Xx4Bi<ijmM<VXNf ze@wWa945_DD(udoBhAdY>K@G2E0XWagP+($DNur@ZiVK?ZQt9z5!ZF?==&86b*#w# zJrfi9q-5p5?k%qUp8=JX5M1i0-lC?<5u?I-KI+#+h$Rra^W!p?yQ!w8AmdqIa<4_X z$b5`a6NWES4Id&r=FTFW8CjqzxY`ovI^9$ley#d;JduN2Q%Y{SRnt41Le`qztigh0 zVDI4`9q#bByws`{LUFKG`P*DHDPRc(lXOGURX!x;Fp<8f%2M^)L(+u&$8x^+T-XL) zn3d3t_4uq*zt&#w?g@fg<AyV7ic%A4>UY7(<D0B0g|}h?PkjsK94Vr3>{B6j*8$vU zAa3#LOFY@U%sR3xYsDR2d9MIjwNDagxWuvgMXjfDOR&3$eAO^vc9(7+-F(#*p8na3 zypHAE!}pK;(&2rERrlP5D?~pwox$XrUD!Hsx^5hgj-enU#j?05&N&`I*k!+r;fm$c zFoQ`R^n3d2PBJ=T7NICerM1}4=;cz+v&b7qDcd8yV8gajc3nI$Q|Qi;{>Vmait=Oz zE|aeC&(C_yL}w>x{FE{>3<@Q<>iWvHNZEdlO4Tg80bQiy7v+SC71V4{ngU&F??$+_ zweX0#b}`|M6XA@Tz?6bh(<0X|ZQ-f6i}~Ogc5NJsKi9X|f#~qu;DP%!%mr4gxIc+` zW+mkCZ6EpnOTYV}(lhg;1DruE;c?NaI(R+}r;(RXYWAsU*3IzVLrf(Cqf|C%!q%NW zwQGuLFVRcff<cw2CcC~4ji$4oc=HyQH3=}d(u$JD|LTg~AF~y{eCC^?v|GQ|_!2M4 zlG%^XX?3Hi_Mo;GQc4h$zj<Is-FST&rRq%oQ)?;=&QrW!Eh8dV5C(fq`P4dVC!_~Y z5C++gxJsG;vFHe1K{dFp#D+O1&bS;lfP^*S@#v_a|1bDCozSQ{u_`YPPGi>Xw9%aA z97wtCn4W8E^(Qm5);)-coJ#$zlp}V?F5pqN=iiSfVwoR}I?JV}6Xakl7dBzS?%_(Z z)gk2jwP@yeE{hG<pO7}}<|fdVx@HkJ3v#WB18+F?E-T@%6T7gFme0=e$nHy1gZkXe zp4h48S~!2q`S~nbwx6SH@r8bQL(>iP&}`N-^SStUGE_axKEDBZw2PW2D&IyKLi9^i ze!Ky<`6T~`nc$_ZCQY`N^y%|0EGaFT_wouRSI@j0^qkkvom>hjR({UDV9gEx{)#OH zNaN0c2euI(|H&jAalJpg<Q1-d7x)|+GGpVS0XZ#s$H50#3+KG+(;{y7lE8Y6PlDx5 zPFZAke4uxGGiJ-d(eN}j@_Pn3d3OdeKV`52xP(LC9X;Y3TO1pfcuX~yxh=CB590w1 z1)Z)sGCfYr5Ra@PrJhD(Yeb7TPRj6pIkkQH*@(>4MS5+rQg5B6QT%j!C_>47MZ`dc z=|=ldu0=5ce7cEf#r9G<c3+Mnz<$2+`!o}dq4GnN5E1JbgOT+&cQ?FE>2(VZrMipO z@t*Fpni9Jy_kN{2og#-!U9`9qUS?adjVrT;{LU}>(Hn+aCNeUNABvb+$xHCL<l+V@ z({74;ty|jVnl1*e_(lRIpO*zwi{W2g&M7$}()QujH9?``$<9(;=d~h6Jer2+0ysXM zJEddcA^y7kaa57Nl~n-nJmGnh@QeZZzDn-q><#M>yG1$K`F-sDD5CDEg)f#3(sZt^ z1)C>-jMY{?;b^)BjX<S%TG`!df*QibY}j^M1o<7q)g)b7^|MCGDwo-}1%5t?Ces~e zoOHVHQ|V{F5~IL5?H}lg-&i~A$=O<H+=@=9xnHc(xBhND$QvEJxvWDh5hb+Sv^y9I z^Ya1kJs;Nt)u$l+V2?^r@;6@P6UwVJ)^+z;!d4iiFDq|pl6iJF?xVT%|Ms~x$UU>x z@)VaUY!rd6iPZtmTO+r}<<+@!d{8JsNRmlRgK`{(0(+2pXt0dQd2X7<;he0_^4&*A z&bz3l2IEbGn!4*PIM_fDiLcNyagNgcA$tkz1NDUyhoXj!AiX5lskJ-t*D4W0HM@)R zYStJ0-~HY%w${ugzO9?vJJQpAAMW2p6W<&@&CcfP49!q;#cs&BAhG<#wAEA3OX|cj z!i0)fHNmOWG$)|{u@QxsC}Z=p=G|NR&)fRB^Sngp{P30mtQ$f1Z8TxL4>Z;EEOl0a z4fB<viKhR6de6sD)HnTz7`!(e%E1JgFj?;dCSrB@Wj&zNMoo~jiN17e{EQ#h`|(Sk zSKzPA(%-F?7T_@=BJ<JeYg<A}q0Y|E8ediaev3a)bv&9=OS0W?pNaa!c3c@(q~bzU zGViZa7~<-3ys>50c-4Q#<<QWW7R<Idm4!EKFf8kZHpkJBm1qw|=K7ZNePFq;pKCjt z;q_GX$-u3WjVlMsMgg|+5H{p*>@pd$1AY{Q?0l7nC|sVPVzr+H^<L}w=iwS1?(&QJ zMDNXnmL`RcVWJEj`9r^SzBX<(IU_B`;2}wgFr`S-8Wz1qNiw7}ZgG6`_Da`^YFN_w z{#-D1akqz#ad)pS_CwC}f)<B0ht(^u#iL-TVzs&{Sk}~A9GC6N9W*BY81}5=#2GWi z+GW}tX;cNL*vvem%|yG)i-~!s!GBh}cSG=<xHm0zMSO4Hk_@XGvF)bQZ(_gKO6XF3 z#(8q7k(K?2iw0cewAH9T)8y@BJWuln99}B>44xFSo}~}^z!7Wa5ugecN@Ex3*MJ%W z%5w?I|M+Dr{!#Z@{~gsqxLy`RQOnGV-+04(>eS~qjo!Hl+f9Wi{o<dMBV6Twl5MY~ zH?C*jK~6fzZR-fk{tZv(-(Xvu$28wya2anKz}xaMjf_g-*)I%KWF??-ajkDqex&MU z{qm(7>6tq|7meOZNpG-1V?I6?GZmu(i_CN7ruq5NnTg52hdXCnF4x_d>^3_#H68Zw zKhVb%n<&pmwNG9&)7<PYWgb!26Nu#we)Y-1GNYE~qg79`T@HyAow;I%>p+m$zu`Cg z6~Jx`DnE`iCZWk<BEx#Rpe-JJM2&}vH9qVnMOB^3K+aiQxMYHU55;2VsDsdH$L1|` zARAFG^?Or>zCra-vdYtWGp1X@^RwkTgNz<Wp`ejo=_|9@=?NQw3uY^6UHiST-^nhA z!(0gsr+@X4*a#I~uQD_GINUUAGDY@L^z?T3^f9DoWD;eMp>{*{6!B5aS>m62@T$yl z6r5IebgcGlDk^C~%&(T8_EtHo{l>$@Xo&gegU6g;G4KLNl1s#vT}^w`${EJG2#-rh zn}@{zn%o%YCZ6DQm!p$O!%OXTa&)REHRN~;W~WO##|{WSM!%0t5te>FIfQM}1}&0u zY;{{9oSU17m;lGaS=4%E7>7<E!bz$HwSb`>1DcpUcWvM}7hXpt$~gC<SpV+(&{OYQ zt*Hqek73HvJ;?lAm2$-{PYBtmZJW3n&D~7_m$E~};9BtQ&@*s9ymHQP8$Z7ptldgQ z)j-pFE_uN1a5}nwQUXLdRQ?;q!s|dCvkKX7Cz=zk@SdsLKUpm-C)$U&#g&f!W^MgQ z-|F`xS0qRQ9|!-_1C`LmEzG;3?#kSYFB$VEREfaX<3qv~%yr|mzX>30V?AOqVksfW z{W4x9^0Mte&URMj5yUHy(xXw8lbZA`gjR2{^KcvF_O8G5ge80M5&Sb7V7p1V+7%T1 zAz;7$&O;;G)%kQ~Y+_DvLOtMcNH3ve70})M_CxQwmrghj*Rt-8o09})hgHMPEFowW zXXsQndnXjKYd#At^H$7U%4OszCr5o`qL#Je+;-&czKEyR6;-G)umhkbA`SnYhzCTG z+;*u7|BK>t8!`&3@&a{GO89j9DKY#7sqNYm8*(z6NT+dy88tU{ynNR3q<ChNbfei% zovJd-Hu1jZ^4(6<boRO`tTgGaHBov`s&e^0NgCw3t;V3?IMdclCAmx+#6qmh#5SwB znmgn2pZ28;QoM9^y@7at#jALuM^V)YnMpX~x{cDiIbb-m|67}1L%Lb&>q&vV-B@L~ z<VM4UVZ+J)PNYUiraoT#(vh8g2c{59A{>7-mL@DO6MA2M7JNi;*xH#m_L=tzuy6d^ z6fVm67mMZoErrjl<mb(PxUnD(UqBvbrL<P~cw2eEE8g1>fz2}oso*?&2FBst?0%b{ zoN-Va`-X=bhbph(vfI0r#FlO)EAlOgu5yp8ZgHB+bh<R5jtL2!A55{Eb3H2n3+Wj~ zc^TpE-Xb;lWp8Vb$6@MQ3=h5oq~$vSD(=P1T<gT{ZEYuZ#+WiPkz6)?!3&EKSHkR~ zx`w<4Kj-)b_Dt3~7AW-;a6~vnk4%aCZ6eZX9tBuloq3QvPyPxSIpa!PLn2n+9GcY6 zC2NJXSmE?=^PU7Br$k{Jr|G4b+8#6nN1cJ|w2{86%JcQ+1b}HfW_n>%*bWJ&;54x; z{T(}yB8m;FSKX_cH*-1wQxVTMXP{6{NewTaly>7Uy6vTmq(y92w>UY{JQRm5#+?Im zO;@ceb!XBYY#|3!!rk?(X8&la_(wR>Cbk+=uIpAqJ`yPKlgz1q7ZD5Ac&2r1GgZgV zqrz^5ikdSUPyc!i3@vbreh{nR!-4=OZtJW#I82H~j^MS~O#_*}QQ_8>?u<H-ra+d` z0CSXn+X(p<*Wi>*gHh2dVqI^M6a5X#ihb9JHnZBey&`BAiFjn2<^fvp?9~ip0PdF1 z+o10WLTh-0-1Skb`0dF?6u{rpD?;VeA4I5cmsdVinnOc`qw-k{$*0p%j{9x75*=G~ zs0!%V^F%hhXJ7P61vX8hyWGUA(w)iP9Xl5#NXx!SCUa-CjbuqQ&EMG7-`FA8=a9%{ zco*q(YH@Gy&m^e-b31VFEm*g{ALkUW;t|NTsR;CHbgE0Ku;Qj6VSJ7Gd}87iK(8a^ zr04L3uxaX2!nYUxw*JrLzdIF;Uu&<<sizL?eH^1r3b*5{52G=YTnQvR6iMG9`9Sm( zE-G<#TAG0WtgUktm~FZH!kILPw@;7Ix_0#9nLFR7NI@c@44y2}Bo)Okpjw~$=lW-T z{6dGk>kE2Od=$G)Gn!fRqXgA?bYbs~-_rO_Mo{>crYRVB+CGm8rN-Tlv|~|-6(kB~ zeSRiLT2>XzC)Q}yb3}CfafQrf!S_u*Z~psWPO7#$i6+~^<*T!y=L!^tAiM)C7cx6+ zHQYcJnRjN^tSD7zes63U*%bb=_;|$0W@*tW!igsXb+E1d>Fm--!C%+Cg<CzPol!(~ z9@a>y7Z){Kcb9AS5ablH>q!2|F6iNrs%jAp3fy(o6H&i*)Ewzl=`OmPc&Ua4XXg3a ztSD9cR?Lm^<Ms0h>MKp*4ZC1Zw%4(`(M?sdP1B0+7Efq*qanKAm$nOIk?Ks6tHPY) z@9WY0y`1+~M*k;9!GeRiu5hfpbXJYD5K-W#5d92v8=D)(JNhOI1fP>qJ$Xt7*{y>O z;E7o%%N7`T8`T#xlgwuFlFUB6w$Kk0v~+A)<Qa2q{KxCEH>JdR*kPJ_#>r-K>3lY8 zIWR3X3`N&Pa8$awz*ISDkD6~|3nBmEbW7+Z959wY(5iM)KC4z2;g@Z~@D<oZs1oW7 z4$@z!B-iA8Kc|>Ct9Xu>Hr{=8jKJ-I!7~0Uw}bW0rWheqqkDoV<4w2ECs5)o)MOV> zVfh=Y-TA|n>?qk|Re!{+TP+*Kx8nvQeRQcdcYEu@-e2K2L7#i1gClTqP;d31TMc7z zje#drM`>jKm;F;&cPk}V+NIS>;i&k^^Ebr@<z)kVAFDnS*?!jPX^O-l&V+d*FgZEz zVtb;Chc*HqD0RRpP+d@Cqn{k5gTk3)1~?Sq#q26Z&@cCu`0|DR2g4!o+Vu@(wVMug zlZ81M%qv70>%PmpZ(C^r--zQ8|2G)snTBL+V^&%Oe1SHg@amhDC3D4Y=J)XL;`F<~ zzO3Z!t_}%sF-X`d-SWI~HEY<of{tGqF<<dedI_|Q5Wn85Sz2xPq4KpRc=G1Zp#h8! zyR6%Fp8RLk!4~y9-k^(PUY=ot`emOH)U35IC6V2T3Hf3QirAVcPe8AW@yaVzp7gV< zkyZsHJ4pv6MbNmPDg!1&jTF526Q)O?4Q#pHjr10Z{968QJp34e-~we0YuhN7a*eDS z8cx5Bg|+?qq;Z=s<oNT}c54{oOU`2jxg|pDnLqDucp%;6wZ{H~wlAFIc5Z=e0b$Er zY@>YRY#y@33IzOHd{6!`Et}6;^>Pdrx_Sl01+ayo9p*&o770dm5wh3Yw^{J61kZh( zcW=pAX5h_bq90fImDVoHx~=XQBtwSwx!?ug;XT6x_n2>Cs}2f^Uc8O`I&z%Jkl-iC zshkb!7tTpr7TxH%BEnyRq~fg0d|jY`aXBF&gyv?2sM_XiAJ67?23M>K#ecBrud+kv z`1|X#*Hjoq6|!C~##ZrXGiAF?SCPxh>GGt{P;yo3&f;yxFHR5xOT>r}UZaif^($db zm8d{NdV8CK!#3&pC064@&$oZP`7&51UzInaE5B5x0$gwN*Jlp|W^~SM1>M3~iQn(g z#5dQ<q7-<G(@I;8>^nFw28)+_F({O*V+PmY4^TLRx#YrZYIG{aawn_knup{fT;(<p zij;hL@N1bI2SSN>FMbR%V$6?<{E|avANrV4_Q8IkkPwOM)41Ogk(RBKLf4IonLQVC zMFwZ+e4qj(NDN+Qe1GzYH?GrK_#`v)O{KMrBx<On^*e=<0ZpgCin`X?Ar@*-#4lm_ z(7Odrz)QdA{}uW7U2c{8ODl9Fb5+gyNFtCesMmf-so3K$tPxZL^-#FV7lt7%vwV0X zUxb{)@%k6C_J10wz#cXYNxm_CCgi^E{-+%_=J1TY3BmbYtG*<8gsaPWV%flX1i1Rk zn)=BSgw4mS=BQCG(Rp}pZJDnHH-`IsdK<$~W0I!OC26fPwjV>R(m4>mbTz`C*rL`Y zr$9HVvwstY65X%t_3H1Q{|6%aj;$(f%9)D&lD~@$(yoQ{_`L2v!Qg$)q0F}`B`pgz z&QWHaoEV#y@AssCsUQS@t4^UBVfyYSFkRPfA#`q@@3)5#SuMFE)cf`{m>rvt<qMlE zj&+;xAV<#F;wi+JU1qEzE796TM@BRs&980hGX9AEu$80d&Ay7tg;s-+Z$LOfnlx$0 z&XwSPn`z$E%1JfwE{zg~h;Jpy??cJQ!EwIt&bo>VGJk<<6g}JivZ%=$=qG2@aBg?E zf0QI9iYpDk^4{1o-O@LK%Y&s_l<LfUcc=a-^0ns&wql3gP8X6;>%qS~2dPAJSt_D$ zaIhIWL|W63XPy7_q_t~G<U^iL_U_nl-7U`V)t>!>HnPOR;jqyW+tr%;$rK`8_%`Te zQSWu(R?OsjzpNJiq>N5X8*PbB0ao-5k9QQ2vCawl<LVQxZz`DwIkRaKH?T4fn1@nA z$zN%?U94aAJ2^sO#yu1S(a080n~Ewmu?2+R99~?{F1{crAv!m6lrnKOdr8x$+9yDS zP0fUn<)5Lqcb?k1<WC#;oV~sSFGj>+s=cY{X4S~!&exjSym$)_t9mz+UCr*|M4lA2 z=8M;A_aA;9PK?Ws&YXh@)-yKji+O_;AiJYofgQ{!5z}_E!dC~|t95`x5Jnj|4*qM0 z_<dgQ8zA#E*604JFYY0b$LAGLEVcq|6VT)Ryf9D6O3P?9T_UsdXM!9<4ZStkb}ADG z#?1-WiT&%2-hLUAZgeh=^MF|rjvTultWu-y8Vs~1FyFsit-~ASJWCB4D3mAt)5Ex` zK$`iUcfM(6tFiVnOO|=2{aA1byWoYT&iYYx8Wqa^Os?&P!YnDelgAKI7MjTY{E7d3 z*rylQjkP_voMxVg)EGT5!m8v`;l8h30>|o@N3Glf7J$+QBuODX9(PDc!^4JKTc)p> zeHF=M;rUOi^AIfv7>O@N0tV_V=e#H~K9AQlx9FI69W=L!f4Mk5Dh#nxE~mH3(cH)@ zi}WK0<==@K*uDJuH?CuPi98wIv}=d0F`c+NE=_~74U~%!#vW!UtMLjv9~F_o(QLzd z_{4PMGdgBwIl<fVH9RK$Nuo@6)C|nh2q%zIbd4Xe(C|$9z}q;DXuxf)4V7<GU^Alg z<JM_Tg$2)<(fQDMp}7TiWUdB`tK2>K`!~<V)8i)Iz0QK}t&M+)X!WpVGTz&7)9RlK zSNQ}k8TBFqMXMiW0Z4pyH=kwVV&Zl2<50nF{VzPx!pbbvjO)bx{#>(0(xg8Qv=3)# zCD)iD3xMkuG!4Z5DGTi<P5Cm2&S2-gywY>wLX1Rzby2{q0$I_)^ym8{zxIq?0R(=h zcd9j~ne4N>aAqgI%v%AG@;bTXwpw=eW|z}=`m;Mo7k@j~j#?E(A`rcV!VJ0r9slO& zs@^ebGf*G4T<{ZoBUgusqtU%32qC=wucZHt74}LIJMY<TF_%^zV|T~7s;4=6D=ifu z#hy3dv`^^h?r`-}Eu2lo8T!Ch$3i%?;9!2XV&YvcB$;xhTfOz-uXTGaGiJ>^YdJxh z<owRp3e~8dmEq&bZG6pj4riC$Ml-vLC{4wfJx?Jg<(MA>t~slZ{2B|R5Plc!N;~9i zO#*?Jd3^V@x;2sO!<Uo{?0k4Xa%Js%YUJOew2a!SirKc3(c3W`8Xgv&x`dB=_{gQQ zyrM=GwhX}B<{Aw|f_cByqcZR{=v(~v5lJc2YTdaE%!J^aA?|U<Jy)mxz$)e{&jtql z*;k30x8WRm5<Jt(b@uv~8{@fWb#c7GpF(;9$Db+fm^KaeQg{9)BFbF<T$V<Q;@HC6 z358?3GWw~e{x*{fgwJh{?XZi_T4_~G0_Sk4KJMaOD;M;42?>pfMj=l!h%!s>m<O9z zAjo+WtA!m0<f7ohl{3j|eX#O9gp|$Ot<)^0UT+-!`e`=~9kw5?-Qv8QSSu0pq)p(f zY&0)%8d-VCUQft8Zc-J<!i!89((_A!pR*_n)F!rYvO2QJAT|f<(**}K3ui9|mkA4L z8<RlUH(cdc#Y<oTi!UE6;NYi5G?dzLKX`B}Mw^+FpFDgs{8%J|jn1&<P0I$6KCp`( zYSin}MK@I(jqiVnu217DCRDBc#Ns||DXZ67I({j-zS*hUb8Lf-gMaB#N(i+NH2Aaf z@;Sm0zuHyVY3zr2?X`WnHdF%;<9-UUzE3j*mf|9#Q$;7@z`l`l7S&ahkhDdh{+t(Y zpc9~wGEcbA;615<+T}5{)T$QnzN(#ued+MbWj@-+lc0nr0}z-L(te{QS~RV+Af~tD zP^RLuSs>DfLSrV}I~0!>;dm`1TEc>F4Z2f#vz8zTn!V6*i&iSzhKtiH2GLG7OtCMa z9JY;Y_Om|U)|)R+Yi;XdZ~o;IkfHd0rn-=fA+U2W2EpI%hY)8nq2Cc)O0;vjFRyMI zcTY1@+@as!8Y~~>c&7hdmTnOI<fm&2-k6s7q5ifQKCyJ_Fjqd0cP&kY{;C}{<_A%b zwWRThbyY>h-=ja|L!M%pKNOC1@g~3i%zS>@5!-TWN)4M3@^ijbN{sE+PvFioX9$HL zY4Gg&b&={_XPt6k@kBrzHJ{Vw(Ubsu$CgByquoMc$Dxh5omw3?8H3_$0VNF>@$<^* z&)!#moFB?*6KUpcdq+se)@-m>*PQc%y^5b(!Z3(YeA^`>NLWA8$>4JnIsyx~^L%KH z<{KO|dcJGJ>H{*mwIkqSB6Q;~{M_A|8fr|Fivj{H^e7UTZi1PWtvd`zNr??L6JEE` z#Ado)8}t^~Lho9G<8(LM)+FAioVO|n{F<6vI=XHsJ6KY^g2RQQj0NwA1z8a~SqFFC z+t^H-Xrm@XOBePrtiAMEb-)*+L%`j?GGJ?_tsj0?&W+D^<Z%CD-j6Rf#%-nt2x@^A z9ZMbAmUs(1P5s)Mls|Nnox?-)s7Zrrl~xCY$g>J;d1T`?++QqfzI||BjPd#4+{4;u zzvml`c6f0|rkZ<UcJ#`-^U9@o>uwtR{T~H0b3pnZXX0dRx<=UmM40=RUxxWkMOT(b zlVTzbe9b4n-W>g}B;)Z)bhL6afiONoQDmw;*Ar;KG_h(MGC0|Ko%)HKIPd<<-<>A1 zn+p9UYGcFn#caVmTvKy_{L7dqpGHo_lrACo*YnHiP5b<pzgI5ec^rL-*lFNUX$;IT zpT(R(w(dZAaCrnLNhAeyew;fU+uYZ%^dBg!C#fcrtonJ-b_vj1JFHKY4)fCjFJpr{ zYi@jItYdhcK6*^E_Ft^EUc61IP|h!FY7}80w*N%gboY;x^L$;Z$aY&XhyKF}B=05L z$hXsH?mx`>WV2m{$M40#RlQ>PHGT&dPLP58;l~jS5|7*7=2qY^Q4IMVHsr|u6*HTW z9F1du_E$rWE=aAzhV^Qff><|Cxm2svp&v`cU^H7-EIE$01J1Y(wbj&I^C2-Vr#4Vp zT>#@+l{1DtUraHxc!AS!&_c@Mz293AnEM>ziSN@WNOn%)7nFU-8|A_uN{2$EYGJu~ z1v!8X3jhNT-MP)Di)1;h=5ZYon84RPhIw|JmE>waCBw{AWzH^Vn>jsW-x`Yfg*|YJ za~n&)M8<**_%Axe*B-7Aw)+UFnQt-WzmACoe2{Gq4y&v=adM3+W{(qzmm;^)0#O10 zXOZ-(5xnK{>2l%Q{ki~`yP4(X19sEaUvHcOuR&`BZ}9C3P?a5DBGo2h^TBx$B+x0P z;A90j+$WU2GaJhru)>;_Z2zt8A^&;eiwM&7M$4DLcV~R&xA`|qZo5@H<G`*Y;Sn@> zUE?!rHMf!{SMbZ4pg95xZ4~(6y1JpKX3kQr$YRxj2ImPYwR@X~w7Yb61>|!qY=}Xj zf-9N%seaRo_sk66(+BiMp?^>+z7P7Tr}!ETm2?}XK@Dlgq~ALGYnesYwt}nu_AT$) zU`+iEya){;A>rVcvk{du&TaG5V;t6$AK?a6(fTJ%?$80y=hAy{5viQd0SBH=_unBv zXr)DzVgIHaA6}*XDgtOZ$k0V&TGd@`m87UdUsek);eQMhk}FcQ--(C{k3^S1$JnxR z!&uwj2cyJ}L%6t9WL4db=#2%_KRfFN@iDpFL}}<NiN;JUtx~&twn?k#zO_97t_V~= zN&koSFG)Mq&4hU|1lrXa$VtihF?$l7=S*;IuyMH&V<Y%P|Exsa{tJEW8uhbdXcVoL zcHV6AxS0^#SaBg&GN3aJr19?}zm)JX4I{M<w19Me7Zt^iIA;8(`ukbL`=3I2^#IcN z9c+r&4s1#_zQCWCzFPeuE!Z!P>){N-83s{ENcUI%3`tds4ZOm}#YLD33UKzYY<8fK zG3zRqv(|5Y4!Ytba=P+|FP{A)x+-U!KtepWjPjtSXAfuM13&Ua`O$VY`__&?<<`;) z&!H9?pG)suT?WZ+YL~^>zpCz9P6l#zD%~Yw%*3;;GO<?Ubrylq<AG9+;JX#GyX$?a zaz_%Lg73p|mUdy|2d<oZJBx)!!8{ps(U!x=_gUcL*X#dftvKb6#Svw`w{V^{Wp70| zku6*wo1I*eq5`y$c*=wW%s{A9B%6ef?tMw<rf+Cq;<&i6Hr{kT-MpX<O!nj7*l+~M z2jcU=Kuoj*ky`dx$GT~j9{0hlPc_7v@Udp#C%Kg$`wbU)0tyU^fnVs#VbiLG;#}UE zMwOKm;xOv}EG_$VHfu4ui583&&|Wg|;x=szYO~gW+;n-#u1b@**_NKJ2LuK$P4OU{ zDbJrO^;6^Nk;bUMZ{fmuala}!%*T_B2}nn&IlyDk9&|lR=>j!qDbL~H$t9Z?VFwQ1 zd`TfgsBkb4B=VZ=)FZhhyLh3DU)F3dX<{`f@(gcJuZ7JTZ(PL9)bdATSOL_U(voIu zUR=X-{oSVIWrw}m7G=cIA&|>O%$yxEy8=J^Wa0he@6vc*Iw~4RD1UOxe8{T3th;=B z4b1XaQ}JDBmyRw3a2nVBd#PFeRi^<_<69q?pYP)o6K|TLl3f3dfrbS6wa;lk!nyVO zgha;0YS#1-Ye9=ZSFuNc$WNLQ1mQ-Wm}dTaaj#of+y9cc=C6ef6^&J+VqdlgaV7M+ zTR-)jJ0)gLTmDV1`5hJK42oDdwtW()3@g6=J-M+nd-nTDcrKe~S6T_ZB7`XZz$YF4 z-YdbfL6|h`VG$x>6-~-Tw4Rc=|CROsJ{X+nDB>9V95v}d7l>{0)CY)cJA-@o&MN-O zA+VQlV%tA+SC_T;chSVL^;fMxNLbX*nvFiJb7`*Hpm)?&>F%Pb$(p6RDW97}4it`w zB<<II%qR9#2_j-j^(4^SAD86&(2?}yKnGN5XMeogzw#AeQhYt;r-8}l%qgxURiK43 zXIUeu9PPT%zLsP^O}>sO`U4;3e6<d7uxbD7?Dm3Amjl9=BGOg78o(DignV#S&$yzb z&&K`boqwuT%oJ$OTMW53WBG@O|H-15nz$nXql03gpo^CxK=qNJde1*<<tVJ;D*o~y zTs;XO1~4$&iww+kqO_kKJwWhqT8H=Ld|hpG(*z&}iLlVv#^<uuhMd%bn!=*GzyPPv zQG}dn5o&^aoGnH-`yVoKkCr!-H~?Xl-7>mK=D51w0yoI16}PTx*|EGy5;3;ZMl+L~ zMtUHF3hZ^|04h7!5;lm=(F<}5Z?59@8bRDy*`Z7Q_Q+ErocnhsJ#p4M0{smsxe^mA zT%J-Q$(mtMWcBwIxSwL7j{N8!1we0Cqh{UU+DB|zAm3TRLu~;e?%<32D6}x>?FS)8 z94WHpd!7EC3uHCb>j<0(1{5b<(|L*0kvy};gMq7thINcqDj|GYEC^btBq56pXbo$Z z<^KYPubP#=?cq^<0VF_A@OvIeP&bwpoJ})>Yy=p%sv;R;qL|VELQbdTs!^!4$417m z#P2A`(TQB4s&zb13ZOC$^v%uJZ;-j{_lKLLWx~u9p1(vD)?STYT$l$$%@>2eyHkrN z)qZ*r;km-2F}^<cx!R-J^og$;=&n2+hh3x(0>%ATIy`MY$j3G|lP1cu0?02Hk1iBs zj{sN|pB_v3zd9U>BJ5f^FrHU9?U>nXx@#dlt+Y`_@<Xa1;Wz7h>)#fFyNk7M0;C*F z8TcS|rd@djV|TBs;lB27{V#}n0@MLk!n|Huzs=>I{x6jBC4`YOG|7K;4nH{H{XJW{ zhmkbNZhV?i;1lb1G8J!~G9jMpWxJ7C<Jr`jntgpW3@XR>9JYd%p4IeW!@T$FTLjUP zC`qZmBA+qLgP(I_@p%YZ+Zbb!z}i=jP#euuqGA%_Gxg9uMoaRZP$xy1M;S(kel4n7 z|2coV%dBTOI@+oXV_JK7byL@K`sLvG=<a-B>7RyN{wQnp5A+QBRU(qCGI-Set2~># zwjs|bT97uONtJ7B-HTOF{`)<4z!Qwg@>i~S@llkm;{7>nv<{vbgG#R9yvL8g@T4Y} zfVBM!5L1GTfa%YpqXY_XsBi7-p*=mZ$akQHmg}-SwX|cYWh_V^O;{UNDu^dT<6RbL zcPlFh;rn)%_JdeE`sm_iH$N8m&FF#0qhg?LM!z2EP)b+vz?VmUb>b{)D0#)Wj@2{R zuj+V7o)vH9WFhf2AM!CXt7@+`nCogGZLslj)p2d#9IQ^TdpTFc$VFQiX*XCS8Ynaf zsb7ZVfAeb#MSvvoQIKMx-R~&j!T+k!Pp$wzQUHq2RblEPfE1un5R?dEw#LQi>xas7 z49$x=mGHp{tBRf0G$}ZqzB59ARmq3={jdeoKGa-pfN-7Jbb3?3=O7xS-@xMi`e;t8 zRje^)-g&FvA%b?4G)dBs*Z0v+^T&q}LERzwxx06)ifI)1y#_jV@(3BybqNO1Gn-1A z3TCtdy01j2jPO9@_Q7@AUrY8=??9<9U#h$w!;7TB33VetqalLB18DO~+q2^iwlt3f zZnD4c^JAB-B<H9uYiFiow|qU{M}#JZm=?oabN+l76kh#sH{}x%Z0a?22rqGq?vQ21 zbht`iDcUiaw+8qtcEzoj4KCzZafxR_3rVXh1)eY`!@a>Qx6vz<ku>z{OBN}Wuu7vq zL3ac=DVLKSWDgJC60w)3sD8w-QiYNUg0#9?ChuCPIZ$K1P`V=VOQhWiSF%2|x;?fM zx@~9|H`M`%D4z17%M&d4PJ+=-H>qcq8}pPn1xPjU7qGXtf~*N(exS=Jhr15MWEB8B zn2IJxN3DjU&}BjF#C1-9$y^nQ-_5BT`D>8y?SsAxE;bZUywN5D{i{aqKLjnuoq)b@ zwjx9t3~bTtA&=}qzcPz|N{VxT!loAg*XH~Lsf|_y<T|5MLKNzf{$gf5A2F!(+g6I1 z$EyW0&;D54hX(XU+LOa`!;43?S_w$+ENecI_SRZ;YxD*yvel&v7LC4wF=lu3(Ns6j zZfvRi*V)f?U2U)xq+;WE)wjO)3Edw6K7HB|LQ-m|)CuUe<-a8V2T(A7PLv2eX%G<W zq(m7(S7bf+RW6jO{|>)*=7x7JZaPK%gEvLW(Je}IQWcj{xv(nw3ep}&qakVmkR9l? zfshz(bgSHlMztEelK_s3ysGJGqc<!}#q<o5*;M1dEGlf5+4{Wv*{wJzm1qabD}GoA zl-q1g{jNNT1n+voh(4chx&<#gtE?ie0!(slLr;LRwAcEQ@d5|Mjq~UTGx31E%h%1U zofISU!^5z89r0<xU;VgBd+uJjI_=&_U8<4R7f&yoBZiY;4V}?Rdb(542}l$iHrIho zG&yReTC&z;IkM%Dc8j_>lbr-?Cez#VFd8OP==AjTH>Sb!H%<*ON!TrGyv}1t-igrB zB*1Hs*9j5??)z{OJ4%THcYDS1*{dg(nY~R|=keW?A7^Z2W~8_$MtV~aoZSMlUk3jB z_R_Dfqwb*1CNU^-L#aw8mmx;MD=mNlP!%95|FRpygyG<-St6e$I@sGC%Vl=D9lCgZ zt!$!bsF|vpnmfO+@Iy&Jk6*`nYS&*yX9YusaR)bndO|RKH0X29=Z~$Sq;G|l07dG+ z4$cGRy+Ybsh71+L>{*a$JlSINg2zw9wEV5N46(q&MKFs3Uoih`=V;~wwFrHa^_|i( z<cLD%tj%BGUGqSuT-OkjnYBe>0x}8Dw!7o_6MK#wsuNIZDG?<%BtrfERaLAz|Ig3s z+4}hW*;(T<x>4x3s)CA=j11a!U1_eaIR8np5eeo>=Z{=8`cD@;DP2o8V=~5ZjhJ#k zS_>GP*nzgS<g1=r!=5TTj{L&!`xoJKd9<Yg+K*fL<uQ!QECKpG>NS*Je4v1h8GlA_ z@ylqEZgTd=n|<-#x6Q9~ezI0+-;p<a+{^*pt9!GE{5Rx3&1+fd??7|-Fe$GRL`Ch) zYKM*h-Tx<zy_XQ0R|$oUY4Y>@LN1`E0b(YmH$S{V0OB_J-?5?tbwr5si(Ytd5PC`3 z(B*`8tXR}38p{Aw9F?eU<5{f&msacO45+qXt!oh{qiil)j(cxuY|gLB#znZxEDB zbO$5dToqM4e`GT_XffG}CY=e4y8Gi|6L2LCiVF>I2D0*netma8zgP1?{`Djp3VT@# z$Y%Rbicj@JN9p_QVWi$iO)P;nd~2i=2nUJ<`Nq3xgstenM>w`E7n$EBKp}Ad0ZJyh zjO169K+RxX!Pril;cE~&V%4!dT)8T-a8#RTYZi2FF2iH#O&qLV`OwwNEz)NHUlHYr zNKRTsN>&-;V(aKbFMW#>$sbxPfxJM$`Az*rDQTDPKNflFK>j@@yYgOl=EaM}Na;*k z9ZbKr5oaa+*uuQYj>Afi_1Bz%Coxl>92Go!IX>fDtMc0z@3VulIcUVumRd|fxS!l2 z@#wil908keXcLIQPQX4JD@P$m@>|q^0HO6kKcko_C%g`vAqlJVtj{`ka#dYHR+&yL zleXJ;qD=h?*xu;(>&05lvUPF&9507SL%I<=;<Fq2&fY!fh55~s<q67`2d0~L#c>|N zi}69E)-1B{{)1s*k#BN30lFXL)tV(;zd0&6%b?}aK)OVRG!v`9D{Pw<5GrOlw!sJj zRKyGDN#Z~<D^<~PGm(VDxD$ydoy`CXDU9yb+~p&n<PK;H6%lr^>REX_t>}-_vgq5? z)#QZF`;*a7rGV54CxZbVbXm)n^)4lTa7G|=Pfk9j<xGmz#`8|8n~nw9c#Huf^IN3y zswak2;gr%ZIr&~bi>PCQbsZlbq<98s8yO>!u|TPB!AQX~GIe@J@;-1dJW>Ps7EKp8 zk9Rs%zrFikxQ_D5TLV0jZ6=nd=<5-APq<9FG^u6mBp-n3tU0Ek35>Ae)`$mfg=kG2 zB%AA;Kd?GpLJ+=d>1YhTds<|R@5syNL5iIkFRjf-@O}Tagps7CjPvS^lVmQ%4Ax}> z60qZF86vq{)_z0fhowKUee@6SG8!@(i|RPq-o=7|QAKxr8O@Ou$Wz4}m&)ZMX+M%P zj-rl8TY!3yLDk8bwP+Dj3yRanbbWfsif{h0`xq^jErZ!1f;zAe7lySr;^{Swn)=D4 z;T+`d_~)on#6%VeBSa6D`h@zwy$*zA4?^X)j_gV1MFEv&DSVDLF`b&(l$DlN@e##$ zD{@HFkD<;SN|ez}=Z))enrT?n*>51r?JxL=QSr65l#KBf5SA{vIMh*mBp;RJhKB)# z$D#%!rtxsm8m0X!vjOr*!4(NX1EI%-KDJOcBP^fUJJ!v3U)FV+lzu{iW9;5AaFE)^ z8y+-K1`JGPreg36#ugt*TUK`bpBhcSPshAJxx4`O-zM6`CVrsuKo$cZ_9MBdQ866< zV)xl~&C}!HgV`na^G~9pgCb+P9j#s&&b8C;J!m}r8qHevniRuQ`-}bCLdi&(;QuCr zWY8_yK;l2e;*&S~SM^{JibkhfC;j3XxM^$VkH5~V3=(qZo|wepwWHd&ElL69ARq?$ zs3jYs7>2Ve0W{sXr?4VkLE=vSLKAUPBlJ8|Q>=gI!LWs`aDs2}Frd?*P1^+2ivu*) zcTXb|3uW0*u#SF9X9^09k!I>^O=dfwKJLkz2m5-`_Bu8b^pRGZz$fJPi!jR*u<WnN zPE;m2LOS=Br#ya(r1X@d`w}k|G210CAeVrd5~JY+BoE~qi;+K?%I<EilXUAgtk0~t z$<V*+2*oI~uH+xtxqZGLVp{x2*mL86DIdvI^qfjOy%F%^^&?$Dh%PEXhcD)+^+o)E z(+&>iPx$e#Wvl1>Uc+QHk1auU`2?BcL|dQBmU6w?`#c_=$A|EcYPwa^GYpMFM;4Ac z<Yr!R@?#9B_GQYs&F^g)8g1`wG)u2zfz-}HDo8Q|{;U~9>xKV5QcG$Q((&i@_Pxpr zMGASeKmvM||MkHvBrhI0D88pyDu6~wF$6nDc`RV+nB{-4bruA80+4?Ugf?%K`PKDn zD>aRjPYzd)$U&S<N4y)$IcWTdB-Ib*^u$-n3V#Ty$TbAiw^sR(3uI8@&R`31#%Y?L z7@eA)n6p_ZHEw<rqa0yMODP@OwOai0+3hrk(o)trS)|}e<}BQ*#q&1@NB37Jxt%{k zFZZ=3R%TT%)WA$Tx;pzSwnz;OY5aW%vcm*OryMuhkaTI4TIeSfe}bTeGkYV-O4w_Q z;aFvYFg?Xr>7V)Y(O4%Ru{`C`IpafXR0cX~5ACu~(l<m59MD?a5O~k5%5AI}ItM@P zz57NGp(lT+V@cf0qFPjEI>;0obo0wvhDS0p!wofg1ETEfb97%0ZmTi)Ut|B1msG)y z>1h=$bd%UK455-Iz<(YT6Sf8oRDN6g!FAB}k{-C)@J#Xh5kVf#3`LzPN+pby6Y!^} z#bTpbTmABuw9w=ShZFYrlIjjX1P7Y&z@ew>`*@E-lFj_uu}H^O(~fM4iyA>Y2>4-? z6)_60be`q79sF=3;e3kM#q;2uJ0ALko-|vHj82V=jq`P8BV|jI#zJ&0Fz44!CgXAw zc%RQA(k)3mb01g#E;JT~xCJ`bWsUDu3gQy)R<AwV!lQp7X*~M+j%d~YVfB*-=QHg# zk9m`_(gOpAv1IWMF>nCw$`}-MlC$9IuY!I#Ls6kvC{aj{-%I3M7Q-{+!0u}+Fd&6? zEeAC;m5YJzameH(-UE;KWHcC(T~yQf`EraqMma`?mh{zg``N-IN7XZajr^FeM3LYd zfq*Ay@yZ!G?O*V;DJkt7?ZWkR_jz(pso6CPPUd!oo7*#rMOD@^5qe=08p<PsILC}& zl59663!_#B${8I6rrDM3*;T$5F+Fd8H!NR^AKW@SbNI(YcDEV_&s{d{=MxuiRhq)> z4z~{2C?Z6E=u>W%krRNp-AxhZzWxy=Eh+sw(n@T#ZE2`EhkvV1o4s&-GoqLLKs&!y zAx~PB^S1G+=6vj)qP6w)6yaxAwaLjzSf2``g=LDhl;3aft^o;f&p5c{P*C>?3xBcQ ziFKk8vNpFe9RAk$q(DMOCMGT>HYu(>4h{OVq9PzqAgx0*A%2bzPew`#&2?2!MyF=o zqp+-^4EYtjt<A~r9X?-P5CArZ#wfd0rhWw(hO*r6C};AE`Upj;L5ste>x%qxN1sQi zWo5&=UCW0{ODc@kF_*`RON25)*V;VG1bTagmRSShnER0LgX;i(HxN$JnkZ~^v&I-4 zGU#{04uGozLjK{K7*?>6(lavC7i4CB3wiO&r=p_5r>aPeJ3FwTAbyRcGYKpy>R+;} zDO7*B(4C&yEc)X;V7>LdA1S3iZD?o+=GYz;X?L5;KG&Pj{g<H4vToBO6i_xA7V!FL z%(7v9X0p24;ryK0!p5SiGV*HPpN2Fk>fdPJ0F;g_JtHijZrvjp|KXWl56?WzrJzI| zyeqh9N=3G`u|PJM`g%-mEtNMWS4zF2o!M=FQ9<|nZXhu3`bhEWmvoAN{_Tf2CL>Yj zN)&DIIX`)l!|GGst8tn|g@paVlc+8UkmPKOGW%y<aik{Z=H-n@QKPH&hwa=Ny--k6 za=bjhfc2c5>~dJ^5cMxy%^-?@%RJvPPF=3^@}&T&g+TM<5O8qCFb{`?J|yvdQdHQ- zV>o29`R0X4p5Sb*3Plz9-*E|vk`k)9$JD9vN=oc?4h)B8o6C!t$JBRxcM#HBR0ib? z$(yS}59rU;*65Iwr%!0j9v>(nqmXFzPg;jRrkb$c`T6zpIKQ|epUvE!?c?xk%9vGK z%JPB)XWk|fRm7W>rrishfrRtC+FGGD?Y(bLZ@+e&<h+P^c%IzD^Ns*L=uEy5Uwz@? zp2FGazPP$oP+xv7d3bSHikg8L;w_YMXgFCdv6M*>5jawJjrSc>EF4`W!YRMOL+fbV zmfX6E@z<++q&d<`eLC%bKiMR?Aahl5#4AjPnE&VMORsXvGea`{YInmzmENY%n6Mb# zk*B@WiYuG{#)fFJ-NW)m>SlSqO}A4{MP(!yn3&9^D`3mn(in99v5Gn7`UCQ1?ECJn z=6pEktm>gi+`xaI7umw2BD7V+Mw|%A8hkmoadEIOn61wYwZGV!&JuAkA6fRiFwkRT zGDkw(YwJz2K)XLMx*iV)#+14Pmy(j|Ex6~!Cy}YrhJZvk57_VVU(ej0R~eW&G!cQK zgm;<H!1qc@*w#K&G*n`~>?AvVZkZa1oGv2+o>TY$4Af!a?x}@U5*kuMLaMKa%qwMO zw)#_rb>;N!Ifc1>-9o*C{rNREq8loksghIGj*Q0#h?eHY$;Z^TnX(gYH#c&kf!g!w zr$|9m(^ibra!##+zwbY@K_+%`f&TrotE>uYDGsjv{0}7!Ivwn;U}3AmZ8G)Sz3gM6 zLVun1!{cJJT*1QB)jCo(_##Q7iV54b`pLC+1jaE$8NTnsx;N#~M^omVdY3b`(LFMN z0EcvR%+!#F`q$7}Rm?%wp;V#)eR_O+fSLC1=#1P3zZJ<2<IqPcc9yzoVMavVVBm(_ z$O*UsrbMTdjx4r8I*IGOTBoN0(hUS5#%H`ID+9MP#{c@8cflGuFOo`$jjbe($7MGE zgq{7R4)d_My+U_;wN|JTJ>VoNjOXXNiW<D%+{3B5;=>N=3aUs!4rVRjixP{$4nk`K z0s{-ApRZ)e<`u2#fQ#rT&sRMMsCOHmwuoh3$IizZO)L(62w9#P8ttS>Hi-TQQIF{Y z+ZFvP3~5M~0hqMgOn2{hadFY#hOv2da$t;5Mr>wsY;5fI@iOJqhM{#}{Mz&s`}s>o z+aubHtwb|Y;(sE;W0c;51|#`__D&w$<o8bzdG>JVzR1XitFbr><{CWOhoq>KvcY!^ z8wVi^3HOL|Y*u5Fo%;8ej*PE{uHDzmmOpk6n#ku#Qz_b(*y`hV3l(eMqs%Xyhld#_ za9RJz-6HujZBu4z9<Ci8@?+xfvaZ@dzet@;f#NJVwP_+)Ct|#EJMPD(&CT2+Qe-SU z#PWm&tpFw!X$tI|%Aa0@_j_0VxOZEYhhD(w!K*JUkL1hfwXR{M7>&tn<iLBqs0nRi zO#y$&J-SM5syY(Hhzd+M5*-8Ub4z<aVKu*F&goOb2PELh{P4dcM>xhf#Od<Y<>^Q7 zK{_$RirQ*gE_RNL{BpricVH_}L~KW9Lt`y!TT}hsZjt`lc;haZ2VChVQha%?cE54I z5FghH+G{e~l9;aj{Q#8K*2b+-+82#FQLbpGt9E~vo4`6=^`tia(D?MDuFaZvW$8$2 zfJG-_49XBg-)N_JuCrU*gE@S7K&i3}**qFxwltJ<+P;B-CU#dJ#wjACmEM(soA7|= zy;PyrFw(;tO83h1sajTgmj!$p;>BOX;Z<sY$?#`S%O4gB`|vPGWUO5!E!V_vcxdC( zTlcqCGCylGR}EdnY`z)l@GEfOm6Vj693LDVAeL8@Vb;b;&@KPOG>XWz?yM^)xL~&L za=$l<(ms4{rQ!Qj#*}o+Qc_Ge_9Gh4pJ@Q)f)&9z+4-La#l&l3qntaLNv*6b4_?Dr zSR94xfjCP-H5SEIqzPMi8~43u#SX=VQ1i~c$M}Kruy64g%Q|X8Y*}YZHm5=07)eY` z3RZ{0lT(t{##{cg!4w72#3d{Ya6?Mll=fi*No3@Wxt$&Zg&#g@>=4ht%*MV4V}6wT zH|tX{u+Uh%XgYD3&C^)w5RDbC4(_h8DPZGbrf1$73Eb!bMmg3xGtrd8`&{pZtyKJ~ z(kg&Xjt_sGmR<n3al%XHyf?0{Uk}hZKtb8FIEYiT^nY!AcRbeb_y0{wimWn2naL_6 zA}b-gAwot7k-ai+?*>K4%H~E$R<gIUvu?Z0$e!7o?|Hes-=E(fzx&~zm)E$i>s;qL z=XsvzT(6fnMIq%tV(f7}-h|AIJP{bb?c<7xKugPcx^3Yl^PfKr^*7yhRa){_QqSn7 zXLYPeGYv~sR^1`z!dtrqP(|w61o^)zr>?6TW;1a+f8i~5X4s7)>Y8=ZJ*}xd`aW>3 z=cvZE!P)lpahCh#G~8Ay3XaX1y^mXo%<FhFf#7buZXB+K31wXXN9(tr$QCoUognaW zaotg<plDXmQfRNWuXpsge1&zOIz8x(2TB*w1v3A`5lv8x284xE&2EP(KI#1G;SAP` zoa<W*Yurw0-p4zs@<n~e>%5Wa7CBTMfpEJe<-6a`>BcWxb^VlVNF4SgTT^!s;$s`h zQw<cf9;|KuI$TL!_ZGS3qzUD}V+@UpdLN$D&r^P_prGZuSihLaDQ%*Ok0}#<wMBo< zjVG_6S<5^~b;R6kx$s<*0Kr|jeuxkwn_6!-s;ae5lRIb2Nnad!vHs6iW8>`>MYXJh z`oigf{yuHCXAD($5F5jK>kzfsYkuW3{ivI9P_L7&e(^CwOpI=ACV@Z_ET9+(^#@va z<4FquIa&|PtSWkqAd`Fh1x^&p+2Bzn^bZ0zmX|iHI|llq(8Vv#*a>v<^Ko=fve1i^ zUOT<W>dGW_8EKB#SoJ@5e??MQXzAIWWSlVUodm=*s>W2|l(`+iG(9oW`BBS)Rs;Xz z&6j{s$#7ECJd~J&xa803^t%@dC1v<ru1|x}a^Agy(-YctypOVoo=6_uaDcM3u%M<J zY8?7@cUAF<Qg7A+jyN&ByLuWMK3m7Vw>MAx$fdY&9dYv|IB!+FO=z|#=fC$d%5R9! zgj-VhkDa3XAQL4G#llGU?KlsVG&1(C_{ih`!av{BD<OZ>2A8Ulh50JKFxUD0W44H# zvC|xWey3(Oy?%dL=gngLI)CgWG8RnSq1u^b6@9v}^}SKd^Pz!_5~;O5?fO41054Q; z92A08abl_}ee^vBF=a(8xGCl0tcY^hzctA8=Vw*1R=R3W&t(^l7cNbTYORg8%g6Qa zr9IQmn>I9U6aRCC3z77Uf#;E=ZIZqQC;CkW^e*8QIIV*sbr4((1J4FsR8l&Xe-Jzo zk|`Tq`s~@W@!?Zs{-A_aJmiOl;L(F8wOjWItv56uT)T#+QavC}|D%|qSr6<T0JW&L z^634t+;q3w4+&*8@QtNld`F<GIjwsp7kyw`D&_*B1Qb(PFfoj|kn{b;KwgbVOX{#c zx<mNF#{~WN_qgzu5oBb88|Ri%iOtuKpMIxE0;HR#Cma1k|EWRo3jx5FH0nR*=2_kQ znqqD~UkY@K&CgT4fWAuc<PslwaQg8{la$oD;Z1FIxJJ%D+SPT3v*;YRG|u(9Jk5c4 z1AWJyT39Vv7~dFC6|X}j-iHVS!RupB4&a59nX$S9d&guLVjqPLou-n`)X>xjg>RDf z|6Cq=`Q;WkE3elq2()=f0GM1)`;o5h^zW%>i@!guilPj(5dFY~LI6R}z)W}@=kG;X z%7Sp<M}q`5Rf&go7KM6$4$zjt^+T0_uX=jHaE>AX6zVJzoJ9x=9KjO+C<=(2n$IEj z9)9D97cqH@^dHXU9G)XSI``)$axcls|L%Q@V5er((Rmi6HxZ(UX2DMmner>Thie89 z3<^Affp4?Kk)J{kFmj&f-}6LBpKl)ZIf4-x59`r*t~@}-8<hL%zwt&~Mb4WYsuKi< zE{ODO50@GQcI=3(jARf4gaCC!-Rr7v;^!e!fuF(M!}C%{=Wieb_C1t5oNGnS9sl>a zXrx_cZjS%@OcelqeouQe-qXm^Yxq}IwHHAaW70>Dy@}ZML@o8B7#M}JL14es`6J<c z2moh6_6jFv)V?$b_IAL(N7Nvy3m276<Ma=@yzmBTmf)z_0enPMB1cau;z2|~Mn!XE z-r>7kui$Dd>Cx3)r{QTBuA{3z@j-Mc#c?Eb3_aY7iQ^XdZ-7_G;A$KBf6q%I=g%A+ z8IZxfznzYzND3bj&NV-h|9Z(P52F}346dQita5PFbu=faX++8zM-RGi1{&dxVI#yD zJGhu1^vtXF-;1xqlvUv$O@f!Q85U*m^xsxBpw%zuss6=?px(%8{osXC!3kdKDqMhs zr$+-;1Pa27mPLvZu{`hz3r1+Vnc~QVfrbYmEk|CI()oAJ5r1-pp1|y1iPd?-Oila) zs&_QAA~3U6UE~-GWJvJ4au2yfmtYFV$N=V~gEo}O@k*$Uroc!Z^dv;aD+_sq6s8Uj zZ%9usPA_g@A&!x8!D_)OE6a+6PDE6sYk%SQ%#2+p%ek<ybK~Pro+OW-3wx5B%*IxG z{P=NAq2+Az{R@(k2g~D1VPU&uXHh661i(<<^r}Gg$xHCjqep!O;F>7<M6Oh|we|M) zs$r$!*2u_6EiJ9-aK2PlsJ&(n`w(^t`N};FGXD`!66;8SIMI$0$Jv7UzNem~L_~MZ zedgve#y*a{di5$vKEQncVVV$#>lv&MgRzs?UPN42`P*i<v*XH3Hs+?mV**`JKduxW zz##5A_cO<jn(uSBd2%nvC=n24RRlRE8(C$TV{U{#5+2*cG=DMim7nOZeGHC|N3Z@3 zN~#ZtlW<g=hN>H{ONdBS38v@~85aqVk_Hkox?>KG9gCq80rX|1sH7BdUgE3$WWdK7 z)?{aB<0hgrKglrG2=Ku0o(VV;D6u{zbByp;|1Yj`KWW|=$&D4Y%JY(*@6xZsq$wdS zFhUE+K#DRYHU@=_8^|<;g@I;J*3g*P81^`*-``W@1AgoyGJh=6UKQjpp<X(F|Nf;B zvwHTh$tIbaoO%EIYscB1Wc`YaRc0{FuJGJD=kd1Lk?3PTuvNE&m`P&bSFP-{EWzij ziUN=R9+l{84>Z&-mBR$QN7V7`Ew2h9<jDtX-?0hD2ubd_o{nsO7ZVg3`e_wjuP1dD zX6e!klm{iE(+79b)RXGpP4|?ScK9`n-P7*t?@Zw=hr%c_KhS|?7O7#xtayc$m+#dm zgn&Avt}-ucZG0ni^E}X<<^k!Rhe^D^fuDB`)^k;dN*sT(Onrc&^~HGKW#79=a&~yo z4ApB`R?8QpERZ?l?pxt2yJGg&Ai3~;cPoRK<i6R<4js__;~;e@L3k4Mtjca}x^r@E zq&jTqrd4N(YPtJ#@Zm*IVEUyl9bI(uIKhd!=15Cx)9h8|ufCNw7uiL%4}0|;>D5WR z?GI*%t1~h+6}H~Tmqbg#g!Jz$mKUOjC6WuFO*tgu35H=(fXobR<J|VQzL831tWw!= zaq=<>n<zhd5Z2)b7qTGkiijLzjacX}+`PQJTpHP5^O}SO3-*_rOyfOL5b0J;A>F%7 zFx8&`P?x&_ORev2_FF2s@J`iOA^VdjCLr5)s>e7|>-P66JUvP1w?)K?e~Q6-f<(-V zN=ifoZNA=1&&)X3a;}~E{P}zOYgo7E&Yc^ld-6ncdHE~9fIu$)3cQwZKibP>d3}Ao z`|rxy@~Yi4tklZW>ED>Gxo5u@My!l1cG~23ceb?dYG|vg@3y5rsg<k@`|!cobaHp4 zF)~O?TjTTUFoA(0vi?NySLYGe_~CoYpht_?VguSrPOdIFPbr))Syxr9Qu~h0AALI; zi1h62dUhv9&(hG)3<&;g8`fVw-Wn$^hF*BoSpWU(Yj5upggyj5uLw>Y7h2!2ce1@| zWANm`6FFD8?eV5YIt2xVw+c_jp9E)|^6RK-<jm8`{hgvB6rbo%FKjh7X|c)ofb;U@ z*5-!WaHUGOyIqQzgczIFxhvMl+9JT)K6DjIOFt<CCHGbZzA`xP{#<%VNP7OdT~T6L z;=bB-5m9LUm5)#1)6Rki=^DY$zPfgQeo1)dHvi|I9}m)Rl$Wude>l4_jMhK+Mi?Ae ze%HX@_wMV{vdVhJhT|hLgtE%l9<5OEh&s=76?p8te@{jm(DdU6y#2;`t|90TRHxd+ zFw%)!dsylj`=&93UAeij5tufOJ9qBXG}E@Kw!WA!H8quO@x6SR`>fyrJIu~}U*T*= zvfqS7tXj1ke@sL~#Pj8`MkDz^RyOuVoAU-GAb$+gzw$R6pXrW^i%V4Fyv)73<ioVF z@XKg3d%vJC_w;Gf#g1{_7Hd~)y@%?8t~Do*2^SOLX-AWvm;w{KHq)IeliCV~-<SK& z-McQMHN!rn45EK~^?kW*#MtRD9((RdiH46Jk$ALk&yS5y%C^OXg@<#P?K=hp1UNg+ zf8Hur3g=C1D`99Ep2%U5CuNdwSfA5Mb^>+>(7~#dSJp8ECG3|t&5By>5+uk2M`=B@ zyB9Fu&&8E|a8+l8s%5bx-C}X5d{F#`gR4zbprq$sw`K>gj8~7z4ZOagZwYPX9w%;W zf|ZU3t~D?*adfcMX<;BI>Gy%2bkf=T#8?Ru`qmgBr2(~srxk&QiKb6Xj88@BH#IfQ zCit8f>Msl|qPCr#0Tb1esdKRVC-C#<%V4bEk%Q55xCuQ(7Vy5F+uqvF_WC*D%A!xt z_jg^=I5|fHuwdmF#GFbtDkK<09rE(?i~rQ8ch9{2Q?FlaC)+1<65IFgqLiDf>zVT+ zpVTUM5Wi5`IKP{NK2XyscvRw`@JmRUSIX`A^INqw&)wXVB#Jd$T(;(>rn<V+rB{Ta zge<OYj>v;_E-##vIeU(3p)cQUryx^SR#tHlpN0i2ZWkq92?Bk*w)}KUYI-%hPT$Oa zRLqZFh{@po$GLLoV_e!&&0ESGKPm-JQJC4Uczun(wsPtjXC;5*RSy@c2<B^h{LZet z=CR`+b5J%=lZT&|o<7^Gb$3*+O;78=b@W2ntIg-qE64DU`4GEg-Jeu_F`@LStZa8O z9^JRzq%m}xvWN{jG5}EGup^Nx>+5<IcF9FsLFD9|jg6xd!>aR7PQN@sS~j@j%%fkh zIu(=@9IxcnlcMg`<9>$tyr2#C_cytn&yzZc?=fw&9=6_InCVVmj<>pr*Y~T|rLI{E z4-b!`wJc~t%ur!&v@=!pEzQuE$4T&h<P_7kxSpPr)oTC5@86j`Hy`)5#&&miBl2MY z&(Y<N(iOhH9exu&$K0LiwvqFvj)-o*-J8^Tet^yFx%k?A??!ul+~1`o3D?yI>r%Aa zavFz>O!WBpq(75@MvX0jnWZnm)Q1rs`9MaN(Vd%&<4?xvg+0rh+w>yagu+nszo)L) zYp92?t9+z;PMNP=Za(f??Q=#+LM!>6xS-8%fwmi#7OQ-Xm9fq5n!D3AQchMpAi8oF zj872*zQf>2a3l8X@C6<5rLGWWcItEI0_m1g-ZZ*S^)Bwed$(FtB+|m<JlEAY*kaih zC(e9^S;`{hx>SE(-x*r|v2XtzQ<=?AE_9gt*-kp7uk+{;cmG><a?Jeve5y*IS$kqJ zYp2!I>TOcz#Si-&ZSL;wa{i2i%-qV$jr{3ZS^J)y&XRk1$&i@>Kx2fUG)sc1lE__B zfH$A2e=hwgb)024T+WIWlj!>!^ro@y9V%0$6~%D&tf1@ML)%{dhDCGfZ*Gg_98=6E zE$f&tmd`9xRoukN9u$ovSxwGnR}3xxAq@_kZ_l0N$}Tt8rvBc=BOx&oE0k_$Zx^OV za0p9_Ky|DT9lq`tDK5q;s9rOcoRd=@$V7eh7XY&SMk<V$^8&wlGYui({qkSGox04| z-~&GSPnxJ`Tltq!nfKDz%hxn~0vOn|rau%|4sK7^`FFH-Wr(lVQWJu0$TRYnR<N~w z_AH}l<0*kM1K6A_aLwe1?J1z*endk?bIEYIxq%jZ2{Svr0H6RD{8jjX%~lPiTGY3E z`ofh-CUk~w>3Y;}VYI*mDs!uqLGtm<@Y01~b2iJJ^#!e+eeI{F<`oL{Dm23dx?)f3 z=>lhFESR$N3$3#UHo}gvBjJt)!IbNjex^pKZV+*L3l{55<DdF~mZm1kqk)oPY6n@3 z)Oq#Fij0>=6BC(j<!S7_JnhFChRWTP@&gNGe9mO*72nI$P!h?#%QDP)u$PU=&XF7M z)%V=m&20-E!oOk;sznum@<75PuV2~)hRK0}g>M?~rz!)(e<V>N671zL^Y1kZv7*it zg2O*QeoSe78^FuQM=dyPc5F30yL_lnU!Q2BxG^n+!-$&i!#dAeSy`D$?JYMK#e?JD zMCSYQSCZi6;pN8-=D^u-Q{im~AgY|3Z~Uu6&fguJUzyEFV+aR0KNDC&H;=7;YEN=o zpJ6jAvIRW#c)!f$!Gj0P-t?wkp8Kz<PmnNj&r1j^Dl5lOQnJhor=>^hs5^vWva{qY zR-A_|`|=b3ejy=>LF7s7nluu^rxu^45+piBan)ruyV7%gI`wGkZ1J|oHT8=R_V?i* z*U9;MGoQS|+s>(Ha@&qHWgDI>QF{9H>eZ_du~4)=`#mOTKhZ38RbShGpiWKVSA}IX zz(ICkBdR*cFtbSh>x!CyX^S~I6}zFGwDdQf5O%=DZ)mI_pU?-}``UFo8uqs8nHDQn z0>+H|d89U&{#GgRm2(@!9t6e4o)hBw{OQY`z8nFQ){_>ryz|PGUrp9#j-mIGSE;<7 zLR|OdJi*lV1;DS(ouO&>`wOl4%r-wTp?9~_vk6FqRY7>n_zv}|u|rWewE801CujL8 z2fjY>%M<XwEHyk>y74B6MV9+M7oQ}b>jZb>Fqh-lRC_u1oOVRCy5<A97GFgLg}big zRmK2<AL8RXcp#W;LSahB4QMLTo~*$_=WNWD+#6Wf%ZCe_i@omKkly#ml-nz9y}`A9 zYB3@gN0k2%VF%q-X~a$5<>!Aw6Y*WX9Pl<SEHa35{-@6glJlagvQJH;5_flZBZGn< zl!J&Qf*8ziuDn+k2Lj-UN$75DZc*!z3F~KSp!c^-b<kT62FT@JL%V&b`H2My)7JCk z!wo5)$}-%0nrR_ayGJ4zj2f(v@lgoj=>Me7e69TwA74BJLu^zOd;xfUzF#d;^g&-_ z3fI-8(F|IFFwlJ@tTuUv(p5urpEh{%@=F;)QqS$iy4@G<?Sz~GhmDDw%>7&tcfBp7 z!r|iLQXg=>rTIsh>zesUce;6gUu}2o;NW0Wf2xPi8I@?kyT<7dBg(aea<9<Jcv*E8 z%#SsOSkBMg%1F5TpLcv(FDfd^>+8R^x*F>AOq`94Jq}{WA7PJLI7}66-zp~CIohw- zZ2YyD>rHObWn^UZeYQ4VIK2CJ&qOxhgfc|pURumjy_5*j@V+5_Lo-W@r&=1q>;6Kk zz3J3=MI}XJ5mio51&R|>>kOjGTZ?Em+6U;RC8GL31~JdMsbr<_nawL|d~KyA4)fSF zBcC%2#{HQ+^7Ik|`S#(gNoPs1Ez!bgG5+sEs$F}Rd(F)asd+>_W<tk8^j){Y_DNU6 z-JdZC>Z=Q(S?U8AuY2w;mJG}1Jhib&ZH--+ao6m8ZxH!m=YYLZh?7e|X+j2XMauhx zfeEZdB$WMkEe20!EH5uht}lw&{Qg~BB;ukPN(-Pv9e`RZuhaCsU%bPYczCw<3Wp1= z`<=#roQq|2aI#(K%|18=J+SOv{<_>~ty1E;_IV^Jlmz?j;ze@upa7EdvfKu1i~drJ z8Cv#$RaoN(@}747vY+%V&Dmy;q>YSfYcAheR}a;kT7~b8{P8&<;jkgT(ctvj_0gk& zvg`I^*H2%Ogiv?l6-pP4uyC}l>&26dE@R}8q%Dn&L;3c-)|GoECVX$$!Mk3Tl05j1 z;FW9csvhTW*ceoMlPdX=Y>%Da9`z$7Ie)2J(GkX%EtJl>nM{1@#8jO>ecQVLJ109T zTh~mIS;>KNov3i%VdMQ5$N#b06C4WHcVAjjlceSU*5B_|^Xcx$$jH4+^}>xIkBz{+ zopI<K66XVZql-S&+fyVY{&C_uX`O1ZH(Zu$8to_B*f`iY^IyIKlfz1c1yY79`DBK( z`dz&ot3kqh<+lq|N}X8b-`RwZpBQR*^Coo6_?XY7a|k{+ZSe)=qqKF%w65IR_GyuP z6EUz8=Qecy>dl(5=tqi5Zl*0!d3k~gDdO8xMs36{8o_rRn_9~GZFfaz&)_#hYRoH* zMXHwx(apHAve!vxAdaGLjhA%WZjLMxDB16@1Ja>~UBQcSJwE)z4H2nekD2$uQ6&&q z55znoGA$`737{92D0Ac@G9Bt@XOaKFr(^P)Ocp;cJX}*<`5m@!vA@7^HkDr3H{zM4 zW%(uWjPcoTMuhuvP1`u=uc>Q1P!JIi6m<Ar^Ok?$T(0L}$uN3HWtG#54Xh_ZX>B9X zfAjqVJkqk&jwVIxC3KB`k<G*$Xh>@^E8970YOZos4*(Lhj2Q77H$L#_TUnTLbSoA* zjMlsbE=WUD^AN82(_H^*nynYCRuQC~>FL&&*W_4nf0Ut-2_rOIERbX(^z7&DnuPrI z`N7=vz5Ey6O)JZjYQk0n1&--&8m$%<f&@%9cXs?}Jatu77seDQEzHed#o<3Z3HFat z)~n}`JNAYcpq5vcd9}BaKJm+^OFTkr1I0xvwv(gpb_P!qVf%<lv3zIf*MG~9D&)8E zFo-_4>HZORGw`{`=3lTWC&j=2tvnrhEHBs3US0hi0wE|ZAoeg@`w?cIuZ`WU&B^h6 zF+1-~Dm*uoe}BNeMRR|;Gu5x6+I4<sak0NtbBf$c7C7K5*9pvG4_!f8o5x1^ejUA{ z^<BQn*1A|R^!{9AryNZZ(ivjx`k6C}6$f$UvE{G02FGfK(R{jJZ>=^7WM*d8zoYNX z)DgCo|I|IR^rvoOa(q@?x)n9wRmEvI@s3?3>WxU&hY6*c7cMI`9&Ju@UDKJ3A;(Fw z+1kd>Wm#F>ZNKYJM^tv3m5q(fZrj0j1+NJZD8jCbUPLBTLtR-p*Q6zM=q4dsZLNF& zqqzHDgMnYU9eVzsg#n@+JC--ab*42|Ojxr{cqE3A_SfgIsvS%v&?Y=O1yg%P&+z)b zwO?dr=0_l<Rz3wKrO<G0SQ!3n^H1A9C60@{R{I;#6*~*1DiEVI)YqG?#}xBs@s0*W zTyy;Vk7f<(Ft7=cf~iBazkp6?IaU~5@iOUUhu;(nz}V)<PH-P8ii+wsB!?YZ?I$PC z)G7R66Hos@<AG}I4K`uTAI=OJ(W$AEqsdAPbF$@_o#kXDAfCE+Y+dm4Vq*vU$%A;b z1*vWCWd4UsMgz=#kFB<rE<&#PR8)L$l&BHNC)~$u_E&l@;HcV`gYn7u%FSJS5So%4 zfTG=#*b=0Styv>gHI|)d|J-=u_}JL~R{cIWz?77fA7~`_yNK5Zqx|K2u4BPINljw= zd40e<486mPkm>w-{K1=ONqaftdxpWDZ9m8mD9@o5HvnLkJhkgbYpZY6xd-YGFD;z( z_uSY(k9TX<w4mK)x|F_F>TSZjiaE`&nmvE~cw{Pw<v0nUfh<hr0Q3r>%bS1w36$LL z5@@?r{+ghpvvXsyztU}^HF%Wb*BLe`3jtOs$z?D;XIwP;JhJ>M$(Se^AT}FJG;KR` z&S|HnM<2a6*QvJVrs3eFk)|Fp<eCmtAalT{u<&?laeED*sf$?5TOx!6xHFP5Bhi&R zR-NpTyu(F7g@qf5Y&oM@kMzG7e2cuFN<cz*#}A;L2sy?Pp}8Z9dwob3J5vKYbZ#eo z{k$AlzEk+@mt7}4hUg{`47a@kBCfLBxbX<OwX-<JNlQEZ!n>E}(j~K@7sp?Cr>3T= z#q!Hmn!Y|UH82o!*GO(-s{MY=uC}af?bY^NegXbkhPz4w!lB+K2b>|p6oR_}C(h8V z0)6_z+x|4+1%%f&0wM#+Y8mXy1v0SYQ8rrl_hb%xl&0k=|Bn3v0fSDvm=q+xGjt1E z<&uNl+5C?Y?rZ0o=f7!O6MWoJ!>hYMA>=+##HE=vwS-AZ`a?L|t5gwKS3^V@=ej)& zVS^uyUV2%XyOXlAxuS0oOV(^(=1$Sl9|DpCgZUk|jk$BsEwH=2WgA)dk8zM!-|y^T zPEnz}O6!F%uSp;W97m&v!wH(Mr#ZvUQd@63g<!?dU?Wz~U}H-iB1N7CkL`5%2KXwz z38WS*lK@Ir_`$=sI)zU2apG>P&+k0)l5icn9bS6txN|xhomoBl+OM^?_N~~cTz18} z>#(PXtTHd}WkxX*2S>;JHzRAS-%raBo?&D)+x!FwSHF$qw;Xs$xfiHRLWIX<UM;OW zct7cL(v>sj9&d7TrhcW~9rv?mF{e+R;_nn-WpZP2li$H&O_<qd88G^;)8qTc7~i`y zaV5FHTg%6r-07}ST;0l+JOCF-C(d}*HTRVWz@iU)y4xE|f4;_Enu>Suk4JA$H}O@F zT`k^s-*Yz#=LTVwI}h&}9e7Gt;|)ND$ZPZ@))hhT+^<u8CY7=)I+B&BfeijRu_$c4 zy-wd)@7r`QMQIVmAmHZoCc=4U2XKAiE_~mjQPkxq@G-P=a_Z5cTyt3}g38K#?Y-70 zA;SG_r2<MoqMDT}0hkd1W|okUU;>it%Kw6%opnok$^Bb3#wceno0tpud5lN|*3<#+ z9p>tZyu3X0qode<QX}i(#d*FH98wmYVp8ZYp5+*|;<uFSV-sU<A~HfuTB2lYYfjQh zC}(d33Dly>AMg2;IxhyV)O`8!LBOQt?A7Z60v11X60>648;#U%?5}pVy@_xK*ZEV{ z@0Rk3J4}G66fa86LL2}DHh_~h$%C71dgY;dw-g~1fR9C%KVM2zKZfYaX)K`M^wpAE zqXF=~cn;NwONL*wGBPtIH~-2Iy19{8r>aH<$HhU#3OhBXa&JAFsbZLWCBb8%ba71> zc3rw`Wa-jN?B1y{*3BojroAq6+qidUd<!Z$yiZ(^#Lx3YM4;65DQGb%P_idgjR{0> zZ!S@mLpA2T@mVl|o_L~V57OT>+$P`I-I|PF^i}A0jkxCF8hy?3ma<-f@pO)nyrYNW zKs1@}7t7fmaPOf91Cw!R&%f2Aei6I-Zj1B7g=)||WNb3~zkKV{ZXk8hqq~*?912W@ zD7e=5lz7@m5r)^eoulpah7`KRdV!Hqagq5S$XtBV65!J<G2TQn4SV<wp!WJ6^N!>@ z?#u1<VouMfuWFIDouOTBNDB0g*xfRX-`j5DE0K6|Gux|Y$DfKs_-M&rb@`zaA|Oy( zF=)2e5-Cvl^!ArGjoalnn7l5)B7uy^tPhf2c`&IOTe>mVKAx3z=FH73^7kr_A3rv( zS=}vjKQ>8*$)Bdi)apu?ntgk5A1Jkm3?$`MCgEO$JWKTFEBw5BeCVDxz5!M}WyNDg z+1*`b+Z{@ED;>YD8r3^3w#(EQCvBUOc|p$FiyPJfQik)F)J?4P41<{CVtct!YHNy4 zVh9Cgl1U5ym;Iv#%LO+x--dk6&Ap}E^Rg;hZh!O7pz~1s8Ykr`EFEm_6|>uXvpd-u zD_N-!!X6wb6z6;)V{I<CO=9_{5i`4mgn&?dJTv>TD+I9m?hu%jBe}6nNhoLq7&M$@ znhlMga<vdjILe=xG!Puhdi5x0LcT*xFREViaBp*SlUpYTU@#?zY7MXU$JzTS=P+EC zFDIF_z8_mYAIlx!zqYagaS<+vU}Z`#tk~Nw-|n+uW=EIU{wc`RkySonAOcog8U~Mm zP#c|*Ozqc&r>tp*K4(U3Jft)>8GHBf<E?TgWbyGyRc`mqdhD#7%L%*--v#l{oR?=p za~s;-GaPNE6>#wQ0pzQk)k4^tr=5yTisLsNwtm1nbB2Ng#XTmPzq_m;U1*IhRX)4o zJI8d*dVf9I8W!M%bI^lEatsN=iu`W<{$1Pqb>XwY&tVg!=g%{Q>ZS6&efu`oq0I@> zp~#HO2herTE|kj78L5L-Jzsp84@rjLKnR3l1s|7XsX<A6kBG-i*Z1S6$8OlRxGfeJ zmncfCcQuCiQSpYlXADINTT5_>YS)YnJI(L^tzLu4cuxi@0E~z?VhS<QU^#fIt91&k zx|uVQZS`o%?P`~S6rJ7T#?$um#B<$@wjQ!yEN5PKSsM>e|NI#=p-pVDVs>VNed{NX z2)0IbQ`@0n4E-T$wGLi7m!IB~vGn)vd-seS-EZXQDfo4oC5~t}@({e+Q1AfBF*6wi z&|>wQ{(i{p&m$tWK6jpCuB%F6+Ht@B^~$74eRI#E7ETww9Fz(E{)WfAPP;Vm7jOu9 z$Xc#d^$Bw76jNj4UFT(E-FW_XR3>@p^QE_y2OlOgYIBr!clW@nEREKFiWFdjnfQ?T zorPuXVElsP&kD<aAdkqwXClkbn?;HqDG>7#EGsXMK0#P$(OaPu-s&qnfQ!w<8Cegt zK~l`PfhADlvSIv^QT@dDI4nRy3yWDbi9KLIYij&*O{N|Rs0d4ihlj~4$H&FSvQ@*! zlbS8*D7d$N0$8+Gi)P<?SnfFW({?$OlbPL~P|zR|*f?TouWK+LDAZe_Gbg8h<`@Ac ztk!wW^|gvm@?aO<NHV*0;m{Orx63oRnKa}wuV4M^!Ja%mJzZ?KW=<Z`7IQsUYQs5C zU=WoV6c;A}s8H7B_0&sdX45yD9qPDLQDgl~rdqs2HYpRY;qp<4`!<4DN)S*3tVt4t z7my0|s;VL+yz#SKlQfcV3Q}-yV<dC~330A~6te5_;d&G~Z8Hzj&oGKyF#O6`GiYbC z+7L9}^kI5CRDsaUzcP?XpMsuiJG78pZQ*BH$eYIO@?E>+_D6U2|Fa3!n>N)8x?->z zFOb4o5GOloy$BY|>U^)}oz>#xyDV~N&z+mz#yNo!i(acg?C+>^+Isap%E#-OKm%ps zcNX0C{Dp5R4;6dV*E69Ji@=Bxs=x&lC&fp*d-ZzVuTxyrT5Lk!q7n0SFSHu`&ubv9 zb3p45RpG|Y-l8MOv^=mfGGaH-lykHKcA<S(0VQDCAgqV>+mj$5?%qoN_M&XPj(%g% z8A#!HtAQfRSq;#^m#9pQ^vTvpkNp7`TX|y7%@OcLdSG^4pZ@(UWO3uhja$lbqE8fE zT}$3HqQ;=M<qmLP)ks0Yd$mQk1(&UHO)vtl;+Bv2Cf#^QAb=%;1cKG(rV;rL2<0f_ z@CHdB^{Um3)%sDz-mtydn+o;17H6r?$;&BM+Ku~SwsaR}_#cn5l7}2@ue3S$WRPFg z(yZ{T$Wi*>8{rN+7`nstKBuLTUp?=|Y=%Y6yhK?Z*8hApKTmeWu}_dMC{44H;ua)+ z^u2l>=9q_J77bZkm~O^<lZ5l=^O|jbB%-vp`|9ZN;|AD4bM3y2hK89LbMk5-eKZxX zHmjlC-Nz#c7*y`*-rHM5Z-r^fDz`Q_M=gJN?@lVE1xwEnmL5`}<Tc>#LEqx*>jd0~ zpnm1XMF58PgM)!A0|P@}TwFRde1Hdw{tUJ=Ma{Z1#eZzaY<IU)P2Y3rHG<4G+vR<T zX_*a|^P{4|eMmb0Naqq^>43MKEH%_q$+(v$F;sN7#@K($eiG2F2KMi^-<vnY)`N=R zP0$AnrO$I<I^N;Y(p5nYoJ1a33V4=%`G7K_Fe9&JWs~IJVb?gN@wWdzY2zL-sediQ zmIPh#^<R`6%4e@?aU3mu!{yhfCr#z+0q$QvZsrexmD%Jv12endPDOvd#iTrw6#`d} zSVXhU6qRAC{RKCGt29KQot6Y<3RuvNJ5~RY&0i8^96eqEHM)ozOtI{LYF){*C6Q0X z_Xv{83ewfHNeZ1Q_LD7?g1e=Q*E}kx#l#r}b5S0dDJH584mWSU7z>HVRX1>NJ@ohW z{c)FNC?iAt>)?&`nm=lid+#tK?RRr2Mw1U1@eH1UURd4q`fyk!c6=3AigBDIUc}Sg z$|_4W5P=8`7)wh_$%h;NWX6_{aXsDj*xd~N`H(zhcDf~q#cgAO$LzU-y82D`GDgxU zEw7$S+}ttO9L?`OPRKKQy}#^t6xY7+9u87W?r>+Y-}p;*wd`Y_aSeT9h2gO)H$hFn z6S5&Sa8h_T(P__X`mIw_Qf5|9f>9j8(>7X0AM_{45I)@~UYJk24{!|v+d+4_ZEl^4 zn^+qey7(=S`{w4W>sQ-_goHW*bPw83xLuVL3l0epS19H(Tqxcc8nLe6G}}y&@zKa6 zTZ<&EGJ|@ohtTRxq*Yojjm%{Vth*4qT2w?>SY{^+P`lm)Q-n;eQ?lhl%GnaC@z-<j z{A8nXq!(~{w!tV)KqC44cNLh)Rkxn@_NA9=v&n<0XMh$_;N-NdE$bfhFR<>fSPBVD zj^$i6m6zz>obJi2JVmER2z5+9*_3XGiyv>%$Hih01RIPsV39&jrPqMjuMn!de3>hd z`{PHGAbM_o-ZJyv_{7+ya&_GazK3KqXITxGVbMA6h3y>n(#+X;qkFN81=2OzIJ{ij zQknc$Ba@k#rM5+%s&wq<lF_y}*CZE4Nl|H{uDSI`^E;q3v6A0~wR2t9{;Av#wlT;q zU1Yp&9{TE-wYr|3#CT@msZ*y2S}J2r<J}g5<Qd6@(VO(vp<y3<qe-h8z?><`d3`vA z#K~t84Gqg~i1%jcx@~u*QTve0x}huiBzJCpJFT#2Hn>g7aGv3o@`TDsw#T=<3Mh6( zJHIC;PTx<}D|dZ>b+;smiJ{`ro4YviLKjIGw<pP^b(`-ohVvj^wk=kS<2t9pO1++* z9%#I?ts}Xsq2q(`cMY@@dL@RYrW?S?T)$=$YW`bg4E@^k;lrkeNP&z@**(m}Q6vfL zEP{+*5Xn;ALs%?jJwK|)z#Vq)FAa&il{hcn?RSDa?#{Qq4SRbBH+czK$ouo@7vHNH zgAiVsMp(5s6<A&%-o#zk&qKQ9)y}}!K(W2OX&aUB0sBq2lDZzq^?~bK^?VeE(nWF* ztpia0Hj8HG&KO^%4Z3zY3~O(1pM6WNO!wX+=I$zCZ_>&_HKw$br~Ruf!homXL2|&q zA#2)s%Ucc?9+3$%c|C>b<%2hh=NuAXkEuim<|u`iT5W}2zI++Iw-&Bnu&eGS<`eXM z?rnX%=Qb^Q2;_#yOU;6^)DUu&1|wV9s%|tH5X*$llOEWey)|r&A;0e7s;KBY6xc+D zNl*W5Xk^qDBjM2*&-C~A%j*3VrUZG57(X##>xJ{>`WHPzpai3mf&60QC!LPD;^!mZ zk+4K8@k3_j{5!4On=&%B43B?Xtl&j(frz1Wm;&Lvlq0g^t>=Q19ns2<@3wD1vas00 z=LXkcyKi@T*^Sp_$41(qvS*xHf9lZr|AVLf2NF><g3rIG?+kPt`Ttjp?S=Z4n)+@k zQ{>dVF_305Y9MIGD=FFT`<kqzIyCX*OZBk(N}Su`IcL{Q;1}KZ#>4NaP&yNOWI|YX z{PYAzO}(GkO{2NZrNI(MO4l05Q12!EM%SPsF3|}ZT!hXdRG9M5rtAv4gT1}km7rh0 zv=FiqO4Veu)eE=zGJL|gK0t}g!%0v9Z6JcPczK&NGVQ(;hMOnr^p%>nZ9C3FwKi)! zI3PV5(baE0jKS{9DpbwU&J)eFGVCWwus~sG@ajWG$Z~kCy{?X3R5S%*L(K6kogDXH zLDllCwB7kS8@7KOE7IjTx*t5S=*`-jZIhHGc6PLnzVXBt+bdxa`r*Snda;7UvYS(! zC?q>|#f`uWO4RUBDT;x0n(7aW?CYV^1yHQ;R%gy>dwq6i#v<D<T5_~y#C6~tRCbs= z_v)cJ%9b=rI2Ju$8vT5ZE9+4)^N6yr^9B(mN4{D6qwkT0XPM&3Ru1@VdZUXFq|=+B z=kd4dpAyOD>T08*-7U~kW~ZX8#6&x}TesxoN?1E@Rh}&AS<o$&7%a;9{8{mPBm@ZY zS8NkYzZBM#skSy{;6(_6Zq)rN?y!sDoQs3Gro$B;lcS>_b<@ryE*jrfE)%h+Q$EDu zA*@iK^E~TOt~TT?G_*BJ*2^~n7>noA9UMC#_ejnnUtCghn?KX)G!?gq(cdTEptOl8 zL!YlvLRhQPYH+b~Pa{5%n26+Uq_}*|8Q~wz4KcK|oQ?kLiNhQ#C81qi-CUYx*TRka z{-{YTb=_A};{R*~cAK&;kdKcqe1seFKExyuZ1{P3;2KdO2<uC?`MEkKX09B3-EbkF zFo)*n%sePatl0g*1*sz_JNmi1H=#1@nN<H0EOIbZ0c#C?TP*Q#+i+iA;_zk_0Gm(k zU2$Q-HzGPl!ro-0%*DHneB;!V0wue7#jUQDmu|!MZP6@pZyx@ULo-QMuwH|#55SAC zk%{-(^pab~I|Y{B%4S^+&NU6>Jm0^6)K0UCN3Z{akN+~aX`z}=kkDNLhU4j>O2~gN zl!v{;;e5KHZtHWA0;Z#*qdydt*k~=*tX(~q(^5;E=FKg6*3p(i=+l4d{af0adjB9{ zSQ>s_dF&w2;^hSHD?|5S?Jic%_vKj1OH2QwN(yPCb;zXe?Jb0ln7q^mRlT30{9%m4 z^Bn|F#5-+D#~9CFFbttra1^>_bNhDl+^@t+L0xA~9w^7KM9=l>r~UCiAC;Z>@&pN# z5~XIw{-p2D?#!UO>QVA0y&F>Dpe3p&u^oqtvTgL|LrFB6JcN>xkG)1;Sw%%bRS^>B z?Q6CmON~tJ{HKE{Dy0P~QG(<-2?fBZXK5GTt2Bi;<|B|6Pz|xOnD;dJ-RD#&`lt_J z7%aCp^%xq+e$w<|Z+m00CqF$iHkP<lccVp9|6mg;V&3t`A!RGzGQVs4?&Y5`&vzYp zaokij{+fH&m+*Z7gQ}O_ZE2OGiK8A6<_?w5287$eo-U{Po=o(?jx5@3=+;V$7z^{w z!0cVvJ_2I_@((u_Wh4Wj635-9MNLW3A;8byA@KDpe{OCr5Qu{4j&Dox!{-mS)Q+_r ztSqlvohKtZdzJ<(B6Wu<Tc@izlA2Gi>o7<rI+j-#kz-W4F?F#!ZySnMRY4w5pP{0l zQ0abMNa(=|$KD=2;fdqmdrlA%3X>4o+B=4!bAawsi57kE`Dq;`Mkq!%X0kPUaeki1 zFn(51QHeRF+;US)&Ah^iN4xA{jpV*_Xh=wOa7;{aOlU-?11Ap;A79(durLj=ImKC; zpC7eyu~qCU4z4e?(w+?$SpMONZ$R^+a)InqX28?7LsGe%Z{Ng_4=MJ9*V5)f2{^ZY zf%0NKGA|@pNXdax-(_8)O~nP?X(_Z1yb88AI{D4lpS3Ru3i0r`PBi~el<Aa<F0f23 z-z#9{iu|W3oKL<brqPWQA7M4FJS8v#as>~?&Vu9p6|En0XLWZe$L?xqd}xiF69rX% zdMr<_GespSG0C{JrY9ovQO@q)gldX2f(59|_F|>*w;}8b6>Tbl!|$T3m8&PA<ZbG8 zf(%K#(Tt+6AH1*lRmDXf*f;~jg7DCe0EX2_BjoCUkf=l7A3p0ZS?lztCy9hi6uXO8 z&(u&-JxmSk{I<J>u5{@v9KJ2qc@pL;SH}Cs@>EjCLehM$f4tS;cKV3DcjeZE_{NaM z=J`%X1hOkT+;o2dmB&|1jm@gY$S`b3a1wDsO|9)*x`vj{O68-2hL-Z>;YMqy&!Oc< z;sS`+p3kJEGNP9i8tbEcks2iJoSVxzb+j0v6xH~l0u_3qjrMi5YyJVMx8GlgZjHDu zGf}uBaCx>ja|d$$P?h8*3%OeMS12BQ$zVcMgcsm8x4#KKFuF^SJdBu{f*Qj^Mv~|h zR^y<o%VAS|W1al!uZG&%TAoK=err4CkA?VOlM)lMi4wO~O_Hmi$oiQ1<;#`j`RT3W zTPIp!H!o7iQ-!&pP{7X<ea)kEt8@`cLcESc!F0+CXcz&Hb1^ZonweUbZL%c?eaqFP zkXXteu?M_WbKDG=m#cs3ph{k)#wIB&B0|J@{aN+!)x{6*u1Q&JZf&*23DoEOlcdfy zcn^N78Cn2A1#ALvg-TO~R?mnlYfqw9Vdv62COmBd1Va53Uv6N5Z;Zrr=+L}o@d?a$ zSfLzz7i{(@ejdCfAnL{w#nScp<1ND1!aF()4GqC;!U2?*wL{=gn4(PXVS%+SbwY2B zxK>X-cX4U)eY?IKx_|nGZC{~tTi-_SWvHn4gDouYpvvN~#ZPJKpC4HC6crb9mcLmZ z{X;p?8p+db3A=cq1S`+G{AKmj)g&mUV_rDP5)|>=R$(sqd%7l|sxd%CM<r4u@%Ib! z7b`2R7xk(qH@9~7oM-w;=(lMkel+~A><hIE8>48TJpTf+2=78Q=|7YD&%b@o{(W0n z`B9z?)Z4v1r2KCYnx&%`N1vNCyq9Nmxi!W4L)78#Z@0w?i{H`4LqSVG@c<lfDxT$4 zkQ-EWbad>>(1NN_??5{{r#O+vhR<g;yaV}FySsZO4put>b#RrRMD4-`Ap@v}c!3ap zb*-rhZ4p<EqJ^xpR+)bw)MW>o(nlUw7&Jd@coBD7qXSMGvB0K{3Yi*^mWgJ%c5tCu z>#$OC4$_QNdsJwbEtk$zm5?#hRQ1c<7En=U%_QJn-5fkR`lKm5HW0q|%8ar(4U6Uz z8RqyQw6Df|dl~OO^OTUV$(3^z;Jw5{u06+OX!a`vi0`rQ0Xzs<4LpqdWBfio9D=Vt zC|qr{k5M21kz{MeiCI_prTeKLvz~r5P`tG=(bLs+n|~f4riblMKULsll?6`N+S<<c zc)pK~O@`+0P=ldoJsLK9b$84B5(ndAZ(;maxvRPkR1ViOdE~(k1oCPyfMdj1I@mi4 z{tv#>TCcL_8fhUUBvt_KvETag4=!V|E_Gm8AcUC~V764hOk`@l0}x;0U``+{1t8Cv zoi?;9)a!LiB?AS7-m0Ah?+%4zs84+Sf*29}Jds1|yY%$u-^SFqnZZ){>cg*nwTFg= z2-LH)w?EzjoHvxJ_Aaljc&z330vpV;QhX1b$iBfKVeo)-1|>rHlS-^&{i?kc#P0X| zjyJ)68Rn5HkW~3{-)zcG>nntEfzlAFb5xgM-lt%j(<MI?Pr$GJDF&259UUP7@pGH1 zy^Re`SMs;8p#crA?X8?2;#oZlR#_I`Av0^Rgw(_KQ(<8tbLwpBmrrJ$@R_{=w{Y#u z4+jpN(0)>Vz;t_3>X?zW`rW(NS->N+=zfn}cXE2k=mNFZjE-M~lY6K26_s{u_I#F> zs6+J#P^-YRh2Hd{LUJru9;mjqf|H&fKJbk9_75GZy%WXsbhpH@|3iypXju4L2|qBQ z-%yro*3)idIrW6%HySpr$BH!W=UUXaLfKJDMxJ^a1C)USB_}5<3y$=I`?5c{Y*V@B zOFSW)JGD(s7E@myLsSvslyS0Fwic{gu3N8FnpMpS)Dz$pw#YHHxLC6{YtVId=2wNT zTm6Xu#l^(jj;|fDVg-vo3z9GxsW?hd3n;6~2QFiE@t~@FV+a`clsWc7^?TaO^%as% z^8-O~yyH#J)(q_Mw5xF#Jh9SLEnYO=<{LhFNcDGZAkOynoQHV~i$<W2=mU?o#ZqT# zq{QYl>h?Qg64+_Ta~3JAW$(+k=q+y2g%VT#x2H+?`T2pOfB?AS$EmHMsuo|A2Ui^# z#sUmh0E5`tG!)$J1QZlpQ_MDkHy1#VSPq5VEIy=~nwnhYt&+-Dr7T`~d$&diHTCpp zV0X0-v-~M4FT90q>`HbP^fuOu`B+(RVaY|)E@g{2O#O~4cWbSWz3)nmB71X!qyfU( zvxp0@iaJ5)Y&M)=lo|lYHgss*WNT}SaDP{6MaS-5F!O0(plH>+Dq6d{^Pz@pU~tx| zT~m0Vb-XG3;3w?0@^BN1&pF!uAS5`PU>!hn(W;`mQu6T^)@$of9&=he*;Yy_Ucv(= zVtSl&Pcv9-dy+%8HEz%TU>{tkMrhM^t}_aGA<u<l_<4}Pf+}6}-(y{1@}Z{DajNTA zOZS5`q2-lj3JP9!cD6V$J)5OQNQC{^t3q|fc@Pr3g7J0#NK5mdcivha7f4lG9IoW! zyc-#Rp_rwU1WU(PzG?^6P$$I`%Q9{lZm-VGaVMm9nS*u!5};>f<Q-M=E$|~Ek`Fxp z3&ym2<IlDNIqE$LEghLJiiZxFy?rS6-93hnPba*-VLf-SctF0fT>kz0cUJ!z^nS?u z_jjJJea5^rxDIKz7m(ucG4mvCcV{FNJmX;FaK*+%_Q@02of9>D|K2@{5DH*kVY>y} zFDfdiu4IE9I`7}VPx*Ec?;pJV{eAB??#tY;Jx5KYJTxW-{s0U<+f+tfAvHGpeUNX@ zr*v2EE{*q9Icb3l%yD)kv`^x8DueIfsU$4Dx)p+=BYh>qw2xlQ@9~6>ek%xUYg~O^ znr_N0)dqVl4eqGL?dIj$FYYhw!S{1ZOG%xaoIH!%HhShMqoWJe;;k*?c8B)2)dCJg zJ)IqP7i!x3o$KqZ^(yyQ#+#_y;wwe$pFb}u+=wQfYOjKwH`P9YjFK*8I|duAJ+Un< zEl>s3A}~C>yR2Kyw7<18_E3#UK2?5aVrrhLEpGmx0{sPTvpJB!??*_=DB-?4)%Zug zUvj9_-tGg3B*(!6wKn_N3I9cj%DFwC4SCbTt)*M~<*s|H)8D@B?+*>dZQMj<VWg{} zLb#X+qC*twX#@5O51y+PpbOY?$ImaQEbC>3>^+AUPRM(EuMuHMP99#!`4szw9j^or zR33%8aFv!$gI!7(NzTDWK@OSfQv_4jz<$FvyHmzy4}DRC-eAgL<LRptBz}~be#EV! zP>MkbkIC^J=y8on^9w#w@v?$yBRKU*!P~p(B(??D*psJNn~QiQxE;qB5n8%da!fRW znNcY33o_NF_zp^t6hfh@V8<lve(a|>Jar;b`Xy>G_^{zC=V_yYh_F1kQN9dLpbbF{ zMgTbpn;>~<quNhm{c)#cIx)1+-H5}xyog&a5lq#=5`v#ZPy(l#mzCOK|9+pQjrvY7 zwT?Sgksy6P3^f>ccvIDJW3w4@Oh0a~ZBZz(3LCxy^<n=|nn4L^(q0P)NsD|bfll!F zNvtkzjNG@p*@f^*P9F-?i>(36$|kje&`(gz51n@)n9{>F_8~<A#-hilB&*z7AA z#tkQohknE=?)VP!xW;CK6W%I&E!;kA%mANG@G%jV0XIrzIdAq0cqJ^j#@;q!UolH< zBp)_*MVvP30>M-Tt}&AYY07_4gW<Tw-Wi*1Q($&*;*E$UR^h{UaK<&p(}2z6kaFfY z)Bx<86Dme<>TqIFDvek{7Abkm;p4GU#45+|9S|@HKaU1%v!Yi3ZWlU+o+N7PB$gZ} z4+nM`0YW^{lSrNs<+X2Ymck}Qh<G8`;`y4mMV(;k0<Mer?U=|{C<fdvaw*7`TTo#B zASnp=F3xRllPCBNN88P98?hNuUIDl*@fQS1qMi_8ZE=mUUXaNZ!4pON3bHfQ*o^9; zln-tr_WQTQE!_lDh#iGra66{>1&YD<P##i>{DRaJn56c@#u^m8C(h%E(&1z}U~DEq ziAloE%Wbun6D<FoVm3A-nDRPY9jKZ23G(bxgsg|-y5mnQ&W<N~0r!L#f>U@zSolNQ z4P}!dmGufxI8;6iKdmbnzJoGOhHH)3aU$$h+;zm#FSAtOI^3LJ;CFa{@9+>eLq<Uf zv*eg++~m;&5$nE1F?_(yD@Je%fb19C5WU%Ds4qy#r{J_flvY#-Pn7?#ODNSwtO5}h z))CUBzi;7NTPTLe!$+Wo-zV6UV_tS1isJVTTG7*Zq9VAvc-dv@Iiv`2s!^3JIQ5$V z{tm@i<V_5HV2D3lh^XF>gkvywxP@bIm*3$MzJoh%klfNQVd4VR4#(w{+=yrir`cgY zzMP^h;lmR>j%!SVLuP|nDivpms<Z{CcnPMsak73GP7Lch_V68b@EWf73DM-3T%08` zP~>+2t)#*=rmXxjE*cdJUL6@{XGp>%ixdY=8>$`|PXRCd7N<5>_-I|V@g10Pw>7K2 zjC+rYMfM-V4;DlS83ts6C?bY|)>Q@HffsjS9=lBTTwPn`?q~Cf$Cr_dr=(u$`J-Y3 z@{t1!DXps(zJq8FY`R8y#Tif45@Dww!aivfYC9x>`l3{-5f5^3Qu<}Z2UKj(H{{^L zDcbE*_zrR>kOkuP(0B@JeQ9ZlkyFgU32oO;W9frUcyW(}>CPs?D&WqJ1}8{TU_$tj zPZFSRambwh_k<nI_5U4o&=g3Y#=87Ed>}vV6*zNruuOYd=71U_yxQzQj94|47D;fz z@u@Q0!CQYU=J-_Cv%@pR-K|)B2lH!(T|D*K#0*bVC>jxu6geAx9x67f1-Ty+YDlZ? z)!O<DIk=+|Nz5Z<YI+Sh_>?B)y?<HC`b{J)t|LEw48haqa8IutZ-J-dnH)ZSn;)KD zg33jnUUkXt$^Q<dD1`oZU;}S0z$-b?ba>gD7pDI^DEe+PWqoYbJ2*1*7t*$Y*;VMG z?#sgiFB|9~Z?zqAqAE?~|2d#0=ZCAj$B{E$)(+OtBepuEN2u1>2oQ_nJLKCus7Dz} z-c%T_tBlBjO;{c6HX6@WrmndO_ede_YtR##KS;Mwy%r&Gqap4_Zu13@2er`+<cz`f z6=YyX*C|D(!4$3iBt=d=tQ~`1Wz~Bk2URod&Cn6?^}~+z2q}11T#~Xji%6qAyf&h0 zn*@f_h`Uy>rxC7Iz+Ib?rw#p7`pSen=1Tb!0T^8H9&+HN#oZ0E|EzoxIck3L4Z5ZI z1a?6}xAb}u;a{di^cThRC@6uC2uqJ|;)Hu9U2%r=!Rd`|dS0a{_j=?K1zFWwSvL({ F{2%4+)nfnv diff --git a/README.md b/README.md index 8876a28e..8ce1c6ba 100644 --- a/README.md +++ b/README.md @@ -22,17 +22,93 @@ your collection so let Shoko handle all the heavy lifting. Learn more about Shoko at https://shokoanime.com/. +## Install + +There are many ways to install the plugin, but the recommended way is to use +the official Jellyfin repository. Alternatively, it can be installed from this +GitHub repository, or you can build it from source. + +Below is a version compatibility matrix for which version of Shokofin is +compatible with what. + +| Shokofin | Jellyfin | Shoko Server | +|------------|----------|---------------| +| `0.x.x` | `10.7` | `4.0.0-4.1.2` | +| `1.x.x` | `10.7` | `4.1.0-4.1.2` | +| `2.x.x` | `10.8` | `4.1.2` | +| `3.x.x` | `10.8` | `4.2.0` | +| `unstable` | `10.8` | `4.2.2` | +| `N/A` | `10.9` | `N/A` | + +### Official Repository + +1. **Access Plugin Repositories:** + - Go to `Dashboard` -> `Plugins` -> `Repositories`. + +2. **Add New Repository:** + - Add a new repository with the following details: + * **Repository Name:** `Shokofin Stable` + * **Repository URL:** `https://raw.githubusercontent.com/ShokoAnime/Shokofin/metadata/stable/manifest.json` + +3. **Install Shokofin:** + - Go to the catalog in the plugins page. + - Find and install `Shoko` from the `Metadata` section. + +4. **Restart Jellyfin:** + - Restart your server to apply the changes. + +### Github Releases + +1. **Download the Plugin:** + - Go to the latest release on GitHub [here](https://github.com/ShokoAnime/shokofin/releases/latest). + - Download the `shoko_*.zip` file. + +2. **Extract and Place Files:** + - Extract all `.dll` files and `meta.json` from the zip file. + - Put them in a folder named `Shoko`. + - Copy this `Shoko` folder to the `plugins` folder in your Jellyfin program + data directory or inside the Jellyfin install directory. For help finding + your Jellyfin install location, check the "Data Directory" section on + [this page](https://jellyfin.org/docs/general/administration/configuration.html). + +3. **Restart Jellyfin:** + - Start or restart your Jellyfin server to apply the changes. + +### Build Process + +1. **Clone or Download the Repository:** + - Clone or download the repository from GitHub. + +2. **Set Up .NET Core SDK:** + - Make sure you have the .NET Core SDK installed on your computer. + +3. **Build the Plugin:** + - Open a terminal and navigate to the repository directory. + - Run the following commands to restore and publish the project: + + ```sh + $ dotnet restore Shokofin/Shokofin.csproj + $ dotnet publish -c Release Shokofin/Shokofin.csproj + ``` +4. **Copy Built Files:** + - After building, go to the `bin/Release/dotnet8.0/` directory. + - Copy all `.dll` files to a folder named `Shoko`. + - Place this `Shoko` folder in the `plugins` directory of your Jellyfin + program data directory or inside the portable install directory. For help + finding your Jellyfin install location, check the "Data Directory" section + on [this page](https://jellyfin.org/docs/general/administration/configuration.html). + ## Feature Overview - [/] Metadata integration - [X] Basic metadata, e.g. titles, description, dates, etc. - - [X] Customisable main title for items + - [X] Customizable main title for items - - [X] Optional customisable alternate/original title for items + - [X] Optional customizable alternate/original title for items - - [X] Customisable description source for items + - [X] Customizable description source for items Choose between AniDB, TvDB, or a mix of the two. @@ -83,7 +159,7 @@ Learn more about Shoko at https://shokoanime.com/. - [X] Specials and extra features. - - [X] Customise how Specials are placed in your library. I.e. if they are + - [X] Customize how Specials are placed in your library. I.e. if they are mapped to the normal seasons, or if they are strictly kept in season zero. - [X] Extra features. The plugin will map specials stored in Shoko such as @@ -106,7 +182,7 @@ Learn more about Shoko at https://shokoanime.com/. AniDB, TvDB, TMDB, etc.) on some item types when an ID is available for the items in Shoko. - - [X] Multiple ways to organise your library. + - [X] Multiple ways to organize your library. - [X] Choose between three ways to group your Shows/Seasons; no grouping, following TvDB (to-be replaced with TMDB soon™-ish), and using Shoko's @@ -152,69 +228,4 @@ Learn more about Shoko at https://shokoanime.com/. - [X] Live scrobbling (every 1 minute during playback after the last play/resume event or when jumping) - - [X] Import and export user data tasks - -## Install - -There are many ways to install the plugin, but the recommended way is to use -the official Jellyfin repository. Alternatively it can be installed from this -GitHub repository. Or you build it from source. - -Below is a version compatibility matrix for which version of Shokofin is -compatible with what. - -| Shokofin | Jellyfin | Shoko Server | -|------------|----------|---------------| -| `0.x.x` | `10.7` | `4.0.0-4.1.2` | -| `1.x.x` | `10.7` | `4.1.0-4.1.2` | -| `2.x.x` | `10.8` | `4.1.2` | -| `3.x.x` | `10.8` | `4.2.0` | -| `unstable` | `10.8` | `4.2.2` | -| `N/A` | `10.9` | `N/A` | - -### Official Repository - -1. Go to Dashboard -> Plugins -> Repositories - -2. Add new repository with the following details - - * Repository Name: `Shokofin Stable` - - * Repository URL: - `https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/manifest.json` - -3. Go to the catalog in the plugins page - -4. Find and install Shokofin from the Metadata section - -5. Restart your server to apply the changes. - -### Github Releases - -1. Download the `shokofin_*.zip` file from the latest release from GitHub - [here](https://github.com/ShokoAnime/shokofin/releases/latest). - -2. Extract the contained `Shokofin.dll` and `meta.json`, place both the files in -a folder named `Shokofin` and copy this folder to the `plugins` folder under -the Jellyfin program data directory or inside the portable install directory. -Refer to the "Data Directory" section on -[this page](https://jellyfin.org/docs/general/administration/configuration.html) -for where to find your jellyfin install. - -3. Start or restart your server to apply the changes - -### Build Process - -1. Clone or download this repository - -2. Ensure you have .NET Core SDK setup and installed - -3. Build plugin with following command. - -```sh -$ dotnet restore Shokofin/Shokofin.csproj -$ dotnet publish -c Release Shokofin/Shokofin.csproj -``` - -4. Copy the resulting file `bin/Shokofin.dll` to the `plugins` folder under the -Jellyfin program data directory or inside the portable install directory. + - [X] Import and export user data tasks \ No newline at end of file diff --git a/build.yaml b/build.yaml index 997c2ce1..b21ca6e7 100644 --- a/build.yaml +++ b/build.yaml @@ -1,6 +1,6 @@ name: "Shoko" guid: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052" -imageUrl: https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png +imageUrl: https://raw.githubusercontent.com/ShokoAnime/Shokofin/metadata/Banner.png targetAbi: "10.8.0.0" owner: "ShokoAnime" overview: "Manage your anime from Jellyfin using metadata from Shoko" diff --git a/build_plugin.py b/build_plugin.py index e85b883e..4bde7113 100644 --- a/build_plugin.py +++ b/build_plugin.py @@ -19,22 +19,20 @@ def extract_target_framework(csproj_path): parser = argparse.ArgumentParser() parser.add_argument("--repo", required=True) parser.add_argument("--version", required=True) +parser.add_argument("--tag", required=True) parser.add_argument("--prerelease", default=True) opts = parser.parse_args() framework = extract_target_framework("./Shokofin/Shokofin.csproj") version = opts.version +tag = opts.tag prerelease = bool(opts.prerelease) artifact_dir = os.path.join(os.getcwd(), "artifacts") if not os.path.exists(artifact_dir): os.mkdir(artifact_dir) -if prerelease: - jellyfin_repo_file="./manifest-unstable.json" -else: - jellyfin_repo_file="./manifest.json" - +jellyfin_repo_file="./manifest.json" jellyfin_repo_url=f"https://github.com/{opts.repo}/releases/download" # Add changelog to the build yaml before we generate the release. @@ -54,7 +52,7 @@ def extract_target_framework(csproj_path): zipfile=os.popen("jprm --verbosity=debug plugin build \".\" --output=\"%s\" --version=\"%s\" --dotnet-framework=\"%s\"" % (artifact_dir, version, framework)).read().strip() -jellyfin_plugin_release_url=f"{jellyfin_repo_url}/{version}/shoko_{version}.zip" +jellyfin_plugin_release_url=f"{jellyfin_repo_url}/{tag}/shoko_{version}.zip" os.system("jprm repo add --plugin-url=%s %s %s" % (jellyfin_plugin_release_url, jellyfin_repo_file, zipfile)) diff --git a/manifest-unstable.json b/manifest-unstable.json deleted file mode 100644 index 37bd3113..00000000 --- a/manifest-unstable.json +++ /dev/null @@ -1,53 +0,0 @@ -[ - { - "guid": "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", - "name": "Shoko", - "description": "A Jellyfin plugin to integrate [Jellyfin](https://jellyfin.org/docs/) with [Shoko Server](https://shokoanime.com/downloads/shoko-server/).\n## Read this before installing\n**This plugin requires that you have already set up and are using Shoko Server**, and that the files you intend to include in Jellyfin are **indexed** (and optionally managed) by Shoko Server. **Otherwise, the plugin won't be able to provide metadata for your files**, since there is no metadata to find for them.\n", - "overview": "Manage your anime from Jellyfin using metadata from Shoko", - "owner": "ShokoAnime", - "category": "Metadata", - "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", - "versions": [ - { - "version": "3.0.1.187", - "changelog": "refactor: update collection metadata\n\n- Make sure the provider runs first, since these providers aren't configurable and we need them to run before the built in TMDB provider so it doesn't try to set it's metadata before us.\n\n- Remove any TMDB ids if found, since they shouldn't be needed on the collections managed by the plugin.\n\n- Removed the AniDB Series id for the collection, since in hindsight it doesn't make sense to have it on there.\n\n- General maintenance on the the collection provider. Renamed the methods and changed a public method that should had been private to a private method.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.187/shoko_3.0.1.187.zip", - "checksum": "c6ec4cb9b4ad206b3ce2f304c9fd1f62", - "timestamp": "2024-05-26T18:48:16Z" - }, - { - "version": "3.0.1.186", - "changelog": "fix: refresh signalr state upon connect/disconnect", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.186/shoko_3.0.1.186.zip", - "checksum": "602baee1a7d05c5ef8f47fb0ea24a417", - "timestamp": "2024-05-24T22:16:13Z" - }, - { - "version": "3.0.1.185", - "changelog": "revert: \"fix: extras \u2192 episodes\"\n\nThis reverts commit 2a3eed4dd910386ee373bdf3f8aadd2511f97ab3.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.185/shoko_3.0.1.185.zip", - "checksum": "bbf406685377d2d4ab11406d1d17c82c", - "timestamp": "2024-05-24T12:48:41Z" - }, - { - "version": "3.0.1.184", - "changelog": "fix: add xref group from latest daily server", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.184/shoko_3.0.1.184.zip", - "checksum": "5fc632851adc8ff1c4be945778f72904", - "timestamp": "2024-05-23T00:01:22Z" - }, - { - "version": "3.0.1.183", - "changelog": "fix: fix up show descriptions + more\n\n- Fixed up show descriptions.\n\n- Fixed up the anidb sanitization in general to convert better to **MarkDown**.\n\nfeat: make trailers/credits optional to include\n\n- Made trailers and credits optional to include, and allowed credits to be added as either theme videos (on by default), or as special features (off by default), or both.\n\nfeat: add trailer & video providers\n\n- Added a trailer and video provider responsible for fetching metadata for theme videos, special featurettes, and trailers __for entities in the VFS__.", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1.183/shoko_3.0.1.183.zip", - "checksum": "77f8b7f416bff24696b4176d3e57ca0f", - "timestamp": "2024-05-22T03:51:43Z" - } - ] - } -] \ No newline at end of file diff --git a/manifest.json b/manifest.json index 7862c764..1cde8ce7 100644 --- a/manifest.json +++ b/manifest.json @@ -2,228 +2,11 @@ { "guid": "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", "name": "Shoko", - "description": "A plugin to provide metadata from Shoko Server for your locally organized anime library in Jellyfin.\n", - "overview": "Manage your anime from Jellyfin using metadata from Shoko", - "owner": "shokoanime", + "description": "Stub. manifest. Use a manifest from the metadata branch instead.", + "owner": "ShokoAnime", "category": "Metadata", - "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/master/LogoWide.png", + "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/metadata/banner.png", "versions": [ - { - "version": "3.0.1.0", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.1/shoko_3.0.1.0.zip", - "checksum": "8bfa1dc2c430c07c0659bbbc757dbf8e", - "timestamp": "2023-04-20T15:44:36Z" - }, - { - "version": "3.0.0.0", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/3.0.0/shoko_3.0.0.0.zip", - "checksum": "c02b32abb548eb3d2153ac957ad88f80", - "timestamp": "2023-03-29T17:54:57Z" - }, - { - "version": "2.0.1.0", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.1/shoko_2.0.1.0.zip", - "checksum": "f6b1520a57381f9425935b10b4048398", - "timestamp": "2022-07-02T10:17:43Z" - }, - { - "version": "2.0.0.0", - "changelog": "NA\n", - "targetAbi": "10.8.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/2.0.0/shoko_2.0.0.0.zip", - "checksum": "635f85542f567644bd8af2e48796e5e6", - "timestamp": "2022-06-27T22:07:22Z" - }, - { - "version": "1.7.3.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.3/shoko_1.7.3.0.zip", - "checksum": "4329184a94c68c621a4c944bee7d7f9d", - "timestamp": "2022-04-21T10:23:21Z" - }, - { - "version": "1.7.2.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.2/shoko_1.7.2.0.zip", - "checksum": "38dd48745750756abbe6850d5527e694", - "timestamp": "2022-01-23T20:55:00Z" - }, - { - "version": "1.7.1.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.1/shoko_1.7.1.0.zip", - "checksum": "c748f37e301aaa891da7e01842d02a87", - "timestamp": "2022-01-21T18:10:49Z" - }, - { - "version": "1.7.0.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.7.0/shoko_1.7.0.0.zip", - "checksum": "e6604c4c9729b2f8a82bb9d4dfb0bfab", - "timestamp": "2022-01-12T19:27:09Z" - }, - { - "version": "1.6.3.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.6.3/shoko_1.6.3.0.zip", - "checksum": "e4edc60e6ca8ecc9ca83627c37cb0109", - "timestamp": "2021-10-22T13:53:49Z" - }, - { - "version": "1.6.2.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.6.2/shoko_1.6.2.0.zip", - "checksum": "92d76c12c13245a8c67dba3b6a532471", - "timestamp": "2021-10-22T12:45:39Z" - }, - { - "version": "1.6.1.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.6.1/shoko_1.6.1.0.zip", - "checksum": "d574380d64ff9fcc1a532f4b4f00dd82", - "timestamp": "2021-10-18T17:23:28Z" - }, - { - "version": "1.6.0.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.6.0/shoko_1.6.0.0.zip", - "checksum": "18dfd06489b5c77ba7e3d310ac255a3d", - "timestamp": "2021-10-11T14:11:13Z" - }, - { - "version": "1.5.0.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.5.0/shokofin_1.5.0.0.zip", - "checksum": "1619ade0f980553dbc056fc414ad6243", - "timestamp": "2021-08-30T00:23:40Z" - }, - { - "version": "1.4.7.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.7/shokofin_1.4.7.zip", - "checksum": "5a9e396ac1775d61cb14796eae6e8f8a", - "timestamp": "2021-06-01T17:14:41Z" - }, - { - "version": "1.4.6.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.6/shokofin_1.4.6.zip", - "checksum": "be14a632a9ff59baccc8d78d6ca1809c", - "timestamp": "2021-05-31T17:29:05Z" - }, - { - "version": "1.4.5.1", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.5.1/shokofin_1.4.5.1.zip", - "checksum": "1a5005234208d651e194ac9987c2ddcd", - "timestamp": "2021-03-26T06:05:25Z" - }, - { - "version": "1.4.5.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.5/shokofin_1.4.5.zip", - "checksum": "ec0638fc707dfe2450dc47b3e161d042", - "timestamp": "2021-03-25T13:10:36Z" - }, - { - "version": "1.4.4.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.4/shokofin_1.4.4.zip", - "checksum": "475abde06b9f67a131bb2737d126d052", - "timestamp": "2021-03-24T09:41:27Z" - }, - { - "version": "1.4.3.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.3/shokofin_1.4.3.zip", - "checksum": "ee2b4d8b79dcd1edf524bd81e2ef7290", - "timestamp": "2021-03-18T17:38:49Z" - }, - { - "version": "1.4.2.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.2/shokofin_1.4.2.zip", - "checksum": "2b90bac9df30315240802d0aa23a706c", - "timestamp": "2021-03-17T07:31:27Z" - }, - { - "version": "1.4.1.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.1/shokofin_1.4.1.zip", - "checksum": "77bc01b63d8dde14401ba8060dd46b38", - "timestamp": "2021-03-16T15:01:11Z" - }, - { - "version": "1.4.0.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.4.0/shokofin_1.4.0.zip", - "checksum": "57e70a963ef95a8f64bdcc394685f594", - "timestamp": "2021-03-03T20:39:56Z" - }, - { - "version": "1.3.1.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.3.1/shokofin_1.3.1.zip", - "checksum": "135888e53f702e0b96846ca2ca7201d7", - "timestamp": "2020-10-12T14:11:59Z" - }, - { - "version": "1.3.0.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.3.0/shokofin_1.3.0.zip", - "checksum": "a30811e8adc9491df0aaa436d6a7cea6", - "timestamp": "2020-09-30T20:54:31Z" - }, - { - "version": "1.2.0.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.2.0/shokojellyfin_1.2.0.zip", - "checksum": "7e1965987f40e62f9e987c44cf98a6fe", - "timestamp": "2020-09-20T21:52:52Z" - }, - { - "version": "1.1.0.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.1.0/shokojellyfin_1.1.0.zip", - "checksum": "610e540182066278d816e06e8f1a01ee", - "timestamp": "2020-09-08T22:17:26Z" - }, - { - "version": "1.0.0.0", - "changelog": "NA", - "targetAbi": "10.7.0.0", - "sourceUrl": "https://github.com/ShokoAnime/Shokofin/releases/download/1.0.0/shokojellyfin_1.0.0.zip", - "checksum": "184c723247ccdc4b0143dc46f5b6d50d", - "timestamp": "2020-09-07T14:43:45Z" - } ] } ] \ No newline at end of file diff --git a/thoughts.md b/thoughts.md deleted file mode 100644 index 5974adcf..00000000 --- a/thoughts.md +++ /dev/null @@ -1,181 +0,0 @@ -# Late nights thoughts - -**Disclaimer**: This file is all about some late night thoughts about how I -envision the plugin to work, and now how it is working currently. The parts -around the seasons and down are completely different from how it currently is, -and the changes below the episode cannot be implemented yet because of lacking -api data… for now. The data is available in Shoko, just not exposed in the -"correct" way in the v3 api yet. - -Collection → Shoko Group - -An object holding information about the shoko group that may contain sub-groups -and/or shoko series. - -**Properties** - -- `Group` (ShokoGroup) — The Shoko Group entry linked to the Collection item. - -- `Collections` (Collection[]) — Any sub-collections within the collection. - -- `Shows` (Show[]) — The Show entries within the collection. - -↓ - -Show → Shoko Group, Shoko Series - -An object holding information about the direct parent shoko group and the -main shoko series for the group if the parent group does not contain sub-groups, -or just the shoko series if the parent group also contains other sub-groups. - -**Properties** - -- `IsStandalone` (boolean) — Indicates the Shoko Series linked is in a Shoko Group with - sub-groups in it, and thus should not contain references to other series. - -- `Group` (ShokoGroup?) — The Shoko Group, if available. - -- `MainSeries` (ShokoSeries) — The main (and/or only) Shoko Series entry. Used for metadata. - -- `AllSeries` (ShokoSeries[]) — All the Shoko Series entries linked to the Show item. - -- `Seasons` (Season[]) — The Season entries within the show. - -↓ - -Season → Shoko Series - -An object holding information about the shoko series and the episodes both -available and not available in the user's library. Can contain multiple shoko -series references. - -**Properties** - -- `SeasonNumber` (int) — The season number. - -- `Name` (string) — The season name. - -- `EpisodeTypes` (EpisodeType[]) — The Shoko Episode Types this season is for. Only if a Shoko Series is split into multiple different - seasons, each for a different Episode Type. E.g. One for "Normal" and "Special", and one for "Other", etc.. - -- `IsMixed` (boolean) — Indicates the Season contains Episode entries from multiple Shoko Series entries. - -- `MainSeries` (ShokoSeries) — The main (and/or only) Shoko Series entry. Used for metadata. - -- `AllSeries` (ShokoSeries[]) — All Shoko Series entries linked to the Season entry. - -- `Episodes` (Episode[]) — All Episode entries within the Season. - -- `Extras` (Episode[]) — Extras that doesn't' count as any episodes. We're re-using the episode model for now. - -↓ - -Episode → Shoko Episode - -An object holding information about the shoko episode and the alternatve -versions available from the user's library. - -**Properties** - -- `Name` — The episode name. - -- `Number` — The computed episode number to use. - -- `AbsoluteNumber` — The absolute episode number to use, if available. - -- `EpisodeType` (EpisodeType) — The Shoko Episode Type for the Episode entry. - -- `ExtraType` (ExtraType) — The extra type assigned to the Episode entry. - -- `MainEpisode` (ShokoEpisode) — The main Shoko Episode entry to use for most of the metadata. - -- `AllEpisodes` (ShokoEpisode[]) — All the Shoko Episode entries linked to the Episode entry. - -- `AlternateVersions` (AlternateVersion) — All alternate episode versions that exist. - -↓ - -Alternate Versions → Shoko Episode, Shoko File - -An object holding information about which shoko files are linked to the same -episodes. - -**Properties** - -- `AllEpisodes` (ShokoEpisode[]) — All the Shoko Episode entries linked to the Episode entry. - -- `AllFiles` (ShokoFile[]) — All Shoko File entries linked to the Episode entry. - -- `PartialVersions` (PartialVersion[]) — All Partial Versions linked to this alternate version. - -↓ - -Partial Versions → Shoko Episode, Shoko File - -An object holding information about which shoko files are part of a multi-file -episode. - -Holds the references to all the file links that form up one alternate version of -the episode. - -**Properties** - -- `MainEpisode` (ShokoEpisode) — The main Shoko Episode entry to use for most of the metadata. - -- `AllEpisodes` (ShokoEpisode[]) — All the Shoko Episode entries linked to the Episode entry. - -- `MainFile` (ShokoFile) — The primary Shoko File to use for file info. - -- `AllFiles` (ShokoFile[]) — All Shoko File entries linked to the Episode entry. - -- `Files` (File[]) — All File entries linked to the Episode entry. - -↓ - -File → Shoko File - -A reference to the shoko file. - -**Properties** - -- `File` (ShokoFile) — The primary Shoko File to use for file info. - -- `Locations` (FileLocation[]) — All File Locations linked to the file, including - the physical file location and any symbolic ones needed to compliment and fill - out the library. - -↓ - -File Location → Shoko File - -An object holding information about either the original file or a symbolic-link -(managed by the plugin) pointing to the original file. Will be used to link the -same file to different episodes within the same series and across different -series. - -When encountering a shoko file with multiple cross-reference for different -episode types within the same series, or cross-series references, then each -episode type within each series referenced will get their own link. The original -link is assigned to the normal episode for the series it is place in physically -in the library. The other links will be created on-demand and placed in a -directory managed by the plugin (outside the actual library), and added to their -respective series. - -The links will be removed from the managed directory when either the library is -destroyed or when the links are otherwise no longer needed. - -**Properties** - -- `ID` (string) — An unique ID for this file location, even among other copies - of the file location. - - In the format `<fileId>-<seriesId>-<episodeIds>.<fileExt>`. - -- `Path` (string) — The full path of the file location. - -- `IsCopy` (boolean) — Indicates the file location is a symbolic link managed - by the plugin. - -- `File` (ShokoFile) — The primary Shoko File to use for file info. - -- `Series` (ShokoSeries) — The ShokoSeries entry for this file location. \ No newline at end of file From cafe1ec9fe9dcdb0b3969e86ecd7336bafffb4b0 Mon Sep 17 00:00:00 2001 From: Mikal S <7761729+revam@users.noreply.github.com> Date: Mon, 27 May 2024 03:53:07 +0200 Subject: [PATCH 1027/1103] misc: update read-me file [skip ci] Signed-off-by: Mikal S. <7761729+revam@users.noreply.github.com> --- README.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8ce1c6ba..ed153e68 100644 --- a/README.md +++ b/README.md @@ -202,17 +202,12 @@ compatible with what. - [X] Supports separating your on-disc library into a two Show and Movie libraries. - _Provided you apply the workaround to do it_. + _Provided you apply the workaround to do it_.¹ - - [/] Automatically populates all missing episodes not in your collection, so + - [X] Automatically populates all missing episodes not in your collection, so you can see at a glance what you are missing out on. - - [ ] Deleting a missing episode item marks the episode as hidden/ignored - in Shoko. - - - [ ] Optionally react to events sent from Shoko. - - Coming soon™-ish + - [X] Optionally react to events sent from Shoko. - [X] User data @@ -228,4 +223,4 @@ compatible with what. - [X] Live scrobbling (every 1 minute during playback after the last play/resume event or when jumping) - - [X] Import and export user data tasks \ No newline at end of file + - [X] Import and export user data tasks From f177882f58472d302e30ccb71cc0a398a512435d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 27 May 2024 04:16:03 +0200 Subject: [PATCH 1028/1103] fix: better season cleanup for shokofin libraries - Updated the custom series provider for Shokofin managed libraries to also check for excessive seasons with the same number, and remove any extras but moving their children to the selected "main" season if there are any. --- Shokofin/Providers/CustomSeriesProvider.cs | 30 ++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/Shokofin/Providers/CustomSeriesProvider.cs b/Shokofin/Providers/CustomSeriesProvider.cs index a3749c7f..333e4b5e 100644 --- a/Shokofin/Providers/CustomSeriesProvider.cs +++ b/Shokofin/Providers/CustomSeriesProvider.cs @@ -51,10 +51,19 @@ public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptio // Get the existing seasons and known seasons. var itemUpdated = ItemUpdateType.None; - var seasons = series.Children + var allSeasons = series.Children .OfType<Season>() .Where(season => season.IndexNumber.HasValue) - .ToDictionary(season => season.IndexNumber!.Value); + .ToList(); + var seasons = allSeasons + .OrderBy(season => season.IndexNumber!.Value) + .ThenBy(season => season.IsVirtualItem) + .ThenBy(season => season.Path) + .GroupBy(season => season.IndexNumber!.Value) + .ToDictionary(groupBy => groupBy.Key, groupBy => groupBy.First()); + var extraSeasonsToRemove = allSeasons + .Except(seasons.Values) + .ToList(); var knownSeasonIds = ShouldAddMetadata ? showInfo.SeasonOrderDictionary.Keys.ToHashSet() : showInfo.SeasonOrderDictionary @@ -74,6 +83,23 @@ public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptio LibraryManager.DeleteItem(season, new() { DeleteFileLocation = false }); } + foreach (var season in extraSeasonsToRemove) { + if (seasons.TryGetValue(season.IndexNumber!.Value, out var mainSeason)) { + var episodes = season.Children + .OfType<Episode>() + .Where(episode => !string.IsNullOrEmpty(episode.Path) && episode.ParentId == season.Id) + .ToList(); + foreach (var episode in episodes) { + Logger.LogInformation("Updating parent of physical episode {EpisodeNumber} {EpisodeName} in Season {SeasonNumber} for {SeriesName} (Series={SeriesId})", episode.IndexNumber, episode.Name, season.IndexNumber, series.Name, seriesId); + episode.SetParent(mainSeason); + } + await LibraryManager.UpdateItemsAsync(episodes, mainSeason, ItemUpdateType.MetadataEdit, CancellationToken.None); + } + + Logger.LogDebug("Removing extra Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", season.IndexNumber!.Value, series.Name, seriesId); + LibraryManager.DeleteItem(season, new() { DeleteFileLocation = false }); + } + // Add missing seasons. if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) foreach (var (seasonNumber, season) in CreateMissingSeasons(showInfo, series, seasons)) { From c210fd04fae0eee5ed56ad7ee8b7eee736c0c39c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 27 May 2024 04:18:03 +0200 Subject: [PATCH 1029/1103] misc: fix (critical) typo --- .github/workflows/release-daily.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 6b0262eb..45c830cb 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -88,7 +88,7 @@ jobs: - name: Fetch Dev Manifest from Metadata Branch run: | - git checkout manifest -- dev/manifest.json; + git checkout metadata -- dev/manifest.json; rm manifest.json; mv dev/manifest.json manifest.json; rmdir dev; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82dc7048..43155575 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,7 +48,7 @@ jobs: - name: Fetch Stable Manifest from Metadata Branch run: | - git checkout manifest -- stable/manifest.json; + git checkout metadata -- stable/manifest.json; rm manifest.json; mv stable/manifest.json manifest.json; rmdir stable; From 8de8c98bef2c52aabff89ccdfc3ab3821bfd715e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 27 May 2024 04:40:02 +0200 Subject: [PATCH 1030/1103] misc: correct git ref in GHA --- .github/workflows/release-daily.yml | 4 ++-- .github/workflows/release.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 45c830cb..565fecd5 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -88,7 +88,7 @@ jobs: - name: Fetch Dev Manifest from Metadata Branch run: | - git checkout metadata -- dev/manifest.json; + git checkout origin/metadata -- dev/manifest.json; rm manifest.json; mv dev/manifest.json manifest.json; rmdir dev; @@ -122,7 +122,7 @@ jobs: git add ./artifacts/shoko_${{ needs.current_info.outputs.version }}.zip; git stash push -m "Temp release details"; git reset --hard; - git checkout metadata; + git checkout origin/metadata -B metadata; git stash pop || git stash apply; git reset; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43155575..4b6d6ca4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: fetch-depth: 0 # This is set to download the full git history for the repo - name: Get Current Version - id: previous_release_info + id: release_info uses: revam/gh-action-get-tag-and-version@v1 with: branch: false @@ -48,7 +48,7 @@ jobs: - name: Fetch Stable Manifest from Metadata Branch run: | - git checkout metadata -- stable/manifest.json; + git checkout origin/metadata -- stable/manifest.json; rm manifest.json; mv stable/manifest.json manifest.json; rmdir stable; @@ -80,7 +80,7 @@ jobs: git add ./artifacts/shoko_${{ needs.current_info.outputs.version }}.zip; git stash push -m "Temp release details"; git reset --hard; - git checkout metadata; + git checkout origin/metadata -B metadata; git stash pop || git stash apply; git reset; From 90ddbc75181792c64288761ea28bf2d4fb58c015 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 27 May 2024 04:43:46 +0200 Subject: [PATCH 1031/1103] misc: set fetch depth 0 for GHA --- .github/workflows/release-daily.yml | 1 + .github/workflows/release.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 565fecd5..3d131b25 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -85,6 +85,7 @@ jobs: uses: actions/checkout@master with: ref: ${{ github.ref }} + fetch-depth: 0 # This is set to download the full git history for the repo - name: Fetch Dev Manifest from Metadata Branch run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b6d6ca4..2b2c4ffa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,7 @@ jobs: uses: actions/checkout@master with: ref: ${{ github.ref }} + fetch-depth: 0 # This is set to download the full git history for the repo - name: Fetch Stable Manifest from Metadata Branch run: | From 32bfaf83efffd28b7c30a6dce4c29bc30a8d34a1 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 27 May 2024 04:45:03 +0200 Subject: [PATCH 1032/1103] revert: "misc: correct git ref in GHA" This partially reverts commit 8de8c98bef2c52aabff89ccdfc3ab3821bfd715e. --- .github/workflows/release-daily.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 3d131b25..91558ccd 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -89,7 +89,7 @@ jobs: - name: Fetch Dev Manifest from Metadata Branch run: | - git checkout origin/metadata -- dev/manifest.json; + git checkout metadata -- dev/manifest.json; rm manifest.json; mv dev/manifest.json manifest.json; rmdir dev; @@ -123,7 +123,7 @@ jobs: git add ./artifacts/shoko_${{ needs.current_info.outputs.version }}.zip; git stash push -m "Temp release details"; git reset --hard; - git checkout origin/metadata -B metadata; + git checkout metadata; git stash pop || git stash apply; git reset; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b2c4ffa..455fb485 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,7 @@ jobs: - name: Fetch Stable Manifest from Metadata Branch run: | - git checkout origin/metadata -- stable/manifest.json; + git checkout metadata -- stable/manifest.json; rm manifest.json; mv stable/manifest.json manifest.json; rmdir stable; @@ -81,7 +81,7 @@ jobs: git add ./artifacts/shoko_${{ needs.current_info.outputs.version }}.zip; git stash push -m "Temp release details"; git reset --hard; - git checkout origin/metadata -B metadata; + git checkout metadata; git stash pop || git stash apply; git reset; From 26554177699ed186408709035d4258306c54fc18 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 27 May 2024 04:47:38 +0200 Subject: [PATCH 1033/1103] revert: "revert: "misc: correct git ref in GHA"" This reverts commit 32bfaf83efffd28b7c30a6dce4c29bc30a8d34a1. --- .github/workflows/release-daily.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 91558ccd..3d131b25 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -89,7 +89,7 @@ jobs: - name: Fetch Dev Manifest from Metadata Branch run: | - git checkout metadata -- dev/manifest.json; + git checkout origin/metadata -- dev/manifest.json; rm manifest.json; mv dev/manifest.json manifest.json; rmdir dev; @@ -123,7 +123,7 @@ jobs: git add ./artifacts/shoko_${{ needs.current_info.outputs.version }}.zip; git stash push -m "Temp release details"; git reset --hard; - git checkout metadata; + git checkout origin/metadata -B metadata; git stash pop || git stash apply; git reset; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 455fb485..2b2c4ffa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,7 @@ jobs: - name: Fetch Stable Manifest from Metadata Branch run: | - git checkout metadata -- stable/manifest.json; + git checkout origin/metadata -- stable/manifest.json; rm manifest.json; mv stable/manifest.json manifest.json; rmdir stable; @@ -81,7 +81,7 @@ jobs: git add ./artifacts/shoko_${{ needs.current_info.outputs.version }}.zip; git stash push -m "Temp release details"; git reset --hard; - git checkout metadata; + git checkout origin/metadata -B metadata; git stash pop || git stash apply; git reset; From 7d4addfca6e966a5cef242d67854b8324cd0ff2f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 27 May 2024 05:06:24 +0200 Subject: [PATCH 1034/1103] fix: last fix for GHA --- .github/workflows/release-daily.yml | 6 +++--- .github/workflows/release.yml | 6 +++--- build.yaml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 3d131b25..4070877e 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -90,6 +90,7 @@ jobs: - name: Fetch Dev Manifest from Metadata Branch run: | git checkout origin/metadata -- dev/manifest.json; + git reset; rm manifest.json; mv dev/manifest.json manifest.json; rmdir dev; @@ -120,11 +121,10 @@ jobs: mkdir dev; mv manifest.json dev git add ./dev/manifest.json; - git add ./artifacts/shoko_${{ needs.current_info.outputs.version }}.zip; - git stash push -m "Temp release details"; + git stash push --staged --message "Temp release details"; git reset --hard; git checkout origin/metadata -B metadata; - git stash pop || git stash apply; + git stash apply || git checkout --theirs dev/manifest.json; git reset; - name: Create Pre-Release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b2c4ffa..2a8ac7eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,7 @@ jobs: - name: Fetch Stable Manifest from Metadata Branch run: | git checkout origin/metadata -- stable/manifest.json; + git reset; rm manifest.json; mv stable/manifest.json manifest.json; rmdir stable; @@ -78,11 +79,10 @@ jobs: mkdir stable; mv manifest.json stable git add ./stable/manifest.json; - git add ./artifacts/shoko_${{ needs.current_info.outputs.version }}.zip; - git stash push -m "Temp release details"; + git stash push --staged --message "Temp release details"; git reset --hard; git checkout origin/metadata -B metadata; - git stash pop || git stash apply; + git stash apply || git checkout --theirs stable/manifest.json; git reset; - name: Update Release diff --git a/build.yaml b/build.yaml index b21ca6e7..1fa90ea2 100644 --- a/build.yaml +++ b/build.yaml @@ -1,6 +1,6 @@ name: "Shoko" guid: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052" -imageUrl: https://raw.githubusercontent.com/ShokoAnime/Shokofin/metadata/Banner.png +imageUrl: https://raw.githubusercontent.com/ShokoAnime/Shokofin/metadata/banner.png targetAbi: "10.8.0.0" owner: "ShokoAnime" overview: "Manage your anime from Jellyfin using metadata from Shoko" From edbc89a05b3a3622d2e2d06b80fe5d8ea275f582 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 28 May 2024 04:31:45 +0200 Subject: [PATCH 1035/1103] fix: start library monitors if enabled --- Shokofin/PluginServiceRegistrator.cs | 1 - Shokofin/Resolvers/ShokoLibraryMonitor.cs | 15 ++++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index 5fe8dae3..9e311bfe 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -17,7 +17,6 @@ public void RegisterServices(IServiceCollection serviceCollection) serviceCollection.AddSingleton<MergeVersions.MergeVersionsManager>(); serviceCollection.AddSingleton<Collections.CollectionManager>(); serviceCollection.AddSingleton<Resolvers.ShokoResolveManager>(); - serviceCollection.AddSingleton<Resolvers.ShokoLibraryMonitor>(); serviceCollection.AddSingleton<SignalR.SignalRConnectionManager>(); } } diff --git a/Shokofin/Resolvers/ShokoLibraryMonitor.cs b/Shokofin/Resolvers/ShokoLibraryMonitor.cs index 8df4a650..0f8183e8 100644 --- a/Shokofin/Resolvers/ShokoLibraryMonitor.cs +++ b/Shokofin/Resolvers/ShokoLibraryMonitor.cs @@ -6,6 +6,7 @@ using Emby.Naming.Common; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Plugins; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Configuration; @@ -15,7 +16,7 @@ namespace Shokofin.Resolvers; -public class ShokoLibraryMonitor +public class ShokoLibraryMonitor : IServerEntryPoint, IDisposable { private readonly ILogger<ShokoLibraryMonitor> Logger; @@ -66,6 +67,18 @@ NamingOptions namingOptions LibraryScanWatcher.ValueChanged -= OnLibraryScanRunningChanged; } + Task IServerEntryPoint.RunAsync() + { + StartWatching(); + return Task.CompletedTask; + } + + void IDisposable.Dispose() + { + GC.SuppressFinalize(this); + StopWatching(); + } + public void StartWatching() { // add blockers/watchers for every media folder with VFS enabled and real time monitoring enabled. From 9d405fd0e1bfcb21c7947fef4caeeac04d5d5a2a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 28 May 2024 23:33:22 +0200 Subject: [PATCH 1036/1103] chore: update read-me [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ed153e68..bc64f217 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ compatible with what. $ dotnet publish -c Release Shokofin/Shokofin.csproj ``` 4. **Copy Built Files:** - - After building, go to the `bin/Release/dotnet8.0/` directory. + - After building, go to the `bin/Release/net6.0/` directory. - Copy all `.dll` files to a folder named `Shoko`. - Place this `Shoko` folder in the `plugins` directory of your Jellyfin program data directory or inside the portable install directory. For help From b5ba527ac0863da762b15ce4d693a8e2a9f58466 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 29 May 2024 02:05:13 +0200 Subject: [PATCH 1037/1103] chore: update desc. for stub. manifest [skip ci] --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 1cde8ce7..faea52e0 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ { "guid": "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", "name": "Shoko", - "description": "Stub. manifest. Use a manifest from the metadata branch instead.", + "description": "Stub. manifest. Use a manifest from the metadata branch instead or learn more about how to set up the plugin at our docs site; https://docs.shokoanime.com/shokofin/install/", "owner": "ShokoAnime", "category": "Metadata", "imageUrl": "https://raw.githubusercontent.com/ShokoAnime/Shokofin/metadata/banner.png", From 3666ced98b68a36302744c49669fd4e64b2b89fe Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 29 May 2024 02:14:24 +0200 Subject: [PATCH 1038/1103] fix: fix signalr refresh state on settings page --- Shokofin/Configuration/configController.js | 18 +++++++++++++----- Shokofin/Configuration/configPage.html | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 45a4d575..6de30145 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -432,8 +432,6 @@ async function defaultSubmit(form) { let result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - await getSignalrStatus().then(refreshSignalr); - Dashboard.processPluginConfigurationUpdateResult(result); } catch (err) { @@ -456,8 +454,6 @@ async function resetConnectionSettings(form) { const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - await getSignalrStatus().then(refreshSignalr); - Dashboard.processPluginConfigurationUpdateResult(result); return config; @@ -879,9 +875,21 @@ export default function (page) { 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).catch(onError); + resetConnectionSettings(form) + .then(refreshSettings) + .then(getSignalrStatus) + .then(refreshSignalr) + .catch(onError); break; case "unlink-user": unlinkUser(form).then(refreshSettings).catch(onError); diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index bae05888..f2cc5414 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -24,7 +24,7 @@ <h3>Connection Settings</h3> <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" class="raised button-submit block emby-button"> + <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> From 4575c6ffbf86f6742ba3e3d9f64742cccbee4329 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 29 May 2024 02:38:48 +0200 Subject: [PATCH 1039/1103] misc: add more ignored sub-titles --- Shokofin/Utils/Text.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index c29dd443..cca109ce 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -61,7 +61,13 @@ public static class Text private static readonly HashSet<string> IgnoredSubTitles = new(StringComparer.InvariantCultureIgnoreCase) { "Complete Movie", + "Music Video", + "OAD", "OVA", + "Short Movie", + "Special", + "TV Special", + "Web", }; /// <summary> From d82453eb170be35c0ea0300edb23ac4c8f93827d Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Thu, 30 May 2024 00:21:52 +0100 Subject: [PATCH 1040/1103] Feat: Add client implementation of new SignalR Event Sources setting --- Shokofin/Configuration/configController.js | 28 ++++++++++++++++++++++ Shokofin/Configuration/configPage.html | 18 ++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 6de30145..8fb7ac80 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -319,6 +319,7 @@ async function defaultSubmit(form) { config.SignalR_AutoConnectEnabled = form.querySelector("#SignalRAutoConnect").checked; config.SignalR_AutoReconnectInSeconds = reconnectIntervals; form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); + setSignalREventSourcesIntoConfig(form, config); mediaFolderId = form.querySelector("#SignalRMediaFolderSelector").value; mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; if (mediaFolderConfig) { @@ -564,6 +565,7 @@ async function syncSignalrSettings(form) { config.SignalR_AutoConnectEnabled = form.querySelector("#SignalRAutoConnect").checked; config.SignalR_AutoReconnectInSeconds = reconnectIntervals; + setSignalREventSourcesIntoConfig(form, config); form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); const mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; @@ -822,6 +824,7 @@ export default function (page) { // SignalR settings form.querySelector("#SignalRAutoConnect").checked = config.SignalR_AutoConnectEnabled; form.querySelector("#SignalRAutoReconnectIntervals").value = config.SignalR_AutoReconnectInSeconds.join(", "); + setSignalREventSourcesFromConfig(form, config); signalrMediaFolderSelector.innerHTML += config.MediaFolders.map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.MediaFolderPath}</option>`).join(""); form.querySelector("#SignalRDefaultFileEvents").checked = config.SignalR_FileEvents; form.querySelector("#SignalRDefaultRefreshEvents").checked = config.SignalR_RefreshEnabled; @@ -1015,4 +1018,29 @@ function setTitleFromConfig(form, type, config) { for (const option of list.querySelectorAll(".sortableOption")) { adjustSortableListElement(option) } +} + +/** @param {HTMLFormElement} form */ +function setSignalREventSourcesIntoConfig(form, config) { + /** @type {HTMLInputElement[]} */ + const checkboxList = form.querySelectorAll(`#SignalREventSources .listItem input[data-signalr-event-source]`); + const resultArr = []; + for (const item of checkboxList) { + if (item.checked) { + resultArr.push(item.dataset.signalrEventSource); + } + } + config.SignalR_EventSources = resultArr; +} + +/** @param {HTMLFormElement} form */ +function setSignalREventSourcesFromConfig(form, config) { + /** @type {HTMLInputElement[]} */ + const checkboxList = form.querySelectorAll(`#SignalREventSources .listItem input[data-signalr-event-source]`); + for (const item of checkboxList) { + const eventSource = item.dataset.signalrEventSource; + if (config.SignalR_EventSources.includes(eventSource)) { + item.checked = true; + } + } } \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index f2cc5414..94f17816 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -491,6 +491,24 @@ <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" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">SignalR Event Sources</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem titleSourceItem"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-signalr-event-source="Shoko"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">Shoko</h3></div> + </div> + <div class="listItem titleSourceItem"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-signalr-event-source="AniDB"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">AniDB</h3></div> + </div> + <div class="listItem titleSourceItem"> + <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-signalr-event-source="TMDB"><span></span></label> + <div class="listItemBody"><h3 class="listItemBodyText">TMDB</h3></div> + </div> + </div> + <div class="fieldDescription">Which event sources should be listened to via the SignalR connection.</div> + </div> <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"> From 7a80257ff9941d3ff03faaa7f0a760635d06f08c Mon Sep 17 00:00:00 2001 From: Jordan Fearnley <fearnlj01@gmail.com> Date: Thu, 30 May 2024 01:27:00 +0100 Subject: [PATCH 1041/1103] Misc: Initial settings implementation of new SignalR setting --- Shokofin/Configuration/PluginConfiguration.cs | 7 +++++++ .../SignalR/Interfaces/IMetadataUpdatedEventArgs.cs | 6 +++--- Shokofin/SignalR/Interfaces/ProviderName.cs | 12 ++++++++++++ .../SignalR/Models/EpisodeInfoUpdatedEventArgs.cs | 2 +- .../SignalR/Models/SeriesInfoUpdatedEventArgs.cs | 2 +- 5 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 Shokofin/SignalR/Interfaces/ProviderName.cs diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 34ed9fd0..1e0c87c5 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -12,6 +12,7 @@ using OrderType = Shokofin.Utils.Ordering.OrderType; using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; using TitleProvider = Shokofin.Utils.Text.TitleProvider; +using ProviderName = Shokofin.SignalR.Interfaces.ProviderName; namespace Shokofin.Configuration; @@ -317,6 +318,11 @@ public virtual string PrettyUrl /// </summary> public bool SignalR_FileEvents { get; set; } + /// <summary> + /// The different SignalR event sources to 'subscribe' to. + /// </summary> + public ProviderName[] SignalR_EventSources { get; set; } + #endregion #region Experimental features @@ -415,6 +421,7 @@ public PluginConfiguration() 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_RefreshEnabled = false; SignalR_FileEvents = false; EXPERIMENTAL_AutoMergeVersions = true; diff --git a/Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs b/Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs index df3557de..addc0d64 100644 --- a/Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs @@ -19,7 +19,7 @@ public interface IMetadataUpdatedEventArgs /// <summary> /// The provider metadata source. /// </summary> - string ProviderName { get; } + ProviderName ProviderName { get; } /// <summary> /// The provided metadata episode id. @@ -29,7 +29,7 @@ public interface IMetadataUpdatedEventArgs /// <summary> /// Provider unique id. /// </summary> - string ProviderUId => $"{ProviderName.ToLowerInvariant()}:{ProviderId.ToString(CultureInfo.InvariantCulture)}"; + string ProviderUId => $"{ProviderName}:{ProviderId.ToString(CultureInfo.InvariantCulture)}"; /// <summary> /// The provided metadata series id. @@ -39,7 +39,7 @@ public interface IMetadataUpdatedEventArgs /// <summary> /// Provider unique parent id. /// </summary> - string? ProviderParentUId => ProviderParentId.HasValue ? $"{ProviderName.ToLowerInvariant()}:{ProviderParentId.Value.ToString(CultureInfo.InvariantCulture)}" : null; + string? ProviderParentUId => ProviderParentId.HasValue ? $"{ProviderName}:{ProviderParentId.Value.ToString(CultureInfo.InvariantCulture)}" : null; /// <summary> /// The first shoko episode id affected by this update. diff --git a/Shokofin/SignalR/Interfaces/ProviderName.cs b/Shokofin/SignalR/Interfaces/ProviderName.cs new file mode 100644 index 00000000..f08fdb4a --- /dev/null +++ b/Shokofin/SignalR/Interfaces/ProviderName.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Shokofin.SignalR.Interfaces; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ProviderName +{ + None = 0, + Shoko = 1, + AniDB = 2, + TMDB = 3, +} \ No newline at end of file diff --git a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs index ec6662dd..bee19c7f 100644 --- a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs @@ -17,7 +17,7 @@ public class EpisodeInfoUpdatedEventArgs : IMetadataUpdatedEventArgs /// The provider metadata source. /// </summary> [JsonInclude, JsonPropertyName("Source")] - public string ProviderName { get; set; } = string.Empty; + public ProviderName ProviderName { get; set; } = ProviderName.None; /// <summary> /// The provided metadata episode id. diff --git a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs index 4447dd73..723b2aab 100644 --- a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs @@ -17,7 +17,7 @@ public class SeriesInfoUpdatedEventArgs : IMetadataUpdatedEventArgs /// The provider metadata source. /// </summary> [JsonInclude, JsonPropertyName("Source")] - public string ProviderName { get; set; } = string.Empty; + public ProviderName ProviderName { get; set; } = ProviderName.None; /// <summary> /// The provided metadata series id. From c58c83ce5d785720075a1c0c675648c0f6d27639 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 30 May 2024 04:55:23 +0200 Subject: [PATCH 1042/1103] =?UTF-8?q?fix:=20add=20delay=20before=20process?= =?UTF-8?q?ing=20file=20events=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/Resolvers/ShokoLibraryMonitor.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Shokofin/Resolvers/ShokoLibraryMonitor.cs b/Shokofin/Resolvers/ShokoLibraryMonitor.cs index 0f8183e8..cc92e3d2 100644 --- a/Shokofin/Resolvers/ShokoLibraryMonitor.cs +++ b/Shokofin/Resolvers/ShokoLibraryMonitor.cs @@ -34,6 +34,12 @@ public class ShokoLibraryMonitor : IServerEntryPoint, IDisposable private readonly ConcurrentDictionary<string, ShokoWatcher> FileSystemWatchers = new(); + /// <summary> + /// A delay so magical it will give Shoko Server some time to finish it's + /// rename/move operation before we ask it if it knows the path. + /// </summary> + private const int MagicalDelay = 5000; // 5 seconds in milliseconds… for now. + // follow the core jf behavior, but use config added/removed instead of library added/removed. public ShokoLibraryMonitor( @@ -228,6 +234,8 @@ private void OnWatcherChanged(object? sender, FileSystemEventArgs e) public async Task ReportFileSystemChanged(MediaFolderConfiguration mediaConfig, WatcherChangeTypes changeTypes, string path) { + Logger.LogTrace("Found potential path with change {ChangeTypes}; {Path}", changeTypes, path); + if (!path.StartsWith(mediaConfig.MediaFolderPath)) { Logger.LogTrace("Skipped path because it is not in the watched folder; {Path}", path); return; @@ -238,6 +246,13 @@ public async Task ReportFileSystemChanged(MediaFolderConfiguration mediaConfig, return; } + await Task.Delay(MagicalDelay).ConfigureAwait(false); + + if (File.Exists(path)) { + Logger.LogTrace("Skipped path because it is disappeared after awhile before we could process it; {Path}", path); + return; + } + var relativePath = path[mediaConfig.MediaFolderPath.Length..]; var files = await ApiClient.GetFileByPath(relativePath); var file = files.FirstOrDefault(file => file.Locations.Any(location => location.ImportFolderId == mediaConfig.ImportFolderId && location.RelativePath == relativePath)); From f23af3ff2e4236042f3655994ea455e64420e0d4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 30 May 2024 06:48:32 +0200 Subject: [PATCH 1043/1103] misc: add C# guard --- Shokofin/SignalR/SignalRConnectionManager.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 79689cf7..325b3dfc 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -277,6 +277,21 @@ private void OnFileDeleted(IFileEventArgs eventArgs) private void OnInfoUpdated(IMetadataUpdatedEventArgs eventArgs) { + if (Plugin.Instance.Configuration.SignalR_EventSources.Contains(eventArgs.ProviderName)) { + Logger.LogTrace( + "{ProviderName} {MetadataType} {ProviderId} ({ProviderParentId}) skipped event with {UpdateReason}; provider not is not enabled in the plugin settings. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", + eventArgs.ProviderName, + eventArgs.Kind, + eventArgs.ProviderId, + eventArgs.ProviderParentId, + eventArgs.Reason, + eventArgs.EpisodeIds, + eventArgs.SeriesIds, + eventArgs.GroupIds + ); + return; + } + Logger.LogDebug( "{ProviderName} {MetadataType} {ProviderId} ({ProviderParentId}) dispatched event with {UpdateReason}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", eventArgs.ProviderName, From dff89e1d899ed8236a80df1f1727d4a48788f96b Mon Sep 17 00:00:00 2001 From: Terrails <github@sl.terrails.com> Date: Thu, 30 May 2024 22:48:28 +0200 Subject: [PATCH 1044/1103] fix: file events being skipped on valid files --- Shokofin/Resolvers/ShokoLibraryMonitor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoLibraryMonitor.cs b/Shokofin/Resolvers/ShokoLibraryMonitor.cs index cc92e3d2..0911efe1 100644 --- a/Shokofin/Resolvers/ShokoLibraryMonitor.cs +++ b/Shokofin/Resolvers/ShokoLibraryMonitor.cs @@ -248,7 +248,7 @@ public async Task ReportFileSystemChanged(MediaFolderConfiguration mediaConfig, await Task.Delay(MagicalDelay).ConfigureAwait(false); - if (File.Exists(path)) { + if (!File.Exists(path)) { Logger.LogTrace("Skipped path because it is disappeared after awhile before we could process it; {Path}", path); return; } From 8cdcf67be348fccc8bd77d99116c779d8d70538d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 31 May 2024 14:46:33 +0200 Subject: [PATCH 1045/1103] refactor: split up resolver and move around files --- .../MediaFolderConfigurationService.cs | 198 ++ ...ediaFolderConfigurationChangedEventArgs.cs | 2 +- Shokofin/Configuration/PluginConfiguration.cs | 2 +- Shokofin/Events/EventDispatchService.cs | 656 ++++++ .../Interfaces/IFileEventArgs.cs | 2 +- .../Interfaces/IFileRelocationEventArgs.cs | 2 +- .../Interfaces/IMetadataUpdatedEventArgs.cs | 2 +- .../Interfaces/ProviderName.cs | 2 +- .../Interfaces/UpdateReason.cs | 2 +- .../Stub/FileEventArgsStub.cs | 7 +- Shokofin/PluginServiceRegistrator.cs | 4 +- .../{ => Models}/LinkGenerationResult.cs | 2 +- .../Resolvers/{ => Models}/ShokoWatcher.cs | 2 +- Shokofin/Resolvers/ShokoIgnoreRule.cs | 198 +- Shokofin/Resolvers/ShokoLibraryMonitor.cs | 33 +- Shokofin/Resolvers/ShokoResolveManager.cs | 2067 ----------------- Shokofin/Resolvers/ShokoResolver.cs | 200 +- .../Resolvers/VirtualFileSystemService.cs | 1009 ++++++++ .../Models/EpisodeInfoUpdatedEventArgs.cs | 2 +- Shokofin/SignalR/Models/FileEventArgs.cs | 2 +- Shokofin/SignalR/Models/FileMovedEventArgs.cs | 2 +- .../SignalR/Models/FileRenamedEventArgs.cs | 2 +- .../Models/SeriesInfoUpdatedEventArgs.cs | 2 +- Shokofin/SignalR/SignalRConnectionManager.cs | 22 +- Shokofin/Tasks/AutoClearPluginCacheTask.cs | 12 +- Shokofin/Tasks/ClearPluginCacheTask.cs | 8 +- Shokofin/Tasks/PostScanTask.cs | 8 +- 27 files changed, 2312 insertions(+), 2138 deletions(-) create mode 100644 Shokofin/Configuration/MediaFolderConfigurationService.cs rename Shokofin/{Resolvers => Configuration/Models}/MediaFolderConfigurationChangedEventArgs.cs (91%) create mode 100644 Shokofin/Events/EventDispatchService.cs rename Shokofin/{SignalR => Events}/Interfaces/IFileEventArgs.cs (97%) rename Shokofin/{SignalR => Events}/Interfaces/IFileRelocationEventArgs.cs (92%) rename Shokofin/{SignalR => Events}/Interfaces/IMetadataUpdatedEventArgs.cs (98%) rename Shokofin/{SignalR => Events}/Interfaces/ProviderName.cs (81%) rename Shokofin/{SignalR => Events}/Interfaces/UpdateReason.cs (82%) rename Shokofin/{SignalR => Events}/Stub/FileEventArgsStub.cs (93%) rename Shokofin/Resolvers/{ => Models}/LinkGenerationResult.cs (98%) rename Shokofin/Resolvers/{ => Models}/ShokoWatcher.cs (93%) delete mode 100644 Shokofin/Resolvers/ShokoResolveManager.cs create mode 100644 Shokofin/Resolvers/VirtualFileSystemService.cs diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs new file mode 100644 index 00000000..75ec51c0 --- /dev/null +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Emby.Naming.Common; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.Configuration.Models; + +namespace Shokofin.Configuration; + +public class MediaFolderConfigurationService +{ + private readonly ILogger<MediaFolderConfigurationService> Logger; + + private readonly ILibraryManager LibraryManager; + + private readonly IFileSystem FileSystem; + + private readonly ShokoAPIClient ApiClient; + + private readonly NamingOptions NamingOptions; + + private readonly Dictionary<Guid, string> MediaFolderChangeKeys = new(); + + public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationAdded; + + public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationUpdated; + + public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationRemoved; + + public MediaFolderConfigurationService( + ILogger<MediaFolderConfigurationService> logger, + ILibraryManager libraryManager, + IFileSystem fileSystem, + ShokoAPIClient apiClient, + NamingOptions namingOptions + ) + { + Logger = logger; + LibraryManager = libraryManager; + FileSystem = fileSystem; + ApiClient = apiClient; + NamingOptions = namingOptions; + + foreach (var mediaConfig in Plugin.Instance.Configuration.MediaFolders) + MediaFolderChangeKeys[mediaConfig.MediaFolderId] = ConstructKey(mediaConfig); + LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; + Plugin.Instance.ConfigurationChanged += OnConfigurationChanged; + } + + ~MediaFolderConfigurationService() + { + GC.SuppressFinalize(this); + LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; + Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; + MediaFolderChangeKeys.Clear(); + } + + #region Changes Tracking + + private static string ConstructKey(MediaFolderConfiguration config) + => $"IsMapped={config.IsMapped},IsFileEventsEnabled={config.IsFileEventsEnabled},IsRefreshEventsEnabled={config.IsRefreshEventsEnabled},IsVirtualFileSystemEnabled={config.IsVirtualFileSystemEnabled},LibraryFilteringMode={config.LibraryFilteringMode}"; + + private void OnConfigurationChanged(object? sender, PluginConfiguration config) + { + foreach (var mediaConfig in config.MediaFolders) { + var currentKey = ConstructKey(mediaConfig); + if (MediaFolderChangeKeys.TryGetValue(mediaConfig.MediaFolderId, out var previousKey) && previousKey != currentKey) { + MediaFolderChangeKeys[mediaConfig.MediaFolderId] = currentKey; + if (LibraryManager.GetItemById(mediaConfig.MediaFolderId) is not Folder mediaFolder) + continue; + ConfigurationUpdated?.Invoke(sender, new(mediaConfig, mediaFolder)); + } + } + } + + private 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)) { + var mediaFolderConfig = Plugin.Instance.Configuration.MediaFolders.FirstOrDefault(c => c.MediaFolderId == folder.Id); + if (mediaFolderConfig != null) { + Logger.LogDebug( + "Removing stored configuration for folder at {Path} (ImportFolder={ImportFolderId},RelativePath={RelativePath})", + folder.Path, + mediaFolderConfig.ImportFolderId, + mediaFolderConfig.ImportFolderRelativePath + ); + Plugin.Instance.Configuration.MediaFolders.Remove(mediaFolderConfig); + Plugin.Instance.SaveConfiguration(); + + MediaFolderChangeKeys.Remove(folder.Id); + ConfigurationRemoved?.Invoke(null, new(mediaFolderConfig, folder)); + } + } + } + + #endregion + + #region Media Folder Mapping + + public IReadOnlyList<(MediaFolderConfiguration config, Folder mediaFolder, string vfsPath)> GetAvailableMediaFolders(bool fileEvents = false, bool refreshEvents = false) + => Plugin.Instance.Configuration.MediaFolders + .Where(mediaFolder => mediaFolder.IsMapped && (!fileEvents || mediaFolder.IsFileEventsEnabled) && (!refreshEvents || mediaFolder.IsRefreshEventsEnabled)) + .Select(config => (config, mediaFolder: LibraryManager.GetItemById(config.MediaFolderId) as Folder)) + .OfType<(MediaFolderConfiguration config, Folder mediaFolder)>() + .Select(tuple => (tuple.config, tuple.mediaFolder, tuple.mediaFolder.GetVirtualRoot())) + .ToList(); + + public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFolder(Folder mediaFolder) + { + var config = Plugin.Instance.Configuration; + var mediaFolderConfig = config.MediaFolders.FirstOrDefault(c => c.MediaFolderId == mediaFolder.Id); + if (mediaFolderConfig != null) + return mediaFolderConfig; + + // Check if we should introduce the VFS for the media folder. + mediaFolderConfig = new() { + MediaFolderId = mediaFolder.Id, + MediaFolderPath = mediaFolder.Path, + IsFileEventsEnabled = config.SignalR_FileEvents, + IsRefreshEventsEnabled = config.SignalR_RefreshEnabled, + IsVirtualFileSystemEnabled = config.VirtualFileSystem, + LibraryFilteringMode = config.LibraryFilteringMode, + }; + + 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(); + + 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 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; + } + catch { } + + // Store and log the result. + MediaFolderChangeKeys[mediaFolder.Id] = ConstructKey(mediaFolderConfig); + config.MediaFolders.Add(mediaFolderConfig); + Plugin.Instance.SaveConfiguration(config); + if (mediaFolderConfig.IsMapped) { + Logger.LogInformation( + "Found a match for media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path},Attempts={Attempts})", + mediaFolder.Path, + DateTime.UtcNow - start, + mediaFolderConfig.ImportFolderId, + mediaFolderConfig.ImportFolderRelativePath, + mediaFolder.Path, + attempts + ); + } + else { + Logger.LogWarning( + "Failed to find a match for media folder at {Path} after {Amount} attempts in {TimeSpan}.", + mediaFolder.Path, + attempts, + DateTime.UtcNow - start + ); + } + + ConfigurationAdded?.Invoke(null, new(mediaFolderConfig, mediaFolder)); + + return mediaFolderConfig; + } + + #endregion +} \ No newline at end of file diff --git a/Shokofin/Resolvers/MediaFolderConfigurationChangedEventArgs.cs b/Shokofin/Configuration/Models/MediaFolderConfigurationChangedEventArgs.cs similarity index 91% rename from Shokofin/Resolvers/MediaFolderConfigurationChangedEventArgs.cs rename to Shokofin/Configuration/Models/MediaFolderConfigurationChangedEventArgs.cs index 9312682a..c9d44ef9 100644 --- a/Shokofin/Resolvers/MediaFolderConfigurationChangedEventArgs.cs +++ b/Shokofin/Configuration/Models/MediaFolderConfigurationChangedEventArgs.cs @@ -2,7 +2,7 @@ using MediaBrowser.Controller.Entities; using Shokofin.Configuration; -namespace Shokofin.Resolvers; +namespace Shokofin.Configuration.Models; public class MediaConfigurationChangedEventArgs : EventArgs { diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 1e0c87c5..24c74cf7 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -10,9 +10,9 @@ using DescriptionProvider = Shokofin.Utils.Text.DescriptionProvider; using LibraryFilteringMode = Shokofin.Utils.Ordering.LibraryFilteringMode; using OrderType = Shokofin.Utils.Ordering.OrderType; +using ProviderName = Shokofin.Events.Interfaces.ProviderName; using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; using TitleProvider = Shokofin.Utils.Text.TitleProvider; -using ProviderName = Shokofin.SignalR.Interfaces.ProviderName; namespace Shokofin.Configuration; diff --git a/Shokofin/Events/EventDispatchService.cs b/Shokofin/Events/EventDispatchService.cs new file mode 100644 index 00000000..aa893240 --- /dev/null +++ b/Shokofin/Events/EventDispatchService.cs @@ -0,0 +1,656 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Timers; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Info; +using Shokofin.API.Models; +using Shokofin.Configuration; +using Shokofin.Events.Interfaces; +using Shokofin.ExternalIds; +using Shokofin.Resolvers; +using Shokofin.Resolvers.Models; +using Shokofin.Utils; + +using File = System.IO.File; +using IDirectoryService = MediaBrowser.Controller.Providers.IDirectoryService; +using ImageType = MediaBrowser.Model.Entities.ImageType; +using LibraryOptions = MediaBrowser.Model.Configuration.LibraryOptions; +using MetadataRefreshMode = MediaBrowser.Controller.Providers.MetadataRefreshMode; +using Timer = System.Timers.Timer; + +namespace Shokofin.Events; + +public class EventDispatchService +{ + private readonly ShokoAPIManager ApiManager; + + private readonly ShokoAPIClient ApiClient; + + private readonly ILibraryManager LibraryManager; + + private readonly ILibraryMonitor LibraryMonitor; + + private readonly LibraryScanWatcher LibraryScanWatcher; + + private readonly MediaFolderConfigurationService ConfigurationService; + + private readonly VirtualFileSystemService ResolveManager; + + private readonly IFileSystem FileSystem; + + private readonly IDirectoryService DirectoryService; + + private readonly ILogger<VirtualFileSystemService> Logger; + + private int ChangesDetectionSubmitterCount = 0; + + private readonly Timer ChangesDetectionTimer; + + private readonly Dictionary<string, (DateTime LastUpdated, List<IMetadataUpdatedEventArgs> List)> ChangesPerSeries = new(); + + private readonly Dictionary<int, (DateTime LastUpdated, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> List)> ChangesPerFile = new(); + + private readonly Dictionary<string, (int refCount, DateTime delayEnd)> MediaFolderChangeMonitor = new(); + + // It's so magical that it matches the magical value in the library monitor in JF core. 🪄 + private const int MagicalDelayValue = 45000; + + private static readonly TimeSpan DetectChangesThreshold = TimeSpan.FromSeconds(5); + + public EventDispatchService( + ShokoAPIManager apiManager, + ShokoAPIClient apiClient, + ILibraryManager libraryManager, + VirtualFileSystemService resolveManager, + MediaFolderConfigurationService configurationService, + ILibraryMonitor libraryMonitor, + LibraryScanWatcher libraryScanWatcher, + IFileSystem fileSystem, + IDirectoryService directoryService, + ILogger<VirtualFileSystemService> logger + ) + { + ApiManager = apiManager; + ApiClient = apiClient; + LibraryManager = libraryManager; + LibraryMonitor = libraryMonitor; + ResolveManager = resolveManager; + ConfigurationService = configurationService; + LibraryScanWatcher = libraryScanWatcher; + FileSystem = fileSystem; + DirectoryService = directoryService; + Logger = logger; + ChangesDetectionTimer = new() { AutoReset = true, Interval = TimeSpan.FromSeconds(4).TotalMilliseconds }; + ChangesDetectionTimer.Elapsed += OnIntervalElapsed; + } + + ~EventDispatchService() + { + + ChangesDetectionTimer.Elapsed -= OnIntervalElapsed; + } + + #region Event Detection + + public IDisposable RegisterEventSubmitter() + { + var count = ChangesDetectionSubmitterCount++; + if (count is 0) + ChangesDetectionTimer.Start(); + + return new DisposableAction(() => DeregisterEventSubmitter()); + } + + private void DeregisterEventSubmitter() + { + var count = --ChangesDetectionSubmitterCount; + if (count is 0) { + ChangesDetectionTimer.Stop(); + if (ChangesPerFile.Count > 0) + ClearFileEvents(); + if (ChangesPerSeries.Count > 0) + ClearMetadataUpdatedEvents(); + } + } + + private void OnIntervalElapsed(object? sender, ElapsedEventArgs eventArgs) + { + var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>)>(); + var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>)>(); + lock (ChangesPerFile) { + if (ChangesPerFile.Count > 0) { + var now = DateTime.Now; + foreach (var (fileId, (lastUpdated, list)) in ChangesPerFile) { + if (now - lastUpdated < DetectChangesThreshold) + continue; + filesToProcess.Add((fileId, list)); + } + foreach (var (fileId, _) in filesToProcess) + ChangesPerFile.Remove(fileId); + } + } + lock (ChangesPerSeries) { + if (ChangesPerSeries.Count > 0) { + var now = DateTime.Now; + foreach (var (metadataId, (lastUpdated, list)) in ChangesPerSeries) { + if (now - lastUpdated < DetectChangesThreshold) + continue; + seriesToProcess.Add((metadataId, list)); + } + foreach (var (metadataId, _) in seriesToProcess) + ChangesPerSeries.Remove(metadataId); + } + } + foreach (var (fileId, changes) in filesToProcess) + Task.Run(() => ProcessFileEvents(fileId, changes)); + foreach (var (metadataId, changes) in seriesToProcess) + Task.Run(() => ProcessMetadataEvents(metadataId, changes)); + } + + private void ClearFileEvents() + { + var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>)>(); + lock (ChangesPerFile) { + foreach (var (fileId, (lastUpdated, list)) in ChangesPerFile) { + filesToProcess.Add((fileId, list)); + } + ChangesPerFile.Clear(); + } + foreach (var (fileId, changes) in filesToProcess) + Task.Run(() => ProcessFileEvents(fileId, changes)); + } + + private void ClearMetadataUpdatedEvents() + { + var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>)>(); + lock (ChangesPerSeries) { + foreach (var (metadataId, (lastUpdated, list)) in ChangesPerSeries) { + seriesToProcess.Add((metadataId, list)); + } + ChangesPerSeries.Clear(); + } + foreach (var (metadataId, changes) in seriesToProcess) + Task.Run(() => ProcessMetadataEvents(metadataId, changes)); + } + + #endregion + + #region File Events + + public void AddFileEvent(int fileId, UpdateReason reason, int importFolderId, string filePath, IFileEventArgs eventArgs) + { + lock (ChangesPerFile) { + if (ChangesPerFile.TryGetValue(fileId, out var tuple)) + tuple.LastUpdated = DateTime.Now; + else + ChangesPerFile.Add(fileId, tuple = (DateTime.Now, new())); + tuple.List.Add((reason, importFolderId, filePath, eventArgs)); + } + } + + private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> changes) + { + try { + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogInformation("Skipped processing {EventCount} file change events because a library scan is running. (File={FileId})", changes.Count, fileId); + return; + } + + Logger.LogInformation("Processing {EventCount} file change events… (File={FileId})", changes.Count, fileId); + + // Something was added or updated. + 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 mediaFolders = ConfigurationService.GetAvailableMediaFolders(fileEvents: true); + var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); + if (reason is not UpdateReason.Removed) { + foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { + if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) + continue; + + var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); + if (!File.Exists(sourceLocation)) + continue; + + // Let the core logic handle the rest. + if (!config.IsVirtualFileSystemEnabled) { + locationsToNotify.Add(sourceLocation); + continue; + } + + var result = new LinkGenerationResult(); + var topFolders = new HashSet<string>(); + var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, sourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) + .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) + .ToList(); + foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(srcLoc, 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); + } + + // Remove old links for file. + var videos = LibraryManager + .GetItemList( + new() { + AncestorIds = new Guid[] { mediaFolder.Id }, + IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Episode, BaseItemKind.Movie }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, + DtoOptions = new(true), + }, + true + ) + .Where(item => !string.IsNullOrEmpty(item.Path) && item.Path.StartsWith(vfsPath) && !result.Paths.Contains(item.Path)) + .ToList(); + foreach (var video in videos) { + File.Delete(video.Path); + topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); + locationsToNotify.Add(video.Path); + result.RemovedVideos++; + } + + result.Print(Logger, mediaFolder.Path); + + // If all the "top-level-folders" exist, then let the core logic handle the rest. + if (topFolders.All(path => LibraryManager.FindByPath(path, true) != null)) { + var old = locationsToNotify.Count; + locationsToNotify.AddRange(vfsLocations.SelectMany(tuple => tuple.symbolicLinks)); + } + // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. + else { + var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); + if (!string.IsNullOrEmpty(fileOrFolder)) + mediaFoldersToNotify.TryAdd(mediaFolder.Path, (fileOrFolder, mediaFolder)); + } + } + } + // Something was removed, so assume the location is gone. + else if (changes.FirstOrDefault(t => t.Reason is UpdateReason.Removed).Event is IFileRelocationEventArgs firstRemovedEvent) { + relativePath = firstRemovedEvent.RelativePath; + importFolderId = firstRemovedEvent.ImportFolderId; + foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { + if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) + continue; + + // Let the core logic handle the rest. + if (!config.IsVirtualFileSystemEnabled) { + var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); + locationsToNotify.Add(sourceLocation); + continue; + } + + // Check if we can use another location for the file. + var result = new LinkGenerationResult(); + var vfsSymbolicLinks = new HashSet<string>(); + var topFolders = new HashSet<string>(); + var newRelativePath = await GetNewRelativePath(config, fileId, relativePath); + if (!string.IsNullOrEmpty(newRelativePath)) { + var newSourceLocation = Path.Join(mediaFolder.Path, newRelativePath[config.ImportFolderRelativePath.Length..]); + var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, newSourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) + .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) + .ToList(); + foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(srcLoc, 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); + } + vfsSymbolicLinks = vfsLocations.Select(tuple => tuple.sourceLocation).ToHashSet(); + } + + // Remove old links for file. + var videos = LibraryManager + .GetItemList( + new() { + AncestorIds = new Guid[] { mediaFolder.Id }, + IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Episode, BaseItemKind.Movie }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, + DtoOptions = new(true), + }, + true + ) + .Where(item => !string.IsNullOrEmpty(item.Path) && item.Path.StartsWith(vfsPath) && !result.Paths.Contains(item.Path)) + .ToList(); + foreach (var video in videos) { + File.Delete(video.Path); + topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); + locationsToNotify.Add(video.Path); + result.RemovedVideos++; + } + + result.Print(Logger, mediaFolder.Path); + + // If all the "top-level-folders" exist, then let the core logic handle the rest. + if (topFolders.All(path => LibraryManager.FindByPath(path, true) is not null)) { + var old = locationsToNotify.Count; + locationsToNotify.AddRange(vfsSymbolicLinks); + } + // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. + else { + var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); + if (!string.IsNullOrEmpty(fileOrFolder)) + mediaFoldersToNotify.TryAdd(mediaFolder.Path, (fileOrFolder, mediaFolder)); + } + } + } + + // We let jellyfin take it from here. + if (!LibraryScanWatcher.IsScanRunning) { + Logger.LogDebug("Notifying Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count + mediaFoldersToNotify.Count, fileId.ToString()); + foreach (var location in locationsToNotify) + LibraryMonitor.ReportFileSystemChanged(location); + if (mediaFoldersToNotify.Count > 0) + await Task.WhenAll(mediaFoldersToNotify.Values.Select(tuple => ReportMediaFolderChanged(tuple.mediaFolder, tuple.pathToReport))).ConfigureAwait(false); + } + else { + Logger.LogDebug("Skipped notifying Jellyfin about {LocationCount} changes because a library scan is running. (File={FileId})", locationsToNotify.Count, fileId.ToString()); + } + } + catch (Exception ex) { + Logger.LogError(ex, "Error processing {EventCount} file change events. (File={FileId})", changes.Count, fileId); + } + } + + private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEventArgs? fileEvent) + { + HashSet<string> seriesIds; + if (fileEvent is not null && fileEvent.CrossReferences.All(xref => xref.ShokoSeriesId.HasValue && xref.ShokoEpisodeId.HasValue)) + seriesIds = fileEvent.CrossReferences.Select(xref => xref.ShokoSeriesId!.Value.ToString()) + .Distinct() + .ToHashSet(); + else + seriesIds = (await ApiClient.GetFile(fileId.ToString())).CrossReferences + .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) + .Select(xref => xref.Series.Shoko!.Value.ToString()) + .Distinct() + .ToHashSet(); + + // TODO: Postpone the processing of the file if the episode or series is not available yet. + + var filteredSeriesIds = new HashSet<string>(); + foreach (var seriesId in seriesIds) { + var seriesPathSet = await ApiManager.GetPathSetForSeries(seriesId); + if (seriesPathSet.Count > 0) { + filteredSeriesIds.Add(seriesId); + } + } + + // Return all series if we only have this file for all of them, + // otherwise return only the series were we have other files that are + // not linked to other series. + return filteredSeriesIds.Count is 0 ? seriesIds : filteredSeriesIds; + } + + private async Task<string?> GetNewRelativePath(MediaFolderConfiguration config, int fileId, string relativePath) + { + // Check if the file still exists, and if it has any other locations we can use. + try { + var file = await ApiClient.GetFile(fileId.ToString()); + var usableLocation = file.Locations + .Where(loc => loc.ImportFolderId == config.ImportFolderId && config.IsEnabledForPath(loc.RelativePath) && loc.RelativePath != relativePath) + .FirstOrDefault(); + return usableLocation?.RelativePath; + } + catch (ApiException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { + return null; + } + } + + private async Task ReportMediaFolderChanged(Folder mediaFolder, string pathToReport) + { + if (LibraryManager.GetLibraryOptions(mediaFolder) is not LibraryOptions libraryOptions || !libraryOptions.EnableRealtimeMonitor) { + LibraryMonitor.ReportFileSystemChanged(pathToReport); + return; + } + + // Since we're blocking real-time file events on the media folder because + // it uses the VFS then we need to temporarily unblock it, then block it + // afterwards again. + var path = mediaFolder.Path; + var delayTime = TimeSpan.Zero; + lock (MediaFolderChangeMonitor) { + if (MediaFolderChangeMonitor.TryGetValue(path, out var entry)) { + MediaFolderChangeMonitor[path] = (entry.refCount + 1, entry.delayEnd); + delayTime = entry.delayEnd - DateTime.Now; + } + else { + MediaFolderChangeMonitor[path] = (1, DateTime.Now + TimeSpan.FromMilliseconds(MagicalDelayValue)); + delayTime = TimeSpan.FromMilliseconds(MagicalDelayValue); + } + } + + LibraryMonitor.ReportFileSystemChangeComplete(path, false); + + if (delayTime > TimeSpan.Zero) + await Task.Delay((int)delayTime.TotalMilliseconds).ConfigureAwait(false); + + LibraryMonitor.ReportFileSystemChanged(pathToReport); + + var shouldResume = false; + lock (MediaFolderChangeMonitor) { + if (MediaFolderChangeMonitor.TryGetValue(path, out var tuple)) { + if (tuple.refCount is 1) { + shouldResume = true; + MediaFolderChangeMonitor.Remove(path); + } + else { + MediaFolderChangeMonitor[path] = (tuple.refCount - 1, tuple.delayEnd); + } + } + } + + if (shouldResume) + LibraryMonitor.ReportFileSystemChangeBeginning(path); + } + + #endregion + + #region Refresh Events + + public void AddSeriesEvent(string metadataId, IMetadataUpdatedEventArgs eventArgs) + { + lock (ChangesPerSeries) { + if (ChangesPerSeries.TryGetValue(metadataId, out var tuple)) + tuple.LastUpdated = DateTime.Now; + else + ChangesPerSeries.Add(metadataId, tuple = (DateTime.Now, new())); + tuple.List.Add(eventArgs); + } + } + + private async Task ProcessMetadataEvents(string metadataId, List<IMetadataUpdatedEventArgs> changes) + { + try { + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogDebug("Skipped processing {EventCount} metadata change events because a library scan is running. (Metadata={ProviderUniqueId})", changes.Count, metadataId); + return; + } + + if (!changes.Any(e => e.Kind is BaseItemKind.Episode && e.EpisodeId.HasValue || e.Kind is BaseItemKind.Series && e.SeriesId.HasValue)) { + Logger.LogDebug("Skipped processing {EventCount} metadata change events because no series or episode ids to use. (Metadata={ProviderUniqueId})", changes.Count, metadataId); + return; + } + + var seriesId = changes.First(e => e.SeriesId.HasValue).SeriesId!.Value.ToString(); + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo is null) { + Logger.LogDebug("Unable to find show info for series id. (Series={SeriesId},Metadata={ProviderUniqueId})", seriesId, metadataId); + return; + } + + var seasonInfo = await ApiManager.GetSeasonInfoForSeries(seriesId); + if (seasonInfo is null) { + Logger.LogDebug("Unable to find season info for series id. (Series={SeriesId},Metadata={ProviderUniqueId})", seriesId, metadataId); + return; + } + + Logger.LogInformation("Processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); + + var updateCount = await ProcessSeriesEvents(showInfo, changes); + updateCount += await ProcessMovieEvents(seasonInfo, changes); + + Logger.LogInformation("Scheduled {UpdateCount} updates for {EventCount} metadata change events. (Metadata={ProviderUniqueId})", updateCount, changes.Count, metadataId); + } + catch (Exception ex) { + Logger.LogError(ex, "Error processing {EventCount} metadata change events. (Metadata={ProviderUniqueId})", changes.Count, metadataId); + } + } + + private async Task<int> ProcessSeriesEvents(ShowInfo showInfo, List<IMetadataUpdatedEventArgs> changes) + { + // Update the series if we got a series event _or_ an episode removed event. + var updateCount = 0; + var animeEvent = changes.Find(e => e.Kind is BaseItemKind.Series || e.Kind is BaseItemKind.Episode && e.Reason is UpdateReason.Removed); + if (animeEvent is not null) { + var shows = LibraryManager + .GetItemList( + new() { + IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Series }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, showInfo.Id } }, + DtoOptions = new(true), + }, + true + ) + .ToList(); + foreach (var show in shows) { + Logger.LogInformation("Refreshing show {ShowName}. (Show={ShowId},Series={SeriesId})", show.Name, show.Id, showInfo.Id); + await show.RefreshMetadata(new(DirectoryService) { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = true, + RemoveOldMetadata = true, + ReplaceImages = Enum.GetValues<ImageType>().ToArray(), + IsAutomated = true, + EnableRemoteContentProbe = true, + }, CancellationToken.None); + updateCount++; + } + } + // Otherwise update all season/episodes where appropriate. + else { + var episodeIds = changes + .Where(e => e.EpisodeId.HasValue && e.Reason is not UpdateReason.Removed) + .Select(e => e.EpisodeId!.Value.ToString()) + .ToHashSet(); + var seasonIds = changes + .Where(e => e.EpisodeId.HasValue && e.SeriesId.HasValue && e.Reason is UpdateReason.Removed) + .Select(e => e.SeriesId!.Value.ToString()) + .ToHashSet(); + var seasonList = showInfo.SeasonList + .Where(seasonInfo => seasonIds.Contains(seasonInfo.Id)) + .ToList(); + foreach (var seasonInfo in seasonList) { + var seasons = LibraryManager + .GetItemList( + new() { + IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Season }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, seasonInfo.Id } }, + DtoOptions = new(true), + }, + true + ) + .ToList(); + foreach (var season in seasons) { + Logger.LogInformation("Refreshing season {SeasonName}. (Season={SeasonId},Series={SeriesId})", season.Name, season.Id, seasonInfo.Id); + await season.RefreshMetadata(new(DirectoryService) { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = true, + RemoveOldMetadata = true, + ReplaceImages = Enum.GetValues<ImageType>().ToArray(), + IsAutomated = true, + EnableRemoteContentProbe = true, + }, CancellationToken.None); + updateCount++; + } + } + var episodeList = showInfo.SeasonList + .Except(seasonList) + .SelectMany(seasonInfo => seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList).Concat(seasonInfo.SpecialsList)) + .Where(episodeInfo => episodeIds.Contains(episodeInfo.Id)) + .ToList(); + foreach (var episodeInfo in episodeList) { + var episodes = LibraryManager + .GetItemList( + new() { + IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Episode }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoEpisodeId.Name, episodeInfo.Id } }, + DtoOptions = new(true), + }, + true + ) + .ToList(); + foreach (var episode in episodes) { + Logger.LogInformation("Refreshing episode {EpisodeName}. (Episode={EpisodeId},Episode={EpisodeId},Series={SeriesId})", episode.Name, episode.Id, episodeInfo.Id, episodeInfo.Shoko.IDs.Series.ToString()); + await episode.RefreshMetadata(new(DirectoryService) { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = true, + RemoveOldMetadata = true, + ReplaceImages = Enum.GetValues<ImageType>().ToArray(), + IsAutomated = true, + EnableRemoteContentProbe = true, + }, CancellationToken.None); + updateCount++; + } + } + } + return updateCount; + } + + private async Task<int> ProcessMovieEvents(SeasonInfo seasonInfo, List<IMetadataUpdatedEventArgs> changes) + { + // Find movies and refresh them. + var updateCount = 0; + var episodeIds = changes + .Where(e => e.EpisodeId.HasValue && e.Reason is not UpdateReason.Removed) + .Select(e => e.EpisodeId!.Value.ToString()) + .ToHashSet(); + var episodeList = seasonInfo.EpisodeList + .Concat(seasonInfo.AlternateEpisodesList) + .Concat(seasonInfo.SpecialsList) + .Where(episodeInfo => episodeIds.Contains(episodeInfo.Id)) + .ToList(); + foreach (var episodeInfo in episodeList) { + var movies = LibraryManager + .GetItemList( + new() { + IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Movie }, + HasAnyProviderId = new Dictionary<string, string> { { ShokoEpisodeId.Name, episodeInfo.Id } }, + DtoOptions = new(true), + }, + true + ) + .ToList(); + foreach (var movie in movies) { + Logger.LogInformation("Refreshing movie {MovieName}. (Movie={MovieId},Episode={EpisodeId},Series={SeriesId})", movie.Name, movie.Id, episodeInfo.Id, seasonInfo.Id); + await movie.RefreshMetadata(new(DirectoryService) { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + ReplaceAllImages = true, + RemoveOldMetadata = true, + ReplaceImages = Enum.GetValues<ImageType>().ToArray(), + IsAutomated = true, + EnableRemoteContentProbe = true, + }, CancellationToken.None); + updateCount++; + } + } + return updateCount; + } + + #endregion +} \ No newline at end of file diff --git a/Shokofin/SignalR/Interfaces/IFileEventArgs.cs b/Shokofin/Events/Interfaces/IFileEventArgs.cs similarity index 97% rename from Shokofin/SignalR/Interfaces/IFileEventArgs.cs rename to Shokofin/Events/Interfaces/IFileEventArgs.cs index 96713be5..a37e79f3 100644 --- a/Shokofin/SignalR/Interfaces/IFileEventArgs.cs +++ b/Shokofin/Events/Interfaces/IFileEventArgs.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -namespace Shokofin.SignalR.Interfaces; +namespace Shokofin.Events.Interfaces; public interface IFileEventArgs { diff --git a/Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs b/Shokofin/Events/Interfaces/IFileRelocationEventArgs.cs similarity index 92% rename from Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs rename to Shokofin/Events/Interfaces/IFileRelocationEventArgs.cs index e6918334..57647978 100644 --- a/Shokofin/SignalR/Interfaces/IFileRelocationEventArgs.cs +++ b/Shokofin/Events/Interfaces/IFileRelocationEventArgs.cs @@ -1,5 +1,5 @@ -namespace Shokofin.SignalR.Interfaces; +namespace Shokofin.Events.Interfaces; public interface IFileRelocationEventArgs : IFileEventArgs { diff --git a/Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs b/Shokofin/Events/Interfaces/IMetadataUpdatedEventArgs.cs similarity index 98% rename from Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs rename to Shokofin/Events/Interfaces/IMetadataUpdatedEventArgs.cs index addc0d64..11957879 100644 --- a/Shokofin/SignalR/Interfaces/IMetadataUpdatedEventArgs.cs +++ b/Shokofin/Events/Interfaces/IMetadataUpdatedEventArgs.cs @@ -2,7 +2,7 @@ using System.Globalization; using Jellyfin.Data.Enums; -namespace Shokofin.SignalR.Interfaces; +namespace Shokofin.Events.Interfaces; public interface IMetadataUpdatedEventArgs { diff --git a/Shokofin/SignalR/Interfaces/ProviderName.cs b/Shokofin/Events/Interfaces/ProviderName.cs similarity index 81% rename from Shokofin/SignalR/Interfaces/ProviderName.cs rename to Shokofin/Events/Interfaces/ProviderName.cs index f08fdb4a..b9d3e4f1 100644 --- a/Shokofin/SignalR/Interfaces/ProviderName.cs +++ b/Shokofin/Events/Interfaces/ProviderName.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Shokofin.SignalR.Interfaces; +namespace Shokofin.Events.Interfaces; [JsonConverter(typeof(JsonStringEnumConverter))] public enum ProviderName diff --git a/Shokofin/SignalR/Interfaces/UpdateReason.cs b/Shokofin/Events/Interfaces/UpdateReason.cs similarity index 82% rename from Shokofin/SignalR/Interfaces/UpdateReason.cs rename to Shokofin/Events/Interfaces/UpdateReason.cs index b9891dbc..b3d9431e 100644 --- a/Shokofin/SignalR/Interfaces/UpdateReason.cs +++ b/Shokofin/Events/Interfaces/UpdateReason.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; -namespace Shokofin.SignalR.Interfaces; +namespace Shokofin.Events.Interfaces; [JsonConverter(typeof(JsonStringEnumConverter))] public enum UpdateReason diff --git a/Shokofin/SignalR/Stub/FileEventArgsStub.cs b/Shokofin/Events/Stub/FileEventArgsStub.cs similarity index 93% rename from Shokofin/SignalR/Stub/FileEventArgsStub.cs rename to Shokofin/Events/Stub/FileEventArgsStub.cs index 63d8e2f4..adbfa7d2 100644 --- a/Shokofin/SignalR/Stub/FileEventArgsStub.cs +++ b/Shokofin/Events/Stub/FileEventArgsStub.cs @@ -1,10 +1,9 @@ using System.Collections.Generic; using System.Linq; -using Shokofin.SignalR.Interfaces; +using Shokofin.API.Models; +using Shokofin.Events.Interfaces; -using File = Shokofin.API.Models.File; - -namespace Shokofin.SignalR.Models; +namespace Shokofin.Events.Stub; public class FileEventArgsStub : IFileEventArgs { diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index 9e311bfe..e6836deb 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -12,11 +12,13 @@ public void RegisterServices(IServiceCollection serviceCollection) serviceCollection.AddSingleton<Utils.LibraryScanWatcher>(); serviceCollection.AddSingleton<API.ShokoAPIClient>(); serviceCollection.AddSingleton<API.ShokoAPIManager>(); + serviceCollection.AddSingleton<Configuration.MediaFolderConfigurationService>(); serviceCollection.AddSingleton<IIdLookup, IdLookup>(); serviceCollection.AddSingleton<Sync.UserDataSyncManager>(); serviceCollection.AddSingleton<MergeVersions.MergeVersionsManager>(); serviceCollection.AddSingleton<Collections.CollectionManager>(); - serviceCollection.AddSingleton<Resolvers.ShokoResolveManager>(); + serviceCollection.AddSingleton<Resolvers.VirtualFileSystemService>(); + serviceCollection.AddSingleton<Events.EventDispatchService>(); serviceCollection.AddSingleton<SignalR.SignalRConnectionManager>(); } } diff --git a/Shokofin/Resolvers/LinkGenerationResult.cs b/Shokofin/Resolvers/Models/LinkGenerationResult.cs similarity index 98% rename from Shokofin/Resolvers/LinkGenerationResult.cs rename to Shokofin/Resolvers/Models/LinkGenerationResult.cs index f8c1c505..9c9cfe19 100644 --- a/Shokofin/Resolvers/LinkGenerationResult.cs +++ b/Shokofin/Resolvers/Models/LinkGenerationResult.cs @@ -3,7 +3,7 @@ using System.Collections.Concurrent; using Microsoft.Extensions.Logging; -namespace Shokofin.Resolvers; +namespace Shokofin.Resolvers.Models; public class LinkGenerationResult { diff --git a/Shokofin/Resolvers/ShokoWatcher.cs b/Shokofin/Resolvers/Models/ShokoWatcher.cs similarity index 93% rename from Shokofin/Resolvers/ShokoWatcher.cs rename to Shokofin/Resolvers/Models/ShokoWatcher.cs index 56cc017c..927fc051 100644 --- a/Shokofin/Resolvers/ShokoWatcher.cs +++ b/Shokofin/Resolvers/Models/ShokoWatcher.cs @@ -4,7 +4,7 @@ using MediaBrowser.Controller.Entities; using Shokofin.Configuration; -namespace Shokofin.Resolvers; +namespace Shokofin.Resolvers.Models; public class ShokoWatcher { diff --git a/Shokofin/Resolvers/ShokoIgnoreRule.cs b/Shokofin/Resolvers/ShokoIgnoreRule.cs index 2e1bc37f..33bed998 100644 --- a/Shokofin/Resolvers/ShokoIgnoreRule.cs +++ b/Shokofin/Resolvers/ShokoIgnoreRule.cs @@ -1,22 +1,210 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Emby.Naming.Common; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Models; +using Shokofin.Configuration; +using Shokofin.Utils; namespace Shokofin.Resolvers; #pragma warning disable CS8766 public class ShokoIgnoreRule : IResolverIgnoreRule { - private readonly ShokoResolveManager ResolveManager; + private readonly ILogger<ShokoIgnoreRule> Logger; - public ShokoIgnoreRule(ShokoResolveManager resolveManager) + private readonly IIdLookup Lookup; + + private readonly ILibraryManager LibraryManager; + + private readonly IFileSystem FileSystem; + + private readonly ShokoAPIManager ApiManager; + + private readonly MediaFolderConfigurationService ConfigurationService; + + private readonly NamingOptions NamingOptions; + + public ShokoIgnoreRule( + ILogger<ShokoIgnoreRule> logger, + IIdLookup lookup, + ILibraryManager libraryManager, + IFileSystem fileSystem, + ShokoAPIManager apiManager, + MediaFolderConfigurationService configurationService, + NamingOptions namingOptions + ) { - ResolveManager = resolveManager; + Lookup = lookup; + Logger = logger; + LibraryManager = libraryManager; + FileSystem = fileSystem; + ApiManager = apiManager; + ConfigurationService = configurationService; + NamingOptions = namingOptions; } - public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent) - => ResolveManager.ShouldFilterItem(parent as Folder, fileInfo) + public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) + { + // Check if the parent is not made yet, or the file info is missing. + if (parent is null || fileInfo is null) + return false; + + // Check if the root is not made yet. This should **never** be false at + // this point in time, but if it is, then bail. + var root = LibraryManager.RootFolder; + if (root is null || parent.Id == root.Id) + return false; + + // Assume anything within the VFS is already okay. + if (fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) + return false; + + try { + // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. + if (!Lookup.IsEnabledForItem(parent, out var isSoleProvider)) + return false; + + if (fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(Path.GetFileName(fileInfo.FullName).ToLowerInvariant())) { + Logger.LogDebug("Skipped excluded folder at path {Path}", fileInfo.FullName); + return true; + } + + if (!fileInfo.IsDirectory && !NamingOptions.VideoFileExtensions.Contains(fileInfo.Extension.ToLowerInvariant())) { + Logger.LogDebug("Skipped excluded file at path {Path}", fileInfo.FullName); + return false; + } + + var fullPath = fileInfo.FullName; + var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent, root); + + // Ignore any media folders that aren't mapped to shoko. + 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; + } + + // Filter out anything in the media folder if the VFS is enabled, + // 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) + return true; + + var shouldIgnore = mediaFolderConfig.LibraryFilteringMode switch { + Ordering.LibraryFilteringMode.Strict => true, + Ordering.LibraryFilteringMode.Lax => false, + // Ordering.LibraryFilteringMode.Auto => + _ => mediaFolderConfig.IsVirtualFileSystemEnabled || isSoleProvider, + }; + var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); + if (fileInfo.IsDirectory) + return await ShouldFilterDirectory(partialPath, fullPath, collectionType, shouldIgnore).ConfigureAwait(false); + + return await ShouldFilterFile(partialPath, fullPath, shouldIgnore).ConfigureAwait(false); + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + throw; + } + } + + private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPath, string? collectionType, bool shouldIgnore) + { + var season = await ApiManager.GetSeasonInfoByPath(fullPath).ConfigureAwait(false); + + // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given folder path. + if (season == null) { + // If we're in strict mode, then check the sub-directories if we have a <Show>/<Season>/<Episodes> structure. + if (shouldIgnore && partialPath[1..].Split(Path.DirectorySeparatorChar).Length is 1) { + try { + var entries = FileSystem.GetDirectories(fullPath, false).ToList(); + Logger.LogDebug("Unable to find shoko series for {Path}, trying {DirCount} sub-directories.", partialPath, entries.Count); + foreach (var entry in entries) { + season = await ApiManager.GetSeasonInfoByPath(entry.FullName).ConfigureAwait(false); + if (season is not null) { + Logger.LogDebug("Found shoko series {SeriesName} for sub-directory of path {Path} (Series={SeriesId})", season.Shoko.Name, partialPath, season.Id); + break; + } + } + } + catch (DirectoryNotFoundException) { } + } + if (season is null) { + if (shouldIgnore) + Logger.LogInformation("Ignored unknown folder at path {Path}", partialPath); + else + Logger.LogWarning("Skipped unknown folder at path {Path}", partialPath); + return shouldIgnore; + } + } + + // Filter library if we enabled the option. + var isMovieSeason = season.Type is SeriesType.Movie; + switch (collectionType) { + case CollectionType.TvShows: + if (isMovieSeason && Plugin.Instance.Configuration.SeparateMovies) { + Logger.LogInformation("Found movie in show library and library separation is enabled, ignoring shoko series. (Series={SeriesId})", season.Id); + return true; + } + break; + case CollectionType.Movies: + if (!isMovieSeason) { + Logger.LogInformation("Found show in movie library, ignoring shoko series. (Series={SeriesId})", season.Id); + return true; + } + break; + } + + var show = await ApiManager.GetShowInfoForSeries(season.Id).ConfigureAwait(false)!; + if (!string.IsNullOrEmpty(show!.GroupId)) + Logger.LogInformation("Found shoko group {GroupName} (Series={SeriesId},Group={GroupId})", show.Name, season.Id, show.GroupId); + else + Logger.LogInformation("Found shoko series {SeriesName} (Series={SeriesId})", season.Shoko.Name, season.Id); + + return false; + } + + private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, bool shouldIgnore) + { + var (file, season, _) = await ApiManager.GetFileInfoByPath(fullPath).ConfigureAwait(false); + + // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given file path. + if (file is null || season is null) { + if (shouldIgnore) + Logger.LogInformation("Ignored unknown file at path {Path}", partialPath); + else + Logger.LogWarning("Skipped unknown file at path {Path}", partialPath); + return shouldIgnore; + } + + Logger.LogInformation("Found {EpisodeCount} shoko episode(s) for {SeriesName} (Series={SeriesId},File={FileId})", file.EpisodeList.Count, season.Shoko.Name, season.Id, file.Id); + + // We're going to post process this file later, but we don't want to include it in our library for now. + if (file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode))) { + Logger.LogInformation("File was assigned an extra type, ignoring file. (Series={SeriesId},File={FileId})", season.Id, file.Id); + return true; + } + + return false; + } + + #region IResolverIgnoreRule Implementation + + bool IResolverIgnoreRule.ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) + => ShouldFilterItem(parent as Folder, fileInfo) .ConfigureAwait(false) .GetAwaiter() .GetResult(); + + #endregion } diff --git a/Shokofin/Resolvers/ShokoLibraryMonitor.cs b/Shokofin/Resolvers/ShokoLibraryMonitor.cs index 0911efe1..7de9fd13 100644 --- a/Shokofin/Resolvers/ShokoLibraryMonitor.cs +++ b/Shokofin/Resolvers/ShokoLibraryMonitor.cs @@ -10,8 +10,11 @@ using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Configuration; -using Shokofin.SignalR.Interfaces; -using Shokofin.SignalR.Models; +using Shokofin.Configuration.Models; +using Shokofin.Events; +using Shokofin.Events.Interfaces; +using Shokofin.Events.Stub; +using Shokofin.Resolvers.Models; using Shokofin.Utils; namespace Shokofin.Resolvers; @@ -22,7 +25,9 @@ public class ShokoLibraryMonitor : IServerEntryPoint, IDisposable private readonly ShokoAPIClient ApiClient; - private readonly ShokoResolveManager ResolveManager; + private readonly EventDispatchService Events; + + private readonly MediaFolderConfigurationService ConfigurationService; private readonly ILibraryManager LibraryManager; @@ -45,7 +50,8 @@ public class ShokoLibraryMonitor : IServerEntryPoint, IDisposable public ShokoLibraryMonitor( ILogger<ShokoLibraryMonitor> logger, ShokoAPIClient apiClient, - ShokoResolveManager resolveManager, + EventDispatchService events, + MediaFolderConfigurationService configurationService, ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, LibraryScanWatcher libraryScanWatcher, @@ -54,10 +60,11 @@ NamingOptions namingOptions { Logger = logger; ApiClient = apiClient; - ResolveManager = resolveManager; - ResolveManager.ConfigurationAdded += OnMediaFolderConfigurationAddedOrUpdated; - ResolveManager.ConfigurationUpdated += OnMediaFolderConfigurationAddedOrUpdated; - ResolveManager.ConfigurationRemoved += OnMediaFolderConfigurationRemoved; + Events = events; + ConfigurationService = configurationService; + ConfigurationService.ConfigurationAdded += OnMediaFolderConfigurationAddedOrUpdated; + ConfigurationService.ConfigurationUpdated += OnMediaFolderConfigurationAddedOrUpdated; + ConfigurationService.ConfigurationRemoved += OnMediaFolderConfigurationRemoved; LibraryManager = libraryManager; LibraryMonitor = libraryMonitor; LibraryScanWatcher = libraryScanWatcher; @@ -67,9 +74,9 @@ NamingOptions namingOptions ~ShokoLibraryMonitor() { - ResolveManager.ConfigurationAdded -= OnMediaFolderConfigurationAddedOrUpdated; - ResolveManager.ConfigurationUpdated -= OnMediaFolderConfigurationAddedOrUpdated; - ResolveManager.ConfigurationRemoved -= OnMediaFolderConfigurationRemoved; + ConfigurationService.ConfigurationAdded -= OnMediaFolderConfigurationAddedOrUpdated; + ConfigurationService.ConfigurationUpdated -= OnMediaFolderConfigurationAddedOrUpdated; + ConfigurationService.ConfigurationRemoved -= OnMediaFolderConfigurationRemoved; LibraryScanWatcher.ValueChanged -= OnLibraryScanRunningChanged; } @@ -156,7 +163,7 @@ private void StartWatchingMediaFolder(Folder mediaFolder, MediaFolderConfigurati watcher.Changed += OnWatcherChanged; watcher.Error += OnWatcherError; - var lease = ResolveManager.RegisterEventSubmitter(); + var lease = Events.RegisterEventSubmitter(); if (FileSystemWatchers.TryAdd(mediaFolder.Path, new(mediaFolder, config, watcher, lease))) { LibraryMonitor.ReportFileSystemChangeBeginning(mediaFolder.Path); watcher.EnableRaisingEvents = true; @@ -282,7 +289,7 @@ public async Task ReportFileSystemChanged(MediaFolderConfiguration mediaConfig, return; } - ResolveManager.AddFileEvent(file.Id, reason, fileLocation.ImportFolderId, relativePath, new FileEventArgsStub(fileLocation, file)); + Events.AddFileEvent(file.Id, reason, fileLocation.ImportFolderId, relativePath, new FileEventArgsStub(fileLocation, file)); } private bool IsVideoFile(string path) => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path)); diff --git a/Shokofin/Resolvers/ShokoResolveManager.cs b/Shokofin/Resolvers/ShokoResolveManager.cs deleted file mode 100644 index 3e2c912f..00000000 --- a/Shokofin/Resolvers/ShokoResolveManager.cs +++ /dev/null @@ -1,2067 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using System.Timers; -using Emby.Naming.Common; -using Emby.Naming.ExternalFiles; -using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Movies; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Resolvers; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.IO; -using Microsoft.Extensions.Logging; -using Shokofin.API; -using Shokofin.API.Info; -using Shokofin.API.Models; -using Shokofin.Configuration; -using Shokofin.ExternalIds; -using Shokofin.SignalR.Interfaces; -using Shokofin.Utils; - -using File = System.IO.File; -using IDirectoryService = MediaBrowser.Controller.Providers.IDirectoryService; -using ImageType = MediaBrowser.Model.Entities.ImageType; -using LibraryOptions = MediaBrowser.Model.Configuration.LibraryOptions; -using MetadataRefreshMode = MediaBrowser.Controller.Providers.MetadataRefreshMode; -using Timer = System.Timers.Timer; -using TvSeries = MediaBrowser.Controller.Entities.TV.Series; - -namespace Shokofin.Resolvers; - -public class ShokoResolveManager -{ - private readonly ShokoAPIManager ApiManager; - - private readonly ShokoAPIClient ApiClient; - - private readonly IIdLookup Lookup; - - private readonly ILibraryManager LibraryManager; - - private readonly ILibraryMonitor LibraryMonitor; - - private readonly LibraryScanWatcher LibraryScanWatcher; - - private readonly IFileSystem FileSystem; - - private readonly IDirectoryService DirectoryService; - - private readonly ILogger<ShokoResolveManager> Logger; - - private readonly NamingOptions NamingOptions; - - private readonly ExternalPathParser ExternalPathParser; - - private readonly GuardedMemoryCache DataCache; - - private int ChangesDetectionSubmitterCount = 0; - - private readonly Timer ChangesDetectionTimer; - - private readonly Dictionary<Guid, string> MediaFolderChangeKeys = new(); - - private readonly Dictionary<string, (DateTime LastUpdated, List<IMetadataUpdatedEventArgs> List)> ChangesPerSeries = new(); - - private readonly Dictionary<int, (DateTime LastUpdated, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> List)> ChangesPerFile = new(); - - private readonly Dictionary<string, (int refCount, DateTime delayEnd)> MediaFolderChangeMonitor = new(); - - // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 characters. - private const int NameCutOff = 64; - - // It's so magical that it matches the magical value in the library monitor in JF core. 🪄 - private const int MagicalDelayValue = 45000; - - private static readonly TimeSpan DetectChangesThreshold = TimeSpan.FromSeconds(5); - - private static readonly IReadOnlySet<string> IgnoreFolderNames = new HashSet<string>() { - "backdrops", - "behind the scenes", - "deleted scenes", - "interviews", - "scenes", - "samples", - "shorts", - "featurettes", - "clips", - "other", - "extras", - "trailers", - }; - - public bool IsCacheStalled => DataCache.IsStalled; - - public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationAdded; - - public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationUpdated; - - public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationRemoved; - - public ShokoResolveManager( - ShokoAPIManager apiManager, - ShokoAPIClient apiClient, - IIdLookup lookup, - ILibraryManager libraryManager, - ILibraryMonitor libraryMonitor, - LibraryScanWatcher libraryScanWatcher, - IFileSystem fileSystem, - IDirectoryService directoryService, - ILogger<ShokoResolveManager> logger, - ILocalizationManager localizationManager, - NamingOptions namingOptions - ) - { - ApiManager = apiManager; - ApiClient = apiClient; - Lookup = lookup; - LibraryManager = libraryManager; - LibraryMonitor = libraryMonitor; - LibraryScanWatcher = libraryScanWatcher; - FileSystem = fileSystem; - DirectoryService = directoryService; - Logger = logger; - DataCache = new(logger, TimeSpan.FromMinutes(15), 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; - ChangesDetectionTimer = new() { AutoReset = true, Interval = TimeSpan.FromSeconds(4).TotalMilliseconds }; - ChangesDetectionTimer.Elapsed += OnIntervalElapsed; - foreach (var mediaConfig in Plugin.Instance.Configuration.MediaFolders) - MediaFolderChangeKeys[mediaConfig.MediaFolderId] = ConstructKey(mediaConfig); - Plugin.Instance.ConfigurationChanged += OnConfigurationChanged; - } - - ~ShokoResolveManager() - { - LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; - ChangesDetectionTimer.Elapsed -= OnIntervalElapsed; - Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; - MediaFolderChangeKeys.Clear(); - DataCache.Dispose(); - } - - public void Clear() - { - Logger.LogDebug("Clearing data…"); - DataCache.Clear(); - } - - #region Changes Tracking - - private static string ConstructKey(MediaFolderConfiguration config) - => $"IsMapped={config.IsMapped},IsFileEventsEnabled={config.IsFileEventsEnabled},IsRefreshEventsEnabled={config.IsRefreshEventsEnabled},IsVirtualFileSystemEnabled={config.IsVirtualFileSystemEnabled},LibraryFilteringMode={config.LibraryFilteringMode}"; - - private void OnConfigurationChanged(object? sender, PluginConfiguration config) - { - foreach (var mediaConfig in config.MediaFolders) { - var currentKey = ConstructKey(mediaConfig); - if (MediaFolderChangeKeys.TryGetValue(mediaConfig.MediaFolderId, out var previousKey) && previousKey != currentKey) { - MediaFolderChangeKeys[mediaConfig.MediaFolderId] = currentKey; - if (LibraryManager.GetItemById(mediaConfig.MediaFolderId) is not Folder mediaFolder) - continue; - ConfigurationUpdated?.Invoke(sender, new(mediaConfig, mediaFolder)); - } - } - } - - 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 Folder folder && folder.ParentId == Guid.Empty && !string.IsNullOrEmpty(folder.Path) && !folder.Path.StartsWith(root.Path)) { - DataCache.Remove($"paths-for-media-folder:{folder.Path}"); - DataCache.Remove($"should-skip-media-folder:{folder.Path}"); - var mediaFolderConfig = Plugin.Instance.Configuration.MediaFolders.FirstOrDefault(c => c.MediaFolderId == folder.Id); - if (mediaFolderConfig != null) { - Logger.LogDebug( - "Removing stored configuration for folder at {Path} (ImportFolder={ImportFolderId},RelativePath={RelativePath})", - folder.Path, - mediaFolderConfig.ImportFolderId, - mediaFolderConfig.ImportFolderRelativePath - ); - Plugin.Instance.Configuration.MediaFolders.Remove(mediaFolderConfig); - Plugin.Instance.SaveConfiguration(); - - if (MediaFolderChangeKeys.ContainsKey(folder.Id)) - MediaFolderChangeKeys.Remove(folder.Id); - ConfigurationRemoved?.Invoke(null, new(mediaFolderConfig, folder)); - } - var vfsPath = folder.GetVirtualRoot(); - 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 Media Folder Mapping - - private IReadOnlySet<string> GetPathsForMediaFolder(Folder mediaFolder) - { - Logger.LogDebug("Looking for files in folder at {Path}", mediaFolder.Path); - var start = DateTime.UtcNow; - var paths = FileSystem.GetFilePaths(mediaFolder.Path, true) - .Where(path => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) - .ToHashSet(); - Logger.LogDebug("Found {FileCount} files in folder at {Path} in {TimeSpan}.", paths.Count, mediaFolder.Path, DateTime.UtcNow - start); - return paths; - } - - public IReadOnlyList<(MediaFolderConfiguration config, Folder mediaFolder, string vfsPath)> GetAvailableMediaFolders(bool fileEvents = false, bool refreshEvents = false) - => Plugin.Instance.Configuration.MediaFolders - .Where(mediaFolder => mediaFolder.IsMapped && (!fileEvents || mediaFolder.IsFileEventsEnabled) && (!refreshEvents || mediaFolder.IsRefreshEventsEnabled)) - .Select(config => (config, mediaFolder: LibraryManager.GetItemById(config.MediaFolderId) as Folder)) - .OfType<(MediaFolderConfiguration config, Folder mediaFolder)>() - .Select(tuple => (tuple.config, tuple.mediaFolder, tuple.mediaFolder.GetVirtualRoot())) - .ToList(); - - public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFolder(Folder mediaFolder) - { - var config = Plugin.Instance.Configuration; - var mediaFolderConfig = config.MediaFolders.FirstOrDefault(c => c.MediaFolderId == mediaFolder.Id); - if (mediaFolderConfig != null) - return mediaFolderConfig; - - // Check if we should introduce the VFS for the media folder. - mediaFolderConfig = new() { - MediaFolderId = mediaFolder.Id, - MediaFolderPath = mediaFolder.Path, - IsFileEventsEnabled = config.SignalR_FileEvents, - IsRefreshEventsEnabled = config.SignalR_RefreshEnabled, - IsVirtualFileSystemEnabled = config.VirtualFileSystem, - LibraryFilteringMode = config.LibraryFilteringMode, - }; - - 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(); - - 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 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; - } - catch { } - - // Store and log the result. - config.MediaFolders.Add(mediaFolderConfig); - Plugin.Instance.SaveConfiguration(config); - if (mediaFolderConfig.IsMapped) { - Logger.LogInformation( - "Found a match for media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path},Attempts={Attempts})", - mediaFolder.Path, - DateTime.UtcNow - start, - mediaFolderConfig.ImportFolderId, - mediaFolderConfig.ImportFolderRelativePath, - mediaFolder.Path, - attempts - ); - } - else { - Logger.LogWarning( - "Failed to find a match for media folder at {Path} after {Amount} attempts in {TimeSpan}.", - mediaFolder.Path, - attempts, - DateTime.UtcNow - start - ); - } - - MediaFolderChangeKeys[mediaFolder.Id] = ConstructKey(mediaFolderConfig); - ConfigurationAdded?.Invoke(null, new(mediaFolderConfig, mediaFolder)); - - return mediaFolderConfig; - } - - #endregion - - #region Generate Structure - - /// <summary> - /// Generates the VFS structure if the VFS is enabled globally or on the - /// <paramref name="mediaFolder"/>. - /// </summary> - /// <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> - private async Task<string?> GenerateStructureInVFS(Folder mediaFolder, string path) - { - // Skip link generation if we've already generated for the media folder. - var vfsPath = mediaFolder.GetVirtualRoot(); - if (DataCache.TryGetValue<bool>($"should-skip-media-folder:{mediaFolder.Path}", out var shouldReturnPath)) - return shouldReturnPath ? vfsPath : null; - - // Check full path and all parent directories if they have been indexed. - if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { - var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).Prepend(vfsPath).ToArray(); - while (pathSegments.Length > 1) { - var subPath = Path.Join(pathSegments); - if (DataCache.TryGetValue<bool>($"should-skip-vfs-path:{subPath}", out _)) - return vfsPath; - pathSegments = pathSegments.SkipLast(1).ToArray(); - } - } - - // Only do this once. - var key = path.StartsWith(mediaFolder.Path) - ? $"should-skip-media-folder:{mediaFolder.Path}" - : $"should-skip-vfs-path:{path}"; - shouldReturnPath = await DataCache.GetOrCreateAsync<bool>(key, async (__) => { - var mediaConfig = await GetOrCreateConfigurationForMediaFolder(mediaFolder); - if (!mediaConfig.IsMapped) - return false; - - // Return early if we're not going to generate them. - if (!mediaConfig.IsVirtualFileSystemEnabled) - return 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."); - - // Iterate the files already in the VFS. - string? pathToClean = null; - IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; - if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { - var allPaths = GetPathsForMediaFolder(mediaFolder); - var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar); - switch (pathSegments.Length) { - // show/movie-folder level - case 1: { - var seriesName = pathSegments[0]; - if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) - break; - - // movie-folder - if (seriesName.TryGetAttributeValue(ShokoEpisodeId.Name, out var episodeId) ) { - if (!int.TryParse(episodeId, out _)) - break; - - pathToClean = path; - allFiles = GetFilesForMovie(episodeId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); - break; - } - - // show - pathToClean = path; - allFiles = GetFilesForShow(seriesId, null, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); - break; - } - - // season/movie level - case 2: { - var (seriesName, seasonOrMovieName) = pathSegments; - if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) - break; - - // movie - if (seriesName.TryGetAttributeValue(ShokoEpisodeId.Name, out _)) { - if (!seasonOrMovieName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) - break; - - if (!seasonOrMovieName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) - break; - - allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path); - break; - } - - // "season" or extras - if (!seasonOrMovieName.StartsWith("Season ") || !int.TryParse(seasonOrMovieName.Split(' ').Last(), out var seasonNumber)) - break; - - pathToClean = path; - allFiles = GetFilesForShow(seriesId, seasonNumber, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); - break; - } - - // episodes level - case 3: { - var (seriesName, seasonName, episodeName) = pathSegments; - if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) - break; - - if (!seasonName.StartsWith("Season ") || !int.TryParse(seasonName.Split(' ').Last(), out _)) - break; - - if (!episodeName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) - break; - - if (!episodeName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) - break; - - allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path); - break; - } - } - } - // Iterate files in the "real" media folder. - else if (path.StartsWith(mediaFolder.Path)) { - var allPaths = GetPathsForMediaFolder(mediaFolder); - pathToClean = vfsPath; - allFiles = GetFilesForImportFolder(mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); - } - - if (allFiles is null) - return false; - - // Generate and cleanup the structure in the VFS. - var result = await GenerateStructure(mediaFolder, vfsPath, allFiles); - if (!string.IsNullOrEmpty(pathToClean)) - result += CleanupStructure(vfsPath, pathToClean, result.Paths.ToArray()); - - // Save which paths we've already generated so we can skip generation - // for them and their sub-paths later, and also print the result. - result.Print(Logger, path.StartsWith(mediaFolder.Path) ? mediaFolder.Path : path); - - return true; - }); - - return shouldReturnPath ? vfsPath : null; - } - - public IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForEpisode(string fileId, string seriesId, int importFolderId, string importFolderSubPath, string mediaFolderPath) - { - var start = DateTime.UtcNow; - var file = ApiClient.GetFile(fileId).ConfigureAwait(false).GetAwaiter().GetResult(); - if (file is null || !file.CrossReferences.Any(xref => xref.Series.ToString() == seriesId)) - yield break; - Logger.LogDebug( - "Iterating 1 file to potentially use within media folder at {Path} (File={FileId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", - mediaFolderPath, - fileId, - seriesId, - importFolderId, - importFolderSubPath - ); - - var location = file.Locations - .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length is 0 || location.RelativePath.StartsWith(importFolderSubPath))) - .FirstOrDefault(); - if (location is null || file.CrossReferences.Count is 0) - yield break; - - var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); - if (!File.Exists(sourceLocation)) - yield break; - - yield return (sourceLocation, fileId, seriesId); - - var timeSpent = DateTime.UtcNow - start; - Logger.LogDebug( - "Iterated 1 file to potentially use within media folder at {Path} in {TimeSpan} (File={FileId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", - mediaFolderPath, - timeSpent, - fileId, - seriesId, - importFolderId, - importFolderSubPath - ); - } - - public IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForMovie(string episodeId, string seriesId, int importFolderId, string importFolderSubPath, string mediaFolderPath, IReadOnlySet<string> fileSet) - { - var start = DateTime.UtcNow; - var totalFiles = 0; - var seasonInfo = ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult(); - if (seasonInfo is null) - yield break; - Logger.LogDebug( - "Iterating files to potentially use within media folder at {Path} (Episode={EpisodeId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", - mediaFolderPath, - episodeId, - seriesId, - importFolderId, - importFolderSubPath - ); - - var episodeIds = seasonInfo.ExtrasList.Select(episode => episode.Id).Append(episodeId).ToHashSet(); - var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); - var fileLocations = files - .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) - .SelectMany(file => file.Locations.Select(location => (file, location))) - .ToList(); - foreach (var (file, location) in fileLocations) { - if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) - continue; - - var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); - if (!fileSet.Contains(sourceLocation)) - continue; - - totalFiles++; - yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); - } - var timeSpent = DateTime.UtcNow - start; - Logger.LogDebug( - "Iterated {FileCount} file to potentially use within media folder at {Path} in {TimeSpan} (Episode={EpisodeId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", - totalFiles, - mediaFolderPath, - timeSpent, - episodeId, - seriesId, - importFolderId, - importFolderSubPath - ); - } - - private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForShow(string seriesId, int? seasonNumber, int importFolderId, string importFolderSubPath, string mediaFolderPath, IReadOnlySet<string> fileSet) - { - var start = DateTime.UtcNow; - var showInfo = ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult(); - if (showInfo is null) - yield break; - Logger.LogDebug( - "Iterating files to potentially use within media folder at {Path} (Series={SeriesId},Season={SeasonNumber},ImportFolder={FolderId},RelativePath={RelativePath})", - mediaFolderPath, - seriesId, - seasonNumber, - importFolderId, - importFolderSubPath - ); - - // Only return the files for the given season. - var totalFiles = 0; - if (seasonNumber.HasValue) { - // Special handling of specials (pun intended) - if (seasonNumber.Value is 0) { - foreach (var seasonInfo in showInfo.SeasonList) { - var episodeIds = seasonInfo.SpecialsList.Select(episode => episode.Id).ToHashSet(); - var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); - var fileLocations = files - .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) - .SelectMany(file => file.Locations.Select(location => (file, location))) - .ToList(); - foreach (var (file, location) in fileLocations) { - if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) - continue; - - var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); - if (!fileSet.Contains(sourceLocation)) - continue; - - totalFiles++; - yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); - } - } - } - // All other seasons. - else { - var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber.Value); - if (seasonInfo != null) { - var baseNumber = showInfo.GetBaseSeasonNumberForSeasonInfo(seasonInfo); - var offset = seasonNumber.Value - baseNumber; - var episodeIds = (offset is 0 ? seasonInfo.EpisodeList.Concat(seasonInfo.ExtrasList) : seasonInfo.AlternateEpisodesList).Select(episode => episode.Id).ToHashSet(); - var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); - var fileLocations = files - .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) - .SelectMany(file => file.Locations.Select(location => (file, location))) - .ToList(); - foreach (var (file, location) in fileLocations) { - if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) - continue; - - var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); - if (!fileSet.Contains(sourceLocation)) - continue; - - totalFiles++; - yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); - } - } - } - } - // Return all files for the show. - else { - foreach (var seasonInfo in showInfo.SeasonList) { - var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); - var fileLocations = files - .SelectMany(file => file.Locations.Select(location => (file, location))) - .ToList(); - foreach (var (file, location) in fileLocations) { - if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) - continue; - - var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); - if (!fileSet.Contains(sourceLocation)) - continue; - - totalFiles++; - yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); - } - } - } - - var timeSpent = DateTime.UtcNow - start; - Logger.LogDebug( - "Iterated {FileCount} files to potentially use within media folder at {Path} in {TimeSpan} (Series={SeriesId},Season={SeasonNumber},ImportFolder={FolderId},RelativePath={RelativePath})", - totalFiles, - mediaFolderPath, - timeSpent, - seriesId, - seasonNumber, - importFolderId, - importFolderSubPath - ); - } - - private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForImportFolder(int importFolderId, string importFolderSubPath, string mediaFolderPath, IReadOnlySet<string> fileSet) - { - var start = DateTime.UtcNow; - var firstPage = ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath); - var pageData = firstPage - .ConfigureAwait(false) - .GetAwaiter() - .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})", - fileSet.Count, - mediaFolderPath, - pageData.Total, - importFolderId, - importFolderSubPath, - pageData.List.Count == pageData.Total ? null : pageData.List.Count, - totalPages - ); - - // Ensure at most 5 pages are in-flight at any given time, until we're done fetching the pages. - var semaphore = new SemaphoreSlim(5); - var pages = new List<Task<ListResult<API.Models.File>>>() { firstPage }; - for (var page = 2; page <= totalPages; page++) - pages.Add(GetImportFolderFilesPage(importFolderId, importFolderSubPath, page, semaphore)); - - var singleSeriesIds = new HashSet<int>(); - var multiSeriesFiles = new List<(API.Models.File, string)>(); - var totalSingleSeriesFiles = 0; - do { - var task = Task.WhenAny(pages).ConfigureAwait(false).GetAwaiter().GetResult(); - pages.Remove(task); - semaphore.Release(); - pageData = task.Result; - - Logger.LogTrace( - "Iterating page {PageNumber} with size {PageSize} (ImportFolder={FolderId},RelativePath={RelativePath})", - totalPages - pages.Count, - pageData.List.Count, - importFolderId, - importFolderSubPath - ); - foreach (var file in pageData.List) { - if (file.CrossReferences.Count is 0) - continue; - - var location = file.Locations - .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length is 0 || location.RelativePath.StartsWith(importFolderSubPath))) - .FirstOrDefault(); - if (location is null) - continue; - - var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); - if (!fileSet.Contains(sourceLocation)) - continue; - - // Yield all single-series files now, and offset the processing of all multi-series files for later. - var seriesIds = file.CrossReferences.Where(x => x.Series.Shoko.HasValue && x.Episodes.All(e => e.Shoko.HasValue)).Select(x => x.Series.Shoko!.Value).ToHashSet(); - if (seriesIds.Count is 1) { - totalSingleSeriesFiles++; - singleSeriesIds.Add(seriesIds.First()); - foreach (var seriesId in seriesIds) - yield return (sourceLocation, file.Id.ToString(), seriesId.ToString()); - } - else if (seriesIds.Count > 1) { - multiSeriesFiles.Add((file, sourceLocation)); - } - } - } while (pages.Count > 0); - - // Check which series of the multiple series we have, and only yield - // the paths for the series we have. This will fail if an OVA episode is - // linked to both the OVA and e.g. a specials for the TV Series. - var totalMultiSeriesFiles = 0; - if (multiSeriesFiles.Count > 0) { - var mappedSingleSeriesIds = singleSeriesIds - .Select(seriesId => - ApiManager.GetShowInfoForSeries(seriesId.ToString()) - .ConfigureAwait(false) - .GetAwaiter() - .GetResult()?.Id - ) - .OfType<string>() - .ToHashSet(); - foreach (var (file, sourceLocation) in multiSeriesFiles) { - var seriesIds = file.CrossReferences - .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) - .Select(xref => xref.Series.Shoko!.Value.ToString()) - .Distinct() - .Select(seriesId => ( - seriesId, - showId: ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult()?.Id - )) - .Where(tuple => !string.IsNullOrEmpty(tuple.showId) && mappedSingleSeriesIds.Contains(tuple.showId)) - .Select(tuple => tuple.seriesId) - .ToList(); - foreach (var seriesId in seriesIds) - yield return (sourceLocation, file.Id.ToString(), seriesId); - totalMultiSeriesFiles += seriesIds.Count; - } - } - - var timeSpent = DateTime.UtcNow - start; - Logger.LogDebug( - "Iterated {FileCount} ({MultiFileCount}→{MultiFileCount}) files to potentially use within media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath})", - totalSingleSeriesFiles, - multiSeriesFiles.Count, - totalMultiSeriesFiles, - mediaFolderPath, - timeSpent, - importFolderId, - importFolderSubPath - ); - } - - private async Task<ListResult<API.Models.File>> GetImportFolderFilesPage(int importFolderId, string importFolderSubPath, int page, SemaphoreSlim semaphore) - { - await semaphore.WaitAsync().ConfigureAwait(false); - return await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath, page).ConfigureAwait(false); - } - - private async Task<LinkGenerationResult> GenerateStructure(Folder mediaFolder, string vfsPath, IEnumerable<(string sourceLocation, string fileId, string seriesId)> allFiles) - { - var result = new LinkGenerationResult(); - var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); - var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); - await Task.WhenAll(allFiles.Select(async (tuple) => { - await semaphore.WaitAsync().ConfigureAwait(false); - - try { - Logger.LogTrace("Generating links for {Path} (File={FileId},Series={SeriesId})", tuple.sourceLocation, tuple.fileId, tuple.seriesId); - - var (sourceLocation, symbolicLinks, importedAt) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); - - // Skip any source files we weren't meant to have in the library. - if (string.IsNullOrEmpty(sourceLocation) || !importedAt.HasValue) - return; - - var subResult = GenerateSymbolicLinks(sourceLocation, symbolicLinks, importedAt.Value); - - // Combine the current results with the overall results. - lock (semaphore) { - result += subResult; - } - } - finally { - semaphore.Release(); - } - })) - .ConfigureAwait(false); - - return result; - } - - private async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(Folder mediaFolder, string sourceLocation, string fileId, string seriesId) - { - var vfsPath = mediaFolder.GetVirtualRoot(); - var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); - return await GenerateLocationsForFile(vfsPath, collectionType, sourceLocation, fileId, seriesId); - } - - private async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId) - { - var season = await ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); - if (season is null) - return (string.Empty, Array.Empty<string>(), null); - - var isMovieSeason = season.Type is SeriesType.Movie; - var config = Plugin.Instance.Configuration; - var shouldAbort = collectionType switch { - CollectionType.TvShows => isMovieSeason && config.SeparateMovies, - CollectionType.Movies => !isMovieSeason, - _ => false, - }; - if (shouldAbort) - return (string.Empty, Array.Empty<string>(), null); - - var show = await ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false); - if (show is null) - return (string.Empty, Array.Empty<string>(), null); - - var file = await ApiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false); - var (episode, episodeXref, _) = (file?.EpisodeList ?? new()).FirstOrDefault(); - if (file is null || episode is null) - return (string.Empty, Array.Empty<string>(), null); - - if (season is null || episode is null) - return (string.Empty, Array.Empty<string>(), null); - - var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters() ?? $"Shoko Series {show.Id}"; - var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); - var episodeName = (episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode == "en")?.Value ?? $"Episode {episode.AniDB.Type} {episodeNumber}").ReplaceInvalidPathCharacters(); - - // For those **really** long names we have to cut if off at some point… - if (showName.Length >= NameCutOff) - showName = showName[..NameCutOff].Split(' ').SkipLast(1).Join(' ') + "…"; - if (episodeName.Length >= NameCutOff) - episodeName = episodeName[..NameCutOff].Split(' ').SkipLast(1).Join(' ') + "…"; - - var isExtra = file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode)); - var folders = new List<string>(); - var extrasFolders = file.ExtraType switch { - null => isExtra ? new string[] { "extras" } : null, - ExtraType.ThemeSong => new string[] { "theme-music" }, - ExtraType.ThemeVideo => config.AddCreditsAsThemeVideos && config.AddCreditsAsSpecialFeatures - ? new string[] { "backdrops", "extras" } - : config.AddCreditsAsThemeVideos - ? new string[] { "backdrops" } - : config.AddCreditsAsSpecialFeatures - ? new string[] { "extras" } - : Array.Empty<string>(), - ExtraType.Trailer => config.AddTrailers - ? new string[] { "trailers" } - : Array.Empty<string>(), - ExtraType.BehindTheScenes => new string[] { "behind the scenes" }, - ExtraType.DeletedScene => new string[] { "deleted scenes" }, - ExtraType.Clip => new string[] { "clips" }, - ExtraType.Interview => new string[] { "interviews" }, - ExtraType.Scene => new string[] { "scenes" }, - ExtraType.Sample => new string[] { "samples" }, - _ => new string[] { "extras" }, - }; - 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 (extrasFolders != null) { - foreach (var extrasFolder in extrasFolders) - foreach (var episodeInfo in season.EpisodeList) - folders.Add(Path.Join(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episodeInfo.Id}]", extrasFolder)); - } - else { - folders.Add(Path.Join(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episode.Id}]")); - episodeName = "Movie"; - } - } - else { - var isSpecial = show.IsSpecial(episode); - var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); - var seasonFolder = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}"; - var showFolder = $"{showName} [{ShokoSeriesId.Name}={show.Id}]"; - if (extrasFolders != null) { - foreach (var extrasFolder in extrasFolders) { - folders.Add(Path.Join(vfsPath, showFolder, extrasFolder)); - - // Only place the extra within the season if we have a season number assigned to the episode. - if (seasonNumber is not 0) - folders.Add(Path.Join(vfsPath, showFolder, seasonFolder, extrasFolder)); - } - } - else { - folders.Add(Path.Join(vfsPath, showFolder, seasonFolder)); - episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber.ToString().PadLeft(show.EpisodePadding, '0')}{filePartSuffix}"; - } - } - - var fileName = $"{episodeName} [{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{Path.GetExtension(sourceLocation)}"; - var symbolicLinks = folders - .Select(folderPath => Path.Join(folderPath, fileName)) - .ToArray(); - - 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); - } - -// TODO: Remove this for 10.9 -#pragma warning disable IDE0060 - private LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, DateTime importedAt) -#pragma warning restore IDE0060 - { - try { - var result = new LinkGenerationResult(); - var sourcePrefixLength = sourceLocation.Length - Path.GetExtension(sourceLocation).Length; - var subtitleLinks = FindSubtitlesForPath(sourceLocation); - foreach (var symbolicLink in symbolicLinks) { - var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; - if (!Directory.Exists(symbolicDirectory)) - Directory.CreateDirectory(symbolicDirectory); - - result.Paths.Add(symbolicLink); - if (!File.Exists(symbolicLink)) { - result.CreatedVideos++; - Logger.LogDebug("Linking {Link} → {LinkTarget}", symbolicLink, sourceLocation); - // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. - try { - File.CreateSymbolicLink(symbolicLink, sourceLocation); - } - catch { - if (!File.Exists(symbolicLink)) - throw; - } - // TODO: Uncomment this for 10.9 - // // Mock the creation date to fake the "date added" order in Jellyfin. - // File.SetCreationTime(symbolicLink, importedAt); - } - else { - var shouldFix = false; - try { - 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); - } - // TODO: Uncomment this for 10.9 - // var date = File.GetCreationTime(symbolicLink); - // if (date != importedAt) { - // shouldFix = true; - // - // 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 (shouldFix) { - // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. - try { - File.Delete(symbolicLink); - File.CreateSymbolicLink(symbolicLink, sourceLocation); - } - catch { - if (!File.Exists(symbolicLink)) - throw; - } - // TODO: Uncomment this for 10.9 - // // Mock the creation date to fake the "date added" order in Jellyfin. - // File.SetCreationTime(symbolicLink, importedAt); - result.FixedVideos++; - } - else { - result.SkippedVideos++; - } - } - - if (subtitleLinks.Count > 0) { - var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); - foreach (var subtitleSource in subtitleLinks) { - var extName = subtitleSource[sourcePrefixLength..]; - var subtitleLink = Path.Join(symbolicDirectory, symbolicName + extName); - - result.Paths.Add(subtitleLink); - if (!File.Exists(subtitleLink)) { - result.CreatedSubtitles++; - Logger.LogDebug("Linking {Link} → {LinkTarget}", subtitleLink, subtitleSource); - // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. - try { - File.CreateSymbolicLink(subtitleLink, subtitleSource); - } - catch { - if (!File.Exists(subtitleLink)) - throw; - } - } - else { - var shouldFix = false; - try { - 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); - } - } - catch (Exception ex) { - Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link} for {LinkTarget}", subtitleLink, subtitleSource); - shouldFix = true; - } - if (shouldFix) { - // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. - try { - File.Delete(subtitleLink); - File.CreateSymbolicLink(subtitleLink, subtitleSource); - } - catch { - if (!File.Exists(subtitleLink)) - throw; - } - result.FixedSubtitles++; - } - else { - result.SkippedSubtitles++; - } - } - } - } - } - - return result; - } - catch (Exception ex) { - Logger.LogError(ex, "An error occurred while trying to generate {LinkCount} links for {SourceLocation}; {ErrorMessage}", symbolicLinks.Length, sourceLocation, ex.Message); - throw; - } - } - - private IReadOnlyList<string> FindSubtitlesForPath(string sourcePath) - { - var externalPaths = new List<string>(); - var folderPath = Path.GetDirectoryName(sourcePath); - if (string.IsNullOrEmpty(folderPath) || !FileSystem.DirectoryExists(folderPath)) - return externalPaths; - - var files = FileSystem.GetFilePaths(folderPath) - .Except(new[] { sourcePath }) - .ToList(); - var sourcePrefix = Path.GetFileNameWithoutExtension(sourcePath); - foreach (var file in files) { - var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file); - if ( - fileNameWithoutExtension.Length >= sourcePrefix.Length && - sourcePrefix.Equals(fileNameWithoutExtension[..sourcePrefix.Length], StringComparison.OrdinalIgnoreCase) && - (fileNameWithoutExtension.Length == sourcePrefix.Length || NamingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[sourcePrefix.Length])) - ) { - var externalPathInfo = ExternalPathParser.ParseFile(file, fileNameWithoutExtension[sourcePrefix.Length..].ToString()); - if (externalPathInfo is not null && !string.IsNullOrEmpty(externalPathInfo.Path)) - externalPaths.Add(externalPathInfo.Path); - } - } - - return externalPaths; - } - - private LinkGenerationResult CleanupStructure(string vfsPath, string directoryToClean, IReadOnlyList<string> allKnownPaths) - { - Logger.LogDebug("Looking for files to remove in folder at {Path}", directoryToClean); - var start = DateTime.Now; - var previousStep = start; - var result = new LinkGenerationResult(); - var searchFiles = NamingOptions.VideoFileExtensions.Concat(NamingOptions.SubtitleFileExtensions).Append(".nfo").ToHashSet(); - 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) - .ToList(); - - var nextStep = DateTime.Now; - 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; - } - result.RemovedNfos++; - } - else if (NamingOptions.SubtitleFileExtensions.Contains(extName)) { - if (TryMoveSubtitleFile(allKnownPaths, location)) { - 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; - } - result.RemovedSubtitles++; - } - else { - if (ShouldIgnoreVideo(vfsPath, 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; - } - result.RemovedVideos++; - } - } - - nextStep = DateTime.Now; - Logger.LogTrace("Removed {FileCount} files in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", result.Removed, directoryToClean, nextStep - previousStep, nextStep - start); - previousStep = nextStep; - - var cleaned = 0; - var directoriesToClean = toBeRemoved - .SelectMany(tuple => { - var path = Path.GetDirectoryName(tuple.path); - var paths = new List<(string path, int level)>(); - while (!string.IsNullOrEmpty(path)) { - var level = path == directoryToClean ? 0 : path[(directoryToClean.Length + 1)..].Split(Path.DirectorySeparatorChar).Length; - paths.Add((path, level)); - if (path == directoryToClean) - break; - path = Path.GetDirectoryName(path); - } - return paths; - }) - .DistinctBy(tuple => tuple.path) - .OrderByDescending(tuple => tuple.level) - .ThenBy(tuple => tuple.path) - .Select(tuple => tuple.path) - .ToList(); - - nextStep = DateTime.Now; - Logger.LogDebug("Found {DirectoryCount} directories to potentially clean in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", toBeRemoved.Count, directoryToClean, nextStep - previousStep, nextStep - start); - previousStep = nextStep; - - foreach (var directoryPath in directoriesToClean) { - if (Directory.Exists(directoryPath) && !Directory.EnumerateFileSystemEntries(directoryPath).Any()) { - Logger.LogTrace("Removing empty directory at {Path}", directoryPath); - Directory.Delete(directoryPath); - cleaned++; - } - } - - Logger.LogTrace("Cleaned {CleanedCount} directories in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", cleaned, directoryToClean, nextStep - previousStep, nextStep - start); - - return result; - } - - private static bool TryMoveSubtitleFile(IReadOnlyList<string> allKnownPaths, string subtitlePath) - { - if (!TryGetIdsForPath(subtitlePath, out var seriesId, out var fileId)) - return false; - - var symbolicLink = allKnownPaths.FirstOrDefault(knownPath => TryGetIdsForPath(knownPath, out var knownSeriesId, out var knownFileId) && seriesId == knownSeriesId && fileId == knownFileId); - if (string.IsNullOrEmpty(symbolicLink)) - return false; - - var sourcePathWithoutExt = symbolicLink[..^Path.GetExtension(symbolicLink).Length]; - if (!subtitlePath.StartsWith(sourcePathWithoutExt)) - return false; - - var extName = subtitlePath[sourcePathWithoutExt.Length..]; - string? realTarget = null; - try { - realTarget = File.ResolveLinkTarget(symbolicLink, false)?.FullName; - } - catch { } - if (string.IsNullOrEmpty(realTarget)) - return false; - - var realSubtitlePath = realTarget[..^Path.GetExtension(realTarget).Length] + extName; - if (!File.Exists(realSubtitlePath)) - File.Move(subtitlePath, realSubtitlePath); - else - File.Delete(subtitlePath); - File.CreateSymbolicLink(subtitlePath, realSubtitlePath); - - return true; - } - - private static bool ShouldIgnoreVideo(string vfsPath, string path) - { - // Ignore the video if it's within one of the folders to potentially ignore _and_ it doesn't have any shoko ids set. - var parentDirectories = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).SkipLast(1).ToArray(); - return parentDirectories.Length > 1 && IgnoreFolderNames.Contains(parentDirectories.Last()) && !TryGetIdsForPath(path, out _, out _); - } - - private static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string? seriesId, [NotNullWhen(true)] out string? fileId) - { - var fileName = Path.GetFileNameWithoutExtension(path); - if (!fileName.TryGetAttributeValue(ShokoFileId.Name, out fileId) || !int.TryParse(fileId, out _) || - !fileName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) { - seriesId = null; - fileId = null; - return false; - } - - return true; - } - - #endregion - - #region Resolvers - - public async Task<BaseItem?> ResolveSingle(Folder? parent, string? collectionType, FileSystemMetadata fileInfo) - { - if (!(collectionType is CollectionType.TvShows or CollectionType.Movies or null) || parent is null || fileInfo is null) - return null; - - var root = LibraryManager.RootFolder; - if (root is null || parent == root) - return null; - - try { - if (!Lookup.IsEnabledForItem(parent)) - return null; - - // Skip anything outside the VFS. - if (!fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) - return null; - - if (parent.GetTopParent() is not Folder mediaFolder) - return null; - - var vfsPath = await GenerateStructureInVFS(mediaFolder, fileInfo.FullName).ConfigureAwait(false); - if (string.IsNullOrEmpty(vfsPath)) - return null; - - if (parent.Id == mediaFolder.Id && fileInfo.IsDirectory) { - if (!fileInfo.Name.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) - return null; - - return new TvSeries() { - Path = fileInfo.FullName, - }; - } - - return null; - } - catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - throw; - } - } - - public async Task<MultiItemResolverResult?> ResolveMultiple(Folder? parent, string? collectionType, List<FileSystemMetadata> fileInfoList) - { - if (!(collectionType is CollectionType.TvShows or CollectionType.Movies or null) || parent is null) - return null; - - var root = LibraryManager.RootFolder; - if (root is null || parent == root) - return null; - - try { - if (!Lookup.IsEnabledForItem(parent)) - return null; - - if (parent.GetTopParent() is not Folder mediaFolder) - return null; - - var vfsPath = await GenerateStructureInVFS(mediaFolder, parent.Path).ConfigureAwait(false); - if (string.IsNullOrEmpty(vfsPath)) - return null; - - // Redirect children of a VFS managed media folder to the VFS. - if (parent.IsTopParent) { - var createMovies = collectionType is CollectionType.Movies || (collectionType is null && Plugin.Instance.Configuration.SeparateMovies); - var items = FileSystem.GetDirectories(vfsPath) - .AsParallel() - .SelectMany(dirInfo => { - if (!dirInfo.Name.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) - return Array.Empty<BaseItem>(); - - var season = ApiManager.GetSeasonInfoForSeries(seriesId) - .ConfigureAwait(false) - .GetAwaiter() - .GetResult(); - if (season is null) - return Array.Empty<BaseItem>(); - - if (createMovies && season.Type is SeriesType.Movie) { - return FileSystem.GetFiles(dirInfo.FullName) - .AsParallel() - .Select(fileInfo => { - // Only allow the video files, since the subtitle files also have the ids set. - if (!NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(fileInfo.Name))) - return null; - - if (!TryGetIdsForPath(fileInfo.FullName, out seriesId, out var fileId)) - return null; - - // This will hopefully just re-use the pre-cached entries from the cache, but it may - // also get it from remote if the cache was emptied for whatever reason. - var file = ApiManager.GetFileInfo(fileId, seriesId) - .ConfigureAwait(false) - .GetAwaiter() - .GetResult(); - - // Abort if the file was not recognized. - if (file is null || file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode))) - return null; - - return new Movie() { - Path = fileInfo.FullName, - } as BaseItem; - }) - .ToArray(); - } - - return new BaseItem[1] { - new TvSeries() { - Path = dirInfo.FullName, - }, - }; - }) - .OfType<BaseItem>() - .ToList(); - - // TODO: uncomment the code snippet once the PR is in stable JF. - // return new() { Items = items, ExtraFiles = new() }; - - // TODO: Remove these two hacks once we have proper support for adding multiple series at once. - if (!items.Any(i => i is Movie) && items.Count > 0) { - fileInfoList.Clear(); - fileInfoList.AddRange(items.OrderBy(s => int.Parse(s.Path.GetAttributeValue(ShokoSeriesId.Name)!)).Select(s => FileSystem.GetFileSystemInfo(s.Path))); - } - - return new() { Items = items.Where(i => i is Movie).ToList(), ExtraFiles = items.OfType<TvSeries>().Select(s => FileSystem.GetFileSystemInfo(s.Path)).ToList() }; - } - - return null; - } - catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - throw; - } - } - - #endregion - - #region Ignore Rule - - public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata fileInfo) - { - // Check if the parent is not made yet, or the file info is missing. - if (parent is null || fileInfo is null) - return false; - - // Check if the root is not made yet. This should **never** be false at - // this point in time, but if it is, then bail. - var root = LibraryManager.RootFolder; - if (root is null || parent.Id == root.Id) - return false; - - // Assume anything within the VFS is already okay. - if (fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) - return false; - - try { - // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. - if (!Lookup.IsEnabledForItem(parent, out var isSoleProvider)) - return false; - - if (fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(Path.GetFileName(fileInfo.FullName).ToLowerInvariant())) { - Logger.LogDebug("Excluded folder at path {Path}", fileInfo.FullName); - return true; - } - - if (!fileInfo.IsDirectory && !NamingOptions.VideoFileExtensions.Contains(fileInfo.Extension.ToLowerInvariant())) { - Logger.LogDebug("Skipped excluded file at path {Path}", fileInfo.FullName); - return false; - } - - var fullPath = fileInfo.FullName; - var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent, root); - - // Ignore any media folders that aren't mapped to shoko. - var mediaFolderConfig = await GetOrCreateConfigurationForMediaFolder(mediaFolder); - if (!mediaFolderConfig.IsMapped) { - Logger.LogDebug("Skipped media folder for path {Path} (MediaFolder={MediaFolderId})", fileInfo.FullName, mediaFolderConfig.MediaFolderId); - return false; - } - - // Filter out anything in the media folder if the VFS is enabled, - // 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) - return true; - - var shouldIgnore = mediaFolderConfig.LibraryFilteringMode switch { - Ordering.LibraryFilteringMode.Strict => true, - Ordering.LibraryFilteringMode.Lax => false, - // Ordering.LibraryFilteringMode.Auto => - _ => mediaFolderConfig.IsVirtualFileSystemEnabled || isSoleProvider, - }; - var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); - if (fileInfo.IsDirectory) - return await ShouldFilterDirectory(partialPath, fullPath, collectionType, shouldIgnore).ConfigureAwait(false); - else - return await ShouldFilterFile(partialPath, fullPath, shouldIgnore).ConfigureAwait(false); - } - catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); - throw; - } - } - - private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPath, string? collectionType, bool shouldIgnore) - { - var season = await ApiManager.GetSeasonInfoByPath(fullPath).ConfigureAwait(false); - - // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given folder path. - if (season == null) { - // If we're in strict mode, then check the sub-directories if we have a <Show>/<Season>/<Episodes> structure. - if (shouldIgnore && partialPath[1..].Split(Path.DirectorySeparatorChar).Length is 1) { - try { - var entries = FileSystem.GetDirectories(fullPath, false).ToList(); - Logger.LogDebug("Unable to find shoko series for {Path}, trying {DirCount} sub-directories.", partialPath, entries.Count); - foreach (var entry in entries) { - season = await ApiManager.GetSeasonInfoByPath(entry.FullName).ConfigureAwait(false); - if (season is not null) { - Logger.LogDebug("Found shoko series {SeriesName} for sub-directory of path {Path} (Series={SeriesId})", season.Shoko.Name, partialPath, season.Id); - break; - } - } - } - catch (DirectoryNotFoundException) { } - } - if (season is null) { - if (shouldIgnore) - Logger.LogInformation("Ignored unknown folder at path {Path}", partialPath); - else - Logger.LogWarning("Skipped unknown folder at path {Path}", partialPath); - return shouldIgnore; - } - } - - // Filter library if we enabled the option. - var isMovieSeason = season.Type is SeriesType.Movie; - switch (collectionType) { - case CollectionType.TvShows: - if (isMovieSeason && Plugin.Instance.Configuration.SeparateMovies) { - Logger.LogInformation("Found movie in show library and library separation is enabled, ignoring shoko series. (Series={SeriesId})", season.Id); - return true; - } - break; - case CollectionType.Movies: - if (!isMovieSeason) { - Logger.LogInformation("Found show in movie library, ignoring shoko series. (Series={SeriesId})", season.Id); - return true; - } - break; - } - - var show = await ApiManager.GetShowInfoForSeries(season.Id).ConfigureAwait(false)!; - if (!string.IsNullOrEmpty(show!.GroupId)) - Logger.LogInformation("Found shoko group {GroupName} (Series={SeriesId},Group={GroupId})", show.Name, season.Id, show.GroupId); - else - Logger.LogInformation("Found shoko series {SeriesName} (Series={SeriesId})", season.Shoko.Name, season.Id); - - return false; - } - - private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, bool shouldIgnore) - { - var (file, season, _) = await ApiManager.GetFileInfoByPath(fullPath).ConfigureAwait(false); - - // We inform/warn here since we enabled the provider in our library, but we can't find a match for the given file path. - if (file is null || season is null) { - if (shouldIgnore) - Logger.LogInformation("Ignored unknown file at path {Path}", partialPath); - else - Logger.LogWarning("Skipped unknown file at path {Path}", partialPath); - return shouldIgnore; - } - - Logger.LogInformation("Found {EpisodeCount} shoko episode(s) for {SeriesName} (Series={SeriesId},File={FileId})", file.EpisodeList.Count, season.Shoko.Name, season.Id, file.Id); - - // We're going to post process this file later, but we don't want to include it in our library for now. - if (file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode))) { - Logger.LogInformation("File was assigned an extra type, ignoring file. (Series={SeriesId},File={FileId})", season.Id, file.Id); - return true; - } - - return false; - } - - #endregion - - #region Event Detection - - public IDisposable RegisterEventSubmitter() - { - var count = ChangesDetectionSubmitterCount++; - if (count is 0) - ChangesDetectionTimer.Start(); - - return new DisposableAction(() => DeregisterEventSubmitter()); - } - - private void DeregisterEventSubmitter() - { - var count = --ChangesDetectionSubmitterCount; - if (count is 0) { - ChangesDetectionTimer.Stop(); - if (ChangesPerFile.Count > 0) - ClearFileEvents(); - if (ChangesPerSeries.Count > 0) - ClearMetadataUpdatedEvents(); - } - } - - private void OnIntervalElapsed(object? sender, ElapsedEventArgs eventArgs) - { - var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>)>(); - var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>)>(); - lock (ChangesPerFile) { - if (ChangesPerFile.Count > 0) { - var now = DateTime.Now; - foreach (var (fileId, (lastUpdated, list)) in ChangesPerFile) { - if (now - lastUpdated < DetectChangesThreshold) - continue; - filesToProcess.Add((fileId, list)); - } - foreach (var (fileId, _) in filesToProcess) - ChangesPerFile.Remove(fileId); - } - } - lock (ChangesPerSeries) { - if (ChangesPerSeries.Count > 0) { - var now = DateTime.Now; - foreach (var (metadataId, (lastUpdated, list)) in ChangesPerSeries) { - if (now - lastUpdated < DetectChangesThreshold) - continue; - seriesToProcess.Add((metadataId, list)); - } - foreach (var (metadataId, _) in seriesToProcess) - ChangesPerSeries.Remove(metadataId); - } - } - foreach (var (fileId, changes) in filesToProcess) - Task.Run(() => ProcessFileEvents(fileId, changes)); - foreach (var (metadataId, changes) in seriesToProcess) - Task.Run(() => ProcessMetadataEvents(metadataId, changes)); - } - - private void ClearFileEvents() - { - var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>)>(); - lock (ChangesPerFile) { - foreach (var (fileId, (lastUpdated, list)) in ChangesPerFile) { - filesToProcess.Add((fileId, list)); - } - ChangesPerFile.Clear(); - } - foreach (var (fileId, changes) in filesToProcess) - Task.Run(() => ProcessFileEvents(fileId, changes)); - } - - private void ClearMetadataUpdatedEvents() - { - var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>)>(); - lock (ChangesPerSeries) { - foreach (var (metadataId, (lastUpdated, list)) in ChangesPerSeries) { - seriesToProcess.Add((metadataId, list)); - } - ChangesPerSeries.Clear(); - } - foreach (var (metadataId, changes) in seriesToProcess) - Task.Run(() => ProcessMetadataEvents(metadataId, changes)); - } - - #endregion - - #region File Events - - public void AddFileEvent(int fileId, UpdateReason reason, int importFolderId, string filePath, IFileEventArgs eventArgs) - { - lock (ChangesPerFile) { - if (ChangesPerFile.TryGetValue(fileId, out var tuple)) - tuple.LastUpdated = DateTime.Now; - else - ChangesPerFile.Add(fileId, tuple = (DateTime.Now, new())); - tuple.List.Add((reason, importFolderId, filePath, eventArgs)); - } - } - - private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> changes) - { - try { - if (LibraryScanWatcher.IsScanRunning) { - Logger.LogInformation("Skipped processing {EventCount} file change events because a library scan is running. (File={FileId})", changes.Count, fileId); - return; - } - - Logger.LogInformation("Processing {EventCount} file change events… (File={FileId})", changes.Count, fileId); - - // Something was added or updated. - 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 mediaFolders = GetAvailableMediaFolders(fileEvents: true); - var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); - if (reason is not UpdateReason.Removed) { - foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { - if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) - continue; - - var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); - if (!File.Exists(sourceLocation)) - continue; - - // Let the core logic handle the rest. - if (!config.IsVirtualFileSystemEnabled) { - locationsToNotify.Add(sourceLocation); - continue; - } - - var result = new LinkGenerationResult(); - var topFolders = new HashSet<string>(); - var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => GenerateLocationsForFile(mediaFolder, sourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) - .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) - .ToList(); - foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { - result += GenerateSymbolicLinks(srcLoc, 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); - } - - // Remove old links for file. - var videos = LibraryManager - .GetItemList( - new() { - AncestorIds = new[] { mediaFolder.Id }, - IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Movie }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, - DtoOptions = new(true), - }, - true - ) - .Where(item => !string.IsNullOrEmpty(item.Path) && item.Path.StartsWith(vfsPath) && !result.Paths.Contains(item.Path)) - .ToList(); - foreach (var video in videos) { - File.Delete(video.Path); - topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); - locationsToNotify.Add(video.Path); - result.RemovedVideos++; - } - - result.Print(Logger, mediaFolder.Path); - - // If all the "top-level-folders" exist, then let the core logic handle the rest. - if (topFolders.All(path => LibraryManager.FindByPath(path, true) != null)) { - var old = locationsToNotify.Count; - locationsToNotify.AddRange(vfsLocations.SelectMany(tuple => tuple.symbolicLinks)); - } - // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. - else { - var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); - if (!string.IsNullOrEmpty(fileOrFolder)) - mediaFoldersToNotify.TryAdd(mediaFolder.Path, (fileOrFolder, mediaFolder)); - } - } - } - // Something was removed, so assume the location is gone. - else if (changes.FirstOrDefault(t => t.Reason is UpdateReason.Removed).Event is IFileRelocationEventArgs firstRemovedEvent) { - relativePath = firstRemovedEvent.RelativePath; - importFolderId = firstRemovedEvent.ImportFolderId; - foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { - if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) - continue; - - // Let the core logic handle the rest. - if (!config.IsVirtualFileSystemEnabled) { - var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); - locationsToNotify.Add(sourceLocation); - continue; - } - - // Check if we can use another location for the file. - var result = new LinkGenerationResult(); - var vfsSymbolicLinks = new HashSet<string>(); - var topFolders = new HashSet<string>(); - var newRelativePath = await GetNewRelativePath(config, fileId, relativePath); - if (!string.IsNullOrEmpty(newRelativePath)) { - var newSourceLocation = Path.Join(mediaFolder.Path, newRelativePath[config.ImportFolderRelativePath.Length..]); - var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => GenerateLocationsForFile(mediaFolder, newSourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) - .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) - .ToList(); - foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { - result += GenerateSymbolicLinks(srcLoc, 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); - } - vfsSymbolicLinks = vfsLocations.Select(tuple => tuple.sourceLocation).ToHashSet(); - } - - // Remove old links for file. - var videos = LibraryManager - .GetItemList( - new() { - AncestorIds = new[] { mediaFolder.Id }, - IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Movie }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, - DtoOptions = new(true), - }, - true - ) - .Where(item => !string.IsNullOrEmpty(item.Path) && item.Path.StartsWith(vfsPath) && !result.Paths.Contains(item.Path)) - .ToList(); - foreach (var video in videos) { - File.Delete(video.Path); - topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); - locationsToNotify.Add(video.Path); - result.RemovedVideos++; - } - - result.Print(Logger, mediaFolder.Path); - - // If all the "top-level-folders" exist, then let the core logic handle the rest. - if (topFolders.All(path => LibraryManager.FindByPath(path, true) is not null)) { - var old = locationsToNotify.Count; - locationsToNotify.AddRange(vfsSymbolicLinks); - } - // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. - else { - var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); - if (!string.IsNullOrEmpty(fileOrFolder)) - mediaFoldersToNotify.TryAdd(mediaFolder.Path, (fileOrFolder, mediaFolder)); - } - } - } - - // We let jellyfin take it from here. - if (!LibraryScanWatcher.IsScanRunning) { - Logger.LogDebug("Notifying Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count + mediaFoldersToNotify.Count, fileId.ToString()); - foreach (var location in locationsToNotify) - LibraryMonitor.ReportFileSystemChanged(location); - if (mediaFoldersToNotify.Count > 0) - await Task.WhenAll(mediaFoldersToNotify.Values.Select(tuple => ReportMediaFolderChanged(tuple.mediaFolder, tuple.pathToReport))).ConfigureAwait(false); - } - else { - Logger.LogDebug("Skipped notifying Jellyfin about {LocationCount} changes because a library scan is running. (File={FileId})", locationsToNotify.Count, fileId.ToString()); - } - } - catch (Exception ex) { - Logger.LogError(ex, "Error processing {EventCount} file change events. (File={FileId})", changes.Count, fileId); - } - } - - private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEventArgs? fileEvent) - { - HashSet<string> seriesIds; - if (fileEvent is not null && fileEvent.CrossReferences.All(xref => xref.ShokoSeriesId.HasValue && xref.ShokoEpisodeId.HasValue)) - seriesIds = fileEvent.CrossReferences.Select(xref => xref.ShokoSeriesId!.Value.ToString()) - .Distinct() - .ToHashSet(); - else - seriesIds = (await ApiClient.GetFile(fileId.ToString())).CrossReferences - .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) - .Select(xref => xref.Series.Shoko!.Value.ToString()) - .Distinct() - .ToHashSet(); - - // TODO: Postpone the processing of the file if the episode or series is not available yet. - - var filteredSeriesIds = new HashSet<string>(); - foreach (var seriesId in seriesIds) { - var seriesPathSet = await ApiManager.GetPathSetForSeries(seriesId); - if (seriesPathSet.Count > 0) { - filteredSeriesIds.Add(seriesId); - } - } - - // Return all series if we only have this file for all of them, - // otherwise return only the series were we have other files that are - // not linked to other series. - return filteredSeriesIds.Count is 0 ? seriesIds : filteredSeriesIds; - } - - private async Task<string?> GetNewRelativePath(MediaFolderConfiguration config, int fileId, string relativePath) - { - // Check if the file still exists, and if it has any other locations we can use. - try { - var file = await ApiClient.GetFile(fileId.ToString()); - var usableLocation = file.Locations - .Where(loc => loc.ImportFolderId == config.ImportFolderId && config.IsEnabledForPath(loc.RelativePath) && loc.RelativePath != relativePath) - .FirstOrDefault(); - return usableLocation?.RelativePath; - } - catch (ApiException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { - return null; - } - } - - private async Task ReportMediaFolderChanged(Folder mediaFolder, string pathToReport) - { - if (LibraryManager.GetLibraryOptions(mediaFolder) is not LibraryOptions libraryOptions || !libraryOptions.EnableRealtimeMonitor) { - LibraryMonitor.ReportFileSystemChanged(pathToReport); - return; - } - - // Since we're blocking real-time file events on the media folder because - // it uses the VFS then we need to temporarily unblock it, then block it - // afterwards again. - var path = mediaFolder.Path; - var delayTime = TimeSpan.Zero; - lock (MediaFolderChangeMonitor) { - if (MediaFolderChangeMonitor.TryGetValue(path, out var entry)) { - MediaFolderChangeMonitor[path] = (entry.refCount + 1, entry.delayEnd); - delayTime = entry.delayEnd - DateTime.Now; - } - else { - MediaFolderChangeMonitor[path] = (1, DateTime.Now + TimeSpan.FromMilliseconds(MagicalDelayValue)); - delayTime = TimeSpan.FromMilliseconds(MagicalDelayValue); - } - } - - LibraryMonitor.ReportFileSystemChangeComplete(path, false); - - if (delayTime > TimeSpan.Zero) - await Task.Delay((int)delayTime.TotalMilliseconds).ConfigureAwait(false); - - LibraryMonitor.ReportFileSystemChanged(pathToReport); - - var shouldResume = false; - lock (MediaFolderChangeMonitor) { - if (MediaFolderChangeMonitor.TryGetValue(path, out var tuple)) { - if (tuple.refCount is 1) { - shouldResume = true; - MediaFolderChangeMonitor.Remove(path); - } - else { - MediaFolderChangeMonitor[path] = (tuple.refCount - 1, tuple.delayEnd); - } - } - } - - if (shouldResume) - LibraryMonitor.ReportFileSystemChangeBeginning(path); - } - - #endregion - - #region Refresh Events - - public void AddSeriesEvent(string metadataId, IMetadataUpdatedEventArgs eventArgs) - { - lock (ChangesPerSeries) { - if (ChangesPerSeries.TryGetValue(metadataId, out var tuple)) - tuple.LastUpdated = DateTime.Now; - else - ChangesPerSeries.Add(metadataId, tuple = (DateTime.Now, new())); - tuple.List.Add(eventArgs); - } - } - - private async Task ProcessMetadataEvents(string metadataId, List<IMetadataUpdatedEventArgs> changes) - { - try { - if (LibraryScanWatcher.IsScanRunning) { - Logger.LogDebug("Skipped processing {EventCount} metadata change events because a library scan is running. (Metadata={ProviderUniqueId})", changes.Count, metadataId); - return; - } - - if (!changes.Any(e => e.Kind is BaseItemKind.Episode && e.EpisodeId.HasValue || e.Kind is BaseItemKind.Series && e.SeriesId.HasValue)) { - Logger.LogDebug("Skipped processing {EventCount} metadata change events because no series or episode ids to use. (Metadata={ProviderUniqueId})", changes.Count, metadataId); - return; - } - - var seriesId = changes.First(e => e.SeriesId.HasValue).SeriesId!.Value.ToString(); - var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); - if (showInfo is null) { - Logger.LogDebug("Unable to find show info for series id. (Series={SeriesId},Metadata={ProviderUniqueId})", seriesId, metadataId); - return; - } - - var seasonInfo = await ApiManager.GetSeasonInfoForSeries(seriesId); - if (seasonInfo is null) { - Logger.LogDebug("Unable to find season info for series id. (Series={SeriesId},Metadata={ProviderUniqueId})", seriesId, metadataId); - return; - } - - Logger.LogInformation("Processing {EventCount} metadata change events… (Metadata={ProviderUniqueId})", changes.Count, metadataId); - - var updateCount = await ProcessSeriesEvents(showInfo, changes); - updateCount += await ProcessMovieEvents(seasonInfo, changes); - - Logger.LogInformation("Scheduled {UpdateCount} updates for {EventCount} metadata change events. (Metadata={ProviderUniqueId})", updateCount, changes.Count, metadataId); - } - catch (Exception ex) { - Logger.LogError(ex, "Error processing {EventCount} metadata change events. (Metadata={ProviderUniqueId})", changes.Count, metadataId); - } - } - - private async Task<int> ProcessSeriesEvents(ShowInfo showInfo, List<IMetadataUpdatedEventArgs> changes) - { - // Update the series if we got a series event _or_ an episode removed event. - var updateCount = 0; - var animeEvent = changes.Find(e => e.Kind is BaseItemKind.Series || e.Kind is BaseItemKind.Episode && e.Reason is UpdateReason.Removed); - if (animeEvent is not null) { - var shows = LibraryManager - .GetItemList( - new() { - IncludeItemTypes = new[] { BaseItemKind.Series }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, showInfo.Id } }, - DtoOptions = new(true), - }, - true - ) - .ToList(); - foreach (var show in shows) { - Logger.LogInformation("Refreshing show {ShowName}. (Show={ShowId},Series={SeriesId})", show.Name, show.Id, showInfo.Id); - await show.RefreshMetadata(new(DirectoryService) { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ReplaceAllMetadata = true, - ReplaceAllImages = true, - RemoveOldMetadata = true, - ReplaceImages = Enum.GetValues<ImageType>().ToArray(), - IsAutomated = true, - EnableRemoteContentProbe = true, - }, CancellationToken.None); - updateCount++; - } - } - // Otherwise update all season/episodes where appropriate. - else { - var episodeIds = changes - .Where(e => e.EpisodeId.HasValue && e.Reason is not UpdateReason.Removed) - .Select(e => e.EpisodeId!.Value.ToString()) - .ToHashSet(); - var seasonIds = changes - .Where(e => e.EpisodeId.HasValue && e.SeriesId.HasValue && e.Reason is UpdateReason.Removed) - .Select(e => e.SeriesId!.Value.ToString()) - .ToHashSet(); - var seasonList = showInfo.SeasonList - .Where(seasonInfo => seasonIds.Contains(seasonInfo.Id)) - .ToList(); - foreach (var seasonInfo in seasonList) { - var seasons = LibraryManager - .GetItemList( - new() { - IncludeItemTypes = new[] { BaseItemKind.Season }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, seasonInfo.Id } }, - DtoOptions = new(true), - }, - true - ) - .ToList(); - foreach (var season in seasons) { - Logger.LogInformation("Refreshing season {SeasonName}. (Season={SeasonId},Series={SeriesId})", season.Name, season.Id, seasonInfo.Id); - await season.RefreshMetadata(new(DirectoryService) { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ReplaceAllMetadata = true, - ReplaceAllImages = true, - RemoveOldMetadata = true, - ReplaceImages = Enum.GetValues<ImageType>().ToArray(), - IsAutomated = true, - EnableRemoteContentProbe = true, - }, CancellationToken.None); - updateCount++; - } - } - var episodeList = showInfo.SeasonList - .Except(seasonList) - .SelectMany(seasonInfo => seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList).Concat(seasonInfo.SpecialsList)) - .Where(episodeInfo => episodeIds.Contains(episodeInfo.Id)) - .ToList(); - foreach (var episodeInfo in episodeList) { - var episodes = LibraryManager - .GetItemList( - new() { - IncludeItemTypes = new[] { BaseItemKind.Episode }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoEpisodeId.Name, episodeInfo.Id } }, - DtoOptions = new(true), - }, - true - ) - .ToList(); - foreach (var episode in episodes) { - Logger.LogInformation("Refreshing episode {EpisodeName}. (Episode={EpisodeId},Episode={EpisodeId},Series={SeriesId})", episode.Name, episode.Id, episodeInfo.Id, episodeInfo.Shoko.IDs.Series.ToString()); - await episode.RefreshMetadata(new(DirectoryService) { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ReplaceAllMetadata = true, - ReplaceAllImages = true, - RemoveOldMetadata = true, - ReplaceImages = Enum.GetValues<ImageType>().ToArray(), - IsAutomated = true, - EnableRemoteContentProbe = true, - }, CancellationToken.None); - updateCount++; - } - } - } - return updateCount; - } - - private async Task<int> ProcessMovieEvents(SeasonInfo seasonInfo, List<IMetadataUpdatedEventArgs> changes) - { - // Find movies and refresh them. - var updateCount = 0; - var episodeIds = changes - .Where(e => e.EpisodeId.HasValue && e.Reason is not UpdateReason.Removed) - .Select(e => e.EpisodeId!.Value.ToString()) - .ToHashSet(); - var episodeList = seasonInfo.EpisodeList - .Concat(seasonInfo.AlternateEpisodesList) - .Concat(seasonInfo.SpecialsList) - .Where(episodeInfo => episodeIds.Contains(episodeInfo.Id)) - .ToList(); - foreach (var episodeInfo in episodeList) { - var movies = LibraryManager - .GetItemList( - new() { - IncludeItemTypes = new[] { BaseItemKind.Movie }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoEpisodeId.Name, episodeInfo.Id } }, - DtoOptions = new(true), - }, - true - ) - .ToList(); - foreach (var movie in movies) { - Logger.LogInformation("Refreshing movie {MovieName}. (Movie={MovieId},Episode={EpisodeId},Series={SeriesId})", movie.Name, movie.Id, episodeInfo.Id, seasonInfo.Id); - await movie.RefreshMetadata(new(DirectoryService) { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ReplaceAllMetadata = true, - ReplaceAllImages = true, - RemoveOldMetadata = true, - ReplaceImages = Enum.GetValues<ImageType>().ToArray(), - IsAutomated = true, - EnableRemoteContentProbe = true, - }, CancellationToken.None); - updateCount++; - } - } - return updateCount; - } - - #endregion -} \ No newline at end of file diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs index 245eef89..6a1c5b30 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -1,33 +1,215 @@ +using System; using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Emby.Naming.Common; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Models; +using Shokofin.ExternalIds; + +using IDirectoryService = MediaBrowser.Controller.Providers.IDirectoryService; +using TvSeries = MediaBrowser.Controller.Entities.TV.Series; namespace Shokofin.Resolvers; -#pragma warning disable CS8766 +#pragma warning disable CS8768 public class ShokoResolver : IItemResolver, IMultiItemResolver { - private readonly ShokoResolveManager ResolveManager; + private readonly ILogger<ShokoResolver> Logger; + + private readonly IIdLookup Lookup; + + private readonly ILibraryManager LibraryManager; + + private readonly IFileSystem FileSystem; + + private readonly ShokoAPIManager ApiManager; - public ResolverPriority Priority => ResolverPriority.Plugin; + private readonly VirtualFileSystemService ResolveManager; - public ShokoResolver(ShokoResolveManager resolveManager) + private readonly NamingOptions NamingOptions; + + public ShokoResolver( + ILogger<ShokoResolver> logger, + IIdLookup lookup, + ILibraryManager libraryManager, + IFileSystem fileSystem, + ShokoAPIManager apiManager, + VirtualFileSystemService resolveManager, + NamingOptions namingOptions + ) { + Logger = logger; + Lookup = lookup; + LibraryManager = libraryManager; + FileSystem = fileSystem; + ApiManager = apiManager; ResolveManager = resolveManager; + NamingOptions = namingOptions; + } + + public async Task<BaseItem?> ResolveSingle(Folder? parent, string? collectionType, FileSystemMetadata fileInfo) + { + if (!(collectionType is CollectionType.TvShows or CollectionType.Movies or null) || parent is null || fileInfo is null) + return null; + + var root = LibraryManager.RootFolder; + if (root is null || parent == root) + return null; + + try { + if (!Lookup.IsEnabledForItem(parent)) + return null; + + // Skip anything outside the VFS. + if (!fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) + return null; + + if (parent.GetTopParent() is not Folder mediaFolder) + return null; + + var vfsPath = await ResolveManager.GenerateStructureInVFS(mediaFolder, fileInfo.FullName).ConfigureAwait(false); + if (string.IsNullOrEmpty(vfsPath)) + return null; + + if (parent.Id == mediaFolder.Id && fileInfo.IsDirectory) { + if (!fileInfo.Name.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + return null; + + return new TvSeries() { + Path = fileInfo.FullName, + }; + } + + return null; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + throw; + } + } + + public async Task<MultiItemResolverResult?> ResolveMultiple(Folder? parent, string? collectionType, List<FileSystemMetadata> fileInfoList) + { + if (!(collectionType is CollectionType.TvShows or CollectionType.Movies or null) || parent is null) + return null; + + var root = LibraryManager.RootFolder; + if (root is null || parent == root) + return null; + + try { + if (!Lookup.IsEnabledForItem(parent)) + return null; + + if (parent.GetTopParent() is not Folder mediaFolder) + return null; + + var vfsPath = await ResolveManager.GenerateStructureInVFS(mediaFolder, parent.Path).ConfigureAwait(false); + if (string.IsNullOrEmpty(vfsPath)) + return null; + + // Redirect children of a VFS managed media folder to the VFS. + if (parent.IsTopParent) { + var createMovies = collectionType is CollectionType.Movies || (collectionType is null && Plugin.Instance.Configuration.SeparateMovies); + var items = FileSystem.GetDirectories(vfsPath) + .AsParallel() + .SelectMany(dirInfo => { + if (!dirInfo.Name.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + return Array.Empty<BaseItem>(); + + var season = ApiManager.GetSeasonInfoForSeries(seriesId) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + if (season is null) + return Array.Empty<BaseItem>(); + + if (createMovies && season.Type is SeriesType.Movie) { + return FileSystem.GetFiles(dirInfo.FullName) + .AsParallel() + .Select(fileInfo => { + // Only allow the video files, since the subtitle files also have the ids set. + if (!NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(fileInfo.Name))) + return null; + + if (!VirtualFileSystemService.TryGetIdsForPath(fileInfo.FullName, out seriesId, out var fileId)) + return null; + + // This will hopefully just re-use the pre-cached entries from the cache, but it may + // also get it from remote if the cache was emptied for whatever reason. + var file = ApiManager.GetFileInfo(fileId, seriesId) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + + // Abort if the file was not recognized. + if (file is null || file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode))) + return null; + + return new Movie() { + Path = fileInfo.FullName, + } as BaseItem; + }) + .ToArray(); + } + + return new BaseItem[1] { + new TvSeries() { + Path = dirInfo.FullName, + }, + }; + }) + .OfType<BaseItem>() + .ToList(); + + // TODO: uncomment the code snippet once the PR is in stable JF. + // return new() { Items = items, ExtraFiles = new() }; + + // TODO: Remove these two hacks once we have proper support for adding multiple series at once. + if (!items.Any(i => i is Movie) && items.Count > 0) { + fileInfoList.Clear(); + fileInfoList.AddRange(items.OrderBy(s => int.Parse(s.Path.GetAttributeValue(ShokoSeriesId.Name)!)).Select(s => FileSystem.GetFileSystemInfo(s.Path))); + } + + return new() { Items = items.Where(i => i is Movie).ToList(), ExtraFiles = items.OfType<TvSeries>().Select(s => FileSystem.GetFileSystemInfo(s.Path)).ToList() }; + } + + return null; + } + catch (Exception ex) { + Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + throw; + } } - public BaseItem? ResolvePath(ItemResolveArgs args) - => ResolveManager.ResolveSingle(args.Parent, args.CollectionType, args.FileInfo) + #region IItemResolver + + ResolverPriority IItemResolver.Priority => ResolverPriority.Plugin; + + BaseItem? IItemResolver.ResolvePath(ItemResolveArgs args) + => ResolveSingle(args.Parent, args.CollectionType, args.FileInfo) .ConfigureAwait(false) .GetAwaiter() .GetResult(); - public MultiItemResolverResult? ResolveMultiple(Folder parent, List<FileSystemMetadata> files, string collectionType, IDirectoryService directoryService) - => ResolveManager.ResolveMultiple(parent, collectionType, files) + #endregion + + #region IMultiItemResolver + + MultiItemResolverResult? IMultiItemResolver.ResolveMultiple(Folder parent, List<FileSystemMetadata> files, string? collectionType, IDirectoryService directoryService) + => ResolveMultiple(parent, collectionType, files) .ConfigureAwait(false) .GetAwaiter() .GetResult(); + + #endregion } diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs new file mode 100644 index 00000000..93b78cfb --- /dev/null +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -0,0 +1,1009 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Emby.Naming.Common; +using Emby.Naming.ExternalFiles; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; +using Shokofin.API; +using Shokofin.API.Models; +using Shokofin.Configuration; +using Shokofin.ExternalIds; +using Shokofin.Resolvers.Models; +using Shokofin.Utils; + +using File = System.IO.File; + +namespace Shokofin.Resolvers; + +public class VirtualFileSystemService +{ + private readonly ShokoAPIManager ApiManager; + + private readonly ShokoAPIClient ApiClient; + + private readonly ILibraryManager LibraryManager; + + private readonly IFileSystem FileSystem; + + private readonly ILogger<VirtualFileSystemService> Logger; + + private readonly MediaFolderConfigurationService ConfigurationService; + + private readonly NamingOptions NamingOptions; + + private readonly ExternalPathParser ExternalPathParser; + + private readonly GuardedMemoryCache DataCache; + + // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 characters. + private const int NameCutOff = 64; + + private static readonly HashSet<string> IgnoreFolderNames = new() { + "backdrops", + "behind the scenes", + "deleted scenes", + "interviews", + "scenes", + "samples", + "shorts", + "featurettes", + "clips", + "other", + "extras", + "trailers", + }; + + public bool IsCacheStalled => DataCache.IsStalled; + + public VirtualFileSystemService( + ShokoAPIManager apiManager, + ShokoAPIClient apiClient, + MediaFolderConfigurationService configurationService, + ILibraryManager libraryManager, + IFileSystem fileSystem, + ILogger<VirtualFileSystemService> logger, + ILocalizationManager localizationManager, + NamingOptions namingOptions + ) + { + ApiManager = apiManager; + ApiClient = apiClient; + ConfigurationService = configurationService; + LibraryManager = libraryManager; + FileSystem = fileSystem; + Logger = logger; + DataCache = new(logger, TimeSpan.FromMinutes(15), 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; + } + + ~VirtualFileSystemService() + { + LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; + DataCache.Dispose(); + } + + public void Clear() + { + Logger.LogDebug("Clearing data…"); + 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 Folder folder && folder.ParentId == Guid.Empty && !string.IsNullOrEmpty(folder.Path) && !folder.Path.StartsWith(root.Path)) { + DataCache.Remove($"paths-for-media-folder:{folder.Path}"); + DataCache.Remove($"should-skip-media-folder:{folder.Path}"); + var vfsPath = folder.GetVirtualRoot(); + 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> + /// Generates the VFS structure if the VFS is enabled for the <paramref name="mediaFolder"/>. + /// </summary> + /// <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?> GenerateStructureInVFS(Folder mediaFolder, string path) + { + // Skip link generation if we've already generated for the media folder. + var vfsPath = mediaFolder.GetVirtualRoot(); + if (DataCache.TryGetValue<bool>($"should-skip-media-folder:{mediaFolder.Path}", out var shouldReturnPath)) + return shouldReturnPath ? vfsPath : null; + + // Check full path and all parent directories if they have been indexed. + if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { + var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).Prepend(vfsPath).ToArray(); + while (pathSegments.Length > 1) { + var subPath = Path.Join(pathSegments); + if (DataCache.TryGetValue<bool>($"should-skip-vfs-path:{subPath}", out _)) + return vfsPath; + pathSegments = pathSegments.SkipLast(1).ToArray(); + } + } + + // Only do this once. + var key = path.StartsWith(mediaFolder.Path) + ? $"should-skip-media-folder:{mediaFolder.Path}" + : $"should-skip-vfs-path:{path}"; + shouldReturnPath = await DataCache.GetOrCreateAsync<bool>(key, async (__) => { + var mediaConfig = await ConfigurationService.GetOrCreateConfigurationForMediaFolder(mediaFolder); + if (!mediaConfig.IsMapped) + return false; + + // Return early if we're not going to generate them. + if (!mediaConfig.IsVirtualFileSystemEnabled) + return 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."); + + // Iterate the files already in the VFS. + string? pathToClean = null; + IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; + if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { + var allPaths = GetPathsForMediaFolder(mediaFolder); + var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar); + switch (pathSegments.Length) { + // show/movie-folder level + case 1: { + var seriesName = pathSegments[0]; + if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; + + // movie-folder + if (seriesName.TryGetAttributeValue(ShokoEpisodeId.Name, out var episodeId) ) { + if (!int.TryParse(episodeId, out _)) + break; + + pathToClean = path; + allFiles = GetFilesForMovie(episodeId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + break; + } + + // show + pathToClean = path; + allFiles = GetFilesForShow(seriesId, null, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + break; + } + + // season/movie level + case 2: { + var (seriesName, seasonOrMovieName) = pathSegments; + if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; + + // movie + if (seriesName.TryGetAttributeValue(ShokoEpisodeId.Name, out _)) { + if (!seasonOrMovieName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) + break; + + if (!seasonOrMovieName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) + break; + + allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path); + break; + } + + // "season" or extras + if (!seasonOrMovieName.StartsWith("Season ") || !int.TryParse(seasonOrMovieName.Split(' ').Last(), out var seasonNumber)) + break; + + pathToClean = path; + allFiles = GetFilesForShow(seriesId, seasonNumber, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + break; + } + + // episodes level + case 3: { + var (seriesName, seasonName, episodeName) = pathSegments; + if (!seriesName.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) + break; + + if (!seasonName.StartsWith("Season ") || !int.TryParse(seasonName.Split(' ').Last(), out _)) + break; + + if (!episodeName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) + break; + + if (!episodeName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) + break; + + allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path); + break; + } + } + } + // Iterate files in the "real" media folder. + else if (path.StartsWith(mediaFolder.Path)) { + var allPaths = GetPathsForMediaFolder(mediaFolder); + pathToClean = vfsPath; + allFiles = GetFilesForImportFolder(mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + } + + if (allFiles is null) + return false; + + // Generate and cleanup the structure in the VFS. + var result = await GenerateStructure(mediaFolder, vfsPath, allFiles); + if (!string.IsNullOrEmpty(pathToClean)) + result += CleanupStructure(vfsPath, pathToClean, result.Paths.ToArray()); + + // Save which paths we've already generated so we can skip generation + // for them and their sub-paths later, and also print the result. + result.Print(Logger, path.StartsWith(mediaFolder.Path) ? mediaFolder.Path : path); + + return true; + }); + + return shouldReturnPath ? vfsPath : null; + } + + private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) + { + Logger.LogDebug("Looking for files in folder at {Path}", mediaFolder.Path); + var start = DateTime.UtcNow; + var paths = FileSystem.GetFilePaths(mediaFolder.Path, true) + .Where(path => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + .ToHashSet(); + Logger.LogDebug("Found {FileCount} files in folder at {Path} in {TimeSpan}.", paths.Count, mediaFolder.Path, DateTime.UtcNow - start); + return paths; + } + + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForEpisode(string fileId, string seriesId, int importFolderId, string importFolderSubPath, string mediaFolderPath) + { + var start = DateTime.UtcNow; + var file = ApiClient.GetFile(fileId).ConfigureAwait(false).GetAwaiter().GetResult(); + if (file is null || !file.CrossReferences.Any(xref => xref.Series.ToString() == seriesId)) + yield break; + Logger.LogDebug( + "Iterating 1 file to potentially use within media folder at {Path} (File={FileId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", + mediaFolderPath, + fileId, + seriesId, + importFolderId, + importFolderSubPath + ); + + var location = file.Locations + .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length is 0 || location.RelativePath.StartsWith(importFolderSubPath))) + .FirstOrDefault(); + if (location is null || file.CrossReferences.Count is 0) + yield break; + + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!File.Exists(sourceLocation)) + yield break; + + yield return (sourceLocation, fileId, seriesId); + + var timeSpent = DateTime.UtcNow - start; + Logger.LogDebug( + "Iterated 1 file to potentially use within media folder at {Path} in {TimeSpan} (File={FileId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", + mediaFolderPath, + timeSpent, + fileId, + seriesId, + importFolderId, + importFolderSubPath + ); + } + + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForMovie(string episodeId, string seriesId, int importFolderId, string importFolderSubPath, string mediaFolderPath, IReadOnlySet<string> fileSet) + { + var start = DateTime.UtcNow; + var totalFiles = 0; + var seasonInfo = ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult(); + if (seasonInfo is null) + yield break; + Logger.LogDebug( + "Iterating files to potentially use within media folder at {Path} (Episode={EpisodeId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", + mediaFolderPath, + episodeId, + seriesId, + importFolderId, + importFolderSubPath + ); + + var episodeIds = seasonInfo.ExtrasList.Select(episode => episode.Id).Append(episodeId).ToHashSet(); + var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); + var fileLocations = files + .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) + .SelectMany(file => file.Locations.Select(location => (file, location))) + .ToList(); + foreach (var (file, location) in fileLocations) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) + continue; + + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); + } + var timeSpent = DateTime.UtcNow - start; + Logger.LogDebug( + "Iterated {FileCount} file to potentially use within media folder at {Path} in {TimeSpan} (Episode={EpisodeId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", + totalFiles, + mediaFolderPath, + timeSpent, + episodeId, + seriesId, + importFolderId, + importFolderSubPath + ); + } + + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForShow(string seriesId, int? seasonNumber, int importFolderId, string importFolderSubPath, string mediaFolderPath, HashSet<string> fileSet) + { + var start = DateTime.UtcNow; + var showInfo = ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult(); + if (showInfo is null) + yield break; + Logger.LogDebug( + "Iterating files to potentially use within media folder at {Path} (Series={SeriesId},Season={SeasonNumber},ImportFolder={FolderId},RelativePath={RelativePath})", + mediaFolderPath, + seriesId, + seasonNumber, + importFolderId, + importFolderSubPath + ); + + // Only return the files for the given season. + var totalFiles = 0; + if (seasonNumber.HasValue) { + // Special handling of specials (pun intended) + if (seasonNumber.Value is 0) { + foreach (var seasonInfo in showInfo.SeasonList) { + var episodeIds = seasonInfo.SpecialsList.Select(episode => episode.Id).ToHashSet(); + var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); + var fileLocations = files + .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) + .SelectMany(file => file.Locations.Select(location => (file, location))) + .ToList(); + foreach (var (file, location) in fileLocations) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) + continue; + + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); + } + } + } + // All other seasons. + else { + var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber.Value); + if (seasonInfo != null) { + var baseNumber = showInfo.GetBaseSeasonNumberForSeasonInfo(seasonInfo); + var offset = seasonNumber.Value - baseNumber; + var episodeIds = (offset is 0 ? seasonInfo.EpisodeList.Concat(seasonInfo.ExtrasList) : seasonInfo.AlternateEpisodesList).Select(episode => episode.Id).ToHashSet(); + var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); + var fileLocations = files + .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) + .SelectMany(file => file.Locations.Select(location => (file, location))) + .ToList(); + foreach (var (file, location) in fileLocations) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) + continue; + + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); + } + } + } + } + // Return all files for the show. + else { + foreach (var seasonInfo in showInfo.SeasonList) { + var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); + var fileLocations = files + .SelectMany(file => file.Locations.Select(location => (file, location))) + .ToList(); + foreach (var (file, location) in fileLocations) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) + continue; + + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); + } + } + } + + var timeSpent = DateTime.UtcNow - start; + Logger.LogDebug( + "Iterated {FileCount} files to potentially use within media folder at {Path} in {TimeSpan} (Series={SeriesId},Season={SeasonNumber},ImportFolder={FolderId},RelativePath={RelativePath})", + totalFiles, + mediaFolderPath, + timeSpent, + seriesId, + seasonNumber, + importFolderId, + importFolderSubPath + ); + } + + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForImportFolder(int importFolderId, string importFolderSubPath, string mediaFolderPath, HashSet<string> fileSet) + { + var start = DateTime.UtcNow; + var firstPage = ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath); + var pageData = firstPage + .ConfigureAwait(false) + .GetAwaiter() + .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})", + fileSet.Count, + mediaFolderPath, + pageData.Total, + importFolderId, + importFolderSubPath, + pageData.List.Count == pageData.Total ? null : pageData.List.Count, + totalPages + ); + + // Ensure at most 5 pages are in-flight at any given time, until we're done fetching the pages. + var semaphore = new SemaphoreSlim(5); + var pages = new List<Task<ListResult<API.Models.File>>>() { firstPage }; + for (var page = 2; page <= totalPages; page++) + pages.Add(GetImportFolderFilesPage(importFolderId, importFolderSubPath, page, semaphore)); + + var singleSeriesIds = new HashSet<int>(); + var multiSeriesFiles = new List<(API.Models.File, string)>(); + var totalSingleSeriesFiles = 0; + do { + var task = Task.WhenAny(pages).ConfigureAwait(false).GetAwaiter().GetResult(); + pages.Remove(task); + semaphore.Release(); + pageData = task.Result; + + Logger.LogTrace( + "Iterating page {PageNumber} with size {PageSize} (ImportFolder={FolderId},RelativePath={RelativePath})", + totalPages - pages.Count, + pageData.List.Count, + importFolderId, + importFolderSubPath + ); + foreach (var file in pageData.List) { + if (file.CrossReferences.Count is 0) + continue; + + var location = file.Locations + .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length is 0 || location.RelativePath.StartsWith(importFolderSubPath))) + .FirstOrDefault(); + if (location is null) + continue; + + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + // Yield all single-series files now, and offset the processing of all multi-series files for later. + var seriesIds = file.CrossReferences.Where(x => x.Series.Shoko.HasValue && x.Episodes.All(e => e.Shoko.HasValue)).Select(x => x.Series.Shoko!.Value).ToHashSet(); + if (seriesIds.Count is 1) { + totalSingleSeriesFiles++; + singleSeriesIds.Add(seriesIds.First()); + foreach (var seriesId in seriesIds) + yield return (sourceLocation, file.Id.ToString(), seriesId.ToString()); + } + else if (seriesIds.Count > 1) { + multiSeriesFiles.Add((file, sourceLocation)); + } + } + } while (pages.Count > 0); + + // Check which series of the multiple series we have, and only yield + // the paths for the series we have. This will fail if an OVA episode is + // linked to both the OVA and e.g. a specials for the TV Series. + var totalMultiSeriesFiles = 0; + if (multiSeriesFiles.Count > 0) { + var mappedSingleSeriesIds = singleSeriesIds + .Select(seriesId => + ApiManager.GetShowInfoForSeries(seriesId.ToString()) + .ConfigureAwait(false) + .GetAwaiter() + .GetResult()?.Id + ) + .OfType<string>() + .ToHashSet(); + foreach (var (file, sourceLocation) in multiSeriesFiles) { + var seriesIds = file.CrossReferences + .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) + .Select(xref => xref.Series.Shoko!.Value.ToString()) + .Distinct() + .Select(seriesId => ( + seriesId, + showId: ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult()?.Id + )) + .Where(tuple => !string.IsNullOrEmpty(tuple.showId) && mappedSingleSeriesIds.Contains(tuple.showId)) + .Select(tuple => tuple.seriesId) + .ToList(); + foreach (var seriesId in seriesIds) + yield return (sourceLocation, file.Id.ToString(), seriesId); + totalMultiSeriesFiles += seriesIds.Count; + } + } + + var timeSpent = DateTime.UtcNow - start; + Logger.LogDebug( + "Iterated {FileCount} ({MultiFileCount}→{MultiFileCount}) files to potentially use within media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath})", + totalSingleSeriesFiles, + multiSeriesFiles.Count, + totalMultiSeriesFiles, + mediaFolderPath, + timeSpent, + importFolderId, + importFolderSubPath + ); + } + + private async Task<ListResult<API.Models.File>> GetImportFolderFilesPage(int importFolderId, string importFolderSubPath, int page, SemaphoreSlim semaphore) + { + await semaphore.WaitAsync().ConfigureAwait(false); + return await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath, page).ConfigureAwait(false); + } + + private async Task<LinkGenerationResult> GenerateStructure(Folder mediaFolder, string vfsPath, IEnumerable<(string sourceLocation, string fileId, string seriesId)> allFiles) + { + var result = new LinkGenerationResult(); + var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); + var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); + await Task.WhenAll(allFiles.Select(async (tuple) => { + await semaphore.WaitAsync().ConfigureAwait(false); + + try { + Logger.LogTrace("Generating links for {Path} (File={FileId},Series={SeriesId})", tuple.sourceLocation, tuple.fileId, tuple.seriesId); + + var (sourceLocation, symbolicLinks, importedAt) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); + + // Skip any source files we weren't meant to have in the library. + if (string.IsNullOrEmpty(sourceLocation) || !importedAt.HasValue) + return; + + var subResult = GenerateSymbolicLinks(sourceLocation, symbolicLinks, importedAt.Value); + + // Combine the current results with the overall results. + lock (semaphore) { + result += subResult; + } + } + finally { + semaphore.Release(); + } + })) + .ConfigureAwait(false); + + return result; + } + + public async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(Folder mediaFolder, string sourceLocation, string fileId, string seriesId) + { + var vfsPath = mediaFolder.GetVirtualRoot(); + var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); + return await GenerateLocationsForFile(vfsPath, collectionType, sourceLocation, fileId, seriesId); + } + + private async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId) + { + var season = await ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); + if (season is null) + return (string.Empty, Array.Empty<string>(), null); + + var isMovieSeason = season.Type is SeriesType.Movie; + var config = Plugin.Instance.Configuration; + var shouldAbort = collectionType switch { + CollectionType.TvShows => isMovieSeason && config.SeparateMovies, + CollectionType.Movies => !isMovieSeason, + _ => false, + }; + if (shouldAbort) + return (string.Empty, Array.Empty<string>(), null); + + var show = await ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false); + if (show is null) + return (string.Empty, Array.Empty<string>(), null); + + var file = await ApiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false); + var (episode, episodeXref, _) = (file?.EpisodeList ?? new()).FirstOrDefault(); + if (file is null || episode is null) + return (string.Empty, Array.Empty<string>(), null); + + if (season is null || episode is null) + return (string.Empty, Array.Empty<string>(), null); + + var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters() ?? $"Shoko Series {show.Id}"; + var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); + var episodeName = (episode.AniDB.Titles.FirstOrDefault(t => t.LanguageCode == "en")?.Value ?? $"Episode {episode.AniDB.Type} {episodeNumber}").ReplaceInvalidPathCharacters(); + + // For those **really** long names we have to cut if off at some point… + if (showName.Length >= NameCutOff) + showName = showName[..NameCutOff].Split(' ').SkipLast(1).Join(' ') + "…"; + if (episodeName.Length >= NameCutOff) + episodeName = episodeName[..NameCutOff].Split(' ').SkipLast(1).Join(' ') + "…"; + + var isExtra = file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode)); + var folders = new List<string>(); + var extrasFolders = file.ExtraType switch { + null => isExtra ? new string[] { "extras" } : null, + ExtraType.ThemeSong => new string[] { "theme-music" }, + ExtraType.ThemeVideo => config.AddCreditsAsThemeVideos && config.AddCreditsAsSpecialFeatures + ? new string[] { "backdrops", "extras" } + : config.AddCreditsAsThemeVideos + ? new string[] { "backdrops" } + : config.AddCreditsAsSpecialFeatures + ? new string[] { "extras" } + : new string[] { }, + ExtraType.Trailer => config.AddTrailers + ? new string[] { "trailers" } + : new string[] { }, + ExtraType.BehindTheScenes => new string[] { "behind the scenes" }, + ExtraType.DeletedScene => new string[] { "deleted scenes" }, + ExtraType.Clip => new string[] { "clips" }, + ExtraType.Interview => new string[] { "interviews" }, + ExtraType.Scene => new string[] { "scenes" }, + ExtraType.Sample => new string[] { "samples" }, + _ => new string[] { "extras" }, + }; + 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 (extrasFolders != null) { + foreach (var extrasFolder in extrasFolders) + foreach (var episodeInfo in season.EpisodeList) + folders.Add(Path.Join(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episodeInfo.Id}]", extrasFolder)); + } + else { + folders.Add(Path.Join(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episode.Id}]")); + episodeName = "Movie"; + } + } + else { + var isSpecial = show.IsSpecial(episode); + var seasonNumber = Ordering.GetSeasonNumber(show, season, episode); + var seasonFolder = $"Season {(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}"; + var showFolder = $"{showName} [{ShokoSeriesId.Name}={show.Id}]"; + if (extrasFolders != null) { + foreach (var extrasFolder in extrasFolders) { + folders.Add(Path.Join(vfsPath, showFolder, extrasFolder)); + + // Only place the extra within the season if we have a season number assigned to the episode. + if (seasonNumber is not 0) + folders.Add(Path.Join(vfsPath, showFolder, seasonFolder, extrasFolder)); + } + } + else { + folders.Add(Path.Join(vfsPath, showFolder, seasonFolder)); + episodeName = $"{showName} S{(isSpecial ? 0 : seasonNumber).ToString().PadLeft(2, '0')}E{episodeNumber.ToString().PadLeft(show.EpisodePadding, '0')}{filePartSuffix}"; + } + } + + var fileName = $"{episodeName} [{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{Path.GetExtension(sourceLocation)}"; + var symbolicLinks = folders + .Select(folderPath => Path.Join(folderPath, fileName)) + .ToArray(); + + 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()); + } + + public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, DateTime importedAt) + { + try { + var result = new LinkGenerationResult(); + var sourcePrefixLength = sourceLocation.Length - Path.GetExtension(sourceLocation).Length; + var subtitleLinks = FindSubtitlesForPath(sourceLocation); + foreach (var symbolicLink in symbolicLinks) { + var symbolicDirectory = Path.GetDirectoryName(symbolicLink)!; + if (!Directory.Exists(symbolicDirectory)) + Directory.CreateDirectory(symbolicDirectory); + + 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); + } + else { + var shouldFix = false; + try { + 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); + } + var date = File.GetCreationTime(symbolicLink).ToLocalTime(); + if (date != importedAt) { + shouldFix = true; + + 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 (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++; + } + else { + result.SkippedVideos++; + } + } + + if (subtitleLinks.Count > 0) { + var symbolicName = Path.GetFileNameWithoutExtension(symbolicLink); + foreach (var subtitleSource in subtitleLinks) { + var extName = subtitleSource[sourcePrefixLength..]; + var subtitleLink = Path.Join(symbolicDirectory, symbolicName + extName); + + result.Paths.Add(subtitleLink); + if (!File.Exists(subtitleLink)) { + result.CreatedSubtitles++; + Logger.LogDebug("Linking {Link} → {LinkTarget}", subtitleLink, subtitleSource); + File.CreateSymbolicLink(subtitleLink, subtitleSource); + } + else { + var shouldFix = false; + try { + 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); + } + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link} for {LinkTarget}", subtitleLink, subtitleSource); + shouldFix = true; + } + if (shouldFix) { + File.Delete(subtitleLink); + File.CreateSymbolicLink(subtitleLink, subtitleSource); + result.FixedSubtitles++; + } + else { + result.SkippedSubtitles++; + } + } + } + } + } + + return result; + } + catch (Exception ex) { + Logger.LogError(ex, "An error occurred while trying to generate {LinkCount} links for {SourceLocation}; {ErrorMessage}", symbolicLinks.Length, sourceLocation, ex.Message); + throw; + } + } + + private List<string> FindSubtitlesForPath(string sourcePath) + { + var externalPaths = new List<string>(); + var folderPath = Path.GetDirectoryName(sourcePath); + if (string.IsNullOrEmpty(folderPath) || !FileSystem.DirectoryExists(folderPath)) + return externalPaths; + + var files = FileSystem.GetFilePaths(folderPath) + .Except(new[] { sourcePath }) + .ToList(); + var sourcePrefix = Path.GetFileNameWithoutExtension(sourcePath); + foreach (var file in files) { + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file); + if ( + fileNameWithoutExtension.Length >= sourcePrefix.Length && + sourcePrefix.Equals(fileNameWithoutExtension[..sourcePrefix.Length], StringComparison.OrdinalIgnoreCase) && + (fileNameWithoutExtension.Length == sourcePrefix.Length || NamingOptions.MediaFlagDelimiters.Contains(fileNameWithoutExtension[sourcePrefix.Length])) + ) { + var externalPathInfo = ExternalPathParser.ParseFile(file, fileNameWithoutExtension[sourcePrefix.Length..].ToString()); + if (externalPathInfo is not null && !string.IsNullOrEmpty(externalPathInfo.Path)) + externalPaths.Add(externalPathInfo.Path); + } + } + + return externalPaths; + } + + private LinkGenerationResult CleanupStructure(string vfsPath, string directoryToClean, IReadOnlyList<string> allKnownPaths) + { + Logger.LogDebug("Looking for files to remove in folder at {Path}", directoryToClean); + var start = DateTime.Now; + var previousStep = start; + var result = new LinkGenerationResult(); + var searchFiles = NamingOptions.VideoFileExtensions.Concat(NamingOptions.SubtitleFileExtensions).Append(".nfo").ToHashSet(); + 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) + .ToList(); + + var nextStep = DateTime.Now; + 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; + } + result.RemovedNfos++; + } + else if (NamingOptions.SubtitleFileExtensions.Contains(extName)) { + if (TryMoveSubtitleFile(allKnownPaths, location)) { + 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; + } + result.RemovedSubtitles++; + } + else { + if (ShouldIgnoreVideo(vfsPath, 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; + } + result.RemovedVideos++; + } + } + + nextStep = DateTime.Now; + Logger.LogTrace("Removed {FileCount} files in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", result.Removed, directoryToClean, nextStep - previousStep, nextStep - start); + previousStep = nextStep; + + var cleaned = 0; + var directoriesToClean = toBeRemoved + .SelectMany(tuple => { + var path = Path.GetDirectoryName(tuple.path); + var paths = new List<(string path, int level)>(); + while (!string.IsNullOrEmpty(path)) { + var level = path == directoryToClean ? 0 : path[(directoryToClean.Length + 1)..].Split(Path.DirectorySeparatorChar).Length; + paths.Add((path, level)); + if (path == directoryToClean) + break; + path = Path.GetDirectoryName(path); + } + return paths; + }) + .DistinctBy(tuple => tuple.path) + .OrderByDescending(tuple => tuple.level) + .ThenBy(tuple => tuple.path) + .Select(tuple => tuple.path) + .ToList(); + + nextStep = DateTime.Now; + Logger.LogDebug("Found {DirectoryCount} directories to potentially clean in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", toBeRemoved.Count, directoryToClean, nextStep - previousStep, nextStep - start); + previousStep = nextStep; + + foreach (var directoryPath in directoriesToClean) { + if (Directory.Exists(directoryPath) && !Directory.EnumerateFileSystemEntries(directoryPath).Any()) { + Logger.LogTrace("Removing empty directory at {Path}", directoryPath); + Directory.Delete(directoryPath); + cleaned++; + } + } + + Logger.LogTrace("Cleaned {CleanedCount} directories in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", cleaned, directoryToClean, nextStep - previousStep, nextStep - start); + + return result; + } + + private static bool TryMoveSubtitleFile(IReadOnlyList<string> allKnownPaths, string subtitlePath) + { + if (!TryGetIdsForPath(subtitlePath, out var seriesId, out var fileId)) + return false; + + var symbolicLink = allKnownPaths.FirstOrDefault(knownPath => TryGetIdsForPath(knownPath, out var knownSeriesId, out var knownFileId) && seriesId == knownSeriesId && fileId == knownFileId); + if (string.IsNullOrEmpty(symbolicLink)) + return false; + + var sourcePathWithoutExt = symbolicLink[..^Path.GetExtension(symbolicLink).Length]; + if (!subtitlePath.StartsWith(sourcePathWithoutExt)) + return false; + + var extName = subtitlePath[sourcePathWithoutExt.Length..]; + string? realTarget = null; + try { + realTarget = File.ResolveLinkTarget(symbolicLink, false)?.FullName; + } + catch { } + if (string.IsNullOrEmpty(realTarget)) + return false; + + var realSubtitlePath = realTarget[..^Path.GetExtension(realTarget).Length] + extName; + if (!File.Exists(realSubtitlePath)) + File.Move(subtitlePath, realSubtitlePath); + else + File.Delete(subtitlePath); + File.CreateSymbolicLink(subtitlePath, realSubtitlePath); + + return true; + } + + private static bool ShouldIgnoreVideo(string vfsPath, string path) + { + // Ignore the video if it's within one of the folders to potentially ignore _and_ it doesn't have any shoko ids set. + var parentDirectories = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).SkipLast(1).ToArray(); + return parentDirectories.Length > 1 && IgnoreFolderNames.Contains(parentDirectories.Last()) && !TryGetIdsForPath(path, out _, out _); + } + + public static bool TryGetIdsForPath(string path, [NotNullWhen(true)] out string? seriesId, [NotNullWhen(true)] out string? fileId) + { + var fileName = Path.GetFileNameWithoutExtension(path); + if (!fileName.TryGetAttributeValue(ShokoFileId.Name, out fileId) || !int.TryParse(fileId, out _) || + !fileName.TryGetAttributeValue(ShokoSeriesId.Name, out seriesId) || !int.TryParse(seriesId, out _)) { + seriesId = null; + fileId = null; + return false; + } + + return true; + } + + #endregion +} diff --git a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs index bee19c7f..5af67540 100644 --- a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; -using Shokofin.SignalR.Interfaces; +using Shokofin.Events.Interfaces; namespace Shokofin.SignalR.Models; diff --git a/Shokofin/SignalR/Models/FileEventArgs.cs b/Shokofin/SignalR/Models/FileEventArgs.cs index a722d1f8..f2fb1a26 100644 --- a/Shokofin/SignalR/Models/FileEventArgs.cs +++ b/Shokofin/SignalR/Models/FileEventArgs.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -using Shokofin.SignalR.Interfaces; +using Shokofin.Events.Interfaces; namespace Shokofin.SignalR.Models; diff --git a/Shokofin/SignalR/Models/FileMovedEventArgs.cs b/Shokofin/SignalR/Models/FileMovedEventArgs.cs index ed1ed2f1..e97234c4 100644 --- a/Shokofin/SignalR/Models/FileMovedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileMovedEventArgs.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -using Shokofin.SignalR.Interfaces; +using Shokofin.Events.Interfaces; namespace Shokofin.SignalR.Models; diff --git a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs index 82e538a9..e027d062 100644 --- a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -using Shokofin.SignalR.Interfaces; +using Shokofin.Events.Interfaces; namespace Shokofin.SignalR.Models; diff --git a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs index 723b2aab..5bd28159 100644 --- a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; using Jellyfin.Data.Enums; -using Shokofin.SignalR.Interfaces; +using Shokofin.Events.Interfaces; namespace Shokofin.SignalR.Models; diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index 325b3dfc..ef75619c 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -7,8 +7,8 @@ using Microsoft.Extensions.Logging; using Shokofin.API.Models; using Shokofin.Configuration; -using Shokofin.Resolvers; -using Shokofin.SignalR.Interfaces; +using Shokofin.Events; +using Shokofin.Events.Interfaces; using Shokofin.SignalR.Models; using Shokofin.Utils; @@ -28,7 +28,7 @@ public class SignalRConnectionManager private readonly ILogger<SignalRConnectionManager> Logger; - private readonly ShokoResolveManager ResolveManager; + private readonly EventDispatchService Events; private readonly LibraryScanWatcher LibraryScanWatcher; @@ -48,12 +48,12 @@ public class SignalRConnectionManager public SignalRConnectionManager( ILogger<SignalRConnectionManager> logger, - ShokoResolveManager resolveManager, + EventDispatchService events, LibraryScanWatcher libraryScanWatcher ) { Logger = logger; - ResolveManager = resolveManager; + Events = events; LibraryScanWatcher = libraryScanWatcher; } @@ -95,7 +95,7 @@ private async Task ConnectAsync(PluginConfiguration config) connection.On<FileRenamedEventArgs>("ShokoEvent:FileRenamed", OnFileRelocated); } - EventSubmitterLease = ResolveManager.RegisterEventSubmitter(); + EventSubmitterLease = Events.RegisterEventSubmitter(); try { await connection.StartAsync().ConfigureAwait(false); @@ -219,7 +219,7 @@ private void OnFileMatched(IFileEventArgs eventArgs) return; } - ResolveManager.AddFileEvent(eventArgs.FileId, UpdateReason.Updated, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); + Events.AddFileEvent(eventArgs.FileId, UpdateReason.Updated, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); } private void OnFileRelocated(IFileRelocationEventArgs eventArgs) @@ -244,8 +244,8 @@ private void OnFileRelocated(IFileRelocationEventArgs eventArgs) return; } - ResolveManager.AddFileEvent(eventArgs.FileId, UpdateReason.Removed, eventArgs.PreviousImportFolderId, eventArgs.PreviousRelativePath, eventArgs); - ResolveManager.AddFileEvent(eventArgs.FileId, UpdateReason.Added, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); + Events.AddFileEvent(eventArgs.FileId, UpdateReason.Removed, eventArgs.PreviousImportFolderId, eventArgs.PreviousRelativePath, eventArgs); + Events.AddFileEvent(eventArgs.FileId, UpdateReason.Added, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); } private void OnFileDeleted(IFileEventArgs eventArgs) @@ -268,7 +268,7 @@ private void OnFileDeleted(IFileEventArgs eventArgs) return; } - ResolveManager.AddFileEvent(eventArgs.FileId, UpdateReason.Removed, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); + Events.AddFileEvent(eventArgs.FileId, UpdateReason.Removed, eventArgs.ImportFolderId, eventArgs.RelativePath, eventArgs); } #endregion @@ -315,7 +315,7 @@ private void OnInfoUpdated(IMetadataUpdatedEventArgs eventArgs) } if (eventArgs.Kind is BaseItemKind.Episode or BaseItemKind.Series) - ResolveManager.AddSeriesEvent(eventArgs.ProviderParentUId ?? eventArgs.ProviderUId, eventArgs); + Events.AddSeriesEvent(eventArgs.ProviderParentUId ?? eventArgs.ProviderUId, eventArgs); } #endregion diff --git a/Shokofin/Tasks/AutoClearPluginCacheTask.cs b/Shokofin/Tasks/AutoClearPluginCacheTask.cs index c697434a..0079d3a8 100644 --- a/Shokofin/Tasks/AutoClearPluginCacheTask.cs +++ b/Shokofin/Tasks/AutoClearPluginCacheTask.cs @@ -42,7 +42,7 @@ public class AutoClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTa private readonly ShokoAPIClient ApiClient; - private readonly ShokoResolveManager ResolveManager; + private readonly VirtualFileSystemService VfsService; private readonly LibraryScanWatcher LibraryScanWatcher; @@ -53,14 +53,14 @@ public AutoClearPluginCacheTask( ILogger<AutoClearPluginCacheTask> logger, ShokoAPIManager apiManager, ShokoAPIClient apiClient, - ShokoResolveManager resolveManager, + VirtualFileSystemService vfsService, LibraryScanWatcher libraryScanWatcher ) { Logger = logger; ApiManager = apiManager; ApiClient = apiClient; - ResolveManager = resolveManager; + VfsService = vfsService; LibraryScanWatcher = libraryScanWatcher; } @@ -86,14 +86,14 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat if (LibraryScanWatcher.IsScanRunning) return Task.CompletedTask; - if (ApiClient.IsCacheStalled || ApiManager.IsCacheStalled || ResolveManager.IsCacheStalled) + if (ApiClient.IsCacheStalled || ApiManager.IsCacheStalled || VfsService.IsCacheStalled) Logger.LogInformation("Automagically clearing cache…"); if (ApiClient.IsCacheStalled) ApiClient.Clear(); if (ApiManager.IsCacheStalled) ApiManager.Clear(); - if (ResolveManager.IsCacheStalled) - ResolveManager.Clear(); + if (VfsService.IsCacheStalled) + VfsService.Clear(); return Task.CompletedTask; } } diff --git a/Shokofin/Tasks/ClearPluginCacheTask.cs b/Shokofin/Tasks/ClearPluginCacheTask.cs index 5a2da479..52ec6e5a 100644 --- a/Shokofin/Tasks/ClearPluginCacheTask.cs +++ b/Shokofin/Tasks/ClearPluginCacheTask.cs @@ -38,16 +38,16 @@ public class ClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTask private readonly ShokoAPIClient ApiClient; - private readonly ShokoResolveManager ResolveManager; + private readonly VirtualFileSystemService VfsService; /// <summary> /// Initializes a new instance of the <see cref="ClearPluginCacheTask" /> class. /// </summary> - public ClearPluginCacheTask(ShokoAPIManager apiManager, ShokoAPIClient apiClient, ShokoResolveManager resolveManager) + public ClearPluginCacheTask(ShokoAPIManager apiManager, ShokoAPIClient apiClient, VirtualFileSystemService vfsService) { ApiManager = apiManager; ApiClient = apiClient; - ResolveManager = resolveManager; + VfsService = vfsService; } /// <summary> @@ -66,7 +66,7 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat { ApiClient.Clear(); ApiManager.Clear(); - ResolveManager.Clear(); + VfsService.Clear(); return Task.CompletedTask; } } diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs index 350dbf2e..7f175d7e 100644 --- a/Shokofin/Tasks/PostScanTask.cs +++ b/Shokofin/Tasks/PostScanTask.cs @@ -16,17 +16,17 @@ public class PostScanTask : ILibraryPostScanTask private readonly ShokoAPIClient ApiClient; - private readonly ShokoResolveManager ResolveManager; + private readonly VirtualFileSystemService VfsService; private readonly MergeVersionsManager VersionsManager; private readonly CollectionManager CollectionManager; - public PostScanTask(ShokoAPIManager apiManager, ShokoAPIClient apiClient, ShokoResolveManager resolveManager, MergeVersionsManager versionsManager, CollectionManager collectionManager) + public PostScanTask(ShokoAPIManager apiManager, ShokoAPIClient apiClient, VirtualFileSystemService vfsService, MergeVersionsManager versionsManager, CollectionManager collectionManager) { ApiManager = apiManager; ApiClient = apiClient; - ResolveManager = resolveManager; + VfsService = vfsService; VersionsManager = versionsManager; CollectionManager = collectionManager; } @@ -57,6 +57,6 @@ public async Task Run(IProgress<double> progress, CancellationToken token) // Clear the cache now, since we don't need it anymore. ApiClient.Clear(); ApiManager.Clear(); - ResolveManager.Clear(); + VfsService.Clear(); } } From e8c8dfa169a0049b40b7ba1dbcde9f336130771e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 31 May 2024 23:47:05 +0200 Subject: [PATCH 1046/1103] fix: disable creation time again until 10.9 --- .../Resolvers/VirtualFileSystemService.cs | 66 ++++++++++++++----- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 93b78cfb..ad04d2b1 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -724,7 +724,10 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { return (sourceLocation, symbolicLinks, (file.Shoko.ImportedAt ?? file.Shoko.CreatedAt).ToLocalTime()); } +// TODO: Remove this for 10.9 +#pragma warning disable IDE0060 public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, DateTime importedAt) +#pragma warning restore IDE0060 { try { var result = new LinkGenerationResult(); @@ -739,9 +742,17 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ 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); + // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. + try { + File.CreateSymbolicLink(symbolicLink, sourceLocation); + } + catch { + if (!File.Exists(symbolicLink)) + throw; + } + // TODO: Uncomment this for 10.9 + // // Mock the creation date to fake the "date added" order in Jellyfin. + // File.SetCreationTime(symbolicLink, importedAt); } else { var shouldFix = false; @@ -752,22 +763,31 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ 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); - } + // TODO: Uncomment this for 10.9 + // var date = File.GetCreationTime(symbolicLink); + // if (date != importedAt) { + // shouldFix = true; + // + // 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 (shouldFix) { - File.Delete(symbolicLink); - File.CreateSymbolicLink(symbolicLink, sourceLocation); - // Mock the creation date to fake the "date added" order in Jellyfin. - File.SetCreationTime(symbolicLink, importedAt); + // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. + try { + File.Delete(symbolicLink); + File.CreateSymbolicLink(symbolicLink, sourceLocation); + } + catch { + if (!File.Exists(symbolicLink)) + throw; + } + // TODO: Uncomment this for 10.9 + // // Mock the creation date to fake the "date added" order in Jellyfin. + // File.SetCreationTime(symbolicLink, importedAt); result.FixedVideos++; } else { @@ -785,7 +805,14 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ if (!File.Exists(subtitleLink)) { result.CreatedSubtitles++; Logger.LogDebug("Linking {Link} → {LinkTarget}", subtitleLink, subtitleSource); - File.CreateSymbolicLink(subtitleLink, subtitleSource); + // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. + try { + File.CreateSymbolicLink(subtitleLink, subtitleSource); + } + catch { + if (!File.Exists(subtitleLink)) + throw; + } } else { var shouldFix = false; @@ -802,8 +829,15 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ shouldFix = true; } if (shouldFix) { - File.Delete(subtitleLink); - File.CreateSymbolicLink(subtitleLink, subtitleSource); + // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. + try { + File.Delete(subtitleLink); + File.CreateSymbolicLink(subtitleLink, subtitleSource); + } + catch { + if (!File.Exists(subtitleLink)) + throw; + } result.FixedSubtitles++; } else { From 6d1035042d6ad2169fc622052c611a92343e39fc Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 4 Jun 2024 04:18:40 +0200 Subject: [PATCH 1047/1103] feat: add automagic season merging - Added an **EXPERIMENTAL** implementation for automagic season merging. The logic may need to be tweaked further, but for now it needs testing. --- Shokofin/API/Info/SeasonInfo.cs | 6 +- Shokofin/API/Models/Role.cs | 57 +++- Shokofin/API/ShokoAPIManager.cs | 252 +++++++++++++++--- Shokofin/Configuration/PluginConfiguration.cs | 9 +- Shokofin/Configuration/configPage.html | 4 +- Shokofin/Events/EventDispatchService.cs | 9 +- Shokofin/Providers/BoxSetProvider.cs | 2 +- Shokofin/Providers/CustomEpisodeProvider.cs | 2 +- Shokofin/Providers/CustomSeasonProvider.cs | 6 +- Shokofin/Providers/CustomSeriesProvider.cs | 4 +- Shokofin/Providers/EpisodeProvider.cs | 2 +- Shokofin/Providers/MovieProvider.cs | 2 +- Shokofin/Providers/VideoProvider.cs | 2 +- Shokofin/Resolvers/ShokoIgnoreRule.cs | 14 +- .../Resolvers/VirtualFileSystemService.cs | 40 +-- Shokofin/Utils/Ordering.cs | 10 +- 16 files changed, 326 insertions(+), 95 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 1bd1179b..f485279e 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -12,6 +12,8 @@ public class SeasonInfo { public readonly string Id; + public readonly IReadOnlyList<string> ExtraIds; + public readonly Series Shoko; public readonly Series.AniDBWithDate AniDB; @@ -91,7 +93,7 @@ public class SeasonInfo /// </summary> public readonly IReadOnlyDictionary<string, RelationType> RelationMap; - public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImportedAt, List<EpisodeInfo> episodes, List<Role> cast, List<Relation> relations, string[] genres, string[] tags) + public SeasonInfo(Series series, IEnumerable<string> extraIds, DateTime? earliestImportedAt, DateTime? lastImportedAt, List<EpisodeInfo> episodes, List<Role> cast, List<Relation> relations, string[] genres, string[] tags) { var seriesId = series.IDs.Shoko.ToString(); var studios = cast @@ -104,6 +106,7 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp .ToArray(); var relationMap = relations .Where(r => r.RelatedIDs.Shoko.HasValue) + .DistinctBy(r => r.RelatedIDs.Shoko!.Value) .ToDictionary(r => r.RelatedIDs.Shoko!.Value.ToString(), r => r.Type); var specialsAnchorDictionary = new Dictionary<EpisodeInfo, EpisodeInfo>(); var specialsList = new List<EpisodeInfo>(); @@ -205,6 +208,7 @@ public SeasonInfo(Series series, DateTime? earliestImportedAt, DateTime? lastImp } Id = seriesId; + ExtraIds = extraIds.ToArray(); Shoko = series; AniDB = series.AniDBEntity; TvDB = series.TvDBEntityList.FirstOrDefault(); diff --git a/Shokofin/API/Models/Role.cs b/Shokofin/API/Models/Role.cs index af2f9882..764ed7b7 100644 --- a/Shokofin/API/Models/Role.cs +++ b/Shokofin/API/Models/Role.cs @@ -1,8 +1,9 @@ +using System; using System.Text.Json.Serialization; namespace Shokofin.API.Models; -public class Role +public class Role : IEquatable<Role> { /// <summary> /// Extra info about the role. For example, role can be voice actor, while role_details is Main Character @@ -30,7 +31,35 @@ public class Role /// </summary> public Person? Character { get; set; } - public class Person + public override bool Equals(object? obj) + => Equals(obj as Role); + + public bool Equals(Role? other) + { + if (other is null) + return false; + + return string.Equals(Name, other.Name, StringComparison.Ordinal) && + Type == other.Type && + string.Equals(Language, other.Language, StringComparison.Ordinal) && + Staff.Equals(other.Staff) && + (Character is null ? other.Character is null : Character.Equals(other.Character)); + } + + public override int GetHashCode() + { + var hash = 17; + + hash = hash * 31 + (Name?.GetHashCode() ?? 0); + hash = hash * 31 + Type.GetHashCode(); + hash = hash * 31 + (Language?.GetHashCode() ?? 0); + hash = hash * 31 + Staff.GetHashCode(); + hash = hash * 31 + (Character?.GetHashCode() ?? 0); + + return hash; + } + + public class Person : IEquatable<Person> { /// <summary> /// Main Name, romanized if needed @@ -55,6 +84,30 @@ public class Person /// picture. /// </summary> public Image Image { get; set; } = new(); + + public override bool Equals(object? obj) + => Equals(obj as Person); + + public bool Equals(Person? other) + { + if (other is null) + return false; + + return string.Equals(Name, other.Name, StringComparison.Ordinal) && + string.Equals(Description, other.Description, StringComparison.Ordinal) && + string.Equals(AlternateName, other.AlternateName, StringComparison.Ordinal); + } + + public override int GetHashCode() + { + var hash = 17; + + hash = hash * 31 + (Name?.GetHashCode() ?? 0); + hash = hash * 31 + (AlternateName?.GetHashCode() ?? 0); + hash = hash * 31 + (Description?.GetHashCode() ?? 0); + + return hash; + } } } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 0c245c00..839f902a 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -13,11 +13,15 @@ using Shokofin.Utils; using Path = System.IO.Path; +using Regex = System.Text.RegularExpressions.Regex; +using RegexOptions = System.Text.RegularExpressions.RegexOptions; namespace Shokofin.API; public class ShokoAPIManager : IDisposable { + private static readonly Regex YearRegex = new(@"\s+\((?<year>\d{4})\)\s*$", RegexOptions.Compiled); + private readonly ILogger<ShokoAPIManager> Logger; private readonly ShokoAPIClient APIClient; @@ -254,15 +258,28 @@ private string SelectTagName(Tag tag) #endregion #region Path Set And Local Episode IDs + public async Task<List<(File file, string seriesId)>> GetFilesForSeason(SeasonInfo seasonInfo) + { + // TODO: Optimise/cache this better now that we do it per season. + var list = (await APIClient.GetFilesForSeries(seasonInfo.Id)).Select(file => (file, seriesId: seasonInfo.Id)).ToList(); + foreach (var extraId in seasonInfo.ExtraIds) + list.AddRange((await APIClient.GetFilesForSeries(extraId)).Select(file => (file, seriesId: extraId))); + return list; + } + /// <summary> /// Get a set of paths that are unique to the series and don't belong to /// any other series. /// </summary> /// <param name="seriesId">Shoko series id.</param> /// <returns>Unique path set for the series</returns> - public async Task<HashSet<string>> GetPathSetForSeries(string seriesId) + public async Task<HashSet<string>> GetPathSetForSeries(string seriesId, IEnumerable<string> extraIds) { + // TODO: Optimise/cache this better now that we do it per season. var (pathSet, _) = await GetPathSetAndLocalEpisodeIdsForSeries(seriesId).ConfigureAwait(false); + foreach (var extraId in extraIds) + foreach (var path in await GetPathSetAndLocalEpisodeIdsForSeries(extraId).ContinueWith(task => task.Result.Item1).ConfigureAwait(false)) + pathSet.Add(path); return pathSet; } @@ -271,12 +288,13 @@ public async Task<HashSet<string>> GetPathSetForSeries(string seriesId) /// </summary> /// <param name="seriesId">Shoko series id.</param> /// <returns>Local episode ids for the series</returns> - public HashSet<string> GetLocalEpisodeIdsForSeries(string seriesId) + public async Task<HashSet<string>> GetLocalEpisodeIdsForSeason(SeasonInfo seasonInfo) { - var (_, episodeIds) = GetPathSetAndLocalEpisodeIdsForSeries(seriesId) - .ConfigureAwait(false) - .GetAwaiter() - .GetResult(); + // TODO: Optimise/cache this better now that we do it per season. + var (_, episodeIds) = await GetPathSetAndLocalEpisodeIdsForSeries(seasonInfo.Id).ConfigureAwait(false); + foreach (var extraId in seasonInfo.ExtraIds) + foreach (var episodeId in await GetPathSetAndLocalEpisodeIdsForSeries(extraId).ContinueWith(task => task.Result.Item2).ConfigureAwait(false)) + episodeIds.Add(episodeId); return episodeIds; } @@ -385,17 +403,18 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu var seriesId = seriesXRef.Series.Shoko!.Value.ToString(); // Check if the file is in the series folder. - var pathSet = await GetPathSetForSeries(seriesId).ConfigureAwait(false); + var (primaryId, extraIds) = await GetSeriesIdsForSeason(seriesId); + var pathSet = await GetPathSetForSeries(primaryId, extraIds).ConfigureAwait(false); if (!pathSet.Contains(selectedPath)) continue; // Find the season info. - var seasonInfo = await GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); + var seasonInfo = await GetSeasonInfoForSeries(primaryId).ConfigureAwait(false); if (seasonInfo == null) return (null, null, null); // Find the show info. - var showInfo = await GetShowInfoForSeries(seriesId).ConfigureAwait(false); + var showInfo = await GetShowInfoForSeries(primaryId).ConfigureAwait(false); if (showInfo == null || showInfo.SeasonList.Count == 0) return (null, null, null); @@ -563,14 +582,8 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) if (string.IsNullOrEmpty(seriesId)) return null; - var key = $"season:{seriesId}"; - if (DataCache.TryGetValue<SeasonInfo>(key, out var seasonInfo)) { - Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId); - return seasonInfo; - } - var series = await APIClient.GetSeries(seriesId).ConfigureAwait(false); - return await CreateSeasonInfo(series, seriesId).ConfigureAwait(false); + return await CreateSeasonInfo(series).ConfigureAwait(false); } public async Task<SeasonInfo?> GetSeasonInfoForSeries(string seriesId) @@ -578,14 +591,8 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) if (string.IsNullOrEmpty(seriesId)) return null; - var cachedKey = $"season:{seriesId}"; - if (DataCache.TryGetValue<SeasonInfo>(cachedKey, out var seasonInfo)) { - Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId); - return seasonInfo; - } - var series = await APIClient.GetSeries(seriesId).ConfigureAwait(false); - return await CreateSeasonInfo(series, seriesId).ConfigureAwait(false); + return await CreateSeasonInfo(series).ConfigureAwait(false); } public async Task<SeasonInfo?> GetSeasonInfoForEpisode(string episodeId) @@ -595,35 +602,80 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) if (series == null) return null; seriesId = series.IDs.Shoko.ToString(); - return await CreateSeasonInfo(series, seriesId).ConfigureAwait(false); + return await CreateSeasonInfo(series).ConfigureAwait(false); } return await GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); } - private Task<SeasonInfo> CreateSeasonInfo(Series series, string seriesId) - => DataCache.GetOrCreateAsync( + private async Task<SeasonInfo> CreateSeasonInfo(Series series) + { + var (seriesId, extraIds) = await GetSeriesIdsForSeason(series); + return await DataCache.GetOrCreateAsync( $"season:{seriesId}", (seasonInfo) => Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId), async (cachedEntry) => { - Logger.LogTrace("Creating info object for season {SeriesName}. (Series={SeriesId})", series.Name, seriesId); - - var (earliestImportedAt, lastImportedAt)= await GetEarliestImportedAtForSeries(seriesId).ConfigureAwait(false); - var episodes = (await APIClient.GetEpisodesFromSeries(seriesId).ConfigureAwait(false) ?? new()).List - .Select(e => CreateEpisodeInfo(e, e.IDs.Shoko.ToString())) - .OrderBy(e => e.AniDB.AirDate) + // We updated the "primary" series id for the merge group, so fetch the new series details from the client cache. + if (!string.Equals(series.IDs.Shoko.ToString(), seriesId, StringComparison.Ordinal)) + series = await APIClient.GetSeries(seriesId); + + Logger.LogTrace("Creating info object for season {SeriesName}. (Series={SeriesId},ExtraSeries={ExtraIds})", series.Name, seriesId, extraIds); + + var (earliestImportedAt, lastImportedAt) = await GetEarliestImportedAtForSeries(seriesId).ConfigureAwait(false); + var episodes = (await Task.WhenAll( + extraIds.Prepend(seriesId) + .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(); - var cast = await APIClient.GetSeriesCast(seriesId).ConfigureAwait(false); - var relations = await APIClient.GetSeriesRelations(seriesId).ConfigureAwait(false); - var genres = await GetGenresForSeries(seriesId).ConfigureAwait(false); - var tags = await GetTagsForSeries(seriesId).ConfigureAwait(false); - var seasonInfo = new SeasonInfo(series, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags); + + SeasonInfo seasonInfo; + if (extraIds.Count > 0) { + var detailsIds = extraIds.Prepend(seriesId).ToList(); + + // Create the tasks. + var castTasks = detailsIds.Select(id => APIClient.GetSeriesCast(id)); + var relationsTasks = detailsIds.Select(id => APIClient.GetSeriesRelations(id)); + var genresTasks = detailsIds.Select(id => GetGenresForSeries(id)); + var tagsTasks = detailsIds.Select(id => GetTagsForSeries(id)); + + // Await the tasks in order. + var cast = (await Task.WhenAll(castTasks)) + .SelectMany(c => c) + .Distinct() + .ToList(); + var relations = (await Task.WhenAll(relationsTasks)) + .SelectMany(r => r) + .Where(r => r.RelatedIDs.Shoko.HasValue && !detailsIds.Contains(r.RelatedIDs.Shoko.Value.ToString())) + .ToList(); + var genres = (await Task.WhenAll(genresTasks)) + .SelectMany(g => g) + .OrderBy(g => g) + .Distinct() + .ToArray(); + var tags = (await Task.WhenAll(tagsTasks)) + .SelectMany(t => t) + .OrderBy(t => t) + .Distinct() + .ToArray(); + + // Create the season info using the merged details. + seasonInfo = new SeasonInfo(series, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags); + } else { + var cast = await APIClient.GetSeriesCast(seriesId).ConfigureAwait(false); + var relations = await APIClient.GetSeriesRelations(seriesId).ConfigureAwait(false); + var genres = await GetGenresForSeries(seriesId).ConfigureAwait(false); + var tags = await GetTagsForSeries(seriesId).ConfigureAwait(false); + seasonInfo = new SeasonInfo(series, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags); + } foreach (var episode in episodes) EpisodeIdToSeriesIdDictionary.TryAdd(episode.Id, seriesId); return seasonInfo; } ); + } private Task<(DateTime?, DateTime?)> GetEarliestImportedAtForSeries(string seriesId) => DataCache.GetOrCreateAsync<(DateTime?, DateTime?)>( @@ -644,6 +696,118 @@ private Task<SeasonInfo> CreateSeasonInfo(Series series, string seriesId) new() ); + public async Task<(string primaryId, List<string> extraIds)> GetSeriesIdsForSeason(string seriesId) + => await GetSeriesIdsForSeason(await APIClient.GetSeries(seriesId)); + + private Task<(string primaryId, List<string> extraIds)> GetSeriesIdsForSeason(Series series) + => DataCache.GetOrCreateAsync( + $"season-series-ids:{series.IDs.Shoko}", + (tuple) => Logger.LogTrace(""), + async (cacheEntry) => { + var primaryId = series.IDs.Shoko.ToString(); + var extraIds = new List<string>(); + if (!Plugin.Instance.Configuration.EXPERIMENTAL_MergeSeasons) + return (primaryId, extraIds); + + if (series.AniDBEntity.AirDate is null) + return (primaryId, extraIds); + + // We potentially have a "follow-up" season candidate, so look for the "primary" season candidate, then jump into that. + var relations = await APIClient.GetSeriesRelations(primaryId).ConfigureAwait(false); + var mainTitle = series.AniDBEntity.Titles.First(title => title.Type == TitleType.Main).Value; + var result = YearRegex.Match(mainTitle); + var maxDaysThreshold = Plugin.Instance.Configuration.EXPERIMENTAL_MergeSeasonsMergeWindowInDays; + if (result.Success) + { + var adjustedMainTitle = mainTitle[..^result.Length]; + var currentDate = series.AniDBEntity.AirDate.Value; + var currentRelations = relations; + while (currentRelations.Count > 0) { + foreach (var prequelRelation in currentRelations.Where(relation => relation.Type == RelationType.Prequel && relation.RelatedIDs.Shoko.HasValue)) { + var prequelSeries = await APIClient.GetSeries(prequelRelation.RelatedIDs.Shoko!.Value.ToString()); + if (prequelSeries.IDs.ParentGroup != series.IDs.ParentGroup) + continue; + + if (prequelSeries.AniDBEntity.Type is SeriesType.Movie or SeriesType.Other or SeriesType.Unknown) + continue; + + if (prequelSeries.AniDBEntity.AirDate is null) + continue; + + var prequelDate = prequelSeries.AniDBEntity.AirDate.Value; + if (prequelDate > currentDate) + continue; + + var deltaDays = (int)Math.Floor((currentDate - prequelDate).TotalDays); + if (deltaDays > maxDaysThreshold) + continue; + + var prequelMainTitle = prequelSeries.AniDBEntity.Titles.First(title => title.Type == TitleType.Main).Value; + var prequelResult = YearRegex.Match(prequelMainTitle); + if (!prequelResult.Success) { + if (string.Equals(adjustedMainTitle, prequelMainTitle, StringComparison.InvariantCultureIgnoreCase)) + return await GetSeriesIdsForSeason(prequelSeries); + continue; + } + + var adjustedPrequelMainTitle = prequelMainTitle[..^prequelResult.Length]; + if (string.Equals(adjustedMainTitle, adjustedPrequelMainTitle, StringComparison.InvariantCultureIgnoreCase)) { + currentDate = prequelDate; + currentRelations = await APIClient.GetSeriesRelations(prequelSeries.IDs.Shoko.ToString()).ConfigureAwait(false); + goto continuePrequelWhileLoop; + } + } + break; + continuePrequelWhileLoop: continue; + } + } + // We potentially have a "primary" season candidate, so look for any "follow-up" season candidates. + else { + var currentDate = series.AniDBEntity.AirDate.Value; + var adjustedMainTitle = mainTitle; + var currentRelations = relations; + while (currentRelations.Count > 0) { + foreach (var sequelRelation in currentRelations.Where(relation => relation.Type == RelationType.Sequel && relation.RelatedIDs.Shoko.HasValue)) { + var sequelSeries = await APIClient.GetSeries(sequelRelation.RelatedIDs.Shoko!.Value.ToString()); + if (sequelSeries.IDs.ParentGroup != series.IDs.ParentGroup) + continue; + + if (sequelSeries.AniDBEntity.Type is SeriesType.Movie or SeriesType.Other or SeriesType.Unknown) + continue; + + if (sequelSeries.AniDBEntity.AirDate is null) + continue; + + var sequelDate = sequelSeries.AniDBEntity.AirDate.Value; + if (sequelDate < currentDate) + continue; + + var deltaDays = (int)Math.Floor((sequelDate - currentDate).TotalDays); + if (deltaDays > maxDaysThreshold) + continue; + + var sequelMainTitle = sequelSeries.AniDBEntity.Titles.First(title => title.Type == TitleType.Main).Value; + var sequelResult = YearRegex.Match(sequelMainTitle); + if (!sequelResult.Success) + continue; + + var adjustedSequelMainTitle = sequelMainTitle[..^sequelResult.Length]; + if (string.Equals(adjustedMainTitle, adjustedSequelMainTitle, StringComparison.InvariantCultureIgnoreCase)) { + extraIds.Add(sequelSeries.IDs.Shoko.ToString()); + currentDate = sequelDate; + currentRelations = await APIClient.GetSeriesRelations(sequelSeries.IDs.Shoko.ToString()).ConfigureAwait(false); + goto continueSequelWhileLoop; + } + } + break; + continueSequelWhileLoop: continue; + } + } + + return (primaryId, extraIds); + } + ); + #endregion #region Series Helpers @@ -700,16 +864,17 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, [NotNullWhen(true) // the input path. foreach (var series in result) { seriesId = series.IDs.Shoko.ToString(); - var pathSet = await GetPathSetForSeries(seriesId).ConfigureAwait(false); + var (primaryId, extraIds) = await GetSeriesIdsForSeason(seriesId); + var pathSet = await GetPathSetForSeries(primaryId, extraIds).ConfigureAwait(false); foreach (var uniquePath in pathSet) { // Remove the trailing slash before matching. if (!uniquePath[..^1].EndsWith(partialPath)) continue; - PathToSeriesIdDictionary[path] = seriesId; - SeriesIdToPathDictionary.TryAdd(seriesId, path); + PathToSeriesIdDictionary[path] = primaryId; + SeriesIdToPathDictionary.TryAdd(primaryId, path); - return seriesId; + return primaryId; } } @@ -745,7 +910,6 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, [NotNullWhen(true) return null; seriesId = series.IDs.Shoko.ToString(); - EpisodeIdToSeriesIdDictionary.TryAdd(episodeId, seriesId); return await GetShowInfoForSeries(seriesId).ConfigureAwait(false); } @@ -782,7 +946,9 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, [NotNullWhen(true) Logger.LogTrace("Creating info object for show {GroupName}. (Group={GroupId})", group.Name, groupId); var seriesInGroup = await APIClient.GetSeriesInGroup(groupId).ConfigureAwait(false); - var seasonList = (await Task.WhenAll(seriesInGroup.Select(s => CreateSeasonInfo(s, s.IDs.Shoko.ToString()))).ConfigureAwait(false)).ToList(); + var seasonList = (await Task.WhenAll(seriesInGroup.Select(CreateSeasonInfo)).ConfigureAwait(false)) + .DistinctBy(seasonInfo => seasonInfo.Id) + .ToList(); var length = seasonList.Count; if (Plugin.Instance.Configuration.SeparateMovies) { diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 24c74cf7..d7a66d31 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -343,10 +343,16 @@ public virtual string PrettyUrl public bool EXPERIMENTAL_SplitThenMergeEpisodes { get; set; } /// <summary> - /// Coming soon™. + /// 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> public bool EXPERIMENTAL_MergeSeasons { get; set; } + /// <summary> + /// Number of days to check between the start of each season, inclusive. + /// </summary> + /// <value></value> + public int EXPERIMENTAL_MergeSeasonsMergeWindowInDays { get; set; } + #endregion public PluginConfiguration() @@ -428,5 +434,6 @@ public PluginConfiguration() EXPERIMENTAL_SplitThenMergeMovies = true; EXPERIMENTAL_SplitThenMergeEpisodes = false; EXPERIMENTAL_MergeSeasons = false; + EXPERIMENTAL_MergeSeasonsMergeWindowInDays = 185; } } diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 94f17816..7e58a7f2 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -764,9 +764,9 @@ <h3>Experimental Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_MergeSeasons" /> - <span>Automatically merge half seasons</span> + <span>Automatically merge seasons</span> </label> - <div class="fieldDescription checkboxFieldDescription"><div>Coming soon™ to a media library near you.</div></div> + <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> diff --git a/Shokofin/Events/EventDispatchService.cs b/Shokofin/Events/EventDispatchService.cs index aa893240..5d7ceaa8 100644 --- a/Shokofin/Events/EventDispatchService.cs +++ b/Shokofin/Events/EventDispatchService.cs @@ -378,7 +378,8 @@ private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEv var filteredSeriesIds = new HashSet<string>(); foreach (var seriesId in seriesIds) { - var seriesPathSet = await ApiManager.GetPathSetForSeries(seriesId); + var (primaryId, extraIds) = await ApiManager.GetSeriesIdsForSeason(seriesId); + var seriesPathSet = await ApiManager.GetPathSetForSeries(primaryId, extraIds); if (seriesPathSet.Count > 0) { filteredSeriesIds.Add(seriesId); } @@ -547,7 +548,7 @@ await show.RefreshMetadata(new(DirectoryService) { .Select(e => e.SeriesId!.Value.ToString()) .ToHashSet(); var seasonList = showInfo.SeasonList - .Where(seasonInfo => seasonIds.Contains(seasonInfo.Id)) + .Where(seasonInfo => seasonIds.Contains(seasonInfo.Id) || seasonIds.Overlaps(seasonInfo.ExtraIds)) .ToList(); foreach (var seasonInfo in seasonList) { var seasons = LibraryManager @@ -561,7 +562,7 @@ await show.RefreshMetadata(new(DirectoryService) { ) .ToList(); foreach (var season in seasons) { - Logger.LogInformation("Refreshing season {SeasonName}. (Season={SeasonId},Series={SeriesId})", season.Name, season.Id, seasonInfo.Id); + Logger.LogInformation("Refreshing season {SeasonName}. (Season={SeasonId},Series={SeriesId},ExtraSeries={ExtraIds})", season.Name, season.Id, seasonInfo.Id, seasonInfo.ExtraIds); await season.RefreshMetadata(new(DirectoryService) { MetadataRefreshMode = MetadataRefreshMode.FullRefresh, ImageRefreshMode = MetadataRefreshMode.FullRefresh, @@ -635,7 +636,7 @@ private async Task<int> ProcessMovieEvents(SeasonInfo seasonInfo, List<IMetadata ) .ToList(); foreach (var movie in movies) { - Logger.LogInformation("Refreshing movie {MovieName}. (Movie={MovieId},Episode={EpisodeId},Series={SeriesId})", movie.Name, movie.Id, episodeInfo.Id, seasonInfo.Id); + Logger.LogInformation("Refreshing movie {MovieName}. (Movie={MovieId},Episode={EpisodeId},Series={SeriesId},ExtraSeries={ExtraIds})", movie.Name, movie.Id, episodeInfo.Id, seasonInfo.Id, seasonInfo.ExtraIds); await movie.RefreshMetadata(new(DirectoryService) { MetadataRefreshMode = MetadataRefreshMode.FullRefresh, ImageRefreshMode = MetadataRefreshMode.FullRefresh, diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 01b65983..fa3f7d89 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -67,7 +67,7 @@ private async Task<MetadataResult<BoxSet>> GetShokoSeriesMetadata(BoxSetInfo inf var (displayTitle, alternateTitle) = Text.GetSeasonTitles(season, info.MetadataLanguage); - Logger.LogInformation("Found collection {CollectionName} (Series={SeriesId})", displayTitle, season.Id); + Logger.LogInformation("Found collection {CollectionName} (Series={SeriesId},ExtraSeries={ExtraIds})", displayTitle, season.Id, season.ExtraIds); result.Item = new BoxSet { Name = displayTitle, diff --git a/Shokofin/Providers/CustomEpisodeProvider.cs b/Shokofin/Providers/CustomEpisodeProvider.cs index 2a1cf862..00bd86d4 100644 --- a/Shokofin/Providers/CustomEpisodeProvider.cs +++ b/Shokofin/Providers/CustomEpisodeProvider.cs @@ -110,7 +110,7 @@ public static bool AddVirtualEpisode(ILibraryManager libraryManager, ILogger log var episodeId = libraryManager.GetNewItemId(season.Series.Id + " Season " + seasonInfo.Id + " Episode " + episodeInfo.Id, typeof(Episode)); var episode = EpisodeProvider.CreateMetadata(showInfo, seasonInfo, episodeInfo, season, episodeId); - logger.LogInformation("Adding virtual Episode {EpisodeNumber} in Season {SeasonNumber} for Series {SeriesName}. (Episode={EpisodeId},Series={SeriesId},Group={GroupId})", episode.IndexNumber, season.IndexNumber, showInfo.Name, episodeInfo.Id, seasonInfo.Id, showInfo.GroupId); + logger.LogInformation("Adding virtual Episode {EpisodeNumber} in Season {SeasonNumber} for Series {SeriesName}. (Episode={EpisodeId},Series={SeriesId},ExtraSeries={ExtraIds},Group={GroupId})", episode.IndexNumber, season.IndexNumber, showInfo.Name, episodeInfo.Id, seasonInfo.Id, seasonInfo.ExtraIds, showInfo.GroupId); season.AddChild(episode); diff --git a/Shokofin/Providers/CustomSeasonProvider.cs b/Shokofin/Providers/CustomSeasonProvider.cs index 88fe0b0d..63dd8017 100644 --- a/Shokofin/Providers/CustomSeasonProvider.cs +++ b/Shokofin/Providers/CustomSeasonProvider.cs @@ -103,7 +103,7 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio // Add missing episodes. if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { foreach (var sI in showInfo.SeasonList) { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(sI.Id)) + foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(sI)) existingEpisodes.Add(episodeId); foreach (var episodeInfo in sI.SpecialsList) { @@ -155,7 +155,7 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio // Add missing episodes. if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) + foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) existingEpisodes.Add(episodeId); foreach (var episodeInfo in episodeList) { @@ -258,7 +258,7 @@ private static bool SeasonExists(ILibraryManager libraryManager, ILogger logger, var seasonId = libraryManager.GetNewItemId(series.Id + "Season " + seasonNumber.ToString(System.Globalization.CultureInfo.InvariantCulture), typeof(Season)); var season = SeasonProvider.CreateMetadata(seasonInfo, seasonNumber, offset, series, seasonId); - logger.LogInformation("Adding virtual Season {SeasonNumber} to Series {SeriesName}. (Series={SeriesId})", seasonNumber, series.Name, seasonInfo.Id); + logger.LogInformation("Adding virtual Season {SeasonNumber} to Series {SeriesName}. (Series={SeriesId},ExtraSeries={ExtraIds})", seasonNumber, series.Name, seasonInfo.Id, seasonInfo.ExtraIds); series.AddChild(season); diff --git a/Shokofin/Providers/CustomSeriesProvider.cs b/Shokofin/Providers/CustomSeriesProvider.cs index 333e4b5e..0a080def 100644 --- a/Shokofin/Providers/CustomSeriesProvider.cs +++ b/Shokofin/Providers/CustomSeriesProvider.cs @@ -142,7 +142,7 @@ public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptio // Add missing episodes. if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) foreach (var seasonInfo in showInfo.SeasonList) { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) + foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) existingEpisodes.Add(episodeId); foreach (var episodeInfo in seasonInfo.SpecialsList) { @@ -195,7 +195,7 @@ public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptio // Add missing episodes. if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { - foreach (var episodeId in ApiManager.GetLocalEpisodeIdsForSeries(seasonInfo.Id)) + foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) existingEpisodes.Add(episodeId); foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList)) { diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index c8b2b220..cf815bbc 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -78,7 +78,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell } result.Item = CreateMetadata(showInfo, seasonInfo, episodeInfo, fileInfo, info.MetadataLanguage); - Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},Group={GroupId})", result.Item.Name, fileInfo?.Id, episodeInfo.Id, seasonInfo.Id, showInfo?.GroupId); + 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; diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 03ceddfc..8955a8db 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -49,7 +49,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio } var (displayTitle, alternateTitle) = Text.GetMovieTitles(episode, season, info.MetadataLanguage); - Logger.LogInformation("Found movie {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId})", displayTitle, file.Id, episode.Id, season.Id); + 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"; diff --git a/Shokofin/Providers/VideoProvider.cs b/Shokofin/Providers/VideoProvider.cs index bf22b9da..8f1d337e 100644 --- a/Shokofin/Providers/VideoProvider.cs +++ b/Shokofin/Providers/VideoProvider.cs @@ -61,7 +61,7 @@ public async Task<MetadataResult<Video>> GetMetadata(ItemLookupInfo info, Cancel Overview = description, CommunityRating = episodeInfo.AniDB.Rating.Value > 0 ? episodeInfo.AniDB.Rating.ToFloat(10) : 0, }; - Logger.LogInformation("Found video {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},Group={GroupId})", result.Item.Name, fileInfo.Id, episodeInfo.Id, seasonInfo.Id, showInfo?.GroupId); + Logger.LogInformation("Found video {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; diff --git a/Shokofin/Resolvers/ShokoIgnoreRule.cs b/Shokofin/Resolvers/ShokoIgnoreRule.cs index 33bed998..7a29b7be 100644 --- a/Shokofin/Resolvers/ShokoIgnoreRule.cs +++ b/Shokofin/Resolvers/ShokoIgnoreRule.cs @@ -132,7 +132,7 @@ private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPa foreach (var entry in entries) { season = await ApiManager.GetSeasonInfoByPath(entry.FullName).ConfigureAwait(false); if (season is not null) { - Logger.LogDebug("Found shoko series {SeriesName} for sub-directory of path {Path} (Series={SeriesId})", season.Shoko.Name, partialPath, season.Id); + Logger.LogDebug("Found shoko series {SeriesName} for sub-directory of path {Path} (Series={SeriesId},ExtraSeries={ExtraIds})", season.Shoko.Name, partialPath, season.Id, season.ExtraIds); break; } } @@ -153,13 +153,13 @@ private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPa switch (collectionType) { case CollectionType.TvShows: if (isMovieSeason && Plugin.Instance.Configuration.SeparateMovies) { - Logger.LogInformation("Found movie in show library and library separation is enabled, ignoring shoko series. (Series={SeriesId})", season.Id); + Logger.LogInformation("Found movie in show library and library separation is enabled, ignoring shoko series. (Series={SeriesId},ExtraSeries={ExtraIds})", season.Id, season.ExtraIds); return true; } break; case CollectionType.Movies: if (!isMovieSeason) { - Logger.LogInformation("Found show in movie library, ignoring shoko series. (Series={SeriesId})", season.Id); + Logger.LogInformation("Found show in movie library, ignoring shoko series. (Series={SeriesId},ExtraSeries={ExtraIds})", season.Id, season.ExtraIds); return true; } break; @@ -167,9 +167,9 @@ private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPa var show = await ApiManager.GetShowInfoForSeries(season.Id).ConfigureAwait(false)!; if (!string.IsNullOrEmpty(show!.GroupId)) - Logger.LogInformation("Found shoko group {GroupName} (Series={SeriesId},Group={GroupId})", show.Name, season.Id, show.GroupId); + Logger.LogInformation("Found shoko group {GroupName} (Series={SeriesId},ExtraSeries={ExtraIds},Group={GroupId})", show.Name, season.Id, season.ExtraIds, show.GroupId); else - Logger.LogInformation("Found shoko series {SeriesName} (Series={SeriesId})", season.Shoko.Name, season.Id); + Logger.LogInformation("Found shoko series {SeriesName} (Series={SeriesId},ExtraSeries={ExtraIds})", season.Shoko.Name, season.Id, season.ExtraIds); return false; } @@ -187,11 +187,11 @@ private async Task<bool> ShouldFilterFile(string partialPath, string fullPath, b return shouldIgnore; } - Logger.LogInformation("Found {EpisodeCount} shoko episode(s) for {SeriesName} (Series={SeriesId},File={FileId})", file.EpisodeList.Count, season.Shoko.Name, season.Id, file.Id); + Logger.LogInformation("Found {EpisodeCount} shoko episode(s) for {SeriesName} (Series={SeriesId},ExtraSeries={ExtraIds},File={FileId})", file.EpisodeList.Count, season.Shoko.Name, season.Id, season.ExtraIds, file.Id); // We're going to post process this file later, but we don't want to include it in our library for now. if (file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode))) { - Logger.LogInformation("File was assigned an extra type, ignoring file. (Series={SeriesId},File={FileId})", season.Id, file.Id); + Logger.LogInformation("File was assigned an extra type, ignoring file. (Series={SeriesId},ExtraSeries={ExtraIds},File={FileId})", season.Id, season.ExtraIds, file.Id); return true; } diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index ad04d2b1..e4075c63 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -329,12 +329,12 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) ); var episodeIds = seasonInfo.ExtrasList.Select(episode => episode.Id).Append(episodeId).ToHashSet(); - var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); + var files = ApiManager.GetFilesForSeason(seasonInfo).ConfigureAwait(false).GetAwaiter().GetResult(); var fileLocations = files - .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) - .SelectMany(file => file.Locations.Select(location => (file, location))) + .Where(tuple => tuple.file.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) + .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) .ToList(); - foreach (var (file, location) in fileLocations) { + foreach (var (file, fileSeriesId, location) in fileLocations) { if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) continue; @@ -343,7 +343,7 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) continue; totalFiles++; - yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); + yield return (sourceLocation, file.Id.ToString(), fileSeriesId); } var timeSpent = DateTime.UtcNow - start; Logger.LogDebug( @@ -380,12 +380,12 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) if (seasonNumber.Value is 0) { foreach (var seasonInfo in showInfo.SeasonList) { var episodeIds = seasonInfo.SpecialsList.Select(episode => episode.Id).ToHashSet(); - var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); + var files = ApiManager.GetFilesForSeason(seasonInfo).ConfigureAwait(false).GetAwaiter().GetResult(); var fileLocations = files - .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) - .SelectMany(file => file.Locations.Select(location => (file, location))) + .Where(tuple => tuple.file.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) + .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) .ToList(); - foreach (var (file, location) in fileLocations) { + foreach (var (file, fileSeriesId, location) in fileLocations) { if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) continue; @@ -394,7 +394,7 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) continue; totalFiles++; - yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); + yield return (sourceLocation, file.Id.ToString(), fileSeriesId); } } } @@ -405,12 +405,12 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) var baseNumber = showInfo.GetBaseSeasonNumberForSeasonInfo(seasonInfo); var offset = seasonNumber.Value - baseNumber; var episodeIds = (offset is 0 ? seasonInfo.EpisodeList.Concat(seasonInfo.ExtrasList) : seasonInfo.AlternateEpisodesList).Select(episode => episode.Id).ToHashSet(); - var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); + var files = ApiManager.GetFilesForSeason(seasonInfo).ConfigureAwait(false).GetAwaiter().GetResult(); var fileLocations = files - .Where(files => files.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) - .SelectMany(file => file.Locations.Select(location => (file, location))) + .Where(tuple => tuple.file.CrossReferences.Any(xref => episodeIds.Overlaps(xref.Episodes.Where(e => e.Shoko.HasValue).Select(e => e.Shoko!.Value.ToString())))) + .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) .ToList(); - foreach (var (file, location) in fileLocations) { + foreach (var (file, fileSeriesId, location) in fileLocations) { if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) continue; @@ -419,7 +419,7 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) continue; totalFiles++; - yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); + yield return (sourceLocation, file.Id.ToString(), fileSeriesId); } } } @@ -427,11 +427,11 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) // Return all files for the show. else { foreach (var seasonInfo in showInfo.SeasonList) { - var files = ApiClient.GetFilesForSeries(seasonInfo.Id).ConfigureAwait(false).GetAwaiter().GetResult(); + var files = ApiManager.GetFilesForSeason(seasonInfo).ConfigureAwait(false).GetAwaiter().GetResult(); var fileLocations = files - .SelectMany(file => file.Locations.Select(location => (file, location))) + .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) .ToList(); - foreach (var (file, location) in fileLocations) { + foreach (var (file, fileSeriesId, location) in fileLocations) { if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) continue; @@ -440,7 +440,7 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) continue; totalFiles++; - yield return (sourceLocation, file.Id.ToString(), seasonInfo.Id); + yield return (sourceLocation, file.Id.ToString(), fileSeriesId); } } } @@ -635,7 +635,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { if (shouldAbort) return (string.Empty, Array.Empty<string>(), null); - var show = await ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false); + var show = await ApiManager.GetShowInfoForSeries(season.Id).ConfigureAwait(false); if (show is null) return (string.Empty, Array.Empty<string>(), null); diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index 62cc3524..dd6eb544 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -124,10 +124,10 @@ public static int GetEpisodeNumber(ShowInfo showInfo, SeasonInfo seasonInfo, Epi if (seasonInfo.IsExtraEpisode(episodeInfo)) { var seasonIndex = showInfo.SeasonList.FindIndex(s => string.Equals(s.Id, seasonInfo.Id)); if (seasonIndex == -1) - throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={showInfo.GroupId},Series={seasonInfo.Id},Episode={episodeInfo.Id})"); + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={showInfo.GroupId},Series={seasonInfo.Id},ExtraSeries={seasonInfo.ExtraIds},Episode={episodeInfo.Id})"); index = seasonInfo.ExtrasList.FindIndex(e => string.Equals(e.Id, episodeInfo.Id)); if (index == -1) - throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={showInfo.GroupId},Series={seasonInfo.Id},Episode={episodeInfo.Id})"); + throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={showInfo.GroupId},Series={seasonInfo.Id},ExtraSeries={seasonInfo.ExtraIds},Episode={episodeInfo.Id})"); offset = showInfo.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.ExtrasList.Count); return offset + index + 1; } @@ -135,10 +135,10 @@ public static int GetEpisodeNumber(ShowInfo showInfo, SeasonInfo seasonInfo, Epi if (showInfo.IsSpecial(episodeInfo)) { var seasonIndex = showInfo.SeasonList.FindIndex(s => string.Equals(s.Id, seasonInfo.Id)); if (seasonIndex == -1) - throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={showInfo.GroupId},Series={seasonInfo.Id},Episode={episodeInfo.Id})"); + throw new System.IndexOutOfRangeException($"Series is not part of the provided group. (Group={showInfo.GroupId},Series={seasonInfo.Id},ExtraSeries={seasonInfo.ExtraIds},Episode={episodeInfo.Id})"); index = seasonInfo.SpecialsList.FindIndex(e => string.Equals(e.Id, episodeInfo.Id)); if (index == -1) - throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={showInfo.GroupId},Series={seasonInfo.Id},Episode={episodeInfo.Id})"); + throw new System.IndexOutOfRangeException($"Episode not in the filtered specials list. (Group={showInfo.GroupId},Series={seasonInfo.Id},ExtraSeries={seasonInfo.ExtraIds},Episode={episodeInfo.Id})"); offset = showInfo.SeasonList.GetRange(0, seasonIndex).Aggregate(0, (count, series) => count + series.SpecialsList.Count); return offset + index + 1; } @@ -150,7 +150,7 @@ public static int GetEpisodeNumber(ShowInfo showInfo, SeasonInfo seasonInfo, Epi // If we still cannot find the episode for whatever reason, then bail. I don't fudging know why, but I know it's not the plugin's fault. if (index == -1) - throw new IndexOutOfRangeException($"Unable to find index to use for \"{episodeInfo.Shoko.Name}\". (Episode={episodeInfo.Id},Series={seasonInfo.Id})"); + throw new IndexOutOfRangeException($"Unable to find index to use for \"{episodeInfo.Shoko.Name}\". (Episode={episodeInfo.Id},Series={seasonInfo.Id},ExtraSeries={seasonInfo.ExtraIds})"); return index + 1; } From 7f06b05b9a4ab94032f98aa3bac4e9614ac7406f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 6 Jun 2024 19:20:32 +0200 Subject: [PATCH 1048/1103] refactor: overhaul tag internals + add content ratings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Overhauled the internals for how we processed tags. We now use the tag hierarchy to filter it locally instead of the flat lists used on the Shoko Server side, which allows us to avoid magical numbers and also gives us more control when searching for tags in the hierarchy. - Custom tag support have also been overhauled to support a _fake_ hierarchy by using `/` (forward slash) as a separator between levels in the tag name. All top level custom tags without "child" tags are added to the list if custom tags have been enabled, while all tags in the second level or below are treated as if they are of the AniDB hierarchy and will follow the other enabled/disabled tag sources, e.g. if you enable "elements" tags then it will also affect any custom tags starting with "elements/". This overhauled support for custom tags also allows us to better support the next item on the list. - Added _assumed_ content ratings based on the AniDB tag hierarchy. This will use the `content indicators` tags and `elements` tags in the hierarchy to make an assumption of the content rating for the AniDB anime using the following content indicators; - TV-G - TV-Y - TV-Y7 (optionally with the FV indicator applied) - TV-PG (optionally with D, L, S, and/or V indicators applied) - TV-14 (optionally with D, L, S, and/or V indicators applied) - TV-MA (optionally with L, S, and/or V indicators applied) - XXX (only for H) We also support custom overrides through custom tags in the form of the regular expressions (regex); ```txt /^\/?target audience\/(?:TV)?[_\-]?(?:G|Y|Y7|PG|14|MA|XXX)[_\-]?(FV|D|S|L|V){1,5}$/i ``` A few examples that all resolve to the `TV-PG` rating if you can't read regex; - `target audience/TV-PG` → `TV-PG` - `Target Audience/TV-PG-DV` → `TV-PG` + `D` & `V` indicators applied - `Target Audience/TV_PG_ld` → `TV-PG` + `D` & `L` indicators applied - `/target audience/pgV` → `TV-PG` + `V` indicator applied - `/TARGET AUDIENCE/tvPGsl` → `TV-PG` + `L` & `S` indicators applied Where you will override the content rating for the series if the custom tag is applied in Shoko Server/Shoko Desktop. - Added production locations based on the AniDB tag hierarchy. This is much simpler, as it's a 1:N mapping for the relevant AniDB tags to production locations. - Overhauled the settings page to accommodate the new settings added in this commit under the Metadata section while also removing the old Tags section. In case it wasn't obvious, **THIS IS A BREAKING CHANGE**. 🙂 **Note**: TMDB content ratings and production locations will be usable when when the data is available in Shoko Server. --- .vscode/settings.json | 7 + Shokofin/API/Info/SeasonInfo.cs | 8 +- Shokofin/API/Info/ShowInfo.cs | 13 +- Shokofin/API/Models/Tag.cs | 62 +- Shokofin/API/ShokoAPIManager.cs | 207 +++-- Shokofin/Configuration/PluginConfiguration.cs | 107 ++- Shokofin/Configuration/configController.js | 495 ++++++----- Shokofin/Configuration/configPage.html | 821 ++++++++++++++---- Shokofin/ListExtensions.cs | 18 + Shokofin/Providers/EpisodeProvider.cs | 5 +- Shokofin/Providers/ImageProvider.cs | 55 +- Shokofin/Providers/MovieProvider.cs | 2 + Shokofin/Providers/SeasonProvider.cs | 4 + Shokofin/Providers/SeriesProvider.cs | 3 +- Shokofin/Utils/ContentRating.cs | 350 ++++++++ Shokofin/Utils/Ordering.cs | 9 +- Shokofin/Utils/TagFilter.cs | 269 ++++++ 17 files changed, 1951 insertions(+), 484 deletions(-) create mode 100644 Shokofin/ListExtensions.cs create mode 100644 Shokofin/Utils/ContentRating.cs create mode 100644 Shokofin/Utils/TagFilter.cs diff --git a/.vscode/settings.json b/.vscode/settings.json index c0641e12..234a5bdb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,7 @@ "automagically", "boxset", "dlna", + "ecchi", "emby", "eroge", "fanart", @@ -20,11 +21,14 @@ "imdbid", "interrobang", "jellyfin", + "josei", "jprm", + "kodomo", "koma", "linkbutton", "manhua", "manhwa", + "mina", "nfo", "nfos", "outro", @@ -32,9 +36,12 @@ "scrobble", "scrobbled", "scrobbling", + "seinen", "seiyuu", "shoko", "shokofin", + "shoujo", + "shounen", "signalr", "tmdb", "tvshow", diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index f485279e..abe58c29 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -34,10 +34,14 @@ public class SeasonInfo /// </summary> public readonly DateTime? LastImportedAt; + public readonly string? AssumedContentRating; + public readonly IReadOnlyList<string> Tags; public readonly IReadOnlyList<string> Genres; + public readonly IReadOnlyList<string> ProductionLocations; + public readonly IReadOnlyList<string> Studios; public readonly IReadOnlyList<PersonInfo> Staff; @@ -93,7 +97,7 @@ public class SeasonInfo /// </summary> public readonly IReadOnlyDictionary<string, RelationType> RelationMap; - public SeasonInfo(Series series, IEnumerable<string> extraIds, DateTime? earliestImportedAt, DateTime? lastImportedAt, List<EpisodeInfo> episodes, List<Role> cast, List<Relation> relations, string[] genres, string[] tags) + public SeasonInfo(Series series, IEnumerable<string> extraIds, DateTime? earliestImportedAt, DateTime? lastImportedAt, List<EpisodeInfo> episodes, List<Role> cast, List<Relation> relations, string[] genres, string[] tags, string[] productionLocations, string? contentRating) { var seriesId = series.IDs.Shoko.ToString(); var studios = cast @@ -215,8 +219,10 @@ public SeasonInfo(Series series, IEnumerable<string> extraIds, DateTime? earlies Type = type; EarliestImportedAt = earliestImportedAt; LastImportedAt = lastImportedAt; + AssumedContentRating = contentRating; Tags = tags; Genres = genres; + ProductionLocations = productionLocations; Studios = studios; Staff = staff; RawEpisodeList = episodes; diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index 7c3c4201..9fa56ac8 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -67,12 +67,6 @@ public class ShowInfo .OrderBy(s => s) .LastOrDefault(); - /// <summary> - /// Overall content rating of the show. - /// </summary> - public string? OfficialRating => - DefaultSeason.AniDB.Restricted ? "XXX" : null; - /// <summary> /// Custom rating of the show. /// </summary> @@ -107,6 +101,11 @@ public class ShowInfo /// </summary> public readonly IReadOnlyList<string> Genres; + /// <summary> + /// All production locations from across all seasons. + /// </summary> + public readonly IReadOnlyList<string> ProductionLocations; + /// <summary> /// All studios from across all seasons. /// </summary> @@ -179,6 +178,7 @@ public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) LastImportedAt = seasonInfo.LastImportedAt; Tags = seasonInfo.Tags; Genres = seasonInfo.Genres; + ProductionLocations = seasonInfo.ProductionLocations; Studios = seasonInfo.Studios; Staff = seasonInfo.Staff; SeasonList = new List<SeasonInfo>() { seasonInfo }; @@ -253,6 +253,7 @@ public ShowInfo(Group group, List<SeasonInfo> seasonList, ILogger logger, bool u LastImportedAt = seasonList.Select(seasonInfo => seasonInfo.LastImportedAt).Max(); Tags = seasonList.SelectMany(s => s.Tags).Distinct().ToArray(); Genres = seasonList.SelectMany(s => s.Genres).Distinct().ToArray(); + ProductionLocations = seasonList.SelectMany(s => s.ProductionLocations).Distinct().ToArray(); Studios = seasonList.SelectMany(s => s.Studios).Distinct().ToArray(); Staff = seasonList.SelectMany(s => s.Staff).DistinctBy(p => new { p.Type, p.Name, p.Role }).ToArray(); SeasonList = seasonList; diff --git a/Shokofin/API/Models/Tag.cs b/Shokofin/API/Models/Tag.cs index 796fbed5..52729421 100644 --- a/Shokofin/API/Models/Tag.cs +++ b/Shokofin/API/Models/Tag.cs @@ -1,6 +1,10 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Text.Json.Serialization; +using TagWeight = Shokofin.Utils.TagFilter.TagWeight; + namespace Shokofin.API.Models; public class Tag @@ -40,7 +44,8 @@ public class Tag /// <summary> /// True if the tag is considered a spoiler for all series it appears on. /// </summary> - public bool IsSpoiler { get; set; } + [JsonPropertyName("IsSpoiler")] + public bool IsGlobalSpoiler { get; set; } /// <summary> /// True if the tag is considered a spoiler for that particular series it is @@ -51,7 +56,7 @@ public class Tag /// <summary> /// How relevant is it to the series /// </summary> - public int? Weight { get; set; } + public TagWeight? Weight { get; set; } /// <summary> /// When the tag info was last updated. @@ -63,3 +68,56 @@ public class Tag /// </summary> public string Source { get; set; } = string.Empty; } + +public class ResolvedTag : Tag +{ + private string? _fullName = null; + + public string FullName => _fullName ??= Namespace + Name; + + public bool IsParent => Children.Count is > 0; + + public bool IsWeightless => Children.Count is 0 && Weight is 0; + + /// <summary> + /// True if the tag is considered a spoiler for that particular series it is + /// set on. + /// </summary> + public new bool IsLocalSpoiler; + + /// <summary> + /// How relevant is it to the series + /// </summary> + public new TagWeight Weight; + + public string Namespace; + + public ResolvedTag? Parent; + + public IReadOnlyDictionary<string, ResolvedTag> Children; + + public IReadOnlyDictionary<string, ResolvedTag> RecursiveNamespacedChildren; + + public ResolvedTag(Tag tag, ResolvedTag? parent, Func<string, int, IEnumerable<Tag>?> getChildren, string ns = "/") + { + Id = tag.Id; + ParentId = parent?.Id; + Name = tag.Name; + Description = tag.Description; + IsVerified = tag.IsVerified; + IsGlobalSpoiler = tag.IsGlobalSpoiler || (parent?.IsGlobalSpoiler ?? false); + IsLocalSpoiler = tag.IsLocalSpoiler ?? parent?.IsLocalSpoiler ?? false; + Weight = tag.Weight ?? TagWeight.Weightless; + LastUpdated = tag.LastUpdated; + Source = tag.Source; + Namespace = ns; + Parent = parent; + Children = (getChildren(Source, Id) ?? Array.Empty<Tag>()) + .DistinctBy(childTag => childTag.Name) + .Select(childTag => new ResolvedTag(childTag, this, getChildren, ns + tag.Name + "/")) + .ToDictionary(childTag => childTag.Name); + RecursiveNamespacedChildren = Children.Values + .SelectMany(childTag => childTag.RecursiveNamespacedChildren.Values.Prepend(childTag)) + .ToDictionary(childTag => childTag.FullName[(ns.Length + Name.Length)..]); + } +} \ No newline at end of file diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 839f902a..61e40c38 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -166,93 +166,140 @@ public void Clear() } #endregion - #region Tags And Genres + #region Tags, Genres, And Content Ratings - private async Task<string[]> GetTagsForSeries(string seriesId) - { - return (await APIClient.GetSeriesTags(seriesId, GetTagFilter()).ConfigureAwait(false)) - .Where(KeepTag) - .Select(SelectTagName) - .ToArray(); - } - - /// <summary> - /// Get the tag filter - /// </summary> - /// <returns></returns> - private static ulong GetTagFilter() - { - var config = Plugin.Instance.Configuration; - ulong filter = 132L; // We exclude genres and source by default - - if (config.HideAniDbTags) filter |= 1 << 0; - if (config.HideArtStyleTags) filter |= 1 << 1; - if (config.HideMiscTags) filter |= 1 << 3; - if (config.HideSettingTags) filter |= 1 << 5; - if (config.HideProgrammingTags) filter |= 1 << 6; + public Task<IReadOnlyDictionary<string, ResolvedTag>> GetNamespacedTagsForSeries(string seriesId) + => DataCache.GetOrCreateAsync( + $"series-linked-tags:{seriesId}", + async (_) => { + var nextUserTagId = 1; + var hasCustomTags = false; + var rootTags = new List<Tag>(); + var tagMap = new Dictionary<string, List<Tag>>(); + var tags = (await APIClient.GetSeriesTags(seriesId).ConfigureAwait(false)) + .OrderBy(tag => tag.Source) + .ThenBy(tag => tag.Source == "User" ? tag.Name.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Length : 0) + .ToList(); + foreach (var tag in tags) { + if (Plugin.Instance.Configuration.HideUnverifiedTags && tag.IsVerified.HasValue && !tag.IsVerified.Value) + continue; - return filter; - } + switch (tag.Source) { + case "AniDB": { + var parentKey = $"{tag.Source}:{tag.ParentId ?? 0}"; + if (!tag.ParentId.HasValue) { + rootTags.Add(tag); + continue; + } + if (!tagMap.TryGetValue(parentKey, out var list)) + tagMap[parentKey] = list = new(); + // Remove comment on tag name itself. + if (tag.Name.Contains("--")) + tag.Name = tag.Name.Split("--").First().Trim(); + list.Add(tag); + break; + } + case "User": { + if (!hasCustomTags) { + rootTags.Add(new() { + Id = 0, + Name = "custom user tags", + Description = string.Empty, + IsVerified = true, + IsGlobalSpoiler = false, + IsLocalSpoiler = false, + LastUpdated = DateTime.UnixEpoch, + Source = "Shokofin", + }); + hasCustomTags = true; + } + var parentNames = tag.Name.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + tag.Name = parentNames.Last(); + parentNames.RemoveAt(parentNames.Count - 1); + var customTagsRoot = rootTags.First(tag => tag.Source == "Shokofin" && tag.Id == 0); + var lastParentTag = customTagsRoot; + while (parentNames.Count > 0) { + // Take the first element from the list. + if (!parentNames.TryRemoveAt(0, out var name)) + break; + + // Make sure the parent's children exists in our map. + var parentKey = $"Shokofin:{lastParentTag.Id}"; + if (!tagMap!.TryGetValue(parentKey, out var children)) + tagMap[parentKey] = children = new(); + + // Add the child tag to the parent's children if needed. + var childTag = children.Find(t => string.Equals(name, t.Name, StringComparison.InvariantCultureIgnoreCase)); + if (childTag is null) + children.Add(childTag = new() { + Id = nextUserTagId++, + ParentId = lastParentTag.Id, + Name = name.ToLowerInvariant(), + IsVerified = true, + Description = string.Empty, + IsGlobalSpoiler = false, + IsLocalSpoiler = false, + LastUpdated = customTagsRoot.LastUpdated, + Source = "Shokofin", + }); + + // Switch to the child tag for the next parent name. + lastParentTag = childTag; + }; + + // Same as above, but for the last parent, be it the root or any other layer. + var lastParentKey = $"Shokofin:{lastParentTag.Id}"; + if (!tagMap!.TryGetValue(lastParentKey, out var lastChildren)) + tagMap[lastParentKey] = lastChildren = new(); + + if (!lastChildren.Any(childTag => string.Equals(childTag.Name, tag.Name, StringComparison.InvariantCultureIgnoreCase))) + lastChildren.Add(new() { + Id = nextUserTagId++, + ParentId = lastParentTag.Id, + Name = tag.Name, + Description = tag.Description, + IsVerified = tag.IsVerified, + IsGlobalSpoiler = tag.IsGlobalSpoiler, + IsLocalSpoiler = tag.IsLocalSpoiler, + Weight = tag.Weight, + LastUpdated = tag.LastUpdated, + Source = "Shokofin", + }); + break; + } + } + } + List<Tag>? getChildren(string source, int id) => tagMap.TryGetValue($"{source}:{id}", out var list) ? list : null; + return rootTags + .Select(tag => new ResolvedTag(tag, null, getChildren)) + .SelectMany(tag => tag.RecursiveNamespacedChildren.Values.Prepend(tag)) + .OrderBy(tag => tag.FullName) + .ToDictionary(childTag => childTag.FullName) as IReadOnlyDictionary<string, ResolvedTag>; + } + ); - public async Task<string[]> GetGenresForSeries(string seriesId) + private async Task<string[]> GetTagsForSeries(string seriesId) { - // The following magic number is the filter value to allow only genres in the returned list. - var genreSet = (await APIClient.GetSeriesTags(seriesId, 2147483776).ConfigureAwait(false)) - .Select(SelectTagName) - .ToHashSet(); - var sourceGenre = await GetSourceGenre(seriesId).ConfigureAwait(false); - genreSet.Add(sourceGenre); - return genreSet.ToArray(); + var tags = await GetNamespacedTagsForSeries(seriesId); + return TagFilter.FilterTags(tags); } - private async Task<string> GetSourceGenre(string seriesId) + private async Task<string[]> GetGenresForSeries(string seriesId) { - // The following magic number is the filter value to allow only the source type in the returned list. - return(await APIClient.GetSeriesTags(seriesId, 2147483652).ConfigureAwait(false))?.FirstOrDefault()?.Name?.ToLowerInvariant() switch { - "american derived" => "Adapted From Western Media", - "cartoon" => "Adapted From Western Media", - "comic book" => "Adapted From Western Media", - "4-koma" => "Adapted From A Manga", - "manga" => "Adapted From A Manga", - "4-koma manga" => "Adapted From A Manga", - "manhua" => "Adapted From A Manhua", - "manhwa" => "Adapted from a Manhwa", - "movie" => "Adapted From A Movie", - "novel" => "Adapted From A Light/Web Novel", - "rpg" => "Adapted From A Video Game", - "action game" => "Adapted From A Video Game", - "game" => "Adapted From A Video Game", - "erotic game" => "Adapted From An Eroge", - "korean drama" => "Adapted From A Korean Drama", - "television programme" => "Adapted From A Live-Action Show", - "visual novel" => "Adapted From A Visual Novel", - "fan-made" => "Fan-Made", - "remake" => "Remake", - "radio programme" => "Radio Programme", - "biographical film" => "Original Work", - "original work" => "Original Work", - "new" => "Original Work", - "ultra jump" => "Original Work", - _ => "Original Work", - }; + var tags = await GetNamespacedTagsForSeries(seriesId); + return TagFilter.FilterGenres(tags); } - private bool KeepTag(Tag tag) + private async Task<string[]> GetProductionLocations(string seriesId) { - // Filter out unverified tags. - if (Plugin.Instance.Configuration.HideUnverifiedTags && tag.IsVerified.HasValue && !tag.IsVerified.Value) - return false; - - // Filter out any and all spoiler tags. - if (Plugin.Instance.Configuration.HidePlotTags && (tag.IsLocalSpoiler ?? tag.IsSpoiler)) - return false; - - return true; + var tags = await GetNamespacedTagsForSeries(seriesId); + return TagFilter.GetProductionCountriesFromTags(tags); } - private string SelectTagName(Tag tag) + private async Task<string?> GetAssumedContentRating(string seriesId) { - return System.Globalization.CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tag.Name); + var tags = await GetNamespacedTagsForSeries(seriesId); + return ContentRating.GetTagBasedContentRating(tags); } #endregion @@ -621,6 +668,7 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) Logger.LogTrace("Creating info object for season {SeriesName}. (Series={SeriesId},ExtraSeries={ExtraIds})", series.Name, seriesId, extraIds); + var contentRating = await GetAssumedContentRating(seriesId).ConfigureAwait(false); var (earliestImportedAt, lastImportedAt) = await GetEarliestImportedAtForSeries(seriesId).ConfigureAwait(false); var episodes = (await Task.WhenAll( extraIds.Prepend(seriesId) @@ -639,6 +687,7 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) var relationsTasks = detailsIds.Select(id => APIClient.GetSeriesRelations(id)); var genresTasks = detailsIds.Select(id => GetGenresForSeries(id)); var tagsTasks = detailsIds.Select(id => GetTagsForSeries(id)); + var productionLocationsTasks = detailsIds.Select(id => GetProductionLocations(id)); // Await the tasks in order. var cast = (await Task.WhenAll(castTasks)) @@ -659,15 +708,21 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) .OrderBy(t => t) .Distinct() .ToArray(); + var productionLocations = (await Task.WhenAll(genresTasks)) + .SelectMany(g => g) + .OrderBy(g => g) + .Distinct() + .ToArray(); // Create the season info using the merged details. - seasonInfo = new SeasonInfo(series, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags); + seasonInfo = new SeasonInfo(series, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags, productionLocations, contentRating); } else { var cast = await APIClient.GetSeriesCast(seriesId).ConfigureAwait(false); var relations = await APIClient.GetSeriesRelations(seriesId).ConfigureAwait(false); var genres = await GetGenresForSeries(seriesId).ConfigureAwait(false); var tags = await GetTagsForSeries(seriesId).ConfigureAwait(false); - seasonInfo = new SeasonInfo(series, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags); + var productionLocations = await GetProductionLocations(seriesId).ConfigureAwait(false); + seasonInfo = new SeasonInfo(series, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags, productionLocations, contentRating); } foreach (var episode in episodes) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index d7a66d31..5761062b 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -12,6 +12,9 @@ using OrderType = Shokofin.Utils.Ordering.OrderType; using ProviderName = Shokofin.Events.Interfaces.ProviderName; using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; +using TagIncludeFilter = Shokofin.Utils.TagFilter.TagIncludeFilter; +using TagSource = Shokofin.Utils.TagFilter.TagSource; +using TagWeight = Shokofin.Utils.TagFilter.TagWeight; using TitleProvider = Shokofin.Utils.Text.TitleProvider; namespace Shokofin.Configuration; @@ -168,20 +171,85 @@ public virtual string PrettyUrl #region Tags - public bool HideArtStyleTags { get; set; } + /// <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> + [JsonConverter(typeof(JsonStringEnumConverter))] + public TagSource TagSources { get; set; } - public bool HideMiscTags { get; set; } + /// <summary> + /// Filter to include tags as tags based on specific criteria. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public TagIncludeFilter TagIncludeFilters { get; set; } + + /// <summary> + /// Minimum weight of tags to be included, except weightless tags, which has their own filtering through <seealso cref="TagIncludeFilter.Weightless"/>. + /// </summary> + public TagWeight TagMinimumWeight { get; set; } - public bool HidePlotTags { get; set; } + /// <summary> + /// Determines if we use the overridden settings for how the genres are set for entries. + /// </summary> + public bool GenreOverride { get; set; } - public bool HideAniDbTags { get; set; } + /// <summary> + /// All tag sources to use for genres. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public TagSource GenreSources { get; set; } - public bool HideSettingTags { get; set; } + /// <summary> + /// Filter to include tags as genres based on specific criteria. + /// </summary> + [JsonConverter(typeof(JsonStringEnumConverter))] + public TagIncludeFilter GenreIncludeFilters { get; set; } - public bool HideProgrammingTags { get; set; } + /// <summary> + /// Minimum weight of tags to be included, except weightless tags, which has their own filtering through <seealso cref="TagIncludeFilter.Weightless"/>. + /// </summary> + public TagWeight GenreMinimumWeight { get; set; } + /// <summary> + /// Hide tags that are not verified by the AniDB moderators yet. + /// </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> + public ProviderName[] ContentRatingList { get; set; } + + /// <summary> + /// The order to go through the content rating providers to retrieve a content rating. + /// </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> + public ProviderName[] ProductionLocationList { get; set; } + + /// <summary> + /// The order to go through the production location providers to retrieve a production location. + /// </summary> + public ProviderName[] ProductionLocationOrder { get; set; } + #endregion #region User @@ -278,7 +346,7 @@ public virtual string PrettyUrl /// <summary> /// Enable/disable the filtering for new media-folders/libraries. /// </summary> - [XmlElement("LibraryFiltering")] + [XmlElement("LibraryFiltering")] public LibraryFilteringMode LibraryFilteringMode { get; set; } /// <summary> @@ -362,13 +430,26 @@ public PluginConfiguration() PublicUrl = string.Empty; Username = "Default"; ApiKey = string.Empty; - HideArtStyleTags = false; - HideMiscTags = false; - HidePlotTags = true; - HideAniDbTags = true; - HideSettingTags = false; - HideProgrammingTags = true; + TagOverride = false; + TagSources = TagSource.Elements | TagSource.Themes | TagSource.Fetishes | TagSource.SettingPlace | TagSource.SettingTimePeriod | TagSource.SettingTimeSeason | TagSource.CustomTags; + TagIncludeFilters = TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Weightless; + TagMinimumWeight = TagWeight.Weightless; + GenreSources = TagSource.SourceMaterial | TagSource.TargetAudience | TagSource.ContentIndicators | TagSource.Elements; + GenreIncludeFilters = TagIncludeFilter.Child | TagIncludeFilter.Weightless; + GenreMinimumWeight = TagWeight.Three; HideUnverifiedTags = true; + ContentRatingOverride = false; + ContentRatingList = new[] { + ProviderName.AniDB, + ProviderName.TMDB, + }; + ContentRatingOrder = ContentRatingList.ToArray(); + ProductionLocationOverride = false; + ProductionLocationList = new[] { + ProviderName.AniDB, + ProviderName.TMDB, + }; + ProductionLocationOrder = ProductionLocationList.ToArray(); TitleAddForMultipleEpisodes = true; SynopsisCleanLinks = true; SynopsisCleanMiscLines = true; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 8fb7ac80..4ea36592 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -48,33 +48,36 @@ function filterReconnectIntervals(value) { return Array.from(filteredSet).sort((a, b) => a - b); } -function adjustSortableListElement(element) { - const btnSortable = element.querySelector(".btnSortable"); - const inner = btnSortable.querySelector(".material-icons"); - - if (element.previousElementSibling) { - btnSortable.title = "Up"; - btnSortable.classList.add("btnSortableMoveUp"); - inner.classList.add("keyboard_arrow_up"); - - btnSortable.classList.remove("btnSortableMoveDown"); - inner.classList.remove("keyboard_arrow_down"); +/** + * + * @param {HTMLElement} element + * @param {number} index + */ +function adjustSortableListElement(element, index) { + const button = element.querySelector(".btnSortable"); + const icon = button.querySelector(".material-icons"); + if (index > 0) { + button.title = "Up"; + button.classList.add("btnSortableMoveUp"); + button.classList.remove("btnSortableMoveDown"); + icon.classList.add("keyboard_arrow_up"); + icon.classList.remove("keyboard_arrow_down"); } else { - btnSortable.title = "Down"; - btnSortable.classList.add("btnSortableMoveDown"); - inner.classList.add("keyboard_arrow_down"); - - btnSortable.classList.remove("btnSortableMoveUp"); - inner.classList.remove("keyboard_arrow_up"); + button.title = "Down"; + button.classList.add("btnSortableMoveDown"); + button.classList.remove("btnSortableMoveUp"); + icon.classList.add("keyboard_arrow_down"); + icon.classList.remove("keyboard_arrow_up"); } } -/** @param {PointerEvent} event */ +/** + * @param {PointerEvent} event + **/ function onSortableContainerClick(event) { - const parentWithClass = (element, className) => { - return (element.parentElement.classList.contains(className)) ? element.parentElement : null; - } + const parentWithClass = (element, className) => + (element.parentElement.classList.contains(className)) ? element.parentElement : null; const btnSortable = parentWithClass(event.target, "btnSortable"); if (btnSortable) { const listItem = parentWithClass(btnSortable, "sortableOption"); @@ -93,10 +96,10 @@ function onSortableContainerClick(event) { prev.parentElement.insertBefore(listItem, prev); } } - + let i = 0; for (const option of list.querySelectorAll(".sortableOption")) { - adjustSortableListElement(option) - }; + adjustSortableListElement(option, i++); + } } } @@ -274,15 +277,32 @@ async function defaultSubmit(form) { const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); // Metadata settings - ["Main", "Alternate"].forEach((type) => setTitleIntoConfig(form, type, config)); + config.TitleMainOverride = form.querySelector("#TitleMainOverride").checked; + ([config.TitleMainList, config.TitleMainOrder] = retrieveSortableList(form, "TitleMainList")); + config.TitleAlternateOverride = form.querySelector("#TitleAlternateOverride").checked; + ([config.TitleAlternateList, config.TitleAlternateOrder] = retrieveSortableList(form, "TitleAlternateList")); config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; - setDescriptionSourcesIntoConfig(form, config); + config.DescriptionSourceOverride = form.querySelector("#DescriptionSourceOverride").checked; + ([config.DescriptionSourceList, config.DescriptionSourceOrder] = retrieveSortableList(form, "DescriptionSourceList")); config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMiscLines = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisRemoveSummary = form.querySelector("#CleanupAniDBDescriptions").checked; + config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; + config.TagOverride = form.querySelector("#TagOverride").checked; + config.TagSources = retrieveSimpleList(form, "TagSources").join(", "); + config.TagIncludeFilters = retrieveSimpleList(form, "TagIncludeFilters").join(", "); + config.TagMinimumWeight = form.querySelector("#TagMinimumWeight").value; + config.GenreOverride = form.querySelector("#GenreOverride").checked; + config.GenreSources = retrieveSimpleList(form, "GenreSources").join(", "); + config.GenreIncludeFilters = retrieveSimpleList(form, "GenreIncludeFilters").join(", "); + config.GenreMinimumWeight = form.querySelector("#GenreMinimumWeight").value + config.ContentRatingOverride = form.querySelector("#ContentRatingOverride").checked; + ([config.ContentRatingList, config.ContentRatingOrder] = retrieveSortableList(form, "ContentRatingList")); + config.ProductionLocationOverride = form.querySelector("#ProductionLocationOverride").checked; + ([config.ProductionLocationList, config.ProductionLocationOrder] = retrieveSortableList(form, "ProductionLocationList")); // Provider settings config.AddAniDBId = form.querySelector("#AddAniDBId").checked; @@ -319,7 +339,7 @@ async function defaultSubmit(form) { config.SignalR_AutoConnectEnabled = form.querySelector("#SignalRAutoConnect").checked; config.SignalR_AutoReconnectInSeconds = reconnectIntervals; form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); - setSignalREventSourcesIntoConfig(form, config); + config.SignalR_EventSources = retrieveSimpleList(form, "SignalREventSources"); mediaFolderId = form.querySelector("#SignalRMediaFolderSelector").value; mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; if (mediaFolderConfig) { @@ -331,15 +351,6 @@ async function defaultSubmit(form) { config.SignalR_RefreshEnabled = form.querySelector("#SignalRDefaultRefreshEvents").checked; } - // Tag settings - config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; - config.HideArtStyleTags = form.querySelector("#HideArtStyleTags").checked; - config.HideMiscTags = form.querySelector("#HideMiscTags").checked; - config.HidePlotTags = form.querySelector("#HidePlotTags").checked; - config.HideAniDbTags = form.querySelector("#HideAniDbTags").checked; - config.HideSettingTags = form.querySelector("#HideSettingTags").checked; - config.HideProgrammingTags = form.querySelector("#HideProgrammingTags").checked; - // Advanced settings config.PublicUrl = publicUrl; config.IgnoredFolders = ignoredFolders; @@ -470,15 +481,32 @@ async function syncSettings(form) { const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); // Metadata settings - ["Main", "Alternate"].forEach((type) => { setTitleIntoConfig(form, type, config) }); + config.TitleMainOverride = form.querySelector("#TitleMainOverride").checked; + ([config.TitleMainList, config.TitleMainOrder] = retrieveSortableList(form, "TitleMainList")); + config.TitleAlternateOverride = form.querySelector("#TitleAlternateOverride").checked; + ([config.TitleAlternateList, config.TitleAlternateOrder] = retrieveSortableList(form, "TitleAlternateList")); config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; - setDescriptionSourcesIntoConfig(form, config); + config.DescriptionSourceOverride = form.querySelector("#DescriptionSourceOverride").checked; + ([config.DescriptionSourceList, config.DescriptionSourceOrder] = retrieveSortableList(form, "DescriptionSourceList")); config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMiscLines = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisRemoveSummary = form.querySelector("#CleanupAniDBDescriptions").checked; + config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; + config.TagOverride = form.querySelector("#TagOverride").checked; + config.TagSources = retrieveSimpleList(form, "TagSources").join(", "); + config.TagIncludeFilters = retrieveSimpleList(form, "TagIncludeFilters").join(", "); + config.TagMinimumWeight = form.querySelector("#TagMinimumWeight").value; + config.GenreOverride = form.querySelector("#GenreOverride").checked; + config.GenreSources = retrieveSimpleList(form, "GenreSources").join(", "); + config.GenreIncludeFilters = retrieveSimpleList(form, "GenreIncludeFilters").join(", "); + config.GenreMinimumWeight = form.querySelector("#GenreMinimumWeight").value + config.ContentRatingOverride = form.querySelector("#ContentRatingOverride").checked; + ([config.ContentRatingList, config.ContentRatingOrder] = retrieveSortableList(form, "ContentRatingList")); + config.ProductionLocationOverride = form.querySelector("#ProductionLocationOverride").checked; + ([config.ProductionLocationList, config.ProductionLocationOrder] = retrieveSortableList(form, "ProductionLocationList")); // Provider settings config.AddAniDBId = form.querySelector("#AddAniDBId").checked; @@ -497,15 +525,6 @@ async function syncSettings(form) { config.AddCreditsAsSpecialFeatures = form.querySelector("#AddCreditsAsSpecialFeatures").checked; config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; - // Tag settings - config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; - config.HideArtStyleTags = form.querySelector("#HideArtStyleTags").checked; - config.HideMiscTags = form.querySelector("#HideMiscTags").checked; - config.HidePlotTags = form.querySelector("#HidePlotTags").checked; - config.HideAniDbTags = form.querySelector("#HideAniDbTags").checked; - config.HideSettingTags = form.querySelector("#HideSettingTags").checked; - config.HideProgrammingTags = form.querySelector("#HideProgrammingTags").checked; - // Advanced settings config.PublicUrl = publicUrl; config.IgnoredFolders = ignoredFolders; @@ -565,7 +584,7 @@ async function syncSignalrSettings(form) { config.SignalR_AutoConnectEnabled = form.querySelector("#SignalRAutoConnect").checked; config.SignalR_AutoReconnectInSeconds = reconnectIntervals; - setSignalREventSourcesIntoConfig(form, config); + config.SignalR_EventSources = retrieveSimpleList(form, "SignalREventSources"); form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); const mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; @@ -629,6 +648,7 @@ async function syncUserSettings(form) { } export default function (page) { + /** @type {HTMLFormElement} */ const form = page.querySelector("#ShokoConfigForm"); const userSelector = form.querySelector("#UserSelector"); const mediaFolderSelector = form.querySelector("#MediaFolderSelector"); @@ -668,7 +688,6 @@ export default function (page) { form.querySelector("#SignalRSection1").removeAttribute("hidden"); form.querySelector("#SignalRSection2").removeAttribute("hidden"); form.querySelector("#UserSection").removeAttribute("hidden"); - form.querySelector("#TagSection").removeAttribute("hidden"); form.querySelector("#AdvancedSection").removeAttribute("hidden"); form.querySelector("#ExperimentalSection").removeAttribute("hidden"); } @@ -685,7 +704,6 @@ export default function (page) { form.querySelector("#SignalRSection1").setAttribute("hidden", ""); form.querySelector("#SignalRSection2").setAttribute("hidden", ""); form.querySelector("#UserSection").setAttribute("hidden", ""); - form.querySelector("#TagSection").setAttribute("hidden", ""); form.querySelector("#AdvancedSection").setAttribute("hidden", ""); form.querySelector("#ExperimentalSection").setAttribute("hidden", ""); } @@ -754,22 +772,65 @@ export default function (page) { } }); - Array.prototype.forEach.call( - form.querySelectorAll("#descriptionSourceList, #TitleMainList, #TitleAlternateList"), - (el) => el.addEventListener("click", onSortableContainerClick) - ); + form.querySelector("#TitleMainList").addEventListener("click", onSortableContainerClick); + form.querySelector("#TitleAlternateList").addEventListener("click", onSortableContainerClick); + form.querySelector("#DescriptionSourceList").addEventListener("click", onSortableContainerClick); + form.querySelector("#ContentRatingList").addEventListener("click", onSortableContainerClick); + form.querySelector("#ProductionLocationList").addEventListener("click", onSortableContainerClick); + + form.querySelector("#TitleMainOverride").addEventListener("change", function () { + const list = form.querySelector(`#TitleMainList`); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); + }); - ["Main", "Alternate"].forEach((type) => { - const settingsList = form.querySelector(`#Title${type}List`); + form.querySelector("#TitleAlternateOverride").addEventListener("change", function () { + const list = form.querySelector(`#TitleAlternateList`); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); + }); - form.querySelector(`#Title${type}Override`).addEventListener("change", ({ target: { checked } }) => { - checked ? settingsList.removeAttribute("hidden") : settingsList.setAttribute("hidden", ""); - }); + form.querySelector("#DescriptionSourceOverride").addEventListener("change", function () { + const list = form.querySelector("#DescriptionSourceList"); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); }); - form.querySelector("#DescriptionSourceOverride").addEventListener("change", ({ target: { checked } }) => { - const root = form.querySelector("#descriptionSourceList"); - checked ? root.removeAttribute("hidden") : root.setAttribute("hidden", ""); + form.querySelector("#TagOverride").addEventListener("change", function () { + if (this.checked) { + form.querySelector("#TagSources").removeAttribute("hidden"); + form.querySelector("#TagIncludeFilters").removeAttribute("hidden"); + form.querySelector("#TagMinimumWeightContainer").removeAttribute("hidden"); + form.querySelector("#TagMinimumWeightContainer").disabled = false; + } + else { + form.querySelector("#TagSources").setAttribute("hidden", ""); + form.querySelector("#TagIncludeFilters").setAttribute("hidden", ""); + form.querySelector("#TagMinimumWeightContainer").setAttribute("hidden", ""); + form.querySelector("#TagMinimumWeightContainer").disabled = true; + } + }); + + form.querySelector("#GenreOverride").addEventListener("change", function () { + if (this.checked) { + form.querySelector("#GenreSources").removeAttribute("hidden"); + form.querySelector("#GenreIncludeFilters").removeAttribute("hidden"); + form.querySelector("#GenreMinimumWeightContainer").removeAttribute("hidden"); + form.querySelector("#GenreMinimumWeightContainer").disabled = false; + } + else { + form.querySelector("#GenreSources").setAttribute("hidden", ""); + form.querySelector("#GenreIncludeFilters").setAttribute("hidden", ""); + form.querySelector("#GenreMinimumWeightContainer").setAttribute("hidden", ""); + form.querySelector("#GenreMinimumWeightContainer").disabled = true; + } + }); + + form.querySelector("#ContentRatingOverride").addEventListener("change", function () { + const list = form.querySelector("#ContentRatingList"); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); + }); + + form.querySelector("#ProductionLocationOverride").addEventListener("change", function () { + const list = form.querySelector("#ProductionLocationList"); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); }); page.addEventListener("viewshow", async function () { @@ -785,31 +846,104 @@ export default function (page) { form.querySelector("#Password").value = ""; // Metadata settings - ["Main", "Alternate"].forEach((t) => { setTitleFromConfig(form, t, config) }); + if (form.querySelector("#TitleMainOverride").checked = config.TitleMainOverride) { + form.querySelector("#TitleMainList").removeAttribute("hidden"); + } + else { + form.querySelector("#TitleMainList").setAttribute("hidden", ""); + } + initSortableList(form, "TitleMainList", config.TitleMainList, config.TitleMainOrder); + if (form.querySelector("#TitleAlternateOverride").checked = config.TitleAlternateOverride) { + form.querySelector("#TitleAlternateList").removeAttribute("hidden"); + } + else { + form.querySelector("#TitleAlternateList").setAttribute("hidden", ""); + } + initSortableList(form, "TitleAlternateList", config.TitleAlternateList, config.TitleAlternateOrder); form.querySelector("#TitleAllowAny").checked = config.TitleAllowAny; - form.querySelector("#TitleAddForMultipleEpisodes").checked = config.TitleAddForMultipleEpisodes != null ? config.TitleAddForMultipleEpisodes : true; + form.querySelector("#TitleAddForMultipleEpisodes").checked = config.TitleAddForMultipleEpisodes != null + ? config.TitleAddForMultipleEpisodes : true; form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; - setDescriptionSourcesFromConfig(form, config); - form.querySelector("#CleanupAniDBDescriptions").checked = config.SynopsisCleanMultiEmptyLines || config.SynopsisCleanLinks || config.SynopsisRemoveSummary || config.SynopsisCleanMiscLines; + if (form.querySelector("#DescriptionSourceOverride").checked = config.DescriptionSourceOverride) { + form.querySelector("#DescriptionSourceList").removeAttribute("hidden"); + } + else { + form.querySelector("#DescriptionSourceList").setAttribute("hidden", ""); + } + initSortableList(form, "DescriptionSourceList", config.DescriptionSourceList, config.DescriptionSourceOrder); + form.querySelector("#CleanupAniDBDescriptions").checked = ( + config.SynopsisCleanMultiEmptyLines || + config.SynopsisCleanLinks || + config.SynopsisRemoveSummary || + config.SynopsisCleanMiscLines + ); + form.querySelector("#HideUnverifiedTags").checked = config.HideUnverifiedTags; + if (form.querySelector("#TagOverride").checked = config.TagOverride) { + form.querySelector("#TagSources").removeAttribute("hidden"); + form.querySelector("#TagIncludeFilters").removeAttribute("hidden"); + form.querySelector("#TagMinimumWeightContainer").removeAttribute("hidden"); + form.querySelector("#TagMinimumWeightContainer").disabled = false; + } + else { + form.querySelector("#TagSources").setAttribute("hidden", ""); + form.querySelector("#TagIncludeFilters").setAttribute("hidden", ""); + form.querySelector("#TagMinimumWeightContainer").setAttribute("hidden", ""); + form.querySelector("#TagMinimumWeightContainer").disabled = true; + } + initSimpleList(form, "TagSources", config.TagSources.split(",").map(s => s.trim()).filter(s => s)); + initSimpleList(form, "TagIncludeFilters", config.TagIncludeFilters.split(",").map(s => s.trim()).filter(s => s)); + form.querySelector("#TagMinimumWeight").value = config.TagMinimumWeight; + if (form.querySelector("#GenreOverride").checked = config.GenreOverride) { + form.querySelector("#GenreSources").removeAttribute("hidden"); + form.querySelector("#GenreIncludeFilters").removeAttribute("hidden"); + form.querySelector("#GenreMinimumWeightContainer").removeAttribute("hidden"); + form.querySelector("#GenreMinimumWeightContainer").disabled = false; + } + else { + form.querySelector("#GenreSources").setAttribute("hidden", ""); + form.querySelector("#GenreIncludeFilters").setAttribute("hidden", ""); + form.querySelector("#GenreMinimumWeightContainer").setAttribute("hidden", ""); + form.querySelector("#GenreMinimumWeightContainer").disabled = true; + } + initSimpleList(form, "GenreSources", config.GenreSources.split(",").map(s => s.trim()).filter(s => s)); + initSimpleList(form, "GenreIncludeFilters", config.GenreIncludeFilters.split(",").map(s => s.trim()).filter(s => s)); + form.querySelector("#GenreMinimumWeight").value = config.GenreMinimumWeight; + if (form.querySelector("#ContentRatingOverride").checked = config.ContentRatingOverride) { + form.querySelector("#ContentRatingList").removeAttribute("hidden"); + } + else { + form.querySelector("#ContentRatingList").setAttribute("hidden", ""); + } + initSortableList(form, "ContentRatingList", config.ContentRatingList, config.ContentRatingOrder); + if (form.querySelector("#ProductionLocationOverride").checked = config.ProductionLocationOverride) { + form.querySelector("#ProductionLocationList").removeAttribute("hidden"); + } + else { + form.querySelector("#ProductionLocationList").setAttribute("hidden", ""); + } + initSortableList(form, "ProductionLocationList", config.ProductionLocationList, config.ProductionLocationOrder); // Provider settings form.querySelector("#AddAniDBId").checked = config.AddAniDBId; form.querySelector("#AddTMDBId").checked = config.AddTMDBId; // Library settings - form.querySelector("#UseGroupsForShows").checked = config.UseGroupsForShows || false; - form.querySelector("#SeasonOrdering").value = config.SeasonOrdering; - form.querySelector("#SeasonOrdering").disabled = !form.querySelector("#UseGroupsForShows").checked; - if (form.querySelector("#UseGroupsForShows").checked) { + if (form.querySelector("#UseGroupsForShows").checked = config.UseGroupsForShows || false) { form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); + form.querySelector("#SeasonOrdering").disabled = false; } else { form.querySelector("#SeasonOrderingContainer").setAttribute("hidden", ""); + form.querySelector("#SeasonOrdering").disabled = true; } + form.querySelector("#SeasonOrdering").value = config.SeasonOrdering; form.querySelector("#CollectionGrouping").value = config.CollectionGrouping || "Default"; - form.querySelector("#CollectionMinSizeOfTwo").checked = config.CollectionMinSizeOfTwo != null ? config.CollectionMinSizeOfTwo : true; - form.querySelector("#SeparateMovies").checked = config.SeparateMovies != null ? config.SeparateMovies : true; - form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" ? "AfterSeason" : config.SpecialsPlacement; + form.querySelector("#CollectionMinSizeOfTwo").checked = config.CollectionMinSizeOfTwo != null + ? config.CollectionMinSizeOfTwo : true; + form.querySelector("#SeparateMovies").checked = config.SeparateMovies != null + ? config.SeparateMovies : true; + form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" + ? "AfterSeason" : config.SpecialsPlacement; form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked = config.MovieSpecialsAsExtraFeaturettes || false; form.querySelector("#AddTrailers").checked = config.AddTrailers || false; form.querySelector("#AddCreditsAsThemeVideos").checked = config.AddCreditsAsThemeVideos || false; @@ -817,37 +951,34 @@ export default function (page) { form.querySelector("#AddMissingMetadata").checked = config.AddMissingMetadata || false; // Media Folder settings - form.querySelector("#VirtualFileSystem").checked = config.VirtualFileSystem != null ? config.VirtualFileSystem : true; + form.querySelector("#VirtualFileSystem").checked = config.VirtualFileSystem != null + ? config.VirtualFileSystem : true; form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; - mediaFolderSelector.innerHTML += config.MediaFolders.map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.MediaFolderPath}</option>`).join(""); + mediaFolderSelector.innerHTML += config.MediaFolders + .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.MediaFolderPath}</option>`) + .join(""); // SignalR settings form.querySelector("#SignalRAutoConnect").checked = config.SignalR_AutoConnectEnabled; form.querySelector("#SignalRAutoReconnectIntervals").value = config.SignalR_AutoReconnectInSeconds.join(", "); - setSignalREventSourcesFromConfig(form, config); - signalrMediaFolderSelector.innerHTML += config.MediaFolders.map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.MediaFolderPath}</option>`).join(""); + initSimpleList(form, "SignalREventSources", config.SignalR_EventSources); + signalrMediaFolderSelector.innerHTML += config.MediaFolders + .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${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(""); - // Tag settings - form.querySelector("#HideUnverifiedTags").checked = config.HideUnverifiedTags; - form.querySelector("#HideArtStyleTags").checked = config.HideArtStyleTags; - form.querySelector("#HideMiscTags").checked = config.HideMiscTags; - form.querySelector("#HidePlotTags").checked = config.HidePlotTags; - form.querySelector("#HideAniDbTags").checked = config.HideAniDbTags; - form.querySelector("#HideSettingTags").checked = config.HideSettingTags; - form.querySelector("#HideProgrammingTags").checked = config.HideProgrammingTags; - // Advanced settings form.querySelector("#PublicUrl").value = config.PublicUrl; form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); // Experimental settings form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked = config.EXPERIMENTAL_AutoMergeVersions || false; - form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked = config.EXPERIMENTAL_SplitThenMergeMovies != null ? config.EXPERIMENTAL_SplitThenMergeMovies : true; + form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked = config.EXPERIMENTAL_SplitThenMergeMovies != null + ? config.EXPERIMENTAL_SplitThenMergeMovies : true; form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked = config.EXPERIMENTAL_SplitThenMergeEpisodes || false; form.querySelector("#EXPERIMENTAL_MergeSeasons").checked = config.EXPERIMENTAL_MergeSeasons || false; @@ -919,128 +1050,86 @@ export default function (page) { }); } -function setDescriptionSourcesIntoConfig(form, config) { - const override = form.querySelector("#DescriptionSourceOverride"); - const descriptionElements = form.querySelectorAll(`#descriptionSourceList .chkDescriptionSource`); - config.DescriptionSourceList = Array.prototype.filter.call(descriptionElements, - (el) => el.checked) - .map((el) => el.dataset.descriptionSource); - - config.DescriptionSourceOrder = Array.prototype.map.call(descriptionElements, - (el) => el.dataset.descriptionSource - ); - - config.DescriptionSourceOverride = override.checked; -} - -function setDescriptionSourcesFromConfig(form, config) { - const root = form.querySelector("#descriptionSourceList"); - const override = form.querySelector("#DescriptionSourceOverride"); - const list = root.querySelector(".checkboxList"); - const listItems = list.querySelectorAll('.listItem'); - - override.checked = config.DescriptionSourceOverride; - override.checked ? root.removeAttribute("hidden") : root.setAttribute("hidden", ""); - - for (const item of listItems) { - const source = item.dataset.descriptionSource; - if (config.DescriptionSourceList.includes(source)) { - item.querySelector(".chkDescriptionSource").checked = true; - } - if (config.DescriptionSourceOrder.includes(source)) { - list.removeChild(item); // This is safe to be removed as we can re-add it in the next loop - } - } - - for (const source of config.DescriptionSourceOrder) { - const targetElement = Array.prototype.find.call(listItems, (el) => el.dataset.descriptionSource === source); - if (targetElement) { - list.append(targetElement); - } - } - - for (const option of list.querySelectorAll(".sortableOption")) { - adjustSortableListElement(option) - }; -} - -/** - * This function **must** be called for each type of title separately, lest the config be incomplete. - * @param {"Main"|"Alternate"} type - */ -function setTitleIntoConfig(form, type, config) { - const titleElements = form.querySelectorAll(`#Title${type}List .chkTitleSource`); - const getSettingName = (el) => `${el.dataset.titleProvider}_${el.dataset.titleStyle}`; - - config[`Title${type}List`] = Array.prototype.filter.call(titleElements, - (el) => el.checked) - .map((el) => getSettingName(el)); - - config[`Title${type}Order`] = Array.prototype.map.call(titleElements, - (el) => getSettingName(el) - ); - - config[`Title${type}Override`] = form.querySelector(`#Title${type}Override`).checked -} - -/** - * This function **must** be called for each type of title separately, lest the config be incomplete. - * @param {"Main"|"Alternate"} type +/** + * Initialize a selectable list. + * + * @param {HTMLFormElement} form + * @param {string} name + * @param {string[]} enabled + * @param {string[]} order + * @returns {void} */ -function setTitleFromConfig(form, type, config) { - const root = form.querySelector(`#Title${type}List`); - const override = form.querySelector(`#Title${type}Override`); - const list = root.querySelector(`.checkboxList`); - const listItems = list.querySelectorAll('.listItem'); - - override.checked = config[`Title${type}Override`]; - override.checked ? root.removeAttribute("hidden") : root.setAttribute("hidden", ""); - - const getSettingName = (el) => `${el.dataset.titleProvider}_${el.dataset.titleStyle}`; - - for (const item of listItems) { - const setting = getSettingName(item); - if (config[`Title${type}List`].includes(setting)) - item.querySelector(".chkTitleSource").checked = true; - if (config[`Title${type}Order`].includes(setting)) - list.removeChild(item); - } - - for (const setting of config[`Title${type}Order`]) { - const targetElement = Array.prototype.find.call( - listItems, - (el) => getSettingName(el) === setting - ); - if (targetElement) - list.append(targetElement); +function initSortableList(form, name, enabled, order) { + let index = 0; + const list = form.querySelector(`#${name} .checkboxList`); + const listItems = Array.from(list.querySelectorAll(".listItem")) + .map((item) => ({ + item, + checkbox: item.querySelector("input[data-option]"), + isSortable: item.className.includes("sortableOption"), + })) + .map(({ item, checkbox, isSortable }) => ({ + item, + checkbox, + isSortable, + option: checkbox.dataset.option, + })); + list.innerHTML = ""; + for (const option of order) { + const { item, checkbox, isSortable } = listItems.find((item) => item.option === option) || {}; + if (!item) + continue; + list.append(item); + if (enabled.includes(option)) + checkbox.checked = true; + if (isSortable) + adjustSortableListElement(item, index++); } +} - for (const option of list.querySelectorAll(".sortableOption")) { - adjustSortableListElement(option) +/** + * @param {HTMLFormElement} form + * @param {string} name + * @param {string[]} enabled + * @returns {void} + **/ +function initSimpleList(form, name, enabled) { + for (const item of Array.from(form.querySelectorAll(`#${name} .listItem input[data-option]`))) { + if (enabled.includes(item.dataset.option)) + item.checked = true; } } -/** @param {HTMLFormElement} form */ -function setSignalREventSourcesIntoConfig(form, config) { - /** @type {HTMLInputElement[]} */ - const checkboxList = form.querySelectorAll(`#SignalREventSources .listItem input[data-signalr-event-source]`); - const resultArr = []; - for (const item of checkboxList) { - if (item.checked) { - resultArr.push(item.dataset.signalrEventSource); - } - } - config.SignalR_EventSources = resultArr; +/** + * Retrieve the enabled state and order list from a sortable list. + * + * @param {HTMLFormElement} form + * @param {string} name + * @returns {[boolean, string[], string[]]} + */ +function retrieveSortableList(form, name) { + const titleElements = Array.from(form.querySelectorAll(`#${name} .listItem input[data-option]`)); + const getValue = (el) => el.dataset.option; + return [ + titleElements + .filter((el) => el.checked) + .map(getValue) + .sort(), + titleElements + .map(getValue), + ]; } -/** @param {HTMLFormElement} form */ -function setSignalREventSourcesFromConfig(form, config) { - /** @type {HTMLInputElement[]} */ - const checkboxList = form.querySelectorAll(`#SignalREventSources .listItem input[data-signalr-event-source]`); - for (const item of checkboxList) { - const eventSource = item.dataset.signalrEventSource; - if (config.SignalR_EventSources.includes(eventSource)) { - item.checked = true; - } - } +/** + * Retrieve the enabled state from a simple list. + * + * @param {HTMLFormElement} form + * @param {string} name - Name of the selector list to retrieve. + * @returns {string[]} + **/ +function retrieveSimpleList(form, name) { + return Array.from(form.querySelectorAll(`#${name} .listItem input[data-option]`)) + .filter(item => item.checked) + .map(item => item.dataset.option) + .sort(); } \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 7e58a7f2..a202f30a 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -44,6 +44,44 @@ <h3>Connection Settings</h3> <legend> <h3>Metadata 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> + </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" /> + <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"> + <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"> + <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"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="TitleMainOverride" /> @@ -56,40 +94,89 @@ <h3>Metadata Settings</h3> <div id="TitleMainList" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced main title source:</h3> <div class="checkboxList paperList checkboxList-paperList"> - <div class="listItem titleSourceItem sortableOption" data-title-provider="Shoko" data-title-style="Default"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="Shoko" data-title-style="Default"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">Shoko | Let Shoko decide</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 titleSourceItem sortableOption" data-title-provider="AniDB" data-title-style="Default"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="AniDB" data-title-style="Default"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">AniDB | Default title</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 titleSourceItem sortableOption" data-title-provider="AniDB" data-title-style="LibraryLanguage"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="AniDB" data-title-style="LibraryLanguage"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">AniDB | 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 titleSourceItem sortableOption" data-title-provider="AniDB" data-title-style="CountryOfOrigin"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="AniDB" data-title-style="CountryOfOrigin"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">AniDB | Use the language from the 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></button> - </div> - <div class="listItem titleSourceItem sortableOption" data-title-provider="TMDB" data-title-style="Default"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="TMDB" data-title-style="Default"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">TMDB | Default title</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 titleSourceItem sortableOption" data-title-provider="TMDB" data-title-style="LibraryLanguage"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="TMDB" data-title-style="LibraryLanguage"><span></span></label> - <div class="listItemBody"><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 titleSourceItem sortableOption" data-title-provider="TMDB" data-title-style="CountryOfOrigin"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="TMDB" data-title-style="CountryOfOrigin"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">TMDB | Use the language from the 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></button> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Shoko_Default"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Shoko | Let Shoko decide</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"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB_Default"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB | Default title</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"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB_LibraryLanguage"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB | 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"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB_CountryOfOrigin"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB | Use the language from the 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> + </button> + </div> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB_Default"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB | Default title</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"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB_LibraryLanguage"> + <span></span> + </label> + <div class="listItemBody"> + <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"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB_CountryOfOrigin"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB | Use the language from the 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> + </button> </div> </div> <div class="fieldDescription">The metadata providers to use as the source of the main title, in priority order.</div> @@ -106,40 +193,89 @@ <h3 class="checkboxListLabel">Advanced main title source:</h3> <div id="TitleAlternateList" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced alternate title source:</h3> <div class="checkboxList paperList checkboxList-paperList"> - <div class="listItem titleSourceItem sortableOption" data-title-provider="Shoko" data-title-style="Default"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="Shoko" data-title-style="Default"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">Shoko | Let Shoko decide</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 titleSourceItem sortableOption" data-title-provider="AniDB" data-title-style="Default"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="AniDB" data-title-style="Default"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">AniDB | Default title</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 titleSourceItem sortableOption" data-title-provider="AniDB" data-title-style="LibraryLanguage"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="AniDB" data-title-style="LibraryLanguage"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">AniDB | 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 titleSourceItem sortableOption" data-title-provider="AniDB" data-title-style="CountryOfOrigin"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="AniDB" data-title-style="CountryOfOrigin"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">AniDB | Use the language from the 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></button> - </div> - <div class="listItem titleSourceItem sortableOption" data-title-provider="TMDB" data-title-style="Default"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="TMDB" data-title-style="Default"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">TMDB | Default title</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 titleSourceItem sortableOption" data-title-provider="TMDB" data-title-style="LibraryLanguage"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="TMDB" data-title-style="LibraryLanguage"><span></span></label> - <div class="listItemBody"><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 titleSourceItem sortableOption" data-title-provider="TMDB" data-title-style="CountryOfOrigin"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-title-provider="TMDB" data-title-style="CountryOfOrigin"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">TMDB | Use the language from the 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></button> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Shoko_Default"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Shoko | Let Shoko decide</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"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB_Default"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB | Default title</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"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB_LibraryLanguage"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB | 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"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB_CountryOfOrigin"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB | Use the language from the 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> + </button> + </div> + <div class="listItem sortableOption"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB_Default"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB | Default title</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"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB_LibraryLanguage"> + <span></span> + </label> + <div class="listItemBody"> + <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"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB_CountryOfOrigin"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB | Use the language from the 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> + </button> </div> </div> <div class="fieldDescription">The metadata providers to use as the source of the alternate title, in priority order.</div> @@ -153,54 +289,468 @@ <h3 class="checkboxListLabel">Advanced alternate title source:</h3> Enables the advanced selector for description source selection. </div> </div> - <div id="descriptionSourceList" style="margin-bottom: 2em;"> + <div id="DescriptionSourceList" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced description source:</h3> <div class="checkboxList paperList checkboxList-paperList"> - <div class="listItem descriptionSourceItem sortableOption" data-description-source="AniDB"> - <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-description-source="AniDB"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">AniDB</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 class="listItem sortableOption" data-option="AniDB"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB"> + <span></span> + </label> + <div class="listItemBody"> + <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> </div> - <div class="listItem descriptionSourceItem sortableOption" data-description-source="TvDB"> - <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-description-source="TvDB"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">TvDB</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 class="listItem sortableOption" data-option="TvDB"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TvDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TvDB</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 descriptionSourceItem sortableOption" data-description-source="TMDB"> - <label class="listItemCheckboxContainer"><input class="chkDescriptionSource" type="checkbox" is="emby-checkbox" data-description-source="TMDB"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">TMDB</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 class="listItem sortableOption" data-option="TMDB"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB</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> <div class="fieldDescription">The metadata providers to use as the source of descriptions, in priority order.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="TitleAllowAny" /> - <span>Allow any title in selected language</span> + <input is="emby-checkbox" type="checkbox" id="TagOverride" /> + <span>Override tag sources</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"> + Enables the advanced selector for tag source selection. + </div> + </div> + <div id="TagSources" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced tag sources:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SourceMaterial"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Source Material</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TargetAudience"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Target Audience</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ContentIndicators"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Content Indicators</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Origin"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Origin</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Elements"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Elements</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Themes"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Themes</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SettingPlace"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Setting | Place</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SettingTimePeriod"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Setting | Time Period</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SettingTimeSeason"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Setting | Yearly Seasons</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="CustomTags"> + <span></span> + </label> + <div class="listItemBody"> + <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> + <div id="TagIncludeFilters" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced tag include filters:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Parent"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Parent Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Child"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Child Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Weightless"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Weightless Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="GlobalSpoiler"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Spoiler | Global Spoiler</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="LocalSpoiler"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> + </div> + </div> + </div> + <div class="fieldDescription">The type of tags to include for tags.</div> + </div> + <div id="TagMinimumWeightContainer" class="selectContainer selectContainer-withDescription"> + <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> + <option value="One">⯪☆☆</option> + <option value="Two">★☆☆</option> + <option value="Three">★⯪☆</option> + <option value="Four">★★☆</option> + <option value="Five">★★⯪</option> + <option value="Six">★★★</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. + </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> + <input is="emby-checkbox" type="checkbox" id="GenreOverride" /> + <span>Override genre sources</span> </label> - <div class="fieldDescription checkboxFieldDescription">Will add the title and description for every episode in a multi-episode entry.</div> + <div class="fieldDescription checkboxFieldDescription"> + Enables the advanced selector for genre source selection. + </div> + </div> + <div id="GenreSources" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced genre sources:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SourceMaterial"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Source Material</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TargetAudience"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Target Audience</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ContentIndicators"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Content Indicators</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Origin"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Origin</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Elements"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Elements</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Themes"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Themes</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SettingPlace"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Setting | Place</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SettingTimePeriod"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Setting | Time Period</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SettingTimeSeason"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Setting | Yearly Seasons</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="CustomTags"> + <span></span> + </label> + <div class="listItemBody"> + <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> + <div id="GenreIncludeFilters" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Advanced genre include filters:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Parent"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Parent Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Child"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Child Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Weightless"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Weightless Tags</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="GlobalSpoiler"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Spoiler | Global Spoiler</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="LocalSpoiler"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> + </div> + </div> + </div> + <div class="fieldDescription">The type of tags to include for genres.</div> + </div> + <div id="GenreMinimumWeightContainer" class="selectContainer selectContainer-withDescription"> + <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> + <option value="One">⯪☆☆</option> + <option value="Two">★☆☆</option> + <option value="Three" selected>★⯪☆ (Default)</option> + <option value="Four">★★☆</option> + <option value="Five">★★⯪</option> + <option value="Six">★★★</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. + </div> </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> + <input is="emby-checkbox" type="checkbox" id="ContentRatingOverride" /> + <span>Override content rating sources</span> </label> - <div class="fieldDescription checkboxFieldDescription">Add the type and number to the title of some episodes.</div> + <div class="fieldDescription checkboxFieldDescription"> + Enables the advanced selector for content rating source selection. + </div> + </div> + <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"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB</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"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB</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> + <div class="fieldDescription">The metadata providers to use as the source of content ratings, in priority order.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="CleanupAniDBDescriptions" /> - <span>Cleanup AniDB descriptions</span> + <input is="emby-checkbox" type="checkbox" id="ProductionLocationOverride" /> + <span>Override production location sources</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"> + Enables the advanced selector for production location source selection. + </div> + </div> + <div id="ProductionLocationList" 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"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB</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"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB</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> + <div class="fieldDescription">The metadata providers to use as the source of production locations, in priority order.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -336,8 +886,6 @@ <h3>Library Settings</h3> </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> - <!-- Insert 'include/exclude' credits as theme videos or special features here. --> - <!-- Insert 'include/exclude' trailers. --> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="AddMissingMetadata" /> @@ -494,17 +1042,32 @@ <h3>SignalR Settings</h3> <div id="SignalREventSources" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">SignalR Event Sources</h3> <div class="checkboxList paperList checkboxList-paperList"> - <div class="listItem titleSourceItem"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-signalr-event-source="Shoko"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">Shoko</h3></div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Shoko"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Shoko</h3> + </div> </div> - <div class="listItem titleSourceItem"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-signalr-event-source="AniDB"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">AniDB</h3></div> + <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 titleSourceItem"> - <label class="listItemCheckboxContainer"><input class="chkTitleSource" type="checkbox" is="emby-checkbox" data-signalr-event-source="TMDB"><span></span></label> - <div class="listItemBody"><h3 class="listItemBodyText">TMDB</h3></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> <div class="fieldDescription">Which event sources should be listened to via the SignalR connection.</div> @@ -644,56 +1207,6 @@ <h3>User Settings</h3> </button> </div> </fieldset> - <fieldset id="TagSection" class="verticalSection verticalSection-extrabottompadding" hidden> - <legend> - <h3>Tag Settings</h3> - </legend> - <div class="checkboxContainer"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="HideUnverifiedTags" /> - <span>Hide unverified tags.</span> - </label> - </div> - <div class="checkboxContainer"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="HideArtStyleTags" /> - <span>Hide art style related tags</span> - </label> - </div> - <div class="checkboxContainer"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="HideMiscTags" /> - <span>Hide misc info tags that may be useful</span> - </label> - </div> - <div class="checkboxContainer"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="HidePlotTags" /> - <span>Hide potentially plot-spoiling tags</span> - </label> - </div> - <div class="checkboxContainer"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="HideAniDbTags" /> - <span>Hide any miscellaneous tags</span> - </label> - </div> - <div class="checkboxContainer"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="HideSettingTags" /> - <span>Hide any tags related to the setting — a time or place in which the story occurs.</span> - </label> - </div> - <div class="checkboxContainer"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="HideProgrammingTags" /> - <span>Hide any tags that involve how or where it aired, or any awards it got</span> - </label> - </div> - <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> - <span>Save</span> - </button> - </fieldset> <fieldset id="ProviderSection" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Plugin Compatibility Settings</h3> diff --git a/Shokofin/ListExtensions.cs b/Shokofin/ListExtensions.cs new file mode 100644 index 00000000..c47a602b --- /dev/null +++ b/Shokofin/ListExtensions.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Shokofin; + +public static class ListExtensions +{ + public static bool TryRemoveAt<T>(this List<T> list, int index, [NotNullWhen(true)] out T? item) + { + if (index < 0 || index >= list.Count) { + item = default; + return false; + } + item = list[index]!; + list.RemoveAt(index); + return true; + } +} \ No newline at end of file diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index cf815bbc..0d585ce8 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -198,7 +198,8 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie SeriesName = season.Series.Name, SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, SeasonName = season.Name, - OfficialRating = group.OfficialRating, + ProductionLocations = TagFilter.GetSeasonContentRating(series).ToArray(), + OfficialRating = ContentRating.GetSeasonContentRating(series), DateLastSaved = DateTime.UtcNow, RunTimeTicks = episode.AniDB.Duration.Ticks, }; @@ -215,7 +216,7 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie AirsBeforeSeasonNumber = airsBeforeSeasonNumber, PremiereDate = episode.AniDB.AirDate, Overview = description, - OfficialRating = group.OfficialRating, + OfficialRating = ContentRating.GetSeasonContentRating(series), CustomRating = group.CustomRating, CommunityRating = episode.AniDB.Rating.Value > 0 ? episode.AniDB.Rating.ToFloat(10) : 0, }; diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index ed57c27f..a9759500 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -49,9 +49,8 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell case Episode episode: { if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { var episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); - if (episodeInfo != null) { + if (episodeInfo is not null) AddImagesForEpisode(ref list, episodeInfo); - } Logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId})", list.Count, episode.Name, episodeId); } break; @@ -59,27 +58,46 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell case Series series: { if (Lookup.TryGetSeriesIdFor(series, out var seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); - if (seriesImages != null) { + if (seriesImages is not null) AddImagesForSeries(ref list, seriesImages); - } // Also attach any images linked to the "seasons" (AKA series within the group). - list = list - .Concat( - series.GetSeasons(null, new(true)) - .Cast<Season>() - .SelectMany(season => GetImages(season, cancellationToken).Result) - ) - .DistinctBy(image => image.Url) - .ToList(); + 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 (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); + } + } + } + list = list + .DistinctBy(image => image.Url) + .ToList(); + } Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId})", list.Count, series.Name, seriesId); } break; } case Season season: { if (Lookup.TryGetSeriesIdFor(season, out var seriesId)) { + var seasonInfo = await ApiManager.GetSeasonInfoForSeries(seriesId); var seriesImages = await ApiClient.GetSeriesImages(seriesId); - if (seriesImages != null) { + if (seriesImages is not null) 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) + AddImagesForSeries(ref list, seriesImages); + } + 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); } @@ -88,24 +106,21 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell case Movie movie: { if (Lookup.TryGetSeriesIdFor(movie, out var seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); - if (seriesImages != null) { + if (seriesImages is not null) AddImagesForSeries(ref list, seriesImages); - } Logger.LogInformation("Getting {Count} images for movie {MovieName} (Series={SeriesId})", list.Count, movie.Name, seriesId); } break; } case BoxSet collection: { string? groupId = null; - if (!collection.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId)) { - if (collection.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out groupId)) + if (!collection.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId) && + collection.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out groupId)) seriesId = (await ApiManager.GetCollectionInfoForGroup(groupId))?.Shoko.IDs.MainSeries.ToString(); - } if (!string.IsNullOrEmpty(seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); - if (seriesImages != null) { + 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); } break; diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 8955a8db..ff2df0e8 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -65,6 +65,8 @@ 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), CommunityRating = rating, DateCreated = file.Shoko.ImportedAt ?? file.Shoko.CreatedAt, }; diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 89b49f77..046eebaf 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -121,6 +121,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), CommunityRating = seasonInfo.AniDB.Rating?.ToFloat(10), SeriesId = series.Id, SeriesName = series.Name, @@ -143,6 +145,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), CommunityRating = seasonInfo.AniDB.Rating?.ToFloat(10), }; } diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index d18e50ea..d095fbf6 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -77,7 +77,8 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat Tags = show.Tags.ToArray(), Genres = show.Genres.ToArray(), Studios = show.Studios.ToArray(), - OfficialRating = show.OfficialRating, + ProductionLocations = TagFilter.GetShowContentRating(show).ToArray(), + OfficialRating = ContentRating.GetShowContentRating(show), CustomRating = show.CustomRating, CommunityRating = show.CommunityRating, }; diff --git a/Shokofin/Utils/ContentRating.cs b/Shokofin/Utils/ContentRating.cs new file mode 100644 index 00000000..c6aaf379 --- /dev/null +++ b/Shokofin/Utils/ContentRating.cs @@ -0,0 +1,350 @@ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Shokofin.API.Info; +using Shokofin.API.Models; +using Shokofin.Events.Interfaces; + +using TagWeight = Shokofin.Utils.TagFilter.TagWeight; + +namespace Shokofin.Utils; + +public static class ContentRating +{ + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class TvContentIndicatorsAttribute : Attribute + { + public TvContentIndicator[] Values { get; init; } + + public TvContentIndicatorsAttribute(params TvContentIndicator[] values) + { + Values = values; + } + } + + /// <summary> + /// Tv Ratings and Parental Controls + /// </summary> + /// <remarks> + /// Based on https://web.archive.org/web/20210720014648/https://www.tvguidelines.org/resources/TheRatings.pdf + /// </remarks> + public enum TvRating { + /// <summary> + /// No rating. + /// </summary> + None = 0, + + /// <summary> + /// This program is designed to be appropriate for all children. Whether + /// animated or live-action, the themes and elements in this program are + /// specifically designed for a very young audience, including children + /// from ages 2-6. This program is not expected to frighten younger + /// children. + /// </summary> + [Description("TV-Y")] + TvY, + + /// <summary> + /// This program is designed for children age 7 and above. It may be + /// more appropriate for children who have acquired the developmental + /// skills needed to distinguish between make-believe and reality. + /// Themes and elements in this program may include mild fantasy + /// violence or comedic violence, or may frighten children under the + /// age of 7. Therefore, parents may wish to consider the suitability of + /// this program for their very young children. + /// + /// This program may contain one or more of the following: + /// - intense or combative fantasy violence (FV). + /// </summary> + [Description("TV-Y7")] + [TvContentIndicators(TvContentIndicator.FV)] + TvY7, + + /// <summary> + /// Most parents would find this program suitable for all ages. Although + /// this rating does not signify a program designed specifically for + /// children, most parents may let younger children watch this program + /// unattended. It contains little or no violence, no strong language + /// and little or no sexual dialogue or situations. + /// </summary> + [Description("TV-G")] + TvG, + + /// <summary> + /// This program contains material that parents may find unsuitable for + /// younger children. Many parents may want to watch it with their + /// younger children. + /// + /// The theme itself may call for parental guidance and/or the program + /// may contain one or more of the following: + /// - some suggestive dialogue (D), + /// - infrequent coarse language (L), + /// - some sexual situations (S), or + /// - moderate violence (V). + /// </summary> + [Description("TV-PG")] + [TvContentIndicators(TvContentIndicator.D, TvContentIndicator.L, TvContentIndicator.S, TvContentIndicator.V)] + TvPG, + + /// <summary> + /// This program contains some material that many parents would find + /// unsuitable for children under 14 years of age. Parents are strongly + /// urged to exercise greater care in monitoring this program and are + /// cautioned against letting children under the age of 14 watch + /// unattended. + /// + /// This program may contain one or more of the following: + /// - intensely suggestive dialogue (D), + /// - strong coarse language (L), + /// - intense sexual situations (S), or + /// - intense violence (V). + /// </summary> + [Description("TV-14")] + [TvContentIndicators(TvContentIndicator.D, TvContentIndicator.L, TvContentIndicator.S, TvContentIndicator.V)] + Tv14, + + /// <summary> + /// This program is specifically designed to be viewed by adults and + /// therefore may be unsuitable for children under 17. + /// + /// This program may contain one or more of the following: + /// - strong coarse language (L), + /// - intense sexual situations (S), or + /// - intense violence (V). + /// </summary> + [Description("TV-MA")] + [TvContentIndicators(TvContentIndicator.D, TvContentIndicator.L, TvContentIndicator.S, TvContentIndicator.V)] + TvMA, + + /// <summary> + /// Porn. No, you didn't read that wrong. + /// </summary> + [Description("XXX")] + [TvContentIndicators(TvContentIndicator.L, TvContentIndicator.S, TvContentIndicator.V)] + XXX, + } + + /// <summary> + /// Available content indicators for the base <see cref="TvRating"/>. + /// </summary> + public enum TvContentIndicator { + /// <summary> + /// Intense or combative fantasy violence (FV), but only for <see cref="TvRating.TvPG"/>. + /// </summary> + FV = 1, + /// <summary> + /// Some or intense suggestive dialogue (D), depending on the base <see cref="TvRating"/>. + /// </summary> + D, + /// <summary> + /// infrequent or intense coarse language (L), depending on the base <see cref="TvRating"/>. + /// </summary> + L, + /// <summary> + /// Moderate or intense sexual situations (S), depending on the base <see cref="TvRating"/>. + /// </summary> + S, + /// <summary> + /// Moderate or intense violence, depending on the base <see cref="TvRating"/>. + /// </summary> + V, + } + + 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 }; + +#pragma warning disable IDE0060 + public static string? GetMovieContentRating(SeasonInfo seasonInfo, EpisodeInfo episodeInfo) +#pragma warning restore IDE0060 + { + // TODO: Add TMDB movie linked to episode content rating here. + foreach (var provider in GetOrderedProviders()) { + var title = provider switch { + ProviderName.AniDB => seasonInfo.AssumedContentRating, + // TODO: Add TMDB series content rating here. + _ => null, + }; + if (!string.IsNullOrEmpty(title)) + return title.Trim(); + } + return null; + } + + public static string? GetSeasonContentRating(SeasonInfo seasonInfo) + { + foreach (var provider in GetOrderedProviders()) { + var title = provider switch { + ProviderName.AniDB => seasonInfo.AssumedContentRating, + // TODO: Add TMDB series content rating here. + _ => null, + }; + if (!string.IsNullOrEmpty(title)) + return title.Trim(); + } + return null; + } + + public static string? GetShowContentRating(ShowInfo showInfo) + { + var (contentRating, contentIndicators) = showInfo.SeasonOrderDictionary.Values + .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())) + .Where(tuple => tuple.contentRating is not TvRating.None) + .GroupBy(tuple => tuple.contentRating) + .OrderByDescending(groupBy => groupBy.Key) + .Select(groupBy => (groupBy.Key, groupBy.SelectMany(tuple => tuple.contentIndicators).ToHashSet())) + .FirstOrDefault(); + return ConvertRatingToText(contentRating, contentIndicators); + } + + public static string? GetTagBasedContentRating(IReadOnlyDictionary<string, ResolvedTag> tags) + { + // User overridden content rating. + if (tags.TryGetValue("/custom user tags/target audience", out var targetAudience)) { + var audience = targetAudience.Children.Count == 1 ? targetAudience.Children.Values.First() : null; + if (TryConvertRatingFromText(audience?.Name.ToLowerInvariant().Replace("-", ""), out var cR, out var cI)) + return ConvertRatingToText(cR, cI); + } + + // Base rating. + var contentRating = TvRating.None; + var contentIndicators = new HashSet<TvContentIndicator>(); + if (tags.TryGetValue("/target audience", out targetAudience)) { + var audience = targetAudience.Children.Count == 1 ? targetAudience.Children.Values.First() : null; + contentRating = (audience?.Name.ToLowerInvariant()) switch { + "mina" => TvRating.TvG, + "kodomo" => TvRating.TvY, + "shoujo" => TvRating.TvY7, + "shounen" => TvRating.TvY7, + "josei" => TvRating.Tv14, + "seinen" => TvRating.Tv14, + "18 restricted" => TvRating.XXX, + _ => 0, + }; + } + + // "Upgrade" the content rating if it contains any of these tags. + if (tags.TryGetValue("/elements", out var elements)) { + if (contentRating is < TvRating.TvMA && elements.RecursiveNamespacedChildren.ContainsKey("/ecchi/borderline porn")) + contentRating = TvRating.TvMA; + if (elements.Children.TryGetValue("ecchi", out var ecchi)) { + if (contentRating is < TvRating.Tv14 && ecchi.Weight is >= TagWeight.Four) + contentRating = TvRating.Tv14; + else if (contentRating is < TvRating.TvPG && ecchi.Weight is >= TagWeight.Two) + contentRating = TvRating.TvPG; + } + } + + // Content indicators. + if (tags.TryGetValue("/elements/sexual humour", out var _)) + contentIndicators.Add(TvContentIndicator.D); + if (tags.TryGetValue("/content indicators/sex", out var sex)) { + if (sex.Weight is <= TagWeight.Two) + contentIndicators.Add(TvContentIndicator.D); + else + contentIndicators.Add(TvContentIndicator.S); + } + if (tags.TryGetValue("/content indicators/nudity", out var nudity)) { + if (nudity.Weight >= TagWeight.Four) + contentIndicators.Add(TvContentIndicator.S); + } + if (tags.TryGetValue("/content indicators/violence", out var violence)) { + if (tags.ContainsKey("/elements/speculative fiction/fantasy")) + contentIndicators.Add(TvContentIndicator.FV); + if (violence.Weight is >= TagWeight.Two) + contentIndicators.Add(TvContentIndicator.V); + } + + return ConvertRatingToText(contentRating, contentIndicators); + } + + private static bool TryConvertRatingFromText(string? value, out TvRating contentRating, [NotNullWhen(true)] out HashSet<TvContentIndicator>? contentIndicators) + { + // Return early if null or empty. + contentRating = TvRating.None; + if (string.IsNullOrEmpty(value)) { + contentIndicators = null; + return false; + } + + // Trim input, remove dashes and underscores, and remove optional prefix. + value = value.ToLowerInvariant().Trim().Replace("-", "").Replace("_", ""); + if (value.Length > 1 && value[0..1] == "tv") + value = value.Length > 2 ? value[2..] : string.Empty; + + // Parse rating. + var offset = 0; + if (value.Length > 0) { + contentRating = value[0] switch { + 'y' => TvRating.TvY, + 'g' => TvRating.TvG, + _ => TvRating.None, + }; + if (contentRating is not TvRating.None) + offset = 1; + } + if (contentRating is TvRating.None && value.Length > 1) { + contentRating = value[0..1] switch { + "y7" => TvRating.TvY7, + "pg" => TvRating.TvPG, + "14" => TvRating.Tv14, + "ma" => TvRating.TvMA, + _ => TvRating.None, + }; + if (contentRating is not TvRating.None) + offset = 2; + } + if (contentRating is TvRating.None && value.Length > 2) { + contentRating = value[0..2] switch { + "xxx" => TvRating.XXX, + _ => TvRating.None, + }; + if (contentRating is not TvRating.None) + offset = 3; + } + if (contentRating is TvRating.None) { + contentIndicators = null; + return false; + } + + // Parse indicators. + contentIndicators = new(); + if (value.Length <= offset) + return true; + foreach (var raw in value[offset..]) { + if (!Enum.TryParse<TvContentIndicator>(raw.ToString(), out var indicator)) { + contentRating = TvRating.None; + contentIndicators = null; + return false; + } + contentIndicators.Add(indicator); + } + + return true; + } + + private static string? ConvertRatingToText(TvRating value, IEnumerable<TvContentIndicator>? contentIndicators) + { + var field = value.GetType().GetField(value.ToString()); + if (field?.GetCustomAttributes(typeof(DescriptionAttribute), false) is DescriptionAttribute[] attributes && attributes.Length != 0) + { + var contentRating = attributes.First().Description; + var allowedIndicators = ( + (field.GetCustomAttributes(typeof(TvContentIndicatorsAttribute), false) as TvContentIndicatorsAttribute[] ?? Array.Empty<TvContentIndicatorsAttribute>()).FirstOrDefault()?.Values ?? Array.Empty<TvContentIndicator>() + ) + .Intersect(contentIndicators ?? Array.Empty<TvContentIndicator>()) + .ToList(); + if (allowedIndicators.Count is > 0) + contentRating += $"-{allowedIndicators.Select(cI => cI.ToString()).Join("")}"; + return contentRating; + } + return null; + } +} \ No newline at end of file diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index dd6eb544..f76c30b4 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -12,8 +12,7 @@ public class Ordering /// <summary> /// Library filtering mode. /// </summary> - public enum LibraryFilteringMode - { + public enum LibraryFilteringMode { /// <summary> /// Will use either <see cref="Strict"/> or <see cref="Lax"/> depending /// on which metadata providers are enabled for the library. @@ -36,8 +35,7 @@ public enum LibraryFilteringMode /// Helps determine what the user wants to group into collections /// (AKA "box-sets"). /// </summary> - public enum CollectionCreationType - { + public enum CollectionCreationType { /// <summary> /// No grouping. All series will have their own entry. /// </summary> @@ -58,8 +56,7 @@ public enum CollectionCreationType /// <summary> /// Season or movie ordering when grouping series/box-sets using Shoko's groups. /// </summary> - public enum OrderType - { + public enum OrderType { /// <summary> /// Let Shoko decide the order. /// </summary> diff --git a/Shokofin/Utils/TagFilter.cs b/Shokofin/Utils/TagFilter.cs new file mode 100644 index 00000000..0a02b1b0 --- /dev/null +++ b/Shokofin/Utils/TagFilter.cs @@ -0,0 +1,269 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using Shokofin.API.Info; +using Shokofin.API.Models; +using Shokofin.Events.Interfaces; + +namespace Shokofin.Utils; + +public static class TagFilter +{ + [Flags] + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum TagSource { + SourceMaterial = 1, + TargetAudience = 2, + ContentIndicators = 4, + Origin = 8, + Elements = 16, + Themes = 32, + Fetishes = 64, + SettingPlace = 128, + SettingTimePeriod = 256, + SettingTimeSeason = 512, + CustomTags = 1024, + } + + [Flags] + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum TagIncludeFilter { + Parent = 1, + Child = 2, + Weightless = 4, + GlobalSpoiler = 8, + LocalSpoiler = 16, + } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum TagWeight { + Weightless = 0, + One = 100, + Two = 200, + Three = 300, + Four = 400, + Five = 500, + Six = 600, + } + + private static ProviderName[] GetOrderedProductionLocationProviders() + => Plugin.Instance.Configuration.ProductionLocationOverride + ? Plugin.Instance.Configuration.ProductionLocationOrder.Where((t) => Plugin.Instance.Configuration.ProductionLocationList.Contains(t)).ToArray() + : new ProviderName[] { ProviderName.AniDB, ProviderName.TMDB }; + +#pragma warning disable IDE0060 + public static IReadOnlyList<string> GetMovieContentRating(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, + // TODO: Add TMDB series content rating here. + _ => Array.Empty<string>(), + }; + if (title.Count > 0) + return title; + } + return Array.Empty<string>(); + } + + public static IReadOnlyList<string> GetSeasonContentRating(SeasonInfo seasonInfo) + { + foreach (var provider in GetOrderedProductionLocationProviders()) { + 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>(); + } + + public static IReadOnlyList<string> GetShowContentRating(ShowInfo showInfo) + { + foreach (var provider in GetOrderedProductionLocationProviders()) { + 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>(); + } + + public static string[] FilterTags(IReadOnlyDictionary<string, ResolvedTag> tags) + { + var config = Plugin.Instance.Configuration; + if (!config.TagOverride) + return FilterInternal( + tags, + TagSource.Elements | TagSource.Themes | TagSource.Fetishes | TagSource.SettingPlace | TagSource.SettingTimePeriod | TagSource.SettingTimeSeason | TagSource.CustomTags, + TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Weightless, + TagWeight.Weightless + ); + return FilterInternal(tags, config.TagSources, config.TagIncludeFilters, config.TagMinimumWeight); + } + + public static string[] FilterGenres(IReadOnlyDictionary<string, ResolvedTag> tags) + { + var config = Plugin.Instance.Configuration; + if (!config.GenreOverride) + return FilterInternal( + tags, + TagSource.SourceMaterial | TagSource.TargetAudience | TagSource.ContentIndicators | TagSource.Elements, + TagIncludeFilter.Child | TagIncludeFilter.Weightless, + TagWeight.Three + ); + return FilterInternal(tags, config.GenreSources, config.GenreIncludeFilters, config.GenreMinimumWeight); + } + + private static string[] FilterInternal(IReadOnlyDictionary<string, ResolvedTag> tags, TagSource source, TagIncludeFilter includeFilter, TagWeight minWeight = TagWeight.Weightless) + { + var tagSet = new List<string>(); + if (source.HasFlag(TagSource.SourceMaterial)) + tagSet.Add(GetSourceMaterial(tags)); + if (source.HasFlag(TagSource.TargetAudience) && tags.TryGetValue("/target audience", out var subTags)) + foreach (var tag in subTags.Children.Values.Select(SelectTagName)) + tagSet.Add(tag); + if (source.HasFlag(TagSource.ContentIndicators) && tags.TryGetValue("/content indicators", out subTags)) + foreach (var tag in subTags.RecursiveNamespacedChildren.Values.Select(SelectTagName)) + tagSet.Add(tag); + if (source.HasFlag(TagSource.Origin) && tags.TryGetValue("/origin", out subTags)) + foreach (var tag in subTags.RecursiveNamespacedChildren.Values.Select(SelectTagName)) + tagSet.Add(tag); + + if (source.HasAnyFlags(TagSource.SettingPlace, TagSource.SettingTimePeriod, TagSource.SettingTimeSeason) && tags.TryGetValue("/setting", out var setting)) { + if (source.HasFlag(TagSource.SettingPlace) && setting.Children.TryGetValue("place", out var place)) { + tagSet.AddRange(place.Children.Values.Select(SelectTagName)); + } + if (source.HasAnyFlags(TagSource.SettingTimePeriod, TagSource.SettingTimeSeason) && setting.Children.TryGetValue("time", out var time)) { + if (source.HasFlag(TagSource.SettingTimeSeason) && time.Children.TryGetValue("season", out var season)) + tagSet.AddRange(season.Children.Values.Select(SelectTagName)); + + if (source.HasFlag(TagSource.SettingTimePeriod)) { + if (time.Children.TryGetValue("present", out var present)) + tagSet.Add(present.Children.ContainsKey("alternative present") ? "Alternative Present" : "Present"); + if (time.Children.TryGetValue("future", out var future)) + tagSet.AddRange(future.Children.Values.Select(SelectTagName).Prepend("Future")); + if (time.Children.TryGetValue("past", out var past)) + tagSet.AddRange(past.Children.ContainsKey("alternative past") + ? new string[] { "Alternative Past" } + : past.Children.Values.Select(SelectTagName).Prepend("Historical Past") + ); + } + } + } + + var tagsToFilter = new List<ResolvedTag>(); + if (source.HasFlag(TagSource.Elements) && tags.TryGetValue("/elements", out subTags)) + tagsToFilter.AddRange(subTags.RecursiveNamespacedChildren.Values); + if (source.HasFlag(TagSource.Fetishes) && tags.TryGetValue("/fetishes", out subTags)) + tagsToFilter.AddRange(subTags.RecursiveNamespacedChildren.Values); + if (source.HasFlag(TagSource.Themes) && tags.TryGetValue("/themes", out subTags)) + tagsToFilter.AddRange(subTags.RecursiveNamespacedChildren.Values); + + if (source.HasFlag(TagSource.CustomTags) && tags.TryGetValue("/custom user tags", out var customTags)) { + tagSet.AddRange(customTags.Children.Values.Where(tag => !tag.IsParent).Select(SelectTagName)); + + if (source.HasFlag(TagSource.Elements) && customTags.Children.TryGetValue("elements", out subTags)) + tagsToFilter.AddRange(subTags.RecursiveNamespacedChildren.Values); + if (source.HasFlag(TagSource.Fetishes) && customTags.Children.TryGetValue("fetishes", out subTags)) + tagsToFilter.AddRange(subTags.RecursiveNamespacedChildren.Values); + if (source.HasFlag(TagSource.Themes) && customTags.Children.TryGetValue("themes", out subTags)) + tagsToFilter.AddRange(subTags.RecursiveNamespacedChildren.Values); + } + + foreach (var tag in tagsToFilter) + { + if (tag.IsWeightless && !includeFilter.HasFlag(TagIncludeFilter.Weightless)) + continue; + if (tag.IsLocalSpoiler && !includeFilter.HasFlag(TagIncludeFilter.LocalSpoiler)) + continue; + if (tag.IsGlobalSpoiler && !includeFilter.HasFlag(TagIncludeFilter.GlobalSpoiler)) + continue; + if (tag.IsParent ? !tag.IsWeightless && !includeFilter.HasFlag(TagIncludeFilter.Parent) : !includeFilter.HasFlag(TagIncludeFilter.Child)) + continue; + if (minWeight is > TagWeight.Weightless && !tag.IsWeightless && tag.Weight < minWeight) + continue; + tagSet.Add(SelectTagName(tag)); + } + + return tagSet + .Distinct() + .ToArray(); + } + + private static string GetSourceMaterial(IReadOnlyDictionary<string, ResolvedTag> tags) + { + if (!tags.TryGetValue("/source material", out var sourceMaterial) || sourceMaterial.Children.ContainsKey("Original Work")) + return "Original Work"; + + var firstSource = sourceMaterial.Children.Keys.OrderBy(material => material).FirstOrDefault()?.ToLowerInvariant(); + return firstSource switch { + "american derived" => "Adapted From Western Media", + "manga" => "Adapted From A Manga", + "manhua" => "Adapted From A Manhua", + "manhwa" => "Adapted from a Manhwa", + "movie" => "Adapted From A Live-Action Movie", + "novel" => "Adapted From A Novel", + "game" => sourceMaterial.Children[firstSource]!.Children.Keys.OrderBy(material => material).FirstOrDefault()?.ToLowerInvariant() switch { + "erotic game" => "Adapted From An Eroge", + "visual novel" => "Adapted From A Visual Novel", + _ => "Adapted From A Video Game", + }, + "television programme" => sourceMaterial.Children[firstSource]!.Children.Keys.OrderBy(material => material).FirstOrDefault()?.ToLowerInvariant() switch { + "korean drama" => "Adapted From A Korean Drama", + _ => "Adapted From A Live-Action Show", + }, + "radio programme" => "Radio Programme", + "western animated cartoon" => "Adapted From Western Media", + "western comics" => "Adapted From Western Media", + _ => "Original Work", + }; + } + + public static string[] GetProductionCountriesFromTags(IReadOnlyDictionary<string, ResolvedTag> tags) + { + if (!tags.TryGetValue("/origin", out var origin)) + return Array.Empty<string>(); + + 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" }, + "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>(), + }); + } + return productionCountries + .Distinct() + .ToArray(); + } + + private static string SelectTagName(Tag tag) + => System.Globalization.CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tag.Name); + + private static bool HasAnyFlags(this Enum value, params Enum[] candidates) + => candidates.Any(value.HasFlag); +} \ No newline at end of file From b5c5a5d8fbf62d462bc7fd5ef53960af44a4e136 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 7 Jun 2024 06:13:11 +0200 Subject: [PATCH 1049/1103] misc: fix release changelog order [skip ci] - Fixed the release changelog's item order by adding the overkill `git-log-json.mjs` script I use in combination with `jq` to format the log. --- .github/workflows/git-log-json.mjs | 111 ++++++++++++++++++++++++++++ .github/workflows/release-daily.yml | 5 +- 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100755 .github/workflows/git-log-json.mjs diff --git a/.github/workflows/git-log-json.mjs b/.github/workflows/git-log-json.mjs new file mode 100755 index 00000000..2bc06e84 --- /dev/null +++ b/.github/workflows/git-log-json.mjs @@ -0,0 +1,111 @@ +#! /bin/env node +import { execSync } from "child_process"; +import process from "process"; + +// https://git-scm.com/docs/pretty-formats/2.21.0 + +// Get the range or hash from the command line arguments +const RangeOrHash = process.argv[2] || ""; + +// Form the git log command +const GitLogCommandBase = `git log ${RangeOrHash}`; + +const Placeholders = { + "H": "commit", + "P": "parents", + "T": "tree", + "s": "subject", + "b": "body", + "an": "author_name", + "ae": "author_email", + "aI": "author_date", + "cn": "committer_name", + "ce": "committer_email", + "cI": "committer_date", +}; + +const commitOrder = []; +const commits = {}; + +for (const [placeholder, name] of Object.entries(Placeholders)) { + const gitCommand = `${GitLogCommandBase} --format="%H2>>>>> %${placeholder}"`; + const output = execSync(gitCommand).toString(); + const lines = output.split(/\r\n|\r|\n/g); + let commitId = ""; + + for (const line of lines) { + const match = line.match(/^([0-9a-f]{40})2>>>>> /); + if (match) { + commitId = match[1]; + if (!commits[commitId]) { + commitOrder.push(commitId); + commits[commitId] = {}; + } + // Handle multiple parent hashes + if (name === "parents") { + commits[commitId][name] = line.substring(match[0].length).trim().split(" "); + } + else { + commits[commitId][name] = line.substring(match[0].length).trimEnd(); + } + } + else if (commitId) { + if (name === "parents") { + const commits = line.trim().split(" ").filter(l => l); + if (commits.length) + commits[commitId][name].push(...commits); + } + else { + commits[commitId][name] += "\n" + line.trimEnd(); + } + } + } +} + +// Trim trailing newlines from all values in the commits object +for (const commit of Object.values(commits)) { + for (const key in commit) { + if (typeof commit[key] === "string") { + commit[key] = commit[key].trimEnd(); + } + } +} + +// Convert commits object to a list of values +const commitsList = commitOrder.slice().reverse() + .map((commitId) => commits[commitId]) + .map(({ commit, parents, tree, subject, body, author_name, author_email, author_date, committer_name, committer_email, committer_date }) => ({ + commit, + parents, + tree, + subject: /^\w+: /i.test(subject) ? subject.split(":").slice(1).join(":").trim() : subject.trim(), + type: /^\w+: /i.test(subject) ? + subject.split(":")[0].toLowerCase() + : subject.startsWith("Partially revert ") ? + "revert" + : parents.length > 1 ? + "merge" + : /^fix/i.test(subject) ? + "fix" + : "misc", + body, + author: { + name: author_name, + email: author_email, + date: new Date(author_date).toISOString(), + timeZone: author_date.substring(19) === "Z" ? "+00:00" : author_date.substring(19), + }, + committer: { + name: committer_name, + email: committer_email, + date: new Date(committer_date).toISOString(), + timeZone: committer_date.substring(19) === "Z" ? "+00:00" : committer_date.substring(19), + }, + })) + .map((commit) => ({ + ...commit, + type: commit.type == "feature" ? "feat" : commit.type === "refacor" ? "refactor" : commit.type, + })) + .filter((commit) => !(commit.type === "misc" && (commit.subject === "update unstable manifest" || commit.subject === "Update repo manifest" || commit.subject === "Update unstable repo manifest"))); + +process.stdout.write(JSON.stringify(commitsList, null, 2)); diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 4070877e..06ce9ea0 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -69,8 +69,8 @@ jobs: run: | EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) echo "CHANGELOG<<$EOF" >> "$GITHUB_OUTPUT" - git log $PREVIOUS_COMMIT..$NEXT_COMMIT --pretty=format:"%B" | head -c -2 >> "$GITHUB_OUTPUT" - echo -e "\n$EOF" >> "$GITHUB_OUTPUT" + node .github/workflows/git-log-json.mjs $PREVIOUS_COMMIT..$NEXT_COMMIT | jq -r '.[] | "`\(.type)`: **\(.subject)**" + if .body != null and .body != "" then ":\n\n\(.body)\n" else "" end' >> "$GITHUB_OUTPUT" + echo -e "$EOF" >> "$GITHUB_OUTPUT" build_plugin: runs-on: ubuntu-latest @@ -177,4 +177,5 @@ jobs: Update your plugin using the [dev manifest](https://raw.githubusercontent.com/${{ github.repository }}/metadata/dev/manifest.json) or by downloading the release from [GitHub Releases](https://github.com/${{ github.repository }}/releases/tag/${{ needs.current_info.outputs.tag }}) and installing it manually! **Changes since last build**: + ${{ needs.current_info.outputs.changelog }} From f755b32284c606d28e44cf86b2830d8dc6389fc3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 7 Jun 2024 06:19:27 +0200 Subject: [PATCH 1050/1103] misc: fix release changelog order (take 2) [skip ci] - Added the missing type and better formatting. --- .github/workflows/git-log-json.mjs | 3 ++- .github/workflows/release-daily.yml | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/git-log-json.mjs b/.github/workflows/git-log-json.mjs index 2bc06e84..6076f345 100755 --- a/.github/workflows/git-log-json.mjs +++ b/.github/workflows/git-log-json.mjs @@ -104,7 +104,8 @@ const commitsList = commitOrder.slice().reverse() })) .map((commit) => ({ ...commit, - type: commit.type == "feature" ? "feat" : commit.type === "refacor" ? "refactor" : commit.type, + subject: /[a-z]/.test(commit.subject[0]) ? commit.subject[0].toUpperCase() + commit.subject.slice(1) : commit.subject, + type: commit.type == "feature" ? "feat" : commit.type === "refacor" ? "refactor" : commit.type == "mics" ? "misc" : commit.type, })) .filter((commit) => !(commit.type === "misc" && (commit.subject === "update unstable manifest" || commit.subject === "Update repo manifest" || commit.subject === "Update unstable repo manifest"))); diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 06ce9ea0..88acf832 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -69,8 +69,8 @@ jobs: run: | EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) echo "CHANGELOG<<$EOF" >> "$GITHUB_OUTPUT" - node .github/workflows/git-log-json.mjs $PREVIOUS_COMMIT..$NEXT_COMMIT | jq -r '.[] | "`\(.type)`: **\(.subject)**" + if .body != null and .body != "" then ":\n\n\(.body)\n" else "" end' >> "$GITHUB_OUTPUT" - echo -e "$EOF" >> "$GITHUB_OUTPUT" + node .github/workflows/git-log-json.mjs $PREVIOUS_COMMIT..$NEXT_COMMIT | jq -r '.[] | "\n`\(.type)`: **\(.subject)**" + if .body != null and .body != "" then ":\n\n\(.body)" else "." end' >> "$GITHUB_OUTPUT" + echo -e "\n$EOF" >> "$GITHUB_OUTPUT" build_plugin: runs-on: ubuntu-latest @@ -177,5 +177,4 @@ jobs: Update your plugin using the [dev manifest](https://raw.githubusercontent.com/${{ github.repository }}/metadata/dev/manifest.json) or by downloading the release from [GitHub Releases](https://github.com/${{ github.repository }}/releases/tag/${{ needs.current_info.outputs.tag }}) and installing it manually! **Changes since last build**: - ${{ needs.current_info.outputs.changelog }} From bb66e639d3744b2113492010cbcd153e43d5bdf1 Mon Sep 17 00:00:00 2001 From: Mikal S <7761729+revam@users.noreply.github.com> Date: Fri, 7 Jun 2024 06:46:45 +0200 Subject: [PATCH 1051/1103] chore: update read-me. [skip ci] --- README.md | 63 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index bc64f217..fb086aa6 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ compatible with what. ## Feature Overview -- [/] Metadata integration +- [ ] Metadata integration - [X] Basic metadata, e.g. titles, description, dates, etc. @@ -110,34 +110,48 @@ compatible with what. - [X] Customizable description source for items - Choose between AniDB, TvDB, or a mix of the two. + Choose between AniDB, TvDB, TMDB, or a mix of the three. - [X] Support optionally adding titles and descriptions for all episodes for multi-entry files. - [X] Genres + With settings to choose which tags to add as genres. + - [X] Tags - With some settings to choose which tags to add. + With settings to choose which tags to add as tags. - - [/] Voice Actors + - [X] Official Ratings - - [X] Displayed on the Show/Season/Movie items + Currently only _assumed_ ratings using AniDB tags or manual overrides using custom user tags are available. Also with settings to choose which providers to use. + + - [X] Production Locations - - [ ] Person provider for image and details + With settings to chose which provider to use. - - [/] General staff (e.g. producer, writer, etc.) + - [ ] Staff - [X] Displayed on the Show/Season/Movie items - - [ ] Person provider for image and details + - [X] Images - - [/] Studios + - [ ] Metadata Provider + + _Needs to add endpoints to the Shoko Server side first._ + + - [ ] Studios - [X] Displayed on the Show/Season/Movie items - - [ ] Studio provider for image and details + - [ ] Images + + _Needs to add support and endpoints to the Shoko Server side **or** fake it client-side first._ + + - [ ] Metadata Provider + + _Needs to add support and endpoints to the Shoko Server side **or** fake it client-side first._ - [X] Library integration @@ -149,6 +163,8 @@ compatible with what. - [X] Mixed show/movie library. + _As long as the VFS is in use for the media library. Also keep in mind that this library type is poorly supported in Jellyfin Core, and we can't work around the poor internal support, so you'll have to take what you get or leave it as is._ + - [X] Supports adding local trailers - [X] on Show items @@ -178,31 +194,26 @@ compatible with what. - [X] Manual merge/split tasks - - [X] Support optionally setting other provider IDs Shoko knows about (e.g. - AniDB, TvDB, TMDB, etc.) on some item types when an ID is available for - the items in Shoko. + - [X] Support optionally setting other provider IDs Shoko knows about on some item types when an ID is available for the items in Shoko. + + _Only AniDB and TMDB IDs are available for now._ - [X] Multiple ways to organize your library. - - [X] Choose between three ways to group your Shows/Seasons; no grouping, - following TvDB (to-be replaced with TMDB soon™-ish), and using Shoko's - groups feature. + - [X] Choose between two ways to group your Shows/Seasons; using AniDB Anime structure (the default mode), or using Shoko Groups. - _For the best compatibility it is **strongly** advised **not** to use - "season" folders with anime as it limits which grouping you can use, you - can still create "seasons" in the UI using Shoko's groups or using the - TvDB/TMDB compatibility mode._ + _For the best compatibility if you're not using the VFS it is **strongly** advised **not** to use "season" folders with anime as it limits which grouping you can use, you can still create "seasons" in the UI using Shoko's groups._ - - [X] Optionally create Box-Sets for your Movies… + - [X] Optionally create Collections for… - - [X] using the Shoko series. + - [X] Movies using the Shoko series. - - [X] using the Shoko groups. + - [X] Movies and Shows using the Shoko groups. - [X] Supports separating your on-disc library into a two Show and Movie libraries. - _Provided you apply the workaround to do it_.¹ + _Provided you apply the workaround to support it_. - [X] Automatically populates all missing episodes not in your collection, so you can see at a glance what you are missing out on. @@ -224,3 +235,7 @@ compatible with what. play/resume event or when jumping) - [X] Import and export user data tasks + +- [X] Virtual File System (VFS) + + _Allows us to disregard the underlying disk file structure while automagically meeting Jellyfin's requirements for file organization._ From 7563f0e2c27f3a75f6a182743268c934f9c54c40 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 8 Jun 2024 20:20:36 +0200 Subject: [PATCH 1052/1103] feat: add custom series type override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added support for manually overriding the series type of a shoko series if needed using custom tags, e.g. to map _that one_ movie series you've been thinking should had been a show and not a collection of movies in your library, etc.. Use at your own discretion. 🙂 To set an override you will simply assign it one of the following custom user tags; - `series type/tv` → `TV` - `series type/tvspecial` → `TVSpecial` - `series type/tvspecials` → `TVSpecial` - `series type/tv special` → `Series` - `series type/tv specials` → `Series` - `series type/web` → `Web` - `series type/movie` → `Movie` - `series type/movies` → `Movie` - `series type/ova` → `OVA` - `series type/ovas` → `OVA` - `series type/other` → `Other` - `series type/others` → `Other` The tags are case insensitive, and the trailing 's' is optional in case you want to use plural form for the tag. Setting multiple tags will make it set the type from the first assigned tag, but it won't break anything. --- Shokofin/API/Info/SeasonInfo.cs | 14 ++++++++++---- Shokofin/API/ShokoAPIManager.cs | 25 +++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index abe58c29..2dc9e792 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -22,6 +22,12 @@ public class SeasonInfo public readonly SeriesType Type; + /// <summary> + /// Indicates that the season have been mapped to a different type, either + /// manually or automagically. + /// </summary> + public bool IsCustomType => Type != AniDB.Type; + /// <summary> /// The date of the earliest imported file, or when the series was created /// in shoko if no files are imported yet. @@ -97,7 +103,7 @@ public class SeasonInfo /// </summary> public readonly IReadOnlyDictionary<string, RelationType> RelationMap; - public SeasonInfo(Series series, IEnumerable<string> extraIds, DateTime? earliestImportedAt, DateTime? lastImportedAt, List<EpisodeInfo> episodes, List<Role> cast, List<Relation> relations, string[] genres, string[] tags, string[] productionLocations, string? contentRating) + public SeasonInfo(Series series, SeriesType? customType, IEnumerable<string> extraIds, DateTime? earliestImportedAt, DateTime? lastImportedAt, List<EpisodeInfo> episodes, List<Role> cast, List<Relation> relations, string[] genres, string[] tags, string[] productionLocations, string? contentRating) { var seriesId = series.IDs.Shoko.ToString(); var studios = cast @@ -167,10 +173,10 @@ public SeasonInfo(Series series, IEnumerable<string> extraIds, DateTime? earlies // Replace the normal episodes if we've hidden all the normal episodes and we have at least one // alternate episode locally. - var type = series.AniDBEntity.Type; + var type = customType ?? series.AniDBEntity.Type; if (episodesList.Count == 0 && altEpisodesList.Count > 0) { // Switch the type from movie to web if we've hidden the main movie, and we have some of the parts. - if (type == SeriesType.Movie) + if (!customType.HasValue && type == SeriesType.Movie) type = SeriesType.Web; episodesList = altEpisodesList; @@ -195,7 +201,7 @@ public SeasonInfo(Series series, IEnumerable<string> extraIds, DateTime? earlies } } // Also switch the type from movie to web if we're hidden the main movies, but the parts are normal episodes. - else if (type == SeriesType.Movie && episodes.Any(episodeInfo => string.Equals(episodeInfo.AniDB.Titles.FirstOrDefault(title => title.LanguageCode == "en")?.Value, "Complete Movie", StringComparison.InvariantCultureIgnoreCase) && episodeInfo.Shoko.IsHidden)) { + else if (!customType.HasValue && type == SeriesType.Movie && episodes.Any(episodeInfo => episodeInfo.AniDB.Titles.Any(title => title.LanguageCode == "en" && string.Equals(title.Value, "Complete Movie", StringComparison.InvariantCultureIgnoreCase)) && episodeInfo.Shoko.IsHidden)) { type = SeriesType.Web; } diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 61e40c38..ae946d34 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -302,6 +302,26 @@ private async Task<string[]> GetProductionLocations(string seriesId) return ContentRating.GetTagBasedContentRating(tags); } + private async Task<SeriesType?> GetCustomSeriesType(string seriesId) + { + var tags = await GetNamespacedTagsForSeries(seriesId); + if (tags.TryGetValue("/custom user tags/series type", out var seriesTypeTag) && + seriesTypeTag.Children.Count is > 1 && + Enum.TryParse<SeriesType>(NormalizeCustomSeriesType(seriesTypeTag.Children.Keys.First()), out var seriesType) && + seriesType is not SeriesType.Unknown + ) + return seriesType; + return null; + } + + private static string NormalizeCustomSeriesType(string seriesType) + { + seriesType = seriesType.ToLowerInvariant().Replace(" ", ""); + if (seriesType[^1] == 's') + seriesType = seriesType[..^1]; + return seriesType; + } + #endregion #region Path Set And Local Episode IDs @@ -668,6 +688,7 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) Logger.LogTrace("Creating info object for season {SeriesName}. (Series={SeriesId},ExtraSeries={ExtraIds})", series.Name, seriesId, extraIds); + var customSeriesType = await GetCustomSeriesType(seriesId).ConfigureAwait(false); var contentRating = await GetAssumedContentRating(seriesId).ConfigureAwait(false); var (earliestImportedAt, lastImportedAt) = await GetEarliestImportedAtForSeries(seriesId).ConfigureAwait(false); var episodes = (await Task.WhenAll( @@ -715,14 +736,14 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) .ToArray(); // Create the season info using the merged details. - seasonInfo = new SeasonInfo(series, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags, productionLocations, contentRating); + seasonInfo = new SeasonInfo(series, customSeriesType, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags, productionLocations, contentRating); } else { var cast = await APIClient.GetSeriesCast(seriesId).ConfigureAwait(false); var relations = await APIClient.GetSeriesRelations(seriesId).ConfigureAwait(false); var genres = await GetGenresForSeries(seriesId).ConfigureAwait(false); var tags = await GetTagsForSeries(seriesId).ConfigureAwait(false); var productionLocations = await GetProductionLocations(seriesId).ConfigureAwait(false); - seasonInfo = new SeasonInfo(series, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags, productionLocations, contentRating); + seasonInfo = new SeasonInfo(series, customSeriesType, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags, productionLocations, contentRating); } foreach (var episode in episodes) From 00b47618da321414e8ef81b2f35949e03bf08236 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 8 Jun 2024 20:42:02 +0200 Subject: [PATCH 1053/1103] fix: fix ending date for shows with "unaired" seasons in them. --- Shokofin/API/Info/ShowInfo.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index 9fa56ac8..4806f3dd 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -62,8 +62,9 @@ public class ShowInfo /// Ended date of the show. /// </summary> public DateTime? EndDate => - SeasonList.Any(s => s.AniDB.EndDate == null) ? null : SeasonList - .Select(s => s.AniDB.AirDate) + SeasonList.Any(s => s.AniDB.AirDate.HasValue && s.AniDB.AirDate.Value < DateTime.Now && s.AniDB.EndDate == null) ? null : SeasonList + .Where(s => s.AniDB.EndDate.HasValue) + .Select(s => s.AniDB.EndDate!.Value) .OrderBy(s => s) .LastOrDefault(); From 61e56dbd393863fbfb1327fc56ef69aef42cf218 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 8 Jun 2024 22:05:03 +0200 Subject: [PATCH 1054/1103] chore: fix release changelog (again) [skip ci] --- .github/workflows/git-log-json.mjs | 10 ++++++++-- .github/workflows/release-daily.yml | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/git-log-json.mjs b/.github/workflows/git-log-json.mjs index 6076f345..b1b72548 100755 --- a/.github/workflows/git-log-json.mjs +++ b/.github/workflows/git-log-json.mjs @@ -78,8 +78,8 @@ const commitsList = commitOrder.slice().reverse() commit, parents, tree, - subject: /^\w+: /i.test(subject) ? subject.split(":").slice(1).join(":").trim() : subject.trim(), - type: /^\w+: /i.test(subject) ? + subject: /^\s*\w+: /i.test(subject) ? subject.split(":").slice(1).join(":").trim() : subject.trim(), + type: /^\s*\w+: /i.test(subject) ? subject.split(":")[0].toLowerCase() : subject.startsWith("Partially revert ") ? "revert" @@ -107,6 +107,12 @@ const commitsList = commitOrder.slice().reverse() subject: /[a-z]/.test(commit.subject[0]) ? commit.subject[0].toUpperCase() + commit.subject.slice(1) : commit.subject, 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)), + })) .filter((commit) => !(commit.type === "misc" && (commit.subject === "update unstable manifest" || commit.subject === "Update repo manifest" || commit.subject === "Update unstable repo manifest"))); process.stdout.write(JSON.stringify(commitsList, null, 2)); diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index 88acf832..bda861d6 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -69,7 +69,7 @@ jobs: run: | EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) echo "CHANGELOG<<$EOF" >> "$GITHUB_OUTPUT" - node .github/workflows/git-log-json.mjs $PREVIOUS_COMMIT..$NEXT_COMMIT | jq -r '.[] | "\n`\(.type)`: **\(.subject)**" + if .body != null and .body != "" then ":\n\n\(.body)" else "." end' >> "$GITHUB_OUTPUT" + node .github/workflows/git-log-json.mjs $PREVIOUS_COMMIT..$NEXT_COMMIT | jq -r '.[] | "\n`\(.type)`: **\(.subject)**" + if .body != null and .body != "" then if .isSkipCI then ": (_Skip CI_)\n\n\(.body)" else ":\n\n\(.body)" end else if .isSkipCI then ". (_Skip CI_)" else "." end end' >> "$GITHUB_OUTPUT" echo -e "\n$EOF" >> "$GITHUB_OUTPUT" build_plugin: @@ -140,7 +140,7 @@ jobs: ${{ needs.current_info.outputs.changelog }} prerelease: true fail_on_unmatched_files: true - generate_release_notes: true + generate_release_notes: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 95c460cf013a7d8d5a8c7a1d25483b8a6a848949 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 9 Jun 2024 17:37:53 +0200 Subject: [PATCH 1055/1103] fix: fix library monitor + more - Fixed the Shoko library monitor that runs if you enable real time monitoring on a library with the VFS enabled, so it properly only sends a single event per change per second. - Fixed the Shoko library monitor so it properly can send a deleted/removed event even if the file data is gone from shoko. - Fixed the event dispatcher so it uses the correct logger. - Fixed the event dispatcher so it properly removes the files and notifies jellyfin core when a file has been removed. - Added more verbose/trace logging statements. - Fixed it so it will gracefully continue if a file is not found in shoko when generating VFS links. --- Shokofin/API/ShokoAPIManager.cs | 10 ++- Shokofin/Events/EventDispatchService.cs | 97 +++++++++++++-------- Shokofin/Events/Stub/FileEventArgsStub.cs | 17 ++-- Shokofin/Resolvers/ShokoLibraryMonitor.cs | 101 +++++++++++++++------- 4 files changed, 150 insertions(+), 75 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index ae946d34..d85c7e08 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -511,7 +511,15 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu if (DataCache.TryGetValue<FileInfo>(cacheKey, out var fileInfo)) return fileInfo; - var file = await APIClient.GetFile(fileId).ConfigureAwait(false); + // Gracefully return if we can't find the file. + File file; + try { + file = await APIClient.GetFile(fileId).ConfigureAwait(false); + } + catch (ApiException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { + return null; + } + return await CreateFileInfo(file, fileId, seriesId).ConfigureAwait(false); } diff --git a/Shokofin/Events/EventDispatchService.cs b/Shokofin/Events/EventDispatchService.cs index 5d7ceaa8..eb8e13d4 100644 --- a/Shokofin/Events/EventDispatchService.cs +++ b/Shokofin/Events/EventDispatchService.cs @@ -49,7 +49,7 @@ public class EventDispatchService private readonly IDirectoryService DirectoryService; - private readonly ILogger<VirtualFileSystemService> Logger; + private readonly ILogger<EventDispatchService> Logger; private int ChangesDetectionSubmitterCount = 0; @@ -76,7 +76,7 @@ public EventDispatchService( LibraryScanWatcher libraryScanWatcher, IFileSystem fileSystem, IDirectoryService directoryService, - ILogger<VirtualFileSystemService> logger + ILogger<EventDispatchService> logger ) { ApiManager = apiManager; @@ -214,6 +214,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int var mediaFolders = ConfigurationService.GetAvailableMediaFolders(fileEvents: true); var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); if (reason is not UpdateReason.Removed) { + Logger.LogTrace("Processing file changed. (File={FileId})", fileId); foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) continue; @@ -244,16 +245,20 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int .GetItemList( new() { AncestorIds = new Guid[] { mediaFolder.Id }, - IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Episode, BaseItemKind.Movie }, HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, DtoOptions = new(true), }, true - ) - .Where(item => !string.IsNullOrEmpty(item.Path) && item.Path.StartsWith(vfsPath) && !result.Paths.Contains(item.Path)) - .ToList(); + ); + Logger.LogTrace("Found {Count} potential videos to remove", videos.Count); foreach (var video in videos) { - File.Delete(video.Path); + if (string.IsNullOrEmpty(video.Path) || !video.Path.StartsWith(vfsPath) || result.Paths.Contains(video.Path)) { + Logger.LogTrace("Skipped a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); + continue; + } + Logger.LogTrace("Found a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); + if (File.Exists(video.Path)) + File.Delete(video.Path); topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); locationsToNotify.Add(video.Path); result.RemovedVideos++; @@ -262,8 +267,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int result.Print(Logger, mediaFolder.Path); // If all the "top-level-folders" exist, then let the core logic handle the rest. - if (topFolders.All(path => LibraryManager.FindByPath(path, true) != null)) { - var old = locationsToNotify.Count; + if (topFolders.All(path => LibraryManager.FindByPath(path, true) is not null)) { locationsToNotify.AddRange(vfsLocations.SelectMany(tuple => tuple.symbolicLinks)); } // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. @@ -275,7 +279,8 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int } } // Something was removed, so assume the location is gone. - else if (changes.FirstOrDefault(t => t.Reason is UpdateReason.Removed).Event is IFileRelocationEventArgs firstRemovedEvent) { + else if (changes.FirstOrDefault(t => t.Reason is UpdateReason.Removed).Event is IFileEventArgs firstRemovedEvent) { + Logger.LogTrace("Processing file removed. (File={FileId})", fileId); relativePath = firstRemovedEvent.RelativePath; importFolderId = firstRemovedEvent.ImportFolderId; foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { @@ -293,9 +298,8 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int var result = new LinkGenerationResult(); var vfsSymbolicLinks = new HashSet<string>(); var topFolders = new HashSet<string>(); - var newRelativePath = await GetNewRelativePath(config, fileId, relativePath); - if (!string.IsNullOrEmpty(newRelativePath)) { - var newSourceLocation = Path.Join(mediaFolder.Path, newRelativePath[config.ImportFolderRelativePath.Length..]); + var newSourceLocation = await GetNewSourceLocation(config, fileId, relativePath, mediaFolder.Path); + if (!string.IsNullOrEmpty(newSourceLocation)) { var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, newSourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) .ToList(); @@ -311,17 +315,20 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int var videos = LibraryManager .GetItemList( new() { - AncestorIds = new Guid[] { mediaFolder.Id }, - IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Episode, BaseItemKind.Movie }, HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, DtoOptions = new(true), }, true - ) - .Where(item => !string.IsNullOrEmpty(item.Path) && item.Path.StartsWith(vfsPath) && !result.Paths.Contains(item.Path)) - .ToList(); + ); + Logger.LogTrace("Found {Count} potential videos to remove", videos.Count); foreach (var video in videos) { - File.Delete(video.Path); + if (string.IsNullOrEmpty(video.Path) || !video.Path.StartsWith(vfsPath) || result.Paths.Contains(video.Path)) { + Logger.LogTrace("Skipped a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); + continue; + } + Logger.LogTrace("Found a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); + if (File.Exists(video.Path)) + File.Delete(video.Path); topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); locationsToNotify.Add(video.Path); result.RemovedVideos++; @@ -331,7 +338,6 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int // If all the "top-level-folders" exist, then let the core logic handle the rest. if (topFolders.All(path => LibraryManager.FindByPath(path, true) is not null)) { - var old = locationsToNotify.Count; locationsToNotify.AddRange(vfsSymbolicLinks); } // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. @@ -343,17 +349,17 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int } } - // We let jellyfin take it from here. - if (!LibraryScanWatcher.IsScanRunning) { - Logger.LogDebug("Notifying Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count + mediaFoldersToNotify.Count, fileId.ToString()); - foreach (var location in locationsToNotify) - LibraryMonitor.ReportFileSystemChanged(location); - if (mediaFoldersToNotify.Count > 0) - await Task.WhenAll(mediaFoldersToNotify.Values.Select(tuple => ReportMediaFolderChanged(tuple.mediaFolder, tuple.pathToReport))).ConfigureAwait(false); - } - else { + if (LibraryScanWatcher.IsScanRunning) { Logger.LogDebug("Skipped notifying Jellyfin about {LocationCount} changes because a library scan is running. (File={FileId})", locationsToNotify.Count, fileId.ToString()); + return; } + + // We let jellyfin take it from here. + Logger.LogDebug("Notifying Jellyfin about {LocationCount} changes. (File={FileId})", locationsToNotify.Count + mediaFoldersToNotify.Count, fileId.ToString()); + foreach (var location in locationsToNotify) + LibraryMonitor.ReportFileSystemChanged(location); + if (mediaFoldersToNotify.Count > 0) + await Task.WhenAll(mediaFoldersToNotify.Values.Select(tuple => ReportMediaFolderChanged(tuple.mediaFolder, tuple.pathToReport))).ConfigureAwait(false); } catch (Exception ex) { Logger.LogError(ex, "Error processing {EventCount} file change events. (File={FileId})", changes.Count, fileId); @@ -363,16 +369,24 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEventArgs? fileEvent) { HashSet<string> seriesIds; - if (fileEvent is not null && fileEvent.CrossReferences.All(xref => xref.ShokoSeriesId.HasValue && xref.ShokoEpisodeId.HasValue)) + if (fileEvent is not null && fileEvent.CrossReferences.All(xref => xref.ShokoSeriesId.HasValue && xref.ShokoEpisodeId.HasValue)) { seriesIds = fileEvent.CrossReferences.Select(xref => xref.ShokoSeriesId!.Value.ToString()) .Distinct() .ToHashSet(); - else - seriesIds = (await ApiClient.GetFile(fileId.ToString())).CrossReferences - .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) - .Select(xref => xref.Series.Shoko!.Value.ToString()) - .Distinct() - .ToHashSet(); + } + else { + try { + var file = await ApiClient.GetFile(fileId.ToString()); + seriesIds = file.CrossReferences + .Where(xref => xref.Series.Shoko.HasValue && xref.Episodes.All(e => e.Shoko.HasValue)) + .Select(xref => xref.Series.Shoko!.Value.ToString()) + .Distinct() + .ToHashSet(); + } + catch (ApiException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { + return new HashSet<string>(); + } + } // TODO: Postpone the processing of the file if the episode or series is not available yet. @@ -391,7 +405,7 @@ private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEv return filteredSeriesIds.Count is 0 ? seriesIds : filteredSeriesIds; } - private async Task<string?> GetNewRelativePath(MediaFolderConfiguration config, int fileId, string relativePath) + private async Task<string?> GetNewSourceLocation(MediaFolderConfiguration config, int fileId, string relativePath, string mediaFolderPath) { // Check if the file still exists, and if it has any other locations we can use. try { @@ -399,7 +413,14 @@ private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEv var usableLocation = file.Locations .Where(loc => loc.ImportFolderId == config.ImportFolderId && config.IsEnabledForPath(loc.RelativePath) && loc.RelativePath != relativePath) .FirstOrDefault(); - return usableLocation?.RelativePath; + if (usableLocation is null) + return null; + + var sourceLocation = Path.Join(mediaFolderPath, usableLocation.RelativePath[config.ImportFolderRelativePath.Length..]); + if (!File.Exists(sourceLocation)) + return null; + + return sourceLocation; } catch (ApiException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { return null; diff --git a/Shokofin/Events/Stub/FileEventArgsStub.cs b/Shokofin/Events/Stub/FileEventArgsStub.cs index adbfa7d2..6211aba0 100644 --- a/Shokofin/Events/Stub/FileEventArgsStub.cs +++ b/Shokofin/Events/Stub/FileEventArgsStub.cs @@ -25,16 +25,21 @@ public class FileEventArgsStub : IFileEventArgs /// <inheritdoc/> public List<IFileEventArgs.FileCrossReference> CrossReferences { get; private init; } + public FileEventArgsStub(int fileId, int? fileLocationId, int importFolderId, string relativePath, IEnumerable<IFileEventArgs.FileCrossReference> xrefs) + { + FileId = fileId; + FileLocationId = fileLocationId; + ImportFolderId = importFolderId; + RelativePath = relativePath; + CrossReferences = xrefs.ToList(); + } + public FileEventArgsStub(File.Location location, File file) { FileId = file.Id; - ImportFolderId = location.ImportFolderId; - RelativePath = location.RelativePath - .Replace('/', System.IO.Path.DirectorySeparatorChar) - .Replace('\\', System.IO.Path.DirectorySeparatorChar); - if (RelativePath[0] != System.IO.Path.DirectorySeparatorChar) - RelativePath = System.IO.Path.DirectorySeparatorChar + RelativePath; FileLocationId = location.Id; + ImportFolderId = location.ImportFolderId; + RelativePath = location.RelativePath; CrossReferences = file.CrossReferences .SelectMany(xref => xref.Episodes.Select(episodeXref => new IFileEventArgs.FileCrossReference() { AnidbEpisodeId = episodeXref.AniDB, diff --git a/Shokofin/Resolvers/ShokoLibraryMonitor.cs b/Shokofin/Resolvers/ShokoLibraryMonitor.cs index 7de9fd13..faef27f3 100644 --- a/Shokofin/Resolvers/ShokoLibraryMonitor.cs +++ b/Shokofin/Resolvers/ShokoLibraryMonitor.cs @@ -14,9 +14,12 @@ using Shokofin.Events; using Shokofin.Events.Interfaces; using Shokofin.Events.Stub; +using Shokofin.ExternalIds; using Shokofin.Resolvers.Models; using Shokofin.Utils; +using ApiException = Shokofin.API.Models.ApiException; + namespace Shokofin.Resolvers; public class ShokoLibraryMonitor : IServerEntryPoint, IDisposable @@ -37,6 +40,8 @@ public class ShokoLibraryMonitor : IServerEntryPoint, IDisposable private readonly NamingOptions NamingOptions; + private readonly GuardedMemoryCache Cache; + private readonly ConcurrentDictionary<string, ShokoWatcher> FileSystemWatchers = new(); /// <summary> @@ -70,6 +75,7 @@ NamingOptions namingOptions LibraryScanWatcher = libraryScanWatcher; LibraryScanWatcher.ValueChanged += OnLibraryScanRunningChanged; NamingOptions = namingOptions; + Cache = new(logger, TimeSpan.FromSeconds(1), new() { ExpirationScanFrequency = TimeSpan.FromSeconds(30) }, new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(1) }); } ~ShokoLibraryMonitor() @@ -255,43 +261,78 @@ public async Task ReportFileSystemChanged(MediaFolderConfiguration mediaConfig, await Task.Delay(MagicalDelay).ConfigureAwait(false); - if (!File.Exists(path)) { + if (changeTypes is not WatcherChangeTypes.Deleted && !File.Exists(path)) { Logger.LogTrace("Skipped path because it is disappeared after awhile before we could process it; {Path}", path); return; } - var relativePath = path[mediaConfig.MediaFolderPath.Length..]; - var files = await ApiClient.GetFileByPath(relativePath); - var file = files.FirstOrDefault(file => file.Locations.Any(location => location.ImportFolderId == mediaConfig.ImportFolderId && location.RelativePath == relativePath)); - if (file is null) { - Logger.LogTrace("Skipped file because it is not a shoko managed file; {Path}", path); - return; - } - - var reason = changeTypes == WatcherChangeTypes.Deleted ? UpdateReason.Removed : changeTypes == WatcherChangeTypes.Created ? UpdateReason.Added : UpdateReason.Updated; - var fileLocation = file.Locations.First(location => location.ImportFolderId == mediaConfig.ImportFolderId && location.RelativePath == relativePath); - Logger.LogDebug( - "File {EventName}; {ImportFolderId} {Path} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", - reason, - fileLocation.ImportFolderId, - relativePath, - file.Id, - fileLocation.Id, - true - ); + await Cache.GetOrCreateAsync( + path, + (_) => Logger.LogTrace("Skipped path because it was handled within a second ago; {Path}", path), + async (_) => { + string? fileId = null; + IFileEventArgs eventArgs; + var reason = changeTypes is WatcherChangeTypes.Deleted ? UpdateReason.Removed : changeTypes is WatcherChangeTypes.Created ? UpdateReason.Added : UpdateReason.Updated; + var relativePath = path[mediaConfig.MediaFolderPath.Length..]; + try { + var files = await ApiClient.GetFileByPath(relativePath); + var file = files.FirstOrDefault(file => file.Locations.Any(location => location.ImportFolderId == mediaConfig.ImportFolderId && location.RelativePath == 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); + return null; + } + if (LibraryManager.FindByPath(path, false) is not Video video) { + Logger.LogTrace("Skipped path because it is not a shoko managed file; {Path}", path); + return null; + } + if (!video.ProviderIds.TryGetValue(ShokoFileId.Name, out fileId)) { + Logger.LogTrace("Skipped path because it is not a shoko managed file; {Path}", path); + return null; + } + // It may throw an ApiException with 404 here, + file = await ApiClient.GetFile(fileId); + } + + var fileLocation = file.Locations.First(location => location.ImportFolderId == mediaConfig.ImportFolderId && location.RelativePath == relativePath); + eventArgs = new FileEventArgsStub(fileLocation, file); + } + // which we catch here. + catch (ApiException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { + if (fileId is null) { + Logger.LogTrace("Skipped path because it is not a shoko managed file; {Path}", path); + return null; + } + + 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>()); + } - if (LibraryScanWatcher.IsScanRunning) { - Logger.LogTrace( - "Library scan is running. Skipping emit of file event. (File={FileId},Location={LocationId})", - file.Id, - fileLocation.Id - ); - return; - } + Logger.LogDebug( + "File {EventName}; {ImportFolderId} {Path} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", + reason, + eventArgs.ImportFolderId, + relativePath, + eventArgs.FileId, + eventArgs.FileLocationId, + true + ); + + if (LibraryScanWatcher.IsScanRunning) { + Logger.LogTrace( + "Library scan is running. Skipping emit of file event. (File={FileId},Location={LocationId})", + eventArgs.FileId, + eventArgs.FileLocationId + ); + return null; + } - Events.AddFileEvent(file.Id, reason, fileLocation.ImportFolderId, relativePath, new FileEventArgsStub(fileLocation, file)); + Events.AddFileEvent(eventArgs.FileId, reason, eventArgs.ImportFolderId, relativePath, eventArgs); + return eventArgs; + } + ); } + private bool IsVideoFile(string path) => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path)); - } From 00feb9daf5ae61925cc25436090f2631474b66e2 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 9 Jun 2024 17:38:10 +0200 Subject: [PATCH 1056/1103] chore: fix ember icon in discord notification --- .github/workflows/release-daily.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-daily.yml b/.github/workflows/release-daily.yml index bda861d6..ea7ff26d 100644 --- a/.github/workflows/release-daily.yml +++ b/.github/workflows/release-daily.yml @@ -169,7 +169,7 @@ jobs: embed-color: 9985983 embed-timestamp: ${{ needs.current_info.outputs.date }} embed-author-name: Shokofin | New Dev Build - embed-author-icon-url: https://raw.githubusercontent.com/${{ github.repository }}/master/.github/images/jellyfin.png + embed-author-icon-url: https://raw.githubusercontent.com/${{ github.repository }}/dev/.github/images/jellyfin.png embed-author-url: https://github.com/${{ github.repository }} embed-description: | **Version**: `${{ needs.current_info.outputs.version }}` (`${{ needs.current_info.outputs.sha_short }}`) From 2200f7de2c8e3de46f3fedeff0244c5e79621710 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 10 Jun 2024 23:05:27 +0200 Subject: [PATCH 1057/1103] fix: Properly resolve public url - Fix a long standing issue where Jellyfin cannot properly resolve the image when a public url is set because it cannot resolve the public url. - Closes #57. --- Shokofin/Configuration/configPage.html | 2 +- Shokofin/Providers/ImageProvider.cs | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index a202f30a..dd82ae19 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1238,7 +1238,7 @@ <h3>Advanced Settings</h3> </legend> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="text" id="PublicUrl" label="Public Shoko host URL:" /> - <div class="fieldDescription">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. If provided, then it should also be possible for Jellyfin to use the URL to access shoko, since this will be needed to grab images from the Shoko instance. It should include both the protocol and the IP/DNS name.</div> + <div class="fieldDescription">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 should include both the protocol and the IP/DNS name.</div> </div> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="text" id="IgnoredFolders" label="Ignored folder names:" /> diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index a9759500..04af5cc2 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -169,5 +169,12 @@ public bool Supports(BaseItem item) => item is Series or Season or Episode or Movie or BoxSet; public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) - => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); + { + var internalUrl = Plugin.Instance.Configuration.Url; + var prettyUrl = Plugin.Instance.Configuration.PrettyUrl; + if (!string.Equals(internalUrl, prettyUrl) && url.StartsWith(prettyUrl)) + url = internalUrl + url[prettyUrl.Length..]; + + return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); + } } From 1106a9db5575f8bf40a1fd97f99cfa8f20869593 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 16 Jun 2024 05:41:47 +0200 Subject: [PATCH 1058/1103] fix: fix default anidb title for entries --- Shokofin/Utils/Text.cs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index cca109ce..eccaf328 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -349,7 +349,7 @@ private static TitleProvider[] GetOrderedTitleProvidersByType(TitleProviderType TitleProvider.Shoko_Default => episodeInfo.Shoko.Name, TitleProvider.AniDB_Default => - GetDefaultTitle(episodeInfo.AniDB.Titles), + episodeInfo.AniDB.Titles.FirstOrDefault(title => title.LanguageCode == "en")?.Value, TitleProvider.AniDB_LibraryLanguage => GetTitlesForLanguage(episodeInfo.AniDB.Titles, false, metadataLanguage), TitleProvider.AniDB_CountryOfOrigin => @@ -369,7 +369,7 @@ private static TitleProvider[] GetOrderedTitleProvidersByType(TitleProviderType TitleProvider.Shoko_Default => defaultName, TitleProvider.AniDB_Default => - GetDefaultTitle(seasonInfo.AniDB.Titles), + seasonInfo.AniDB.Titles.FirstOrDefault(title => title.Type == TitleType.Main)?.Value, TitleProvider.AniDB_LibraryLanguage => GetTitlesForLanguage(seasonInfo.AniDB.Titles, true, metadataLanguage), TitleProvider.AniDB_CountryOfOrigin => @@ -382,14 +382,6 @@ private static TitleProvider[] GetOrderedTitleProvidersByType(TitleProviderType return null; } - /// <summary> - /// Get the default title from the title list. - /// </summary> - /// <param name="titles"></param> - /// <returns>The default title.</returns> - private static string? GetDefaultTitle(List<Title> titles) - => titles.FirstOrDefault(t => t.IsDefault)?.Value; - /// <summary> /// Get the first title available for the language, optionally using types /// to filter the list in addition to the metadata languages provided. From af26980cf5003c535530689de2c586b62183d587 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 15 Jun 2024 00:39:17 +0200 Subject: [PATCH 1059/1103] fix: don't try to merge seasons for movies --- Shokofin/API/ShokoAPIManager.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index d85c7e08..bea06a64 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -793,6 +793,9 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) if (!Plugin.Instance.Configuration.EXPERIMENTAL_MergeSeasons) return (primaryId, extraIds); + if (series.AniDBEntity.Type is SeriesType.Movie or SeriesType.Other or SeriesType.Unknown) + return (primaryId, extraIds); + if (series.AniDBEntity.AirDate is null) return (primaryId, extraIds); From 3d918767114a13037ed0d3ecf7857f062de08ca3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 15 Jun 2024 00:50:58 +0200 Subject: [PATCH 1060/1103] feat: allow tweaking series types to merge - Add a configuration option to allow tweaking the series types to merge during season merging. - Allow disabling the time window by setting it to 0 (or below, but please don't set it to a negative value). --- Shokofin/API/ShokoAPIManager.cs | 27 +++++++++++-------- Shokofin/Configuration/PluginConfiguration.cs | 6 +++++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index bea06a64..6270e98d 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -790,10 +790,11 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) async (cacheEntry) => { var primaryId = series.IDs.Shoko.ToString(); var extraIds = new List<string>(); - if (!Plugin.Instance.Configuration.EXPERIMENTAL_MergeSeasons) + var config = Plugin.Instance.Configuration; + if (!config.EXPERIMENTAL_MergeSeasons) return (primaryId, extraIds); - if (series.AniDBEntity.Type is SeriesType.Movie or SeriesType.Other or SeriesType.Unknown) + if (!config.EXPERIMENTAL_MergeSeasonsTypes.Contains(await GetCustomSeriesType(series.IDs.Shoko.ToString()) ?? series.AniDBEntity.Type)) return (primaryId, extraIds); if (series.AniDBEntity.AirDate is null) @@ -803,7 +804,7 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) var relations = await APIClient.GetSeriesRelations(primaryId).ConfigureAwait(false); var mainTitle = series.AniDBEntity.Titles.First(title => title.Type == TitleType.Main).Value; var result = YearRegex.Match(mainTitle); - var maxDaysThreshold = Plugin.Instance.Configuration.EXPERIMENTAL_MergeSeasonsMergeWindowInDays; + var maxDaysThreshold = config.EXPERIMENTAL_MergeSeasonsMergeWindowInDays; if (result.Success) { var adjustedMainTitle = mainTitle[..^result.Length]; @@ -815,7 +816,7 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) if (prequelSeries.IDs.ParentGroup != series.IDs.ParentGroup) continue; - if (prequelSeries.AniDBEntity.Type is SeriesType.Movie or SeriesType.Other or SeriesType.Unknown) + if (!config.EXPERIMENTAL_MergeSeasonsTypes.Contains(await GetCustomSeriesType(prequelSeries.IDs.Shoko.ToString()) ?? prequelSeries.AniDBEntity.Type)) continue; if (prequelSeries.AniDBEntity.AirDate is null) @@ -825,9 +826,11 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) if (prequelDate > currentDate) continue; - var deltaDays = (int)Math.Floor((currentDate - prequelDate).TotalDays); - if (deltaDays > maxDaysThreshold) - continue; + if (maxDaysThreshold > 0) { + var deltaDays = (int)Math.Floor((currentDate - prequelDate).TotalDays); + if (deltaDays > maxDaysThreshold) + continue; + } var prequelMainTitle = prequelSeries.AniDBEntity.Titles.First(title => title.Type == TitleType.Main).Value; var prequelResult = YearRegex.Match(prequelMainTitle); @@ -859,7 +862,7 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) if (sequelSeries.IDs.ParentGroup != series.IDs.ParentGroup) continue; - if (sequelSeries.AniDBEntity.Type is SeriesType.Movie or SeriesType.Other or SeriesType.Unknown) + if (!config.EXPERIMENTAL_MergeSeasonsTypes.Contains(await GetCustomSeriesType(sequelSeries.IDs.Shoko.ToString()) ?? sequelSeries.AniDBEntity.Type)) continue; if (sequelSeries.AniDBEntity.AirDate is null) @@ -869,9 +872,11 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) if (sequelDate < currentDate) continue; - var deltaDays = (int)Math.Floor((sequelDate - currentDate).TotalDays); - if (deltaDays > maxDaysThreshold) - continue; + if (maxDaysThreshold > 0) { + var deltaDays = (int)Math.Floor((sequelDate - currentDate).TotalDays); + if (deltaDays > maxDaysThreshold) + continue; + } var sequelMainTitle = sequelSeries.AniDBEntity.Titles.First(title => title.Type == TitleType.Main).Value; var sequelResult = YearRegex.Match(sequelMainTitle); diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 5761062b..ca05ff0f 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -415,6 +415,11 @@ public virtual string PrettyUrl /// </summary> public bool EXPERIMENTAL_MergeSeasons { get; set; } + /// <summary> + /// Series types to attempt to merge. Will respect custom series type overrides. + /// </summary> + public SeriesType[] EXPERIMENTAL_MergeSeasonsTypes { get; set; } + /// <summary> /// Number of days to check between the start of each season, inclusive. /// </summary> @@ -515,6 +520,7 @@ public PluginConfiguration() 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; } } From bb581f27d94587396eb4f4904c9221911134358d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 14 Jun 2024 05:14:20 +0200 Subject: [PATCH 1061/1103] refactor: tweak content ratings - Tweaked the content rating criteria to catch more edge cases. --- Shokofin/Utils/ContentRating.cs | 80 ++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/Shokofin/Utils/ContentRating.cs b/Shokofin/Utils/ContentRating.cs index c6aaf379..709ebb5b 100644 --- a/Shokofin/Utils/ContentRating.cs +++ b/Shokofin/Utils/ContentRating.cs @@ -37,6 +37,16 @@ public enum TvRating { /// </summary> None = 0, + /// <summary> + /// Most parents would find this program suitable for all ages. Although + /// this rating does not signify a program designed specifically for + /// children, most parents may let younger children watch this program + /// unattended. It contains little or no violence, no strong language + /// and little or no sexual dialogue or situations. + /// </summary> + [Description("TV-G")] + TvG, + /// <summary> /// This program is designed to be appropriate for all children. Whether /// animated or live-action, the themes and elements in this program are @@ -63,16 +73,6 @@ public enum TvRating { [TvContentIndicators(TvContentIndicator.FV)] TvY7, - /// <summary> - /// Most parents would find this program suitable for all ages. Although - /// this rating does not signify a program designed specifically for - /// children, most parents may let younger children watch this program - /// unattended. It contains little or no violence, no strong language - /// and little or no sexual dialogue or situations. - /// </summary> - [Description("TV-G")] - TvG, - /// <summary> /// This program contains material that parents may find unsuitable for /// younger children. Many parents may want to watch it with their @@ -207,8 +207,8 @@ private static ProviderName[] GetOrderedProviders() public static string? GetTagBasedContentRating(IReadOnlyDictionary<string, ResolvedTag> tags) { // User overridden content rating. - if (tags.TryGetValue("/custom user tags/target audience", out var targetAudience)) { - var audience = targetAudience.Children.Count == 1 ? targetAudience.Children.Values.First() : null; + if (tags.TryGetValue("/custom user tags/target audience", out var tag)) { + var audience = tag.Children.Count == 1 ? tag.Children.Values.First() : null; if (TryConvertRatingFromText(audience?.Name.ToLowerInvariant().Replace("-", ""), out var cR, out var cI)) return ConvertRatingToText(cR, cI); } @@ -216,8 +216,8 @@ private static ProviderName[] GetOrderedProviders() // Base rating. var contentRating = TvRating.None; var contentIndicators = new HashSet<TvContentIndicator>(); - if (tags.TryGetValue("/target audience", out targetAudience)) { - var audience = targetAudience.Children.Count == 1 ? targetAudience.Children.Values.First() : null; + if (tags.TryGetValue("/target audience", out tag)) { + var audience = tag.Children.Count == 1 ? tag.Children.Values.First() : null; contentRating = (audience?.Name.ToLowerInvariant()) switch { "mina" => TvRating.TvG, "kodomo" => TvRating.TvY, @@ -231,34 +231,54 @@ private static ProviderName[] GetOrderedProviders() } // "Upgrade" the content rating if it contains any of these tags. - if (tags.TryGetValue("/elements", out var elements)) { - if (contentRating is < TvRating.TvMA && elements.RecursiveNamespacedChildren.ContainsKey("/ecchi/borderline porn")) - contentRating = TvRating.TvMA; - if (elements.Children.TryGetValue("ecchi", out var ecchi)) { - if (contentRating is < TvRating.Tv14 && ecchi.Weight is >= TagWeight.Four) - contentRating = TvRating.Tv14; - else if (contentRating is < TvRating.TvPG && ecchi.Weight is >= TagWeight.Two) - contentRating = TvRating.TvPG; - } + if (contentRating is < TvRating.TvMA && tags.ContainsKey("/elements/ecchi/borderline porn")) + contentRating = TvRating.TvMA; + if (tags.TryGetValue("/elements/ecchi", out tag)) { + if (contentRating is < TvRating.Tv14 && tag.Weight is >= TagWeight.Four) + contentRating = TvRating.Tv14; + else if (contentRating is < TvRating.TvPG && tag.Weight is >= TagWeight.Three) + contentRating = TvRating.TvPG; + else if (contentRating is < TvRating.TvY7 && tag.Weight is >= TagWeight.Two) + contentRating = TvRating.TvY7; + } + if (contentRating is < TvRating.Tv14 && tags.ContainsKey("/content indicators/sex")) + contentRating = TvRating.Tv14; + if (tags.TryGetValue("/content indicators/nudity", out tag)) { + if (contentRating is < TvRating.Tv14 && tag.Weight is >= TagWeight.Four) + contentRating = TvRating.Tv14; + else if (contentRating is < TvRating.TvPG && tag.Weight is >= TagWeight.Three) + contentRating = TvRating.TvPG; + else if (contentRating is < TvRating.TvY7 && tag.Weight is >= TagWeight.Two) + contentRating = TvRating.TvY7; + } + if (tags.TryGetValue("/content indicators/violence", out tag)) { + if (contentRating is > TvRating.TvG && contentRating is < TvRating.Tv14 && tag.Weight is >= TagWeight.Four) + contentRating = TvRating.Tv14; + if (contentRating is > TvRating.TvG && contentRating is < TvRating.TvY7 && tag.Weight is >= TagWeight.Two) + contentRating = TvRating.TvY7; } + if (contentRating is < TvRating.TvPG && tags.ContainsKey("/technical aspects/very bloody wound in low-pg series")) + contentRating = TvRating.TvPG; + if (contentRating is > TvRating.TvG && contentRating is < TvRating.TvY7 && tags.ContainsKey("/content indicators/violence/gore")) + contentRating = TvRating.TvY7; // Content indicators. - if (tags.TryGetValue("/elements/sexual humour", out var _)) + if (tags.ContainsKey("/elements/sexual humour")) contentIndicators.Add(TvContentIndicator.D); - if (tags.TryGetValue("/content indicators/sex", out var sex)) { - if (sex.Weight is <= TagWeight.Two) + if (tags.TryGetValue("/content indicators/sex", out tag)) { + if (tag.Weight is <= TagWeight.Two) contentIndicators.Add(TvContentIndicator.D); else contentIndicators.Add(TvContentIndicator.S); } - if (tags.TryGetValue("/content indicators/nudity", out var nudity)) { - if (nudity.Weight >= TagWeight.Four) + if (tags.TryGetValue("/content indicators/nudity", out tag)) { + if (tag.Weight >= TagWeight.Four) contentIndicators.Add(TvContentIndicator.S); } - if (tags.TryGetValue("/content indicators/violence", out var violence)) { + if (tags.TryGetValue("/content indicators/violence", out tag)) { if (tags.ContainsKey("/elements/speculative fiction/fantasy")) contentIndicators.Add(TvContentIndicator.FV); - if (violence.Weight is >= TagWeight.Two) + if (tag.Weight is >= TagWeight.Two) contentIndicators.Add(TvContentIndicator.V); } From b5e83b6d67d7f78dcef4dd4f240a6d6cee7dc560 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 16 Jun 2024 03:51:20 +0200 Subject: [PATCH 1062/1103] refactor: smarter usage tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace tracking the last access time on the data caches with usage tracking on when something entered a plugin path and may be using the cache, which allows us to more swiftly clear the cache when nothing is using it. Oh, and the timeout before clearing the cache is configurable in the XML for advanced users btw. 😉 - Remove the auto clear cache task, since it's not needed anymore. - Removed all unneeded comments from all tasks. --- Shokofin/API/ShokoAPIClient.cs | 31 +- Shokofin/API/ShokoAPIManager.cs | 13 +- Shokofin/Configuration/PluginConfiguration.cs | 14 + Shokofin/Events/EventDispatchService.cs | 62 ++-- Shokofin/Plugin.cs | 13 +- Shokofin/Providers/BoxSetProvider.cs | 12 +- Shokofin/Providers/CustomBoxSetProvider.cs | 18 +- Shokofin/Providers/CustomEpisodeProvider.cs | 9 +- Shokofin/Providers/CustomSeasonProvider.cs | 195 ++++++------ Shokofin/Providers/CustomSeriesProvider.cs | 299 +++++++++--------- Shokofin/Providers/EpisodeProvider.cs | 4 + Shokofin/Providers/ImageProvider.cs | 5 + Shokofin/Providers/MovieProvider.cs | 5 +- Shokofin/Providers/SeasonProvider.cs | 50 +-- Shokofin/Providers/SeriesProvider.cs | 4 + Shokofin/Providers/TrailerProvider.cs | 15 +- Shokofin/Providers/VideoProvider.cs | 15 +- Shokofin/Resolvers/ShokoIgnoreRule.cs | 4 + Shokofin/Resolvers/ShokoLibraryMonitor.cs | 6 +- Shokofin/Resolvers/ShokoResolver.cs | 8 + .../Resolvers/VirtualFileSystemService.cs | 9 +- Shokofin/Tasks/AutoClearPluginCacheTask.cs | 99 ------ Shokofin/Tasks/ClearPluginCacheTask.cs | 14 +- Shokofin/Tasks/ExportUserDataTask.cs | 19 -- Shokofin/Tasks/ImportUserDataTask.cs | 18 -- Shokofin/Tasks/MergeEpisodesTask.cs | 31 +- Shokofin/Tasks/MergeMoviesTask.cs | 31 +- Shokofin/Tasks/PostScanTask.cs | 16 +- Shokofin/Tasks/ReconstructCollectionsTask.cs | 6 +- Shokofin/Tasks/SplitEpisodesTask.cs | 32 +- Shokofin/Tasks/SplitMoviesTask.cs | 28 +- Shokofin/Tasks/SyncUserDataTask.cs | 18 -- Shokofin/Tasks/VersionCheckTask.cs | 9 - Shokofin/Utils/GuardedMemoryCache.cs | 23 +- Shokofin/Utils/LibraryScanWatcher.cs | 17 +- Shokofin/Utils/UsageTracker.cs | 103 ++++++ 36 files changed, 619 insertions(+), 636 deletions(-) delete mode 100644 Shokofin/Tasks/AutoClearPluginCacheTask.cs create mode 100644 Shokofin/Utils/UsageTracker.cs diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 5fe80d14..1f6b6188 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Shokofin.API.Models; @@ -33,8 +34,6 @@ 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 bool IsCacheStalled => _cache.IsStalled; - private readonly GuardedMemoryCache _cache; public ShokoAPIClient(ILogger<ShokoAPIClient> logger) @@ -43,9 +42,18 @@ public ShokoAPIClient(ILogger<ShokoAPIClient> logger) Timeout = TimeSpan.FromMinutes(10), }; Logger = logger; - _cache = new(logger, TimeSpan.FromMinutes(30), new() { ExpirationScanFrequency = TimeSpan.FromMinutes(25) }, new() { SlidingExpiration = new(2, 30, 0) }); + _cache = new(logger, new() { ExpirationScanFrequency = TimeSpan.FromMinutes(25) }, new() { SlidingExpiration = new(2, 30, 0) }); + Plugin.Instance.Tracker.Stalled += OnTrackerStalled; + } + + ~ShokoAPIClient() + { + Plugin.Instance.Tracker.Stalled -= OnTrackerStalled; } + private void OnTrackerStalled(object? sender, EventArgs eventArgs) + => Clear(); + #region Base Implementation public void Clear() @@ -218,17 +226,18 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method public async Task<ComponentVersion?> GetVersion() { - var apiBaseUrl = Plugin.Instance.Configuration.Url; - var response = await _httpClient.GetAsync($"{apiBaseUrl}/api/v3/Init/Version"); - if (response.StatusCode == HttpStatusCode.OK) { - try { + try { + var apiBaseUrl = Plugin.Instance.Configuration.Url; + var source = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var response = await _httpClient.GetAsync($"{apiBaseUrl}/api/v3/Init/Version", source.Token); + if (response.StatusCode == HttpStatusCode.OK) { var componentVersionSet = await JsonSerializer.DeserializeAsync<ComponentVersionSet>(response.Content.ReadAsStreamAsync().Result); return componentVersionSet?.Server; } - catch (Exception e) { - Logger.LogTrace("Unable to connect to Shoko Server to read the version. Exception; {e}", e.Message); - return null; - } + } + catch (Exception e) { + Logger.LogTrace("Unable to connect to Shoko Server to read the version. Exception; {e}", e.Message); + return null; } return null; diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 6270e98d..122c1f5c 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -52,17 +52,24 @@ public class ShokoAPIManager : IDisposable private readonly ConcurrentDictionary<string, List<string>> FileAndSeriesIdToEpisodeIdDictionary = new(); + private readonly GuardedMemoryCache DataCache; + public ShokoAPIManager(ILogger<ShokoAPIManager> logger, ShokoAPIClient apiClient, ILibraryManager libraryManager) { Logger = logger; APIClient = apiClient; LibraryManager = libraryManager; - DataCache = new(logger, TimeSpan.FromMinutes(15), new() { ExpirationScanFrequency = TimeSpan.FromMinutes(25) }, new() { AbsoluteExpirationRelativeToNow = new(2, 30, 0) }); + DataCache = new(logger, new() { ExpirationScanFrequency = TimeSpan.FromMinutes(25) }, new() { AbsoluteExpirationRelativeToNow = new(2, 30, 0) }); + Plugin.Instance.Tracker.Stalled += OnTrackerStalled; } - public bool IsCacheStalled => DataCache.IsStalled; + ~ShokoAPIManager() + { + Plugin.Instance.Tracker.Stalled -= OnTrackerStalled; + } - private readonly GuardedMemoryCache DataCache; + private void OnTrackerStalled(object? sender, EventArgs eventArgs) + => Clear(); #region Ignore rule diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index ca05ff0f..0fa0cf80 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -393,6 +393,19 @@ public virtual string PrettyUrl #endregion + #region Usage Tracker + + /// <summary> + /// Amount of seconds that needs to pass before the usage tracker considers the usage as stalled and resets it's tracking and dispatches it's <seealso cref="Utils.UsageTracker.Stalled"/> event. + /// </summary> + /// <remarks> + /// It can be configured between 1 second and 3 hours. + /// </remarks> + [Range(1, 10800)] + public int UsageTracker_StalledTimeInSeconds { get; set; } + + #endregion + #region Experimental features /// <summary> @@ -516,6 +529,7 @@ public PluginConfiguration() SignalR_EventSources = new[] { ProviderName.Shoko, ProviderName.AniDB, ProviderName.TMDB }; SignalR_RefreshEnabled = false; SignalR_FileEvents = false; + UsageTracker_StalledTimeInSeconds = 60; EXPERIMENTAL_AutoMergeVersions = true; EXPERIMENTAL_SplitThenMergeMovies = true; EXPERIMENTAL_SplitThenMergeEpisodes = false; diff --git a/Shokofin/Events/EventDispatchService.cs b/Shokofin/Events/EventDispatchService.cs index eb8e13d4..a5551fa0 100644 --- a/Shokofin/Events/EventDispatchService.cs +++ b/Shokofin/Events/EventDispatchService.cs @@ -55,9 +55,9 @@ public class EventDispatchService private readonly Timer ChangesDetectionTimer; - private readonly Dictionary<string, (DateTime LastUpdated, List<IMetadataUpdatedEventArgs> List)> ChangesPerSeries = new(); + private readonly Dictionary<string, (DateTime LastUpdated, List<IMetadataUpdatedEventArgs> List, Guid trackerId)> ChangesPerSeries = new(); - private readonly Dictionary<int, (DateTime LastUpdated, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> List)> ChangesPerFile = new(); + private readonly Dictionary<int, (DateTime LastUpdated, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> List, Guid trackerId)> ChangesPerFile = new(); private readonly Dictionary<string, (int refCount, DateTime delayEnd)> MediaFolderChangeMonitor = new(); @@ -124,62 +124,62 @@ private void DeregisterEventSubmitter() private void OnIntervalElapsed(object? sender, ElapsedEventArgs eventArgs) { - var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>)>(); - var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>)>(); + var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>, Guid trackerId)>(); + var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>, Guid trackerId)>(); lock (ChangesPerFile) { if (ChangesPerFile.Count > 0) { var now = DateTime.Now; - foreach (var (fileId, (lastUpdated, list)) in ChangesPerFile) { + foreach (var (fileId, (lastUpdated, list, trackerId)) in ChangesPerFile) { if (now - lastUpdated < DetectChangesThreshold) continue; - filesToProcess.Add((fileId, list)); + filesToProcess.Add((fileId, list, trackerId)); } - foreach (var (fileId, _) in filesToProcess) + foreach (var (fileId, _, _) in filesToProcess) ChangesPerFile.Remove(fileId); } } lock (ChangesPerSeries) { if (ChangesPerSeries.Count > 0) { var now = DateTime.Now; - foreach (var (metadataId, (lastUpdated, list)) in ChangesPerSeries) { + foreach (var (metadataId, (lastUpdated, list, trackerId)) in ChangesPerSeries) { if (now - lastUpdated < DetectChangesThreshold) continue; - seriesToProcess.Add((metadataId, list)); + seriesToProcess.Add((metadataId, list, trackerId)); } - foreach (var (metadataId, _) in seriesToProcess) + foreach (var (metadataId, _, _) in seriesToProcess) ChangesPerSeries.Remove(metadataId); } } - foreach (var (fileId, changes) in filesToProcess) - Task.Run(() => ProcessFileEvents(fileId, changes)); - foreach (var (metadataId, changes) in seriesToProcess) - Task.Run(() => ProcessMetadataEvents(metadataId, changes)); + foreach (var (fileId, changes, trackerId) in filesToProcess) + Task.Run(() => ProcessFileEvents(fileId, changes, trackerId)); + foreach (var (metadataId, changes, trackerId) in seriesToProcess) + Task.Run(() => ProcessMetadataEvents(metadataId, changes, trackerId)); } private void ClearFileEvents() { - var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>)>(); + var filesToProcess = new List<(int, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)>, Guid trackerId)>(); lock (ChangesPerFile) { - foreach (var (fileId, (lastUpdated, list)) in ChangesPerFile) { - filesToProcess.Add((fileId, list)); + foreach (var (fileId, (lastUpdated, list, trackerId)) in ChangesPerFile) { + filesToProcess.Add((fileId, list, trackerId)); } ChangesPerFile.Clear(); } - foreach (var (fileId, changes) in filesToProcess) - Task.Run(() => ProcessFileEvents(fileId, changes)); + foreach (var (fileId, changes, trackerId) in filesToProcess) + Task.Run(() => ProcessFileEvents(fileId, changes, trackerId)); } private void ClearMetadataUpdatedEvents() { - var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>)>(); + var seriesToProcess = new List<(string, List<IMetadataUpdatedEventArgs>, Guid trackerId)>(); lock (ChangesPerSeries) { - foreach (var (metadataId, (lastUpdated, list)) in ChangesPerSeries) { - seriesToProcess.Add((metadataId, list)); + foreach (var (metadataId, (lastUpdated, list, trackerId)) in ChangesPerSeries) { + seriesToProcess.Add((metadataId, list, trackerId)); } ChangesPerSeries.Clear(); } - foreach (var (metadataId, changes) in seriesToProcess) - Task.Run(() => ProcessMetadataEvents(metadataId, changes)); + foreach (var (metadataId, changes, trackerId) in seriesToProcess) + Task.Run(() => ProcessMetadataEvents(metadataId, changes, trackerId)); } #endregion @@ -192,12 +192,12 @@ 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())); + ChangesPerFile.Add(fileId, tuple = (DateTime.Now, new(), Plugin.Instance.Tracker.Add($"File event. (Reason=\"{reason}\",ImportFolder={eventArgs.ImportFolderId},RelativePath=\"{eventArgs.RelativePath}\")"))); tuple.List.Add((reason, importFolderId, filePath, eventArgs)); } } - private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> changes) + private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> changes, Guid trackerId) { try { if (LibraryScanWatcher.IsScanRunning) { @@ -364,6 +364,9 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int catch (Exception ex) { Logger.LogError(ex, "Error processing {EventCount} file change events. (File={FileId})", changes.Count, fileId); } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } } private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEventArgs? fileEvent) @@ -484,12 +487,12 @@ 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())); + ChangesPerSeries.Add(metadataId, tuple = (DateTime.Now, new(), Plugin.Instance.Tracker.Add($"Metadata event. (Reason=\"{eventArgs.Reason}\",Kind=\"{eventArgs.Kind}\",ProviderUId=\"{eventArgs.ProviderUId}\")"))); tuple.List.Add(eventArgs); } } - private async Task ProcessMetadataEvents(string metadataId, List<IMetadataUpdatedEventArgs> changes) + private async Task ProcessMetadataEvents(string metadataId, List<IMetadataUpdatedEventArgs> changes, Guid trackerId) { try { if (LibraryScanWatcher.IsScanRunning) { @@ -525,6 +528,9 @@ private async Task ProcessMetadataEvents(string metadataId, List<IMetadataUpdate catch (Exception ex) { Logger.LogError(ex, "Error processing {EventCount} metadata change events. (Metadata={ProviderUniqueId})", changes.Count, metadataId); } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } } private async Task<int> ProcessSeriesEvents(ShowInfo showInfo, List<IMetadataUpdatedEventArgs> changes) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index de287d2f..61b05115 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -9,6 +9,7 @@ using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; using Shokofin.Configuration; +using Shokofin.Utils; namespace Shokofin; @@ -25,6 +26,11 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages /// </summary> public readonly bool CanCreateSymbolicLinks; + /// <summary> + /// Usage tracker for automagically clearing the caches when nothing is using them. + /// </summary> + public readonly UsageTracker Tracker; + private readonly ILogger<Plugin> Logger; /// <summary> @@ -37,12 +43,12 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages /// </summary> public new event EventHandler<PluginConfiguration>? ConfigurationChanged; - public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger) : base(applicationPaths, xmlSerializer) + public Plugin(ILoggerFactory loggerFactory, IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger) : base(applicationPaths, xmlSerializer) { Instance = this; base.ConfigurationChanged += OnConfigChanged; - IgnoredFolders = Configuration.IgnoredFolders.ToHashSet(); VirtualRoot = Path.Join(applicationPaths.ProgramDataPath, "Shokofin", "VFS"); + Tracker = new(loggerFactory.CreateLogger<UsageTracker>(), TimeSpan.FromSeconds(60)); Logger = logger; CanCreateSymbolicLinks = true; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { @@ -64,6 +70,8 @@ public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, File.Delete(target); } } + 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); } @@ -73,6 +81,7 @@ public void OnConfigChanged(object? sender, BasePluginConfiguration e) if (e is not PluginConfiguration config) return; IgnoredFolders = config.IgnoredFolders.ToHashSet(); + Tracker.UpdateTimeout(TimeSpan.FromSeconds(Configuration.UsageTracker_StalledTimeInSeconds)); ConfigurationChanged?.Invoke(sender, config); } diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index fa3f7d89..8e2292e6 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -38,14 +38,14 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat { try { // Try to read the shoko group id - if (info.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var collectionId) || - info.Path.TryGetAttributeValue(ShokoCollectionGroupId.Name, out collectionId)) - return await GetShokoGroupMetadata(info, collectionId); + if (info.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var collectionId) || info.Path.TryGetAttributeValue(ShokoCollectionGroupId.Name, out collectionId)) + using (Plugin.Instance.Tracker.Enter($"Providing info for Collection \"{info.Name}\". (Path=\"{info.Path}\",Collection=\"{collectionId}\")")) + return await GetShokoGroupMetadata(info, collectionId); // Try to read the shoko series id - if (info.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId) || - info.Path.TryGetAttributeValue(ShokoCollectionSeriesId.Name, out seriesId)) - return await GetShokoSeriesMetadata(info, seriesId); + if (info.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId) || info.Path.TryGetAttributeValue(ShokoCollectionSeriesId.Name, out seriesId)) + using (Plugin.Instance.Tracker.Enter($"Providing info for Collection \"{info.Name}\". (Path=\"{info.Path}\",Series=\"{seriesId}\")")) + return await GetShokoSeriesMetadata(info, seriesId); return new(); } diff --git a/Shokofin/Providers/CustomBoxSetProvider.cs b/Shokofin/Providers/CustomBoxSetProvider.cs index 7050d55b..c5a47d5c 100644 --- a/Shokofin/Providers/CustomBoxSetProvider.cs +++ b/Shokofin/Providers/CustomBoxSetProvider.cs @@ -54,18 +54,16 @@ public async Task<ItemUpdateType> FetchAsync(BoxSet collection, MetadataRefreshO return ItemUpdateType.None; // Try to read the shoko group id - if (( - collection.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var collectionId) || - collection.Path.TryGetAttributeValue(ShokoCollectionGroupId.Name, out collectionId) - ) && await EnsureGroupCollectionIsCorrect(collectionRoot, collection, collectionId, cancellationToken)) - return ItemUpdateType.MetadataEdit; + if (collection.ProviderIds.TryGetValue(ShokoCollectionGroupId.Name, out var collectionId) || collection.Path.TryGetAttributeValue(ShokoCollectionGroupId.Name, out collectionId)) + using (Plugin.Instance.Tracker.Enter($"Providing custom info for Collection \"{collection.Name}\". (Path=\"{collection.Path}\",Collection=\"{collectionId}\")")) + if (await EnsureGroupCollectionIsCorrect(collectionRoot, collection, collectionId, cancellationToken)) + return ItemUpdateType.MetadataEdit; // Try to read the shoko series id - if (( - collection.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId) || - collection.Path.TryGetAttributeValue(ShokoCollectionSeriesId.Name, out seriesId) - ) && await EnsureSeriesCollectionIsCorrect(collection, seriesId, cancellationToken)) - return ItemUpdateType.MetadataEdit; + if (collection.ProviderIds.TryGetValue(ShokoCollectionSeriesId.Name, out var seriesId) || collection.Path.TryGetAttributeValue(ShokoCollectionSeriesId.Name, out seriesId)) + using (Plugin.Instance.Tracker.Enter($"Providing custom info for Collection \"{collection.Name}\". (Path=\"{collection.Path}\",Series=\"{seriesId}\")")) + if (await EnsureSeriesCollectionIsCorrect(collection, seriesId, cancellationToken)) + return ItemUpdateType.MetadataEdit; return ItemUpdateType.None; } diff --git a/Shokofin/Providers/CustomEpisodeProvider.cs b/Shokofin/Providers/CustomEpisodeProvider.cs index 00bd86d4..35b3647a 100644 --- a/Shokofin/Providers/CustomEpisodeProvider.cs +++ b/Shokofin/Providers/CustomEpisodeProvider.cs @@ -43,11 +43,10 @@ public Task<ItemUpdateType> FetchAsync(Episode episode, MetadataRefreshOptions o return Task.FromResult(ItemUpdateType.None); // Abort if we're unable to get the shoko episode id - if (!Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) - return Task.FromResult(ItemUpdateType.None); - - if (RemoveDuplicates(LibraryManager, Logger, episodeId, episode, series.GetPresentationUniqueKey())) - return Task.FromResult(ItemUpdateType.MetadataEdit); + if (episode.ProviderIds.TryGetValue(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); return Task.FromResult(ItemUpdateType.None); } diff --git a/Shokofin/Providers/CustomSeasonProvider.cs b/Shokofin/Providers/CustomSeasonProvider.cs index 63dd8017..ba5e0338 100644 --- a/Shokofin/Providers/CustomSeasonProvider.cs +++ b/Shokofin/Providers/CustomSeasonProvider.cs @@ -8,6 +8,7 @@ using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; +using Shokofin.ExternalIds; using Info = Shokofin.API.Info; @@ -52,123 +53,129 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio // Silently abort if we're unable to get the shoko series id. var series = season.Series; - if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + if (!series.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId)) return ItemUpdateType.None; - // Loudly abort if the show metadata doesn't exist. var seasonNumber = season.IndexNumber!.Value; - var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); - if (showInfo == null || showInfo.SeasonList.Count == 0) { - Logger.LogWarning("Unable to find show info for season. (Series={SeriesId})", seriesId); - return ItemUpdateType.None; - } + var trackerId = Plugin.Instance.Tracker.Add($"Providing custom info for Season \"{season.Name}\". (Path=\"{season.Path}\",Series=\"{seriesId}\",Season={seasonNumber})"); + try { + // Loudly abort if the show metadata doesn't exist. + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for season. (Series={SeriesId})", seriesId); + return ItemUpdateType.None; + } - // Remove duplicates of the same season. - var itemUpdated = ItemUpdateType.None; - if (RemoveDuplicates(LibraryManager, Logger, seasonNumber, season, series, seriesId)) - itemUpdated |= ItemUpdateType.MetadataEdit; - - // Special handling of specials (pun intended). - if (seasonNumber == 0) { - // Get known episodes, existing episodes, and episodes to remove. - var knownEpisodeIds = ShouldAddMetadata - ? showInfo.SpecialsDict.Keys.ToHashSet() - : showInfo.SpecialsDict - .Where(pair => pair.Value) - .Select(pair => pair.Key) - .ToHashSet(); - 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)) - toRemoveEpisodes.Add(episode); - else - foreach (var episodeId in episodeIds) + // Remove duplicates of the same season. + var itemUpdated = ItemUpdateType.None; + if (RemoveDuplicates(LibraryManager, Logger, seasonNumber, season, series, seriesId)) + itemUpdated |= ItemUpdateType.MetadataEdit; + + // Special handling of specials (pun intended). + if (seasonNumber == 0) { + // Get known episodes, existing episodes, and episodes to remove. + var knownEpisodeIds = ShouldAddMetadata + ? showInfo.SpecialsDict.Keys.ToHashSet() + : showInfo.SpecialsDict + .Where(pair => pair.Value) + .Select(pair => pair.Key) + .ToHashSet(); + 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)) + toRemoveEpisodes.Add(episode); + 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); + else existingEpisodes.Add(episodeId); - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { - if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Contains(episodeId)) - toRemoveEpisodes.Add(episode); - else - existingEpisodes.Add(episodeId); + } } - } - // Remove unknown or unwanted episodes. - foreach (var episode in toRemoveEpisodes) { - Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, 0, series.Name, seriesId); - LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); - } + // Remove unknown or unwanted episodes. + foreach (var episode in toRemoveEpisodes) { + Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, 0, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } - // Add missing episodes. - if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { - foreach (var sI in showInfo.SeasonList) { - foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(sI)) - existingEpisodes.Add(episodeId); + // Add missing episodes. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + foreach (var sI in showInfo.SeasonList) { + foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(sI)) + existingEpisodes.Add(episodeId); - foreach (var episodeInfo in sI.SpecialsList) { - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; + foreach (var episodeInfo in sI.SpecialsList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; - if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, sI, episodeInfo, season, series)) - itemUpdated |= ItemUpdateType.MetadataImport; + if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, sI, episodeInfo, season, series)) + itemUpdated |= ItemUpdateType.MetadataImport; + } } } } - } - // Every other "season." - else { - // Loudly abort if the season metadata doesn't exist. - var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber); - if (seasonInfo == null || !showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var baseSeasonNumber)) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); - return ItemUpdateType.None; - } + // Every other "season." + else { + // Loudly abort if the season metadata doesn't exist. + var seasonInfo = showInfo.GetSeasonInfoBySeasonNumber(seasonNumber); + if (seasonInfo == null || !showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var baseSeasonNumber)) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); + return ItemUpdateType.None; + } - // 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 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)) - toRemoveEpisodes.Add(episode); - else - foreach (var episodeId in episodeIds) + // 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 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)) + toRemoveEpisodes.Add(episode); + 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); + else existingEpisodes.Add(episodeId); - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { - if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Contains(episodeId)) - toRemoveEpisodes.Add(episode); - else - existingEpisodes.Add(episodeId); + } } - } - // Remove unknown or unwanted episodes. - foreach (var episode in toRemoveEpisodes) { - Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, seasonNumber, series.Name, seriesId); - LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); - } + // Remove unknown or unwanted episodes. + foreach (var episode in toRemoveEpisodes) { + Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, seasonNumber, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } - // Add missing episodes. - if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { - foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) - existingEpisodes.Add(episodeId); + // Add missing episodes. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) + existingEpisodes.Add(episodeId); - foreach (var episodeInfo in episodeList) { - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; + foreach (var episodeInfo in episodeList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; - if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, seasonInfo, episodeInfo, season, series)) - itemUpdated |= ItemUpdateType.MetadataImport; + if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, seasonInfo, episodeInfo, season, series)) + itemUpdated |= ItemUpdateType.MetadataImport; + } } } - } - return itemUpdated; + return itemUpdated; + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } } private static bool RemoveDuplicates(ILibraryManager libraryManager, ILogger logger, int seasonNumber, Season season, Series series, string seriesId) diff --git a/Shokofin/Providers/CustomSeriesProvider.cs b/Shokofin/Providers/CustomSeriesProvider.cs index 0a080def..ffe8e3f6 100644 --- a/Shokofin/Providers/CustomSeriesProvider.cs +++ b/Shokofin/Providers/CustomSeriesProvider.cs @@ -8,6 +8,7 @@ using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; using Shokofin.API; +using Shokofin.ExternalIds; using Shokofin.Utils; using Info = Shokofin.API.Info; @@ -39,180 +40,186 @@ public CustomSeriesProvider(ILogger<CustomSeriesProvider> logger, ShokoAPIManage public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptions options, CancellationToken cancellationToken) { // Abort if we're unable to get the shoko series id - if (!Lookup.TryGetSeriesIdFor(series, out var seriesId)) + if (!series.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId)) return ItemUpdateType.None; - // Provide metadata for a series using Shoko's Group feature - var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); - if (showInfo == null || showInfo.SeasonList.Count == 0) { - Logger.LogWarning("Unable to find show info for series. (Series={SeriesID})", seriesId); - return ItemUpdateType.None; - } - - // Get the existing seasons and known seasons. - var itemUpdated = ItemUpdateType.None; - var allSeasons = series.Children - .OfType<Season>() - .Where(season => season.IndexNumber.HasValue) - .ToList(); - var seasons = allSeasons - .OrderBy(season => season.IndexNumber!.Value) - .ThenBy(season => season.IsVirtualItem) - .ThenBy(season => season.Path) - .GroupBy(season => season.IndexNumber!.Value) - .ToDictionary(groupBy => groupBy.Key, groupBy => groupBy.First()); - var extraSeasonsToRemove = allSeasons - .Except(seasons.Values) - .ToList(); - var knownSeasonIds = ShouldAddMetadata - ? showInfo.SeasonOrderDictionary.Keys.ToHashSet() - : showInfo.SeasonOrderDictionary - .Where(pair => !pair.Value.IsEmpty(Math.Abs(pair.Key - showInfo.GetBaseSeasonNumberForSeasonInfo(pair.Value)))) - .Select(pair => pair.Key) - .ToHashSet(); - if (ShouldAddMetadata ? showInfo.HasSpecials : showInfo.HasSpecialsWithFiles) - knownSeasonIds.Add(0); - - // Remove unknown or unwanted seasons. - var toRemoveSeasons = seasons.ExceptBy(knownSeasonIds, season => season.Key) - .Where(season => string.IsNullOrEmpty(season.Value.Path) || season.Value.IsVirtualItem) - .ToList(); - foreach (var (seasonNumber, season) in toRemoveSeasons) { - Logger.LogDebug("Removing Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Name, seriesId); - seasons.Remove(seasonNumber); - LibraryManager.DeleteItem(season, new() { DeleteFileLocation = false }); - } + var trackerId = Plugin.Instance.Tracker.Add($"Providing custom info for Series \"{series.Name}\". (Series=\"{seriesId}\")"); + try { + // Provide metadata for a series using Shoko's Group feature + var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); + if (showInfo == null || showInfo.SeasonList.Count == 0) { + Logger.LogWarning("Unable to find show info for series. (Series={SeriesID})", seriesId); + return ItemUpdateType.None; + } - foreach (var season in extraSeasonsToRemove) { - if (seasons.TryGetValue(season.IndexNumber!.Value, out var mainSeason)) { - var episodes = season.Children - .OfType<Episode>() - .Where(episode => !string.IsNullOrEmpty(episode.Path) && episode.ParentId == season.Id) - .ToList(); - foreach (var episode in episodes) { - Logger.LogInformation("Updating parent of physical episode {EpisodeNumber} {EpisodeName} in Season {SeasonNumber} for {SeriesName} (Series={SeriesId})", episode.IndexNumber, episode.Name, season.IndexNumber, series.Name, seriesId); - episode.SetParent(mainSeason); - } - await LibraryManager.UpdateItemsAsync(episodes, mainSeason, ItemUpdateType.MetadataEdit, CancellationToken.None); + // Get the existing seasons and known seasons. + var itemUpdated = ItemUpdateType.None; + var allSeasons = series.Children + .OfType<Season>() + .Where(season => season.IndexNumber.HasValue) + .ToList(); + var seasons = allSeasons + .OrderBy(season => season.IndexNumber!.Value) + .ThenBy(season => season.IsVirtualItem) + .ThenBy(season => season.Path) + .GroupBy(season => season.IndexNumber!.Value) + .ToDictionary(groupBy => groupBy.Key, groupBy => groupBy.First()); + var extraSeasonsToRemove = allSeasons + .Except(seasons.Values) + .ToList(); + var knownSeasonIds = ShouldAddMetadata + ? showInfo.SeasonOrderDictionary.Keys.ToHashSet() + : showInfo.SeasonOrderDictionary + .Where(pair => !pair.Value.IsEmpty(Math.Abs(pair.Key - showInfo.GetBaseSeasonNumberForSeasonInfo(pair.Value)))) + .Select(pair => pair.Key) + .ToHashSet(); + if (ShouldAddMetadata ? showInfo.HasSpecials : showInfo.HasSpecialsWithFiles) + knownSeasonIds.Add(0); + + // Remove unknown or unwanted seasons. + var toRemoveSeasons = seasons.ExceptBy(knownSeasonIds, season => season.Key) + .Where(season => string.IsNullOrEmpty(season.Value.Path) || season.Value.IsVirtualItem) + .ToList(); + foreach (var (seasonNumber, season) in toRemoveSeasons) { + Logger.LogDebug("Removing Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", seasonNumber, series.Name, seriesId); + seasons.Remove(seasonNumber); + LibraryManager.DeleteItem(season, new() { DeleteFileLocation = false }); } - Logger.LogDebug("Removing extra Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", season.IndexNumber!.Value, series.Name, seriesId); - LibraryManager.DeleteItem(season, new() { DeleteFileLocation = false }); - } + foreach (var season in extraSeasonsToRemove) { + if (seasons.TryGetValue(season.IndexNumber!.Value, out var mainSeason)) { + var episodes = season.Children + .OfType<Episode>() + .Where(episode => !string.IsNullOrEmpty(episode.Path) && episode.ParentId == season.Id) + .ToList(); + foreach (var episode in episodes) { + Logger.LogInformation("Updating parent of physical episode {EpisodeNumber} {EpisodeName} in Season {SeasonNumber} for {SeriesName} (Series={SeriesId})", episode.IndexNumber, episode.Name, season.IndexNumber, series.Name, seriesId); + episode.SetParent(mainSeason); + } + await LibraryManager.UpdateItemsAsync(episodes, mainSeason, ItemUpdateType.MetadataEdit, CancellationToken.None); + } - // Add missing seasons. - if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) - foreach (var (seasonNumber, season) in CreateMissingSeasons(showInfo, series, seasons)) { - itemUpdated |= ItemUpdateType.MetadataImport; - seasons.TryAdd(seasonNumber, season); + Logger.LogDebug("Removing extra Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", season.IndexNumber!.Value, series.Name, seriesId); + LibraryManager.DeleteItem(season, new() { DeleteFileLocation = false }); } - // Special handling of Specials (pun intended). - if (seasons.TryGetValue(0, out var zeroSeason)) { - // Get known episodes, existing episodes, and episodes to remove. - var knownEpisodeIds = ShouldAddMetadata - ? showInfo.SpecialsDict.Keys.ToHashSet() - : showInfo.SpecialsDict - .Where(pair => pair.Value) - .Select(pair => pair.Key) - .ToHashSet(); - var existingEpisodes = new HashSet<string>(); - var toRemoveEpisodes = new List<Episode>(); - foreach (var episode in zeroSeason.Children.OfType<Episode>()) { - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) - toRemoveEpisodes.Add(episode); - else - foreach (var episodeId in episodeIds) + // Add missing seasons. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) + foreach (var (seasonNumber, season) in CreateMissingSeasons(showInfo, series, seasons)) { + itemUpdated |= ItemUpdateType.MetadataImport; + seasons.TryAdd(seasonNumber, season); + } + + // Special handling of Specials (pun intended). + if (seasons.TryGetValue(0, out var zeroSeason)) { + // Get known episodes, existing episodes, and episodes to remove. + var knownEpisodeIds = ShouldAddMetadata + ? showInfo.SpecialsDict.Keys.ToHashSet() + : showInfo.SpecialsDict + .Where(pair => pair.Value) + .Select(pair => pair.Key) + .ToHashSet(); + var existingEpisodes = new HashSet<string>(); + var toRemoveEpisodes = new List<Episode>(); + foreach (var episode in zeroSeason.Children.OfType<Episode>()) { + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) + if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) + toRemoveEpisodes.Add(episode); + 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); + else existingEpisodes.Add(episodeId); - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { - if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Contains(episodeId)) - toRemoveEpisodes.Add(episode); - else - existingEpisodes.Add(episodeId); + } } - } - // Remove unknown or unwanted episodes. - foreach (var episode in toRemoveEpisodes) { - Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, 0, series.Name, seriesId); - LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); - } + // Remove unknown or unwanted episodes. + foreach (var episode in toRemoveEpisodes) { + Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, 0, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } - // Add missing episodes. - if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) - foreach (var seasonInfo in showInfo.SeasonList) { - foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) - existingEpisodes.Add(episodeId); + // Add missing episodes. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) + foreach (var seasonInfo in showInfo.SeasonList) { + foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) + existingEpisodes.Add(episodeId); - foreach (var episodeInfo in seasonInfo.SpecialsList) { - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; + foreach (var episodeInfo in seasonInfo.SpecialsList) { + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; - if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, seasonInfo, episodeInfo, zeroSeason, series)) - itemUpdated |= ItemUpdateType.MetadataImport; + if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, seasonInfo, episodeInfo, zeroSeason, series)) + itemUpdated |= ItemUpdateType.MetadataImport; + } } - } - } + } - // All other seasons. - foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { - // Silently continue if the season doesn't exist. - if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) - continue; + // All other seasons. + foreach (var (seasonNumber, seasonInfo) in showInfo.SeasonOrderDictionary) { + // Silently continue if the season doesn't exist. + if (!seasons.TryGetValue(seasonNumber, out var season) || season == null) + continue; - // Loudly skip if the season metadata doesn't exist. - if (seasonInfo == null || !showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var baseSeasonNumber)) { - Logger.LogWarning("Unable to find series info for Season {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); - continue; - } + // Loudly skip if the season metadata doesn't exist. + if (seasonInfo == null || !showInfo.TryGetBaseSeasonNumberForSeasonInfo(seasonInfo, out var baseSeasonNumber)) { + Logger.LogWarning("Unable to find series info for Season {SeasonNumber} in group for series. (Group={GroupId})", seasonNumber, showInfo.GroupId); + continue; + } - // 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 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)) - toRemoveEpisodes.Add(episode); - else - foreach (var episodeId in episodeIds) + // 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 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)) + toRemoveEpisodes.Add(episode); + 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); + else existingEpisodes.Add(episodeId); - else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { - if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Contains(episodeId)) - toRemoveEpisodes.Add(episode); - else - existingEpisodes.Add(episodeId); + } } - } - // Remove unknown or unwanted episodes. - foreach (var episode in toRemoveEpisodes) { - Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, seasonNumber, series.Name, seriesId); - LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); - } + // Remove unknown or unwanted episodes. + foreach (var episode in toRemoveEpisodes) { + Logger.LogDebug("Removing Episode {EpisodeName} from Season {SeasonNumber} for Series {SeriesName} (Series={SeriesId})", episode.Name, seasonNumber, series.Name, seriesId); + LibraryManager.DeleteItem(episode, new() { DeleteFileLocation = false }); + } - // Add missing episodes. - if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { - foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) - existingEpisodes.Add(episodeId); + // Add missing episodes. + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) + existingEpisodes.Add(episodeId); - foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList)) { - var episodeParentIndex = Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); - if (episodeParentIndex != seasonNumber) - continue; + foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList)) { + var episodeParentIndex = Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); + if (episodeParentIndex != seasonNumber) + continue; - if (existingEpisodes.Contains(episodeInfo.Id)) - continue; + if (existingEpisodes.Contains(episodeInfo.Id)) + continue; - if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, seasonInfo, episodeInfo, season, series)) - itemUpdated |= ItemUpdateType.MetadataImport; + if (CustomEpisodeProvider.AddVirtualEpisode(LibraryManager, Logger, showInfo, seasonInfo, episodeInfo, season, series)) + itemUpdated |= ItemUpdateType.MetadataImport; + } } } - } - return itemUpdated; + return itemUpdated; + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } } private IEnumerable<(int, Season)> CreateMissingSeasons(Info.ShowInfo showInfo, Series series, Dictionary<int, Season> seasons) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 0d585ce8..7a70ace5 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -40,6 +40,7 @@ public EpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<EpisodeProv public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) { + var trackerId = Plugin.Instance.Tracker.Add($"Providing info for Episode \"{info.Name}\". (Path=\"{info.Path}\",IsMissingEpisode={info.IsMissingEpisode})"); try { var result = new MetadataResult<Episode>(); var config = Plugin.Instance.Configuration; @@ -88,6 +89,9 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); return new MetadataResult<Episode>(); } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } } public static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Season season, Guid episodeId) diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 04af5cc2..b86e7e7a 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -44,6 +44,8 @@ public ImageProvider(IHttpClientFactory httpClientFactory, ILogger<ImageProvider public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var list = new List<RemoteImageInfo>(); + var baseKind = item.GetBaseItemKind(); + var trackerId = Plugin.Instance.Tracker.Add($"Providing images for {baseKind} \"{item.Name}\". (Path=\"{item.Path}\")"); try { switch (item) { case Episode episode: { @@ -132,6 +134,9 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); return list; } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } } private static void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Info.EpisodeInfo episodeInfo) diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index ff2df0e8..21164f93 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -36,9 +36,9 @@ public MovieProvider(IHttpClientFactory httpClientFactory, ILogger<MovieProvider public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken) { + var trackerId = Plugin.Instance.Tracker.Add($"Providing info for Movie \"{info.Name}\". (Path=\"{info.Path}\")"); try { var result = new MetadataResult<Movie>(); - var (file, season, _) = await ApiManager.GetFileInfoByPath(info.Path); var episode = file?.EpisodeList.FirstOrDefault().Episode; @@ -86,6 +86,9 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); return new MetadataResult<Movie>(); } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 046eebaf..797d3638 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -37,33 +37,34 @@ public SeasonProvider(IHttpClientFactory httpClientFactory, ILogger<SeasonProvid public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) { - try { - var result = new MetadataResult<Season>(); - if (!info.IndexNumber.HasValue) - return result; + var result = new MetadataResult<Season>(); + if (!info.IndexNumber.HasValue) + return result; - // Special handling of the "Specials" season (pun intended). - if (info.IndexNumber.Value == 0) { - // We're forcing the sort names to start with "ZZ" to make it - // always appear last in the UI. - var seasonName = info.Name; - result.Item = new Season { - Name = seasonName, - IndexNumber = info.IndexNumber, - SortName = $"ZZ - {seasonName}", - ForcedSortName = $"ZZ - {seasonName}", - }; - result.HasMetadata = true; + // Special handling of the "Specials" season (pun intended). + if (info.IndexNumber.Value == 0) { + // We're forcing the sort names to start with "ZZ" to make it + // always appear last in the UI. + var seasonName = info.Name; + result.Item = new Season { + Name = seasonName, + IndexNumber = info.IndexNumber, + SortName = $"ZZ - {seasonName}", + ForcedSortName = $"ZZ - {seasonName}", + }; + result.HasMetadata = true; - return result; - } + return result; + } - if (!info.SeriesProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId) || !info.IndexNumber.HasValue) { - Logger.LogDebug("Unable refresh Season {SeasonNumber} {SeasonName}", info.IndexNumber, info.Name); - return result; - } + if (!info.SeriesProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId) || !info.IndexNumber.HasValue) { + Logger.LogDebug("Unable refresh Season {SeasonNumber} {SeasonName}", info.IndexNumber, info.Name); + return result; + } - var seasonNumber = info.IndexNumber.Value; + var seasonNumber = info.IndexNumber.Value; + var trackerId = Plugin.Instance.Tracker.Add($"Providing info for Season \"{info.Name}\". (Path=\"{info.Path}\",Series=\"{seriesId}\",Season={seasonNumber})"); + try { var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); if (showInfo == null) { Logger.LogWarning("Unable to find show info for Season {SeasonNumber}. (Series={SeriesId})", seasonNumber, seriesId); @@ -92,6 +93,9 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); return new MetadataResult<Season>(); } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } } public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage) diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index d095fbf6..b2b6078b 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -41,6 +41,7 @@ public SeriesProvider(IHttpClientFactory httpClientFactory, ILogger<SeriesProvid public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) { + var trackerId = Plugin.Instance.Tracker.Add($"Providing info for Series \"{info.Name}\". (Path=\"{info.Path}\")"); try { var result = new MetadataResult<Series>(); var show = await ApiManager.GetShowInfoByPath(info.Path); @@ -97,6 +98,9 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); return new MetadataResult<Series>(); } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } } public static void AddProviderIds(IHasProviderIds item, string seriesId, string? groupId = null, string? anidbId = null, string? tmdbId = null) diff --git a/Shokofin/Providers/TrailerProvider.cs b/Shokofin/Providers/TrailerProvider.cs index a99cd78d..706a2bac 100644 --- a/Shokofin/Providers/TrailerProvider.cs +++ b/Shokofin/Providers/TrailerProvider.cs @@ -36,13 +36,13 @@ public TrailerProvider(IHttpClientFactory httpClientFactory, ILogger<TrailerProv public async Task<MetadataResult<Trailer>> GetMetadata(TrailerInfo info, CancellationToken cancellationToken) { - try { - var result = new MetadataResult<Trailer>(); - var config = Plugin.Instance.Configuration; - if (string.IsNullOrEmpty(info.Path) || !info.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { - return result; - } + var result = new MetadataResult<Trailer>(); + if (string.IsNullOrEmpty(info.Path) || !info.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { + return result; + } + var trackerId = Plugin.Instance.Tracker.Add($"Providing info for Trailer \"{info.Name}\". (Path=\"{info.Path}\")"); + try { var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(info.Path); var episodeInfo = fileInfo?.EpisodeList.FirstOrDefault().Episode; if (fileInfo == null || episodeInfo == null || seasonInfo == null || showInfo == null) { @@ -71,6 +71,9 @@ public async Task<MetadataResult<Trailer>> GetMetadata(TrailerInfo info, Cancell Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); return new MetadataResult<Trailer>(); } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(TrailerInfo searchInfo, CancellationToken cancellationToken) diff --git a/Shokofin/Providers/VideoProvider.cs b/Shokofin/Providers/VideoProvider.cs index 8f1d337e..31644aa8 100644 --- a/Shokofin/Providers/VideoProvider.cs +++ b/Shokofin/Providers/VideoProvider.cs @@ -36,13 +36,13 @@ public VideoProvider(IHttpClientFactory httpClientFactory, ILogger<VideoProvider public async Task<MetadataResult<Video>> GetMetadata(ItemLookupInfo info, CancellationToken cancellationToken) { - try { - var result = new MetadataResult<Video>(); - var config = Plugin.Instance.Configuration; - if (string.IsNullOrEmpty(info.Path) || !info.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { - return result; - } + var result = new MetadataResult<Video>(); + if (string.IsNullOrEmpty(info.Path) || !info.Path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { + return result; + } + var trackerId = Plugin.Instance.Tracker.Add($"Providing info for Video \"{info.Name}\". (Path=\"{info.Path}\")"); + try { var (fileInfo, seasonInfo, showInfo) = await ApiManager.GetFileInfoByPath(info.Path); var episodeInfo = fileInfo?.EpisodeList.FirstOrDefault().Episode; if (fileInfo == null || episodeInfo == null || seasonInfo == null || showInfo == null) { @@ -71,6 +71,9 @@ public async Task<MetadataResult<Video>> GetMetadata(ItemLookupInfo info, Cancel Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); return new MetadataResult<Video>(); } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ItemLookupInfo searchInfo, CancellationToken cancellationToken) diff --git a/Shokofin/Resolvers/ShokoIgnoreRule.cs b/Shokofin/Resolvers/ShokoIgnoreRule.cs index 7a29b7be..54b4da28 100644 --- a/Shokofin/Resolvers/ShokoIgnoreRule.cs +++ b/Shokofin/Resolvers/ShokoIgnoreRule.cs @@ -68,6 +68,7 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file if (fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) return false; + var trackerId = Plugin.Instance.Tracker.Add($"Should ignore path \"{fileInfo.FullName}\"."); try { // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. if (!Lookup.IsEnabledForItem(parent, out var isSoleProvider)) @@ -116,6 +117,9 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); throw; } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } } private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPath, string? collectionType, bool shouldIgnore) diff --git a/Shokofin/Resolvers/ShokoLibraryMonitor.cs b/Shokofin/Resolvers/ShokoLibraryMonitor.cs index faef27f3..eae34dc1 100644 --- a/Shokofin/Resolvers/ShokoLibraryMonitor.cs +++ b/Shokofin/Resolvers/ShokoLibraryMonitor.cs @@ -75,7 +75,7 @@ NamingOptions namingOptions LibraryScanWatcher = libraryScanWatcher; LibraryScanWatcher.ValueChanged += OnLibraryScanRunningChanged; NamingOptions = namingOptions; - Cache = new(logger, TimeSpan.FromSeconds(1), new() { ExpirationScanFrequency = TimeSpan.FromSeconds(30) }, new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(1) }); + Cache = new(logger, new() { ExpirationScanFrequency = TimeSpan.FromSeconds(30) }, new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(1) }); } ~ShokoLibraryMonitor() @@ -274,6 +274,7 @@ await Cache.GetOrCreateAsync( IFileEventArgs eventArgs; var reason = changeTypes is WatcherChangeTypes.Deleted ? UpdateReason.Removed : changeTypes is WatcherChangeTypes.Created ? UpdateReason.Added : UpdateReason.Updated; var relativePath = path[mediaConfig.MediaFolderPath.Length..]; + 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)); @@ -307,6 +308,9 @@ 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>()); } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } Logger.LogDebug( "File {EventName}; {ImportFolderId} {Path} (File={FileId},Location={LocationId},CrossReferences={HasCrossReferences})", diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs index 6a1c5b30..1c39424c 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -65,6 +65,7 @@ NamingOptions namingOptions if (root is null || parent == root) return null; + var trackerId = Plugin.Instance.Tracker.Add($"Resolve path \"{fileInfo.FullName}\"."); try { if (!Lookup.IsEnabledForItem(parent)) return null; @@ -95,6 +96,9 @@ NamingOptions namingOptions Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); throw; } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } } public async Task<MultiItemResolverResult?> ResolveMultiple(Folder? parent, string? collectionType, List<FileSystemMetadata> fileInfoList) @@ -106,6 +110,7 @@ NamingOptions namingOptions if (root is null || parent == root) return null; + var trackerId = Plugin.Instance.Tracker.Add($"Resolve children of \"{parent.Path}\". (Children={fileInfoList.Count})"); try { if (!Lookup.IsEnabledForItem(parent)) return null; @@ -189,6 +194,9 @@ NamingOptions namingOptions Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); throw; } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } } #region IItemResolver diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index e4075c63..5a1880db 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -62,8 +62,6 @@ public class VirtualFileSystemService "trailers", }; - public bool IsCacheStalled => DataCache.IsStalled; - public VirtualFileSystemService( ShokoAPIManager apiManager, ShokoAPIClient apiClient, @@ -81,18 +79,23 @@ NamingOptions namingOptions LibraryManager = libraryManager; FileSystem = fileSystem; Logger = logger; - DataCache = new(logger, TimeSpan.FromMinutes(15), new() { ExpirationScanFrequency = TimeSpan.FromMinutes(25) }, new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1), SlidingExpiration = TimeSpan.FromMinutes(15) }); + 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(); } + private void OnTrackerStalled(object? sender, EventArgs eventArgs) + => Clear(); + public void Clear() { Logger.LogDebug("Clearing data…"); diff --git a/Shokofin/Tasks/AutoClearPluginCacheTask.cs b/Shokofin/Tasks/AutoClearPluginCacheTask.cs deleted file mode 100644 index 0079d3a8..00000000 --- a/Shokofin/Tasks/AutoClearPluginCacheTask.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.Tasks; -using Microsoft.Extensions.Logging; -using Shokofin.API; -using Shokofin.Resolvers; -using Shokofin.Utils; - -namespace Shokofin.Tasks; - -/// <summary> -/// For automagic maintenance. Will clear the plugin cache if there has been no recent activity to the cache. -/// </summary> -public class AutoClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTask -{ - /// <inheritdoc /> - public string Name => "Clear Plugin Cache (Auto)"; - - /// <inheritdoc /> - public string Description => "For automagic maintenance. Will clear the plugin cache if there has been no recent activity to the cache."; - - /// <inheritdoc /> - public string Category => "Shokofin"; - - /// <inheritdoc /> - public string Key => "ShokoAutoClearPluginCache"; - - /// <inheritdoc /> - public bool IsHidden => false; - - /// <inheritdoc /> - public bool IsEnabled => true; - - /// <inheritdoc /> - public bool IsLogged => false; - - private readonly ILogger<AutoClearPluginCacheTask> Logger; - - private readonly ShokoAPIManager ApiManager; - - private readonly ShokoAPIClient ApiClient; - - private readonly VirtualFileSystemService VfsService; - - private readonly LibraryScanWatcher LibraryScanWatcher; - - /// <summary> - /// Initializes a new instance of the <see cref="AutoClearPluginCacheTask" /> class. - /// </summary> - public AutoClearPluginCacheTask( - ILogger<AutoClearPluginCacheTask> logger, - ShokoAPIManager apiManager, - ShokoAPIClient apiClient, - VirtualFileSystemService vfsService, - LibraryScanWatcher libraryScanWatcher - ) - { - Logger = logger; - ApiManager = apiManager; - ApiClient = apiClient; - VfsService = vfsService; - LibraryScanWatcher = libraryScanWatcher; - } - - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - => new TaskTriggerInfo[] { - new() { - Type = TaskTriggerInfo.TriggerInterval, - IntervalTicks = TimeSpan.FromMinutes(15).Ticks, - } - }; - - /// <summary> - /// Returns the task to be executed. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <param name="progress">The progress.</param> - /// <returns>Task.</returns> - public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - if (LibraryScanWatcher.IsScanRunning) - return Task.CompletedTask; - - if (ApiClient.IsCacheStalled || ApiManager.IsCacheStalled || VfsService.IsCacheStalled) - Logger.LogInformation("Automagically clearing cache…"); - if (ApiClient.IsCacheStalled) - ApiClient.Clear(); - if (ApiManager.IsCacheStalled) - ApiManager.Clear(); - if (VfsService.IsCacheStalled) - VfsService.Clear(); - return Task.CompletedTask; - } -} diff --git a/Shokofin/Tasks/ClearPluginCacheTask.cs b/Shokofin/Tasks/ClearPluginCacheTask.cs index 52ec6e5a..dd8655e1 100644 --- a/Shokofin/Tasks/ClearPluginCacheTask.cs +++ b/Shokofin/Tasks/ClearPluginCacheTask.cs @@ -14,7 +14,7 @@ namespace Shokofin.Tasks; public class ClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> - public string Name => "Clear Plugin Cache (Force)"; + public string Name => "Clear Plugin Cache"; /// <inheritdoc /> public string Description => "Forcefully clear the plugin cache. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING."; @@ -40,9 +40,6 @@ public class ClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTask private readonly VirtualFileSystemService VfsService; - /// <summary> - /// Initializes a new instance of the <see cref="ClearPluginCacheTask" /> class. - /// </summary> public ClearPluginCacheTask(ShokoAPIManager apiManager, ShokoAPIClient apiClient, VirtualFileSystemService vfsService) { ApiManager = apiManager; @@ -50,18 +47,9 @@ public ClearPluginCacheTask(ShokoAPIManager apiManager, ShokoAPIClient apiClient VfsService = vfsService; } - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> 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> public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { ApiClient.Clear(); diff --git a/Shokofin/Tasks/ExportUserDataTask.cs b/Shokofin/Tasks/ExportUserDataTask.cs index 6209f186..24972b0c 100644 --- a/Shokofin/Tasks/ExportUserDataTask.cs +++ b/Shokofin/Tasks/ExportUserDataTask.cs @@ -7,9 +7,6 @@ namespace Shokofin.Tasks; -/// <summary> -/// Class ExportUserDataTask. -/// </summary> public class ExportUserDataTask : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> @@ -33,31 +30,15 @@ public class ExportUserDataTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public bool IsLogged => true; - /// <summary> - /// The _library manager. - /// </summary> private readonly UserDataSyncManager _userSyncManager; - /// <summary> - /// Initializes a new instance of the <see cref="ExportUserDataTask" /> class. - /// </summary> public ExportUserDataTask(UserDataSyncManager userSyncManager) { _userSyncManager = userSyncManager; } - - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> 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> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { await _userSyncManager.ScanAndSync(SyncDirection.Export, progress, cancellationToken); diff --git a/Shokofin/Tasks/ImportUserDataTask.cs b/Shokofin/Tasks/ImportUserDataTask.cs index 2c8e47de..8c8095f2 100644 --- a/Shokofin/Tasks/ImportUserDataTask.cs +++ b/Shokofin/Tasks/ImportUserDataTask.cs @@ -7,9 +7,6 @@ namespace Shokofin.Tasks; -/// <summary> -/// Class ImportUserDataTask. -/// </summary> public class ImportUserDataTask : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> @@ -33,31 +30,16 @@ public class ImportUserDataTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public bool IsLogged => true; - /// <summary> - /// The _library manager. - /// </summary> private readonly UserDataSyncManager _userSyncManager; - /// <summary> - /// Initializes a new instance of the <see cref="ImportUserDataTask" /> class. - /// </summary> public ImportUserDataTask(UserDataSyncManager userSyncManager) { _userSyncManager = userSyncManager; } - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> 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> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { await _userSyncManager.ScanAndSync(SyncDirection.Import, progress, cancellationToken); diff --git a/Shokofin/Tasks/MergeEpisodesTask.cs b/Shokofin/Tasks/MergeEpisodesTask.cs index 13e68dfa..dbabfc9b 100644 --- a/Shokofin/Tasks/MergeEpisodesTask.cs +++ b/Shokofin/Tasks/MergeEpisodesTask.cs @@ -4,12 +4,10 @@ using System.Threading.Tasks; using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; +using Shokofin.Utils; namespace Shokofin.Tasks; -/// <summary> -/// Class MergeEpisodesTask. -/// </summary> public class MergeEpisodesTask : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> @@ -33,33 +31,26 @@ public class MergeEpisodesTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public bool IsLogged => true; - /// <summary> - /// The merge-versions manager. - /// </summary> private readonly MergeVersionsManager VersionsManager; + + private readonly LibraryScanWatcher LibraryScanWatcher; - /// <summary> - /// Initializes a new instance of the <see cref="MergeEpisodesTask" /> class. - /// </summary> - public MergeEpisodesTask(MergeVersionsManager userSyncManager) + public MergeEpisodesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) { VersionsManager = userSyncManager; + LibraryScanWatcher = libraryScanWatcher; } - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> 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> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - await VersionsManager.MergeAllEpisodes(progress, cancellationToken); + if (LibraryScanWatcher.IsScanRunning) + return; + + using (Plugin.Instance.Tracker.Enter("Merge Episodes Task")) { + await VersionsManager.MergeAllEpisodes(progress, cancellationToken); + } } } diff --git a/Shokofin/Tasks/MergeMoviesTask.cs b/Shokofin/Tasks/MergeMoviesTask.cs index f0f8d2e1..8fbbed23 100644 --- a/Shokofin/Tasks/MergeMoviesTask.cs +++ b/Shokofin/Tasks/MergeMoviesTask.cs @@ -4,12 +4,10 @@ using System.Threading.Tasks; using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; +using Shokofin.Utils; namespace Shokofin.Tasks; -/// <summary> -/// Class MergeMoviesTask. -/// </summary> public class MergeMoviesTask : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> @@ -33,33 +31,26 @@ public class MergeMoviesTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public bool IsLogged => true; - /// <summary> - /// The merge-versions manager. - /// </summary> private readonly MergeVersionsManager VersionsManager; - /// <summary> - /// Initializes a new instance of the <see cref="MergeMoviesTask" /> class. - /// </summary> - public MergeMoviesTask(MergeVersionsManager userSyncManager) + private readonly LibraryScanWatcher LibraryScanWatcher; + + public MergeMoviesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) { VersionsManager = userSyncManager; + LibraryScanWatcher = libraryScanWatcher; } - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> 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> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - await VersionsManager.MergeAllMovies(progress, cancellationToken); + if (LibraryScanWatcher.IsScanRunning) + return; + + using (Plugin.Instance.Tracker.Enter("Merge Movies Task")) { + await VersionsManager.MergeAllMovies(progress, cancellationToken); + } } } diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs index 7f175d7e..70ab991b 100644 --- a/Shokofin/Tasks/PostScanTask.cs +++ b/Shokofin/Tasks/PostScanTask.cs @@ -12,21 +12,12 @@ namespace Shokofin.Tasks; public class PostScanTask : ILibraryPostScanTask { - private readonly ShokoAPIManager ApiManager; - - private readonly ShokoAPIClient ApiClient; - - private readonly VirtualFileSystemService VfsService; - private readonly MergeVersionsManager VersionsManager; private readonly CollectionManager CollectionManager; - public PostScanTask(ShokoAPIManager apiManager, ShokoAPIClient apiClient, VirtualFileSystemService vfsService, MergeVersionsManager versionsManager, CollectionManager collectionManager) + public PostScanTask(MergeVersionsManager versionsManager, CollectionManager collectionManager) { - ApiManager = apiManager; - ApiClient = apiClient; - VfsService = vfsService; VersionsManager = versionsManager; CollectionManager = collectionManager; } @@ -53,10 +44,5 @@ public async Task Run(IProgress<double> progress, CancellationToken token) // Reconstruct collections. await CollectionManager.ReconstructCollections(progress, token); } - - // Clear the cache now, since we don't need it anymore. - ApiClient.Clear(); - ApiManager.Clear(); - VfsService.Clear(); } } diff --git a/Shokofin/Tasks/ReconstructCollectionsTask.cs b/Shokofin/Tasks/ReconstructCollectionsTask.cs index fd4c334b..c641e29e 100644 --- a/Shokofin/Tasks/ReconstructCollectionsTask.cs +++ b/Shokofin/Tasks/ReconstructCollectionsTask.cs @@ -35,7 +35,7 @@ public class ReconstructCollectionsTask : IScheduledTask, IConfigurableScheduled public bool IsLogged => true; private readonly CollectionManager CollectionManager; - + private readonly LibraryScanWatcher LibraryScanWatcher; public ReconstructCollectionsTask(CollectionManager collectionManager, LibraryScanWatcher libraryScanWatcher) @@ -58,6 +58,8 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can if (LibraryScanWatcher.IsScanRunning) return; - await CollectionManager.ReconstructCollections(progress, cancellationToken); + using (Plugin.Instance.Tracker.Enter("Reconstruct Collections Task")) { + await CollectionManager.ReconstructCollections(progress, cancellationToken); + } } } diff --git a/Shokofin/Tasks/SplitEpisodesTask.cs b/Shokofin/Tasks/SplitEpisodesTask.cs index ea66a964..fe87c518 100644 --- a/Shokofin/Tasks/SplitEpisodesTask.cs +++ b/Shokofin/Tasks/SplitEpisodesTask.cs @@ -4,12 +4,11 @@ using System.Threading.Tasks; using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; +using Shokofin.Utils; namespace Shokofin.Tasks; -/// <summary> -/// Class SplitEpisodesTask. -/// </summary> +/// <summary public class SplitEpisodesTask : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> @@ -33,33 +32,26 @@ public class SplitEpisodesTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public bool IsLogged => true; - /// <summary> - /// The merge-versions manager. - /// </summary> private readonly MergeVersionsManager VersionsManager; - /// <summary> - /// Initializes a new instance of the <see cref="SplitEpisodesTask" /> class. - /// </summary> - public SplitEpisodesTask(MergeVersionsManager userSyncManager) + private readonly LibraryScanWatcher LibraryScanWatcher; + + public SplitEpisodesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) { VersionsManager = userSyncManager; + LibraryScanWatcher = libraryScanWatcher; } - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> 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> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - await VersionsManager.SplitAllEpisodes(progress, cancellationToken); + if (LibraryScanWatcher.IsScanRunning) + return; + + using (Plugin.Instance.Tracker.Enter("Merge Movies Task")) { + await VersionsManager.SplitAllEpisodes(progress, cancellationToken); + } } } diff --git a/Shokofin/Tasks/SplitMoviesTask.cs b/Shokofin/Tasks/SplitMoviesTask.cs index 7b296252..d7edc722 100644 --- a/Shokofin/Tasks/SplitMoviesTask.cs +++ b/Shokofin/Tasks/SplitMoviesTask.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using MediaBrowser.Model.Tasks; using Shokofin.MergeVersions; +using Shokofin.Utils; namespace Shokofin.Tasks; @@ -33,33 +34,26 @@ public class SplitMoviesTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public bool IsLogged => true; - /// <summary> - /// The merge-versions manager. - /// </summary> private readonly MergeVersionsManager VersionsManager; - /// <summary> - /// Initializes a new instance of the <see cref="SplitMoviesTask" /> class. - /// </summary> - public SplitMoviesTask(MergeVersionsManager userSyncManager) + private readonly LibraryScanWatcher LibraryScanWatcher; + + public SplitMoviesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) { VersionsManager = userSyncManager; + LibraryScanWatcher = libraryScanWatcher; } - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> 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> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - await VersionsManager.SplitAllMovies(progress, cancellationToken); + if (LibraryScanWatcher.IsScanRunning) + return; + + using (Plugin.Instance.Tracker.Enter("Merge Movies Task")) { + await VersionsManager.SplitAllMovies(progress, cancellationToken); + } } } diff --git a/Shokofin/Tasks/SyncUserDataTask.cs b/Shokofin/Tasks/SyncUserDataTask.cs index a7c0c65e..1fb7e5ce 100644 --- a/Shokofin/Tasks/SyncUserDataTask.cs +++ b/Shokofin/Tasks/SyncUserDataTask.cs @@ -7,9 +7,6 @@ namespace Shokofin.Tasks; -/// <summary> -/// Class SyncUserDataTask. -/// </summary> public class SyncUserDataTask : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> @@ -33,31 +30,16 @@ public class SyncUserDataTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public bool IsLogged => true; - /// <summary> - /// The _library manager. - /// </summary> private readonly UserDataSyncManager _userSyncManager; - /// <summary> - /// Initializes a new instance of the <see cref="SyncUserDataTask" /> class. - /// </summary> public SyncUserDataTask(UserDataSyncManager userSyncManager) { _userSyncManager = userSyncManager; } - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> 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> 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 1cc87fe3..e36610be 100644 --- a/Shokofin/Tasks/VersionCheckTask.cs +++ b/Shokofin/Tasks/VersionCheckTask.cs @@ -47,9 +47,6 @@ public VersionCheckTask(ILogger<VersionCheckTask> logger, ShokoAPIClient apiClie ApiClient = apiClient; } - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => new TaskTriggerInfo[2] { new() { @@ -61,12 +58,6 @@ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() }, }; - /// <summary> - /// Returns the task to be executed. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <param name="progress">The progress.</param> - /// <returns>Task.</returns> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { var updated = false; diff --git a/Shokofin/Utils/GuardedMemoryCache.cs b/Shokofin/Utils/GuardedMemoryCache.cs index dd1e6cb9..134c7cb7 100644 --- a/Shokofin/Utils/GuardedMemoryCache.cs +++ b/Shokofin/Utils/GuardedMemoryCache.cs @@ -20,22 +20,12 @@ sealed class GuardedMemoryCache : IDisposable, IMemoryCache private readonly ConcurrentDictionary<object, SemaphoreSlim> Semaphores = new(); - public DateTime LastClearedAt { get; private set; } - - public DateTime LastAccessedAt { get; private set; } - - public readonly TimeSpan StallTime; - - public bool IsStalled => LastAccessedAt - LastClearedAt > StallTime; - - public GuardedMemoryCache(ILogger logger, TimeSpan stallTime, MemoryCacheOptions options, MemoryCacheEntryOptions? cacheEntryOptions = null) + public GuardedMemoryCache(ILogger logger, MemoryCacheOptions options, MemoryCacheEntryOptions? cacheEntryOptions = null) { Logger = logger; CacheOptions = options; CacheEntryOptions = cacheEntryOptions; Cache = new MemoryCache(CacheOptions); - StallTime = stallTime; - LastClearedAt = LastAccessedAt = DateTime.Now; } public void Clear() @@ -44,7 +34,6 @@ public void Clear() var cache = Cache; Cache = new MemoryCache(CacheOptions); Semaphores.Clear(); - LastClearedAt = LastAccessedAt = DateTime.Now; cache.Dispose(); } @@ -193,14 +182,8 @@ public bool TryGetValue(object key, [NotNullWhen(true)] out object? value) => Cache.TryGetValue(key, out value); public bool TryGetValue<TItem>(object key, [NotNullWhen(true)] out TItem? value) - { - LastAccessedAt = DateTime.Now; - return Cache.TryGetValue(key, out value); - } + => Cache.TryGetValue(key, out value); public TItem? Set<TItem>(object key, [NotNullIfNotNull("value")] TItem? value, MemoryCacheEntryOptions? createOptions = null) - { - LastAccessedAt = DateTime.Now; - return Cache.Set(key, value, createOptions ?? CacheEntryOptions); - } + => Cache.Set(key, value, createOptions ?? CacheEntryOptions); } \ No newline at end of file diff --git a/Shokofin/Utils/LibraryScanWatcher.cs b/Shokofin/Utils/LibraryScanWatcher.cs index 9c1f255e..20dd529d 100644 --- a/Shokofin/Utils/LibraryScanWatcher.cs +++ b/Shokofin/Utils/LibraryScanWatcher.cs @@ -9,6 +9,8 @@ public class LibraryScanWatcher private readonly PropertyWatcher<bool> Watcher; + private Guid? TrackerId = null; + public bool IsScanRunning => Watcher.Value; public event EventHandler<bool>? ValueChanged; @@ -28,5 +30,18 @@ public LibraryScanWatcher(ILibraryManager libraryManager) } private void OnLibraryScanRunningChanged(object? sender, bool isScanRunning) - => ValueChanged?.Invoke(sender, isScanRunning); + { + if (isScanRunning) { + if (!TrackerId.HasValue) { + TrackerId = Plugin.Instance.Tracker.Add("Library Scan Watcher"); + } + } + else { + if (TrackerId.HasValue) { + Plugin.Instance.Tracker.Remove(TrackerId.Value); + TrackerId = null; + } + } + ValueChanged?.Invoke(sender, isScanRunning); + } } \ No newline at end of file diff --git a/Shokofin/Utils/UsageTracker.cs b/Shokofin/Utils/UsageTracker.cs new file mode 100644 index 00000000..3cf0a3b3 --- /dev/null +++ b/Shokofin/Utils/UsageTracker.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Concurrent; +using System.Timers; +using Microsoft.Extensions.Logging; + +namespace Shokofin.Utils; + +public class UsageTracker +{ + private readonly ILogger<UsageTracker> Logger; + + private readonly object LockObj = new(); + + private readonly Timer StalledTimer; + + public TimeSpan Timeout { get; private set; } + + public ConcurrentDictionary<Guid, string> CurrentTrackers { get; private init; } = new(); + + public event EventHandler? Stalled; + + public UsageTracker(ILogger<UsageTracker> logger, TimeSpan timeout) + { + Logger = logger; + Timeout = timeout; + StalledTimer = new(timeout.TotalMilliseconds) { + AutoReset = false, + Enabled = false, + }; + StalledTimer.Elapsed += OnTimerElapsed; + } + + ~UsageTracker() { + StalledTimer.Elapsed -= OnTimerElapsed; + StalledTimer.Dispose(); + } + + public void UpdateTimeout(TimeSpan timeout) + { + if (Timeout == timeout) + return; + + lock (LockObj) { + if (Timeout == timeout) + return; + + Logger.LogTrace("Timeout changed. (Previous={PreviousTimeout},Next={NextTimeout})", Timeout, timeout); + var timerRunning = StalledTimer.Enabled; + if (timerRunning) + StalledTimer.Stop(); + + Timeout = timeout; + StalledTimer.Interval = timeout.TotalMilliseconds; + + if (timerRunning) + StalledTimer.Start(); + } + } + + private void OnTimerElapsed(object? sender, ElapsedEventArgs eventArgs) + { + Logger.LogDebug("Dispatching stalled event."); + Stalled?.Invoke(this, new()); + } + + public IDisposable Enter(string name) + { + var trackerId = Add(name); + return new DisposableAction(() => Remove(trackerId)); + } + + public Guid Add(string name) + { + Guid trackerId = Guid.NewGuid(); + while (!CurrentTrackers.TryAdd(trackerId, name)) + trackerId = Guid.NewGuid(); + Logger.LogTrace("Added tracker to {Name}. (Id={TrackerId})", name, trackerId); + if (StalledTimer.Enabled) { + lock (LockObj) { + if (StalledTimer.Enabled) { + Logger.LogTrace("Stopping timer."); + StalledTimer.Stop(); + } + } + } + return trackerId; + } + + public void Remove(Guid trackerId) + { + if (CurrentTrackers.TryRemove(trackerId, out var name)) { + Logger.LogTrace("Removed tracker from {Name}. (Id={TrackerId})", name, trackerId); + if (CurrentTrackers.IsEmpty && !StalledTimer.Enabled) { + lock (LockObj) { + if (CurrentTrackers.IsEmpty && !StalledTimer.Enabled) { + Logger.LogTrace("Starting timer."); + StalledTimer.Start(); + } + } + } + } + } +} \ No newline at end of file From c80d6567b592b787e78484ae385481da656fa1be Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 16 Jun 2024 16:29:11 +0200 Subject: [PATCH 1063/1103] refactor: add remaining tag sources + more - Added the remaining tag sources, and new inclusion/exclusion filters for abstract tags and/or weighted tags. - Internally the filtering have been rewritten to use attributes on the tag sources instead of hard coding all the paths. This makes it easier to maintain in the long run. --- Shokofin/API/Models/Tag.cs | 108 ++++- Shokofin/API/ShokoAPIManager.cs | 19 +- Shokofin/Configuration/PluginConfiguration.cs | 29 +- Shokofin/Configuration/configPage.html | 354 +++++++++++++- Shokofin/Utils/TagFilter.cs | 442 ++++++++++++++---- 5 files changed, 831 insertions(+), 121 deletions(-) diff --git a/Shokofin/API/Models/Tag.cs b/Shokofin/API/Models/Tag.cs index 52729421..a4b61fef 100644 --- a/Shokofin/API/Models/Tag.cs +++ b/Shokofin/API/Models/Tag.cs @@ -71,13 +71,108 @@ public class Tag public class ResolvedTag : Tag { + // All the abstract tags I know about. + private static readonly HashSet<string> AbstractTags = new() { + "/content indicators", + "/dynamic", + "/dynamic/cast", + "/dynamic/ending", + "/dynamic/storytelling", + "/elements", + "/elements/motifs", + "/elements/pornography", + "/elements/pornography/group sex", + "/elements/pornography/oral", + "/elements/sexual abuse", + "/elements/speculative fiction", + "/elements/tropes", + "/fetishes", + "/fetishes/breasts", + "/maintenance tags", + "/maintenance tags/TO BE MOVED TO CHARACTER", + "/maintenance tags/TO BE MOVED TO EPISODE", + "/origin", + "/original work", + "/setting", + "/setting/place", + "/setting/time", + "/setting/time/season", + "/target audience", + "/technical aspects", + "/technical aspects/adapted into other media", + "/technical aspects/awards", + "/technical aspects/multi-anime projects", + "/themes", + "/themes/body and host", + "/themes/death", + "/themes/family life", + "/themes/money", + "/themes/tales", + "/ungrouped", + "/unsorted", + "/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" }, + { "/setting/past", "Historical Past" }, + { "/setting/past/alternative past", "Alternative Past" }, + { "/setting/past/historical", "Historical Past" }, + { "/ungrouped/3dd cg", "3D CG animation" }, + { "/ungrouped/condom", "uses condom" }, + { "/ungrouped/dilf", "DILF" }, + { "/unsorted/old animetags/preview in ed", "preview in ED" }, + { "/unsorted/old animetags/recap in opening", "recap in OP" }, + }; + + private static readonly Dictionary<string, string> TagNamespaceOverride = new() { + { "/ungrouped/1950s", "/setting/time/past" }, + { "/ungrouped/1990s", "/setting/time/past" }, + { "/ungrouped/3dd cg", "/technical aspects/CGI" }, + { "/ungrouped/afterlife world", "/setting/place" }, + { "/ungrouped/airhead", "/maintenance tags/TO BE MOVED TO CHARACTER" }, + { "/ungrouped/airport", "/setting/place" }, + { "/ungrouped/anal prolapse", "/elements/pornography" }, + { "/ungrouped/child protagonist", "/dynamic/cast" }, + { "/ungrouped/condom", "/elements/pornography" }, + { "/ungrouped/dilf", "/fetishes" }, + { "/ungrouped/Italian-Japanese co-production", "/target audience" }, + { "/ungrouped/Middle-Aged Protagonist", "/dynamic/cast" }, + { "/ungrouped/creation magic", "/elements/speculative fiction/fantasy/magic" }, + { "/ungrouped/destruction magic", "/elements/speculative fiction/fantasy/magic" }, + { "/ungrouped/overpowered magic", "/elements/speculative fiction/fantasy/magic" }, + { "/ungrouped/paper talisman magic", "/elements/speculative fiction/fantasy/magic" }, + { "/ungrouped/space magic", "/elements/speculative fiction/fantasy/magic" }, + { "/ungrouped/very bloody wound in low-pg series", "/technical aspects" }, + { "/unsorted/ending tags that need merging/anti-climactic end", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/cliffhanger ending", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/complete manga adaptation", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/downer ending", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/incomplete story", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/only the beginning", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/series end", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/tragic ending", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/twisted ending", "/dynamic/ending" }, + { "/unsorted/ending tags that need merging/unresolved romance", "/dynamic/ending" }, + { "/unsorted/old animetags/preview in ed", "/technical aspects" }, + { "/unsorted/old animetags/recap in opening", "/technical aspects" }, + }; + + private string? _displayName = null; + + public string DisplayName => _displayName ??= TagNameOverrides.TryGetValue(FullName, out var altName) ? altName : Name; + private string? _fullName = null; - + public string FullName => _fullName ??= Namespace + Name; public bool IsParent => Children.Count is > 0; - public bool IsWeightless => Children.Count is 0 && Weight is 0; + public bool IsAbstract => AbstractTags.Contains(FullName); + + public bool IsWeightless => !IsAbstract && Weight is 0; /// <summary> /// True if the tag is considered a spoiler for that particular series it is @@ -92,8 +187,6 @@ public class ResolvedTag : Tag public string Namespace; - public ResolvedTag? Parent; - public IReadOnlyDictionary<string, ResolvedTag> Children; public IReadOnlyDictionary<string, ResolvedTag> RecursiveNamespacedChildren; @@ -110,14 +203,13 @@ public ResolvedTag(Tag tag, ResolvedTag? parent, Func<string, int, IEnumerable<T Weight = tag.Weight ?? TagWeight.Weightless; LastUpdated = tag.LastUpdated; Source = tag.Source; - Namespace = ns; - Parent = parent; + Namespace = TagNamespaceOverride.TryGetValue(ns + "/" + tag.Name, out var newNs) ? newNs : ns; Children = (getChildren(Source, Id) ?? Array.Empty<Tag>()) .DistinctBy(childTag => childTag.Name) - .Select(childTag => new ResolvedTag(childTag, this, getChildren, ns + tag.Name + "/")) + .Select(childTag => new ResolvedTag(childTag, this, getChildren, FullName + "/")) .ToDictionary(childTag => childTag.Name); RecursiveNamespacedChildren = Children.Values .SelectMany(childTag => childTag.RecursiveNamespacedChildren.Values.Prepend(childTag)) - .ToDictionary(childTag => childTag.FullName[(ns.Length + Name.Length)..]); + .ToDictionary(childTag => childTag.FullName[FullName.Length..]); } } \ No newline at end of file diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 122c1f5c..99c885c7 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -201,7 +201,9 @@ public Task<IReadOnlyDictionary<string, ResolvedTag>> GetNamespacedTagsForSeries if (!tagMap.TryGetValue(parentKey, out var list)) tagMap[parentKey] = list = new(); // Remove comment on tag name itself. - if (tag.Name.Contains("--")) + if (tag.Name.Contains(" - ")) + tag.Name = tag.Name.Split(" - ").First().Trim(); + else if (tag.Name.Contains("--")) tag.Name = tag.Name.Split("--").First().Trim(); list.Add(tag); break; @@ -277,11 +279,20 @@ public Task<IReadOnlyDictionary<string, ResolvedTag>> GetNamespacedTagsForSeries } } List<Tag>? getChildren(string source, int id) => tagMap.TryGetValue($"{source}:{id}", out var list) ? list : null; - return rootTags + var allResolvedTags = rootTags .Select(tag => new ResolvedTag(tag, null, getChildren)) .SelectMany(tag => tag.RecursiveNamespacedChildren.Values.Prepend(tag)) - .OrderBy(tag => tag.FullName) - .ToDictionary(childTag => childTag.FullName) as IReadOnlyDictionary<string, ResolvedTag>; + .ToDictionary(tag => tag.FullName); + // We reassign the children because they may have been moved to a different namespace. + foreach (var groupBy in allResolvedTags.Values.GroupBy(tag => tag.Namespace).OrderByDescending(pair => pair.Key)) { + if (!allResolvedTags.TryGetValue(groupBy.Key[..^1], out var nsTag)) + continue; + nsTag.Children = groupBy.ToDictionary(childTag => childTag.Name); + nsTag.RecursiveNamespacedChildren = nsTag.Children.Values + .SelectMany(childTag => childTag.RecursiveNamespacedChildren.Values.Prepend(childTag)) + .ToDictionary(childTag => childTag.FullName[nsTag.FullName.Length..]); + } + return allResolvedTags as IReadOnlyDictionary<string, ResolvedTag>; } ); diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 0fa0cf80..98db5395 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -193,6 +193,12 @@ public virtual string PrettyUrl /// </summary> public TagWeight TagMinimumWeight { get; set; } + /// <summary> + /// The maximum relative depth of the tag from it's source type to use for tags. + /// </summary> + [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> @@ -215,6 +221,12 @@ public virtual string PrettyUrl /// </summary> public TagWeight GenreMinimumWeight { get; set; } + /// <summary> + /// The maximum relative depth of the tag from it's source type to use for genres. + /// </summary> + [Range(0, 10)] + public int GenreMaximumDepth { get; set; } + /// <summary> /// Hide tags that are not verified by the AniDB moderators yet. /// </summary> @@ -449,12 +461,19 @@ public PluginConfiguration() Username = "Default"; ApiKey = string.Empty; TagOverride = false; - TagSources = TagSource.Elements | TagSource.Themes | TagSource.Fetishes | TagSource.SettingPlace | TagSource.SettingTimePeriod | TagSource.SettingTimeSeason | TagSource.CustomTags; - TagIncludeFilters = TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Weightless; + 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 | + TagSource.SettingTimePeriod | TagSource.SettingTimeSeason | TagSource.TargetAudience | TagSource.TechnicalAspects | + TagSource.TechnicalAspectsAdaptions | TagSource.TechnicalAspectsAwards | TagSource.TechnicalAspectsMultiAnimeProjects | + TagSource.Themes | TagSource.ThemesDeath | TagSource.ThemesTales | TagSource.CustomTags; + TagIncludeFilters = TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Abstract | TagIncludeFilter.Weightless | TagIncludeFilter.Weighted; TagMinimumWeight = TagWeight.Weightless; - GenreSources = TagSource.SourceMaterial | TagSource.TargetAudience | TagSource.ContentIndicators | TagSource.Elements; - GenreIncludeFilters = TagIncludeFilter.Child | TagIncludeFilter.Weightless; - GenreMinimumWeight = TagWeight.Three; + TagMaximumDepth = 0; + GenreSources = TagSource.SourceMaterial | TagSource.TargetAudience | TagSource.Elements; + GenreIncludeFilters = TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Abstract | TagIncludeFilter.Weightless | TagIncludeFilter.Weighted; + GenreMinimumWeight = TagWeight.Four; + GenreMaximumDepth = 1; HideUnverifiedTags = true; ContentRatingOverride = false; ContentRatingList = new[] { diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index dd82ae19..2e8ab5a6 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -346,38 +346,38 @@ <h3 class="checkboxListLabel">Advanced tag sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="SourceMaterial"> + <input is="emby-checkbox" type="checkbox" data-option="ContentIndicators"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Source Material</h3> + <h3 class="listItemBodyText">Content Indicators</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="TargetAudience"> + <input is="emby-checkbox" type="checkbox" data-option="Dynamic"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Target Audience</h3> + <h3 class="listItemBodyText">Dynamic | General</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="ContentIndicators"> + <input is="emby-checkbox" type="checkbox" data-option="DynamicCast"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Content Indicators</h3> + <h3 class="listItemBodyText">Dynamic | Cast</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="Origin"> + <input is="emby-checkbox" type="checkbox" data-option="DynamicEnding"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Origin</h3> + <h3 class="listItemBodyText">Dynamic | Ending</h3> </div> </div> <div class="listItem"> @@ -386,16 +386,52 @@ <h3 class="listItemBodyText">Origin</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Elements</h3> + <h3 class="listItemBodyText">Elements | General</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="Themes"> + <input is="emby-checkbox" type="checkbox" data-option="ElementsPornographyAndSexualAbuse"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Elements | Pornography & Sexual Abuse</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ElementsTropesAndMotifs"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Elements | Tropes & Motifs</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Fetishes"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Themes</h3> + <h3 class="listItemBodyText">Fetishes</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="OriginProduction"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Origin | Production</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="OriginDevelopment"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Origin | Development</h3> </div> </div> <div class="listItem"> @@ -425,6 +461,105 @@ <h3 class="listItemBodyText">Setting | Time Period</h3> <h3 class="listItemBodyText">Setting | Yearly Seasons</h3> </div> </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SourceMaterial"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Source Material</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TargetAudience"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Target Audience</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspects"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | General</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspectsAdaptions"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | Adaptions</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspectsAwards"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | Awards</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspectsMultiAnimeProjects"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | Multi-Anime Projects</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Themes"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Themes | General</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ThemesDeath"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Themes | Death</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ThemesTales"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Themes | Tales</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Ungrouped"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Ungrouped</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Unsorted"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Unsorted</h3> + </div> + </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="CustomTags"> @@ -458,6 +593,15 @@ <h3 class="listItemBodyText">Type | Parent Tags</h3> <h3 class="listItemBodyText">Type | Child Tags</h3> </div> </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Abstract"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Abstract Tags</h3> + </div> + </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="Weightless"> @@ -467,6 +611,15 @@ <h3 class="listItemBodyText">Type | Child Tags</h3> <h3 class="listItemBodyText">Type | Weightless Tags</h3> </div> </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Weighted"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Weighted Tags</h3> + </div> + </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="GlobalSpoiler"> @@ -517,38 +670,38 @@ <h3 class="checkboxListLabel">Advanced genre sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="SourceMaterial"> + <input is="emby-checkbox" type="checkbox" data-option="ContentIndicators"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Source Material</h3> + <h3 class="listItemBodyText">Content Indicators</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="TargetAudience"> + <input is="emby-checkbox" type="checkbox" data-option="Dynamic"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Target Audience</h3> + <h3 class="listItemBodyText">Dynamic | General</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="ContentIndicators"> + <input is="emby-checkbox" type="checkbox" data-option="DynamicCast"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Content Indicators</h3> + <h3 class="listItemBodyText">Dynamic | Cast</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="Origin"> + <input is="emby-checkbox" type="checkbox" data-option="DynamicEnding"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Origin</h3> + <h3 class="listItemBodyText">Dynamic | Ending</h3> </div> </div> <div class="listItem"> @@ -557,16 +710,52 @@ <h3 class="listItemBodyText">Origin</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Elements</h3> + <h3 class="listItemBodyText">Elements | General</h3> </div> </div> <div class="listItem"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="Themes"> + <input is="emby-checkbox" type="checkbox" data-option="ElementsPornographyAndSexualAbuse"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Elements | Pornography & Sexual Abuse</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ElementsTropesAndMotifs"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Elements | Tropes & Motifs</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Fetishes"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Themes</h3> + <h3 class="listItemBodyText">Fetishes</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="OriginProduction"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Origin | Production</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="OriginDevelopment"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Origin | Development</h3> </div> </div> <div class="listItem"> @@ -596,6 +785,105 @@ <h3 class="listItemBodyText">Setting | Time Period</h3> <h3 class="listItemBodyText">Setting | Yearly Seasons</h3> </div> </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="SourceMaterial"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Source Material</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TargetAudience"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Target Audience</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspects"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | General</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspectsAdaptions"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | Adaptions</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspectsAwards"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | Awards</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TechnicalAspectsMultiAnimeProjects"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Technical Aspects | Multi-Anime Projects</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Themes"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Themes | General</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ThemesDeath"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Themes | Death</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="ThemesTales"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Themes | Tales</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Ungrouped"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Ungrouped</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Unsorted"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Unsorted</h3> + </div> + </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="CustomTags"> @@ -629,6 +917,15 @@ <h3 class="listItemBodyText">Type | Parent Tags</h3> <h3 class="listItemBodyText">Type | Child Tags</h3> </div> </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Abstract"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Abstract Tags</h3> + </div> + </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="Weightless"> @@ -638,6 +935,15 @@ <h3 class="listItemBodyText">Type | Child Tags</h3> <h3 class="listItemBodyText">Type | Weightless Tags</h3> </div> </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Weighted"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Type | Weighted Tags</h3> + </div> + </div> <div class="listItem"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="GlobalSpoiler"> @@ -665,8 +971,8 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> <option value="Weightless">Disabled</option> <option value="One">⯪☆☆</option> <option value="Two">★☆☆</option> - <option value="Three" selected>★⯪☆ (Default)</option> - <option value="Four">★★☆</option> + <option value="Three" selected>★⯪☆</option> + <option value="Four">★★☆ (Default)</option> <option value="Five">★★⯪</option> <option value="Six">★★★</option> </select> diff --git a/Shokofin/Utils/TagFilter.cs b/Shokofin/Utils/TagFilter.cs index 0a02b1b0..e1a161a5 100644 --- a/Shokofin/Utils/TagFilter.cs +++ b/Shokofin/Utils/TagFilter.cs @@ -11,20 +11,288 @@ namespace Shokofin.Utils; public static class TagFilter { + /// <summary> + /// Include only the children of the selected tags. + /// </summary> + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class TagSourceIncludeAttribute : Attribute + { + public string[] Values { get; init; } + + public TagSourceIncludeAttribute(params string[] values) + { + Values = values; + } + } + + /// <summary> + /// Include only the selected tags, but not their children. + /// </summary> + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class TagSourceIncludeOnlyAttribute : Attribute + { + public string[] Values { get; init; } + + public TagSourceIncludeOnlyAttribute(params string[] values) + { + Values = values; + } + } + + /// <summary> + /// Exclude the selected tags and all their children. + /// </summary> + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class TagSourceExcludeOnlyAttribute : Attribute + { + public string[] Values { get; init; } + + public TagSourceExcludeOnlyAttribute(params string[] values) + { + Values = values; + } + } + + /// <summary> + /// Exclude the selected tags, but don't exclude their children. + /// </summary> + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class TagSourceExcludeAttribute : Attribute + { + public string[] Values { get; init; } + + public TagSourceExcludeAttribute(params string[] values) + { + Values = values; + } + } + + /// <summary> + /// All available tag sources to use. + /// </summary> [Flags] [JsonConverter(typeof(JsonStringEnumConverter))] public enum TagSource { - SourceMaterial = 1, - TargetAudience = 2, - ContentIndicators = 4, - Origin = 8, - Elements = 16, - Themes = 32, - Fetishes = 64, - SettingPlace = 128, - SettingTimePeriod = 256, - SettingTimeSeason = 512, - CustomTags = 1024, + /// <summary> + /// The content indicators branch is intended to be a less geographically specific + /// tool than the `age rating` used by convention, for warning about things that + /// might cause offense. Obviously there is still a degree of subjectivity + /// involved, but hopefully it will prove useful for parents with delicate + /// children, or children with delicate parents. + /// </summary> + [TagSourceInclude("/content indicators")] + ContentIndicators = 1 << 0, + + /// <summary> + /// Central structural elements in the anime. + /// </summary> + [TagSourceInclude("/dynamic")] + [TagSourceExclude("/dynamic/cast", "/dynamic/ending")] + Dynamic = 1 << 1, + + /// <summary> + /// Cast related structural elements in the anime. + /// </summary> + [TagSourceInclude("/dynamic/cast")] + DynamicCast = 1 << 2, + + /// <summary> + /// Ending related structural elements in the anime. + /// </summary> + [TagSourceInclude("/dynamic/ending")] + DynamicEnding = 1 << 3, + + // 4 is reserved for story telling if we add it as a separate source. + + /// <summary> + /// Next to <see cref="Themes"/> setting the backdrop for the protagonists in the + /// anime, there are the more detailed plot elements that centre on character + /// interactions: "What do characters do to each other or what is done to them?". + /// Is it violent action, an awe-inspiring adventure in a foreign place, the + /// gripping life of a detective, a slapstick comedy, an ecchi harem anime, + /// a sci-fi epic, or some fantasy traveling adventure, etc.. + /// </summary> + [TagSourceInclude("/elements/speculative fiction", "/elements")] + [TagSourceExclude("/elements/pornography", "/elements/sexual abuse", "/elements/tropes", "/elements/motifs")] + [TagSourceExcludeOnly("/elements/speculative fiction")] + Elements = 1 << 5, + + /// <summary> + /// Anime clearly marked as "Restricted 18" material centring on all variations of + /// adult sex, some of which can be considered as quite perverse. To a certain + /// extent, some of the elements can be seen on late night TV animations. Sexual + /// abuse is the act of one person forcing sexual activities upon another. Sexual + /// abuse includes not only physical coercion and sexual assault, especially rape, + /// but also psychological abuse, such as verbal sexual behavior or stalking, + /// including emotional manipulation. + /// </summary> + [TagSourceInclude("/elements/pornography", "/elements/sexual abuse")] + ElementsPornographyAndSexualAbuse = 1 << 6, + + /// <summary> + /// A trope is a commonly recurring literary and rhetorical devices, motifs or + /// clichés in creative works. + /// </summary> + [TagSourceInclude("/elements/tropes", "/elements/motifs")] + ElementsTropesAndMotifs = 1 << 7, + + /// <summary> + /// For non-porn anime, the fetish must be a major element of the show; incidental + /// appearances of the fetish is not sufficient for a fetish tag. Please do not + /// add fetish tags to anime that do not pander to the fetish in question in any + /// meaningful way. For example, there's some ecchi in Shinseiki Evangelion, but + /// the fact you get to see Asuka's panties is not sufficient to warrant applying + /// the school girl fetish tag. Most porn anime play out the fetish, making tag + /// application fairly straightforward. + /// </summary> + [TagSourceInclude("/fetishes/breasts", "/fetishes")] + [TagSourceExcludeOnly("/fetishes/breasts")] + Fetishes = 1 << 8, + + /// <summary> + /// Origin production locations. + /// </summary> + [TagSourceInclude("/origin")] + [TagSourceExcludeOnly("/origin/development hell", "/origin/fan-made", "/origin/remake")] + OriginProduction = 1 << 9, + + /// <summary> + /// Origin development information. + /// </summary> + [TagSourceIncludeOnly("/origin/development hell", "/origin/fan-made", "/origin/remake")] + OriginDevelopment = 1 << 10, + + /// <summary> + /// The places the anime takes place in. Includes more specific places such as a + /// country on Earth, as well as more general places such as a dystopia or a + /// mirror world. + /// </summary> + [TagSourceInclude("/setting/place")] + [TagSourceExcludeOnly("/settings/place/Earth")] + SettingPlace = 1 << 11, + + /// <summary> + /// This placeholder lists different epochs in human history and more vague but + /// important timelines such as the future, the present and the past. + /// </summary> + [TagSourceInclude("/setting/time")] + [TagSourceExclude("/setting/time/season")] + SettingTimePeriod = 1 << 12, + + /// <summary> + /// In temperate and sub-polar regions, four calendar-based seasons (with their + /// adjectives) are generally recognized: + /// - spring (vernal), + /// - summer (estival), + /// - autumn/fall (autumnal), and + /// - winter (hibernal). + /// </summary> + [TagSourceInclude("/setting/time/season")] + SettingTimeSeason = 1 << 13, + + /// <summary> + /// What the anime is based on! This is given as the original work credit in the + /// OP. Mostly of academic interest, but a useful bit of info, hinting at the + /// possible depth of story. + /// </summary> + /// <remarks> + /// This is not sourced from the tags, but rather from the dedicated method. + /// </remarks> + SourceMaterial = 1 << 14, + + /// <summary> + /// Anime, like everything else in the modern world, is targeted towards specific + /// audiences, both implicitly by the creators and overtly by the marketing. + /// </summary> + [TagSourceInclude("/target audience")] + TargetAudience = 1 << 15, + + /// <summary> + /// It may sometimes be useful to know about technical aspects of a show, such as + /// information about its broadcasting or censorship. Such information can be + /// found here. + /// </summary> + [TagSourceInclude("/technical aspects")] + [TagSourceExclude("/technical aspects/adapted into other media", "/technical aspects/awards", "/technical aspects/multi-anime projects")] + TechnicalAspects = 1 << 16, + + /// <summary> + /// This anime is a new original work, and it has been adapted into other media + /// formats. + /// + /// In exceedingly rare instances, a specific episode of a new original work anime + /// can also be adapted. + /// </summary> + [TagSourceInclude("/technical aspects/adapted into other media")] + TechnicalAspectsAdaptions = 1 << 17, + + /// <summary> + /// Awards won by the anime. + /// </summary> + [TagSourceInclude("/technical aspects/awards")] + TechnicalAspectsAwards = 1 << 18, + + /// <summary> + /// Many anime are created as part of larger projects encompassing many shows + /// without direct relation to one another. Normally, there is a specific idea in + /// mind: for example, the Young Animator Training Project aims to stimulate the + /// on-the-job training of next-generation professionals of the anime industry, + /// whereas the World Masterpiece Theatre aims to animate classical stories from + /// all over the world. + /// </summary> + [TagSourceInclude("/technical aspects/multi-anime projects")] + TechnicalAspectsMultiAnimeProjects = 1 << 19, + + /// <summary> + /// Themes describe the very central elements important to the anime stories. They + /// set the backdrop against which the protagonists must face their challenges. + /// Be it school life, present daily life, military action, cyberpunk, law and + /// order detective work, sports, or the underworld. These are only but a few of + /// the more typical backgrounds for anime plots. Add to that a conspiracy setting + /// with a possible tragic outcome, the themes span most of the imaginable subject + /// matter relevant to the anime. + /// </summary> + [TagSourceInclude("/themes")] + [TagSourceExclude("/themes/death", "/themes/tales")] + [TagSourceExcludeOnly("/themes/body and host", "/themes/family life", "/themes/money")] + Themes = 1 << 20, + + // 21 to 23 are reserved for the above exclusions if we decide to branch them off + // into their own source. + + /// <summary> + /// Death is the state of no longer being alive or the process of ceasing to be + /// alive. As Emiya Shirou once said it; "People die when they're killed." + /// </summary> + [TagSourceInclude("/themes/death")] + ThemesDeath = 1 << 24, + + /// <summary> + /// Tales are stories told time and again and passed down from generation to + /// generation, and some of those show up in anime not just once or twice, but + /// several times. + /// </summary> + [TagSourceInclude("/themes/tales")] + ThemesTales = 1 << 25, + + /// <summary> + /// Everything under the ungrouped tag. + /// </summary> + [TagSourceInclude("/ungrouped")] + Ungrouped = 1 << 26, + + /// <summary> + /// Everything under the unsorted tag. + /// </summary> + [TagSourceInclude("/unsorted")] + [TagSourceExclude("/unsorted/old animetags", "/unsorted/ending tags that need merging", "/unsorted/character related tags which need deleting or merging")] + Unsorted = 1 << 27, + + /// <summary> + /// Custom user tags. + /// </summary> + [TagSourceInclude("/custom user tags")] + CustomTags = 1 << 30, } [Flags] @@ -32,9 +300,11 @@ public enum TagSource { public enum TagIncludeFilter { Parent = 1, Child = 2, - Weightless = 4, - GlobalSpoiler = 8, - LocalSpoiler = 16, + Abstract = 4, + Weightless = 8, + Weighted = 16, + GlobalSpoiler = 32, + LocalSpoiler = 64, } [JsonConverter(typeof(JsonStringEnumConverter))] @@ -51,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[] { ProviderName.AniDB, ProviderName.TMDB }; + : new[] { ProviderName.AniDB, ProviderName.TMDB }; #pragma warning disable IDE0060 public static IReadOnlyList<string> GetMovieContentRating(SeasonInfo seasonInfo, EpisodeInfo episodeInfo) @@ -104,11 +374,17 @@ public static string[] FilterTags(IReadOnlyDictionary<string, ResolvedTag> tags) if (!config.TagOverride) return FilterInternal( tags, - TagSource.Elements | TagSource.Themes | TagSource.Fetishes | TagSource.SettingPlace | TagSource.SettingTimePeriod | TagSource.SettingTimeSeason | TagSource.CustomTags, - TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Weightless, - TagWeight.Weightless + 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); + return FilterInternal(tags, config.TagSources, config.TagIncludeFilters, config.TagMinimumWeight, config.TagMaximumDepth); } public static string[] FilterGenres(IReadOnlyDictionary<string, ResolvedTag> tags) @@ -117,87 +393,92 @@ public static string[] FilterGenres(IReadOnlyDictionary<string, ResolvedTag> tag if (!config.GenreOverride) return FilterInternal( tags, - TagSource.SourceMaterial | TagSource.TargetAudience | TagSource.ContentIndicators | TagSource.Elements, - TagIncludeFilter.Child | TagIncludeFilter.Weightless, - TagWeight.Three + 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); + return FilterInternal(tags, config.GenreSources, config.GenreIncludeFilters, config.GenreMinimumWeight, config.GenreMaximumDepth); } - private static string[] FilterInternal(IReadOnlyDictionary<string, ResolvedTag> tags, TagSource source, TagIncludeFilter includeFilter, TagWeight minWeight = TagWeight.Weightless) + private static readonly HashSet<TagSource> AllFlagsToUse = Enum.GetValues<TagSource>().Except(new[] { TagSource.CustomTags }).ToHashSet(); + + private static readonly HashSet<TagSource> AllFlagsToUseForCustomTags = AllFlagsToUse.Except(new[] { TagSource.SourceMaterial, TagSource.TargetAudience }).ToHashSet(); + + private static string[] FilterInternal(IReadOnlyDictionary<string, ResolvedTag> tags, TagSource source, TagIncludeFilter includeFilter, TagWeight minWeight = TagWeight.Weightless, int maxDepth = 0) { var tagSet = new List<string>(); - if (source.HasFlag(TagSource.SourceMaterial)) - tagSet.Add(GetSourceMaterial(tags)); - if (source.HasFlag(TagSource.TargetAudience) && tags.TryGetValue("/target audience", out var subTags)) - foreach (var tag in subTags.Children.Values.Select(SelectTagName)) - tagSet.Add(tag); - if (source.HasFlag(TagSource.ContentIndicators) && tags.TryGetValue("/content indicators", out subTags)) - foreach (var tag in subTags.RecursiveNamespacedChildren.Values.Select(SelectTagName)) - tagSet.Add(tag); - if (source.HasFlag(TagSource.Origin) && tags.TryGetValue("/origin", out subTags)) - foreach (var tag in subTags.RecursiveNamespacedChildren.Values.Select(SelectTagName)) - tagSet.Add(tag); - - if (source.HasAnyFlags(TagSource.SettingPlace, TagSource.SettingTimePeriod, TagSource.SettingTimeSeason) && tags.TryGetValue("/setting", out var setting)) { - if (source.HasFlag(TagSource.SettingPlace) && setting.Children.TryGetValue("place", out var place)) { - tagSet.AddRange(place.Children.Values.Select(SelectTagName)); - } - if (source.HasAnyFlags(TagSource.SettingTimePeriod, TagSource.SettingTimeSeason) && setting.Children.TryGetValue("time", out var time)) { - if (source.HasFlag(TagSource.SettingTimeSeason) && time.Children.TryGetValue("season", out var season)) - tagSet.AddRange(season.Children.Values.Select(SelectTagName)); - - if (source.HasFlag(TagSource.SettingTimePeriod)) { - if (time.Children.TryGetValue("present", out var present)) - tagSet.Add(present.Children.ContainsKey("alternative present") ? "Alternative Present" : "Present"); - if (time.Children.TryGetValue("future", out var future)) - tagSet.AddRange(future.Children.Values.Select(SelectTagName).Prepend("Future")); - if (time.Children.TryGetValue("past", out var past)) - tagSet.AddRange(past.Children.ContainsKey("alternative past") - ? new string[] { "Alternative Past" } - : past.Children.Values.Select(SelectTagName).Prepend("Historical Past") - ); - } - } - } - - var tagsToFilter = new List<ResolvedTag>(); - if (source.HasFlag(TagSource.Elements) && tags.TryGetValue("/elements", out subTags)) - tagsToFilter.AddRange(subTags.RecursiveNamespacedChildren.Values); - if (source.HasFlag(TagSource.Fetishes) && tags.TryGetValue("/fetishes", out subTags)) - tagsToFilter.AddRange(subTags.RecursiveNamespacedChildren.Values); - if (source.HasFlag(TagSource.Themes) && tags.TryGetValue("/themes", out subTags)) - tagsToFilter.AddRange(subTags.RecursiveNamespacedChildren.Values); + foreach (var flag in AllFlagsToUse.Where(flag => source.HasFlag(flag))) + tagSet.AddRange(GetTagsFromSource(tags, flag, includeFilter, minWeight, maxDepth)); if (source.HasFlag(TagSource.CustomTags) && tags.TryGetValue("/custom user tags", out var customTags)) { + var count = tagSet.Count; tagSet.AddRange(customTags.Children.Values.Where(tag => !tag.IsParent).Select(SelectTagName)); + count = tagSet.Count - count; - if (source.HasFlag(TagSource.Elements) && customTags.Children.TryGetValue("elements", out subTags)) - tagsToFilter.AddRange(subTags.RecursiveNamespacedChildren.Values); - if (source.HasFlag(TagSource.Fetishes) && customTags.Children.TryGetValue("fetishes", out subTags)) - tagsToFilter.AddRange(subTags.RecursiveNamespacedChildren.Values); - if (source.HasFlag(TagSource.Themes) && customTags.Children.TryGetValue("themes", out subTags)) - tagsToFilter.AddRange(subTags.RecursiveNamespacedChildren.Values); + // If we have any children that weren't added above, then run the additional checks on them. + if (customTags.RecursiveNamespacedChildren.Count != count) + foreach (var flag in AllFlagsToUseForCustomTags.Where(flag => source.HasFlag(flag))) + tagSet.AddRange(GetTagsFromSource(customTags.RecursiveNamespacedChildren, flag, includeFilter, minWeight, maxDepth)); } - foreach (var tag in tagsToFilter) - { - if (tag.IsWeightless && !includeFilter.HasFlag(TagIncludeFilter.Weightless)) + return tagSet + .Distinct() + .OrderBy(a => a) + .ToArray(); + } + + 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) }; + + var tagSet = new HashSet<string>(); + var includeTags = new List<KeyValuePair<string, ResolvedTag>>(); + var exceptTags = new List<ResolvedTag>(); + var field = source.GetType().GetField(source.ToString()); + if (field?.GetCustomAttributes(typeof(TagSourceIncludeAttribute), false) is TagSourceIncludeAttribute[] includeAttributes && includeAttributes.Length is not 0) + foreach (var tagName in includeAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + includeTags.AddRange(tag.RecursiveNamespacedChildren); + if (field?.GetCustomAttributes(typeof(TagSourceIncludeOnlyAttribute), false) is TagSourceIncludeOnlyAttribute[] includeOnlyAttributes && includeOnlyAttributes.Length is not 0) + foreach (var tagName in includeOnlyAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + includeTags.Add(KeyValuePair.Create($"/{tag.Name}", tag)); + if (field?.GetCustomAttributes(typeof(TagSourceExcludeAttribute), false) is TagSourceExcludeAttribute[] excludeAttributes && excludeAttributes.Length is not 0) + foreach (var tagName in excludeAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + exceptTags.AddRange(tag.RecursiveNamespacedChildren.Values.Append(tag)); + + if (field?.GetCustomAttributes(typeof(TagSourceExcludeOnlyAttribute), false) is TagSourceExcludeOnlyAttribute[] excludeOnlyAttributes && excludeOnlyAttributes.Length is not 0) + foreach (var tagName in excludeOnlyAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + exceptTags.Add(tag); + + includeTags = includeTags + .DistinctBy(pair => $"{pair.Value.Source}:{pair.Value.Id}") + .ExceptBy(exceptTags, pair => pair.Value) + .ToList(); + foreach (var (relativeName, tag) in includeTags) { + var depth = relativeName[1..].Split('/').Length; + if (maxDepth > 0 && depth > maxDepth) continue; if (tag.IsLocalSpoiler && !includeFilter.HasFlag(TagIncludeFilter.LocalSpoiler)) continue; if (tag.IsGlobalSpoiler && !includeFilter.HasFlag(TagIncludeFilter.GlobalSpoiler)) continue; - if (tag.IsParent ? !tag.IsWeightless && !includeFilter.HasFlag(TagIncludeFilter.Parent) : !includeFilter.HasFlag(TagIncludeFilter.Child)) + if (tag.IsAbstract && !includeFilter.HasFlag(TagIncludeFilter.Abstract)) + continue; + if (tag.IsWeightless ? !includeFilter.HasFlag(TagIncludeFilter.Weightless) : !includeFilter.HasFlag(TagIncludeFilter.Weighted)) + continue; + if (tag.IsParent ? !includeFilter.HasFlag(TagIncludeFilter.Parent) : !includeFilter.HasFlag(TagIncludeFilter.Child)) continue; if (minWeight is > TagWeight.Weightless && !tag.IsWeightless && tag.Weight < minWeight) continue; tagSet.Add(SelectTagName(tag)); } - return tagSet - .Distinct() - .ToArray(); + return tagSet; } private static string GetSourceMaterial(IReadOnlyDictionary<string, ResolvedTag> tags) @@ -248,6 +529,7 @@ public static string[] GetProductionCountriesFromTags(IReadOnlyDictionary<string "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" }, @@ -261,8 +543,8 @@ public static string[] GetProductionCountriesFromTags(IReadOnlyDictionary<string .ToArray(); } - private static string SelectTagName(Tag tag) - => System.Globalization.CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tag.Name); + private static string SelectTagName(ResolvedTag tag) + => System.Globalization.CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tag.DisplayName); private static bool HasAnyFlags(this Enum value, params Enum[] candidates) => candidates.Any(value.HasFlag); From 0a0bf04757db2ee17c575669aa0a8e088c0b76bd Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 16 Jun 2024 03:51:55 +0200 Subject: [PATCH 1064/1103] cleanup: style fixes - Remove brackets on singular blocks in id lookup. - Use more `var` in guarded memory cache. - Fix bracket position in content rating. --- Shokofin/IdLookup.cs | 17 +++++------------ Shokofin/Utils/ContentRating.cs | 3 +-- Shokofin/Utils/GuardedMemoryCache.cs | 8 ++++---- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index e22da65b..0ed4cd97 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -167,19 +167,14 @@ public bool TryGetSeriesIdFromEpisodeId(string episodeId, [NotNullWhen(true)] ou public bool TryGetSeriesIdFor(Series series, [NotNullWhen(true)] out string? seriesId) { - if (series.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId!) && !string.IsNullOrEmpty(seriesId)) { + if (series.ProviderIds.TryGetValue(ShokoSeriesId.Name, out seriesId!) && !string.IsNullOrEmpty(seriesId)) return true; - } if (TryGetSeriesIdFor(series.Path, out seriesId)) { - // Set the ShokoGroupId.Name and ShokoSeriesId.Name provider ids for the series, since it haven't been set again. It doesn't matter if it's not saved to the database, since we only need it while running the following code. - if (ApiManager.TryGetDefaultSeriesIdForSeriesId(seriesId, out var defaultSeriesId)) { + if (ApiManager.TryGetDefaultSeriesIdForSeriesId(seriesId, out var defaultSeriesId)) SeriesProvider.AddProviderIds(series, defaultSeriesId); - } - // Same as above, but only set the ShokoSeriesId.Name id. - else { + else SeriesProvider.AddProviderIds(series, seriesId); - } // Make sure the presentation unique is not cached, so we won't reuse the cache key. series.PresentationUniqueKey = null; return true; @@ -258,14 +253,12 @@ public bool TryGetEpisodeIdsFor(string path, [NotNullWhen(true)] out List<string public bool TryGetEpisodeIdsFor(BaseItem item, [NotNullWhen(true)] out List<string>? episodeIds) { // This will account for virtual episodes and existing episodes - if (item.ProviderIds.TryGetValue(ShokoFileId.Name, out var fileId) && item.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId) && ApiManager.TryGetEpisodeIdsForFileId(fileId, seriesId, out episodeIds!)) { + if (item.ProviderIds.TryGetValue(ShokoFileId.Name, out var fileId) && item.ProviderIds.TryGetValue(ShokoSeriesId.Name, out var seriesId) && ApiManager.TryGetEpisodeIdsForFileId(fileId, seriesId, out episodeIds!)) return true; - } // This will account for new episodes that haven't received their first metadata update yet. - if (TryGetEpisodeIdsFor(item.Path, out episodeIds)) { + if (TryGetEpisodeIdsFor(item.Path, out episodeIds)) return true; - } return false; } diff --git a/Shokofin/Utils/ContentRating.cs b/Shokofin/Utils/ContentRating.cs index 709ebb5b..0ca38266 100644 --- a/Shokofin/Utils/ContentRating.cs +++ b/Shokofin/Utils/ContentRating.cs @@ -353,8 +353,7 @@ private static bool TryConvertRatingFromText(string? value, out TvRating content private static string? ConvertRatingToText(TvRating value, IEnumerable<TvContentIndicator>? contentIndicators) { var field = value.GetType().GetField(value.ToString()); - if (field?.GetCustomAttributes(typeof(DescriptionAttribute), false) is DescriptionAttribute[] attributes && attributes.Length != 0) - { + if (field?.GetCustomAttributes(typeof(DescriptionAttribute), false) is DescriptionAttribute[] attributes && attributes.Length != 0) { var contentRating = attributes.First().Description; var allowedIndicators = ( (field.GetCustomAttributes(typeof(TvContentIndicatorsAttribute), false) as TvContentIndicatorsAttribute[] ?? Array.Empty<TvContentIndicatorsAttribute>()).FirstOrDefault()?.Values ?? Array.Empty<TvContentIndicator>() diff --git a/Shokofin/Utils/GuardedMemoryCache.cs b/Shokofin/Utils/GuardedMemoryCache.cs index 134c7cb7..fe6e79bf 100644 --- a/Shokofin/Utils/GuardedMemoryCache.cs +++ b/Shokofin/Utils/GuardedMemoryCache.cs @@ -54,7 +54,7 @@ public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICac return value; } - using ICacheEntry entry = Cache.CreateEntry(key); + using var entry = Cache.CreateEntry(key); createOptions ??= CacheEntryOptions; if (createOptions != null) entry.SetOptions(createOptions); @@ -86,7 +86,7 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> found return value; } - using ICacheEntry entry = Cache.CreateEntry(key); + using var entry = Cache.CreateEntry(key); createOptions ??= CacheEntryOptions; if (createOptions != null) entry.SetOptions(createOptions); @@ -114,7 +114,7 @@ public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFacto if (TryGetValue(key, out value)) return value; - using ICacheEntry entry = Cache.CreateEntry(key); + using var entry = Cache.CreateEntry(key); createOptions ??= CacheEntryOptions; if (createOptions != null) entry.SetOptions(createOptions); @@ -142,7 +142,7 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, T if (TryGetValue(key, out value)) return value; - using ICacheEntry entry = Cache.CreateEntry(key); + using var entry = Cache.CreateEntry(key); createOptions ??= CacheEntryOptions; if (createOptions != null) entry.SetOptions(createOptions); From 8ff44afdf52a9b70149b4730e43a35573fd2de53 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 16 Jun 2024 20:24:38 +0200 Subject: [PATCH 1065/1103] refactor: update settings page - Remove advanced section, move settings to other sections. - Remove provider compatibility section, move settings to the metadata sections. - Add max. depth options for tags/genres. Most people shouldn't ever touch these settings, but if you want to play around then they're configurable in the UI now. --- Shokofin/Configuration/configController.js | 31 +++++++-- Shokofin/Configuration/configPage.html | 75 +++++++++------------- 2 files changed, 57 insertions(+), 49 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 4ea36592..c7f4df2b 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -295,10 +295,12 @@ async function defaultSubmit(form) { 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.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; @@ -499,10 +501,12 @@ async function syncSettings(form) { 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.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; @@ -682,13 +686,11 @@ export default function (page) { form.querySelector("#ConnectionResetContainer").removeAttribute("hidden"); form.querySelector("#ConnectionSection").removeAttribute("hidden"); form.querySelector("#MetadataSection").removeAttribute("hidden"); - form.querySelector("#ProviderSection").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("#AdvancedSection").removeAttribute("hidden"); form.querySelector("#ExperimentalSection").removeAttribute("hidden"); } else { @@ -698,13 +700,11 @@ export default function (page) { form.querySelector("#ConnectionResetContainer").setAttribute("hidden", ""); form.querySelector("#ConnectionSection").removeAttribute("hidden"); form.querySelector("#MetadataSection").setAttribute("hidden", ""); - form.querySelector("#ProviderSection").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("#AdvancedSection").setAttribute("hidden", ""); form.querySelector("#ExperimentalSection").setAttribute("hidden", ""); } @@ -799,12 +799,16 @@ export default function (page) { 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; } }); @@ -814,12 +818,16 @@ export default function (page) { 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; } }); @@ -883,31 +891,42 @@ export default function (page) { 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"); } diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 2e8ab5a6..17e4d2a1 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -12,8 +12,12 @@ <h2 class="sectionTitle">Shoko</h2> <h3>Connection Settings</h3> </legend> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="Url" required label="Host:" /> - <div class="fieldDescription">This is the URL leading to where Shoko is running. You can input a full url, or just the dns name/ip.</div> + <input is="emby-input" type="text" id="Url" required 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:" /> + <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:" /> @@ -656,6 +660,10 @@ <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"> + <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"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="GenreOverride" /> @@ -980,6 +988,10 @@ <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"> + <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"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="ContentRatingOverride" /> @@ -1058,6 +1070,20 @@ <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> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> </button> @@ -1210,6 +1236,10 @@ <h3>Media Folder Settings</h3> <div class="fieldDescription verticalSection-extrabottompadding"> Placeholder description. </div> + <div class="inputContainer inputContainer-withDescription"> + <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="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"> @@ -1513,47 +1543,6 @@ <h3>User Settings</h3> </button> </div> </fieldset> - <fieldset id="ProviderSection" class="verticalSection verticalSection-extrabottompadding" hidden> - <legend> - <h3>Plugin Compatibility Settings</h3> - </legend> - <div class="fieldDescription verticalSection-extrabottompadding"> - We can optionally set some ids for interoperability with other plugins. - </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> - <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> - <span>Save</span> - </button> - </fieldset> - <fieldset id="AdvancedSection" class="verticalSection verticalSection-extrabottompadding" hidden> - <legend> - <h3>Advanced Settings</h3> - </legend> - <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="PublicUrl" label="Public Shoko host URL:" /> - <div class="fieldDescription">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 should include both the protocol and the IP/DNS name.</div> - </div> - <div class="inputContainer inputContainer-withDescription"> - <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 the library scan.</div> - </div> - <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> - <span>Save</span> - </button> - </fieldset> <fieldset id="ExperimentalSection" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Experimental Settings</h3> From 686785c4b004c29b02b267f8d35a9242f84f9b5b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 17 Jun 2024 20:16:09 +0200 Subject: [PATCH 1066/1103] cleanup: simplify attribute usage --- Shokofin/Utils/ContentRating.cs | 29 +++++++++++++----------- Shokofin/Utils/TagFilter.cs | 40 +++++++++++++++++---------------- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/Shokofin/Utils/ContentRating.cs b/Shokofin/Utils/ContentRating.cs index 0ca38266..42afade4 100644 --- a/Shokofin/Utils/ContentRating.cs +++ b/Shokofin/Utils/ContentRating.cs @@ -350,20 +350,23 @@ private static bool TryConvertRatingFromText(string? value, out TvRating content return true; } + 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>(); + private static string? ConvertRatingToText(TvRating value, IEnumerable<TvContentIndicator>? contentIndicators) { - var field = value.GetType().GetField(value.ToString()); - if (field?.GetCustomAttributes(typeof(DescriptionAttribute), false) is DescriptionAttribute[] attributes && attributes.Length != 0) { - var contentRating = attributes.First().Description; - var allowedIndicators = ( - (field.GetCustomAttributes(typeof(TvContentIndicatorsAttribute), false) as TvContentIndicatorsAttribute[] ?? Array.Empty<TvContentIndicatorsAttribute>()).FirstOrDefault()?.Values ?? Array.Empty<TvContentIndicator>() - ) - .Intersect(contentIndicators ?? Array.Empty<TvContentIndicator>()) - .ToList(); - if (allowedIndicators.Count is > 0) - contentRating += $"-{allowedIndicators.Select(cI => cI.ToString()).Join("")}"; - return contentRating; - } - return null; + var field = value.GetType().GetField(value.ToString())!; + var attributes = field.GetCustomAttributes<DescriptionAttribute>(); + if (attributes.Length is 0) + return null; + + var contentRating = attributes.First().Description; + var allowedIndicators = (field.GetCustomAttributes<TvContentIndicatorsAttribute>().FirstOrDefault()?.Values ?? Array.Empty<TvContentIndicator>()) + .Intersect(contentIndicators ?? Array.Empty<TvContentIndicator>()) + .ToList(); + if (allowedIndicators.Count is > 0) + contentRating += $"-{allowedIndicators.Select(cI => cI.ToString()).Join("")}"; + + return contentRating; } } \ No newline at end of file diff --git a/Shokofin/Utils/TagFilter.cs b/Shokofin/Utils/TagFilter.cs index e1a161a5..5ae52596 100644 --- a/Shokofin/Utils/TagFilter.cs +++ b/Shokofin/Utils/TagFilter.cs @@ -434,26 +434,28 @@ private static HashSet<string> GetTagsFromSource(IReadOnlyDictionary<string, Res return new() { GetSourceMaterial(tags) }; var tagSet = new HashSet<string>(); - var includeTags = new List<KeyValuePair<string, ResolvedTag>>(); var exceptTags = new List<ResolvedTag>(); - var field = source.GetType().GetField(source.ToString()); - if (field?.GetCustomAttributes(typeof(TagSourceIncludeAttribute), false) is TagSourceIncludeAttribute[] includeAttributes && includeAttributes.Length is not 0) - foreach (var tagName in includeAttributes.First().Values) - if (tags.TryGetValue(tagName, out var tag)) - includeTags.AddRange(tag.RecursiveNamespacedChildren); - if (field?.GetCustomAttributes(typeof(TagSourceIncludeOnlyAttribute), false) is TagSourceIncludeOnlyAttribute[] includeOnlyAttributes && includeOnlyAttributes.Length is not 0) - foreach (var tagName in includeOnlyAttributes.First().Values) - if (tags.TryGetValue(tagName, out var tag)) - includeTags.Add(KeyValuePair.Create($"/{tag.Name}", tag)); - if (field?.GetCustomAttributes(typeof(TagSourceExcludeAttribute), false) is TagSourceExcludeAttribute[] excludeAttributes && excludeAttributes.Length is not 0) - foreach (var tagName in excludeAttributes.First().Values) - if (tags.TryGetValue(tagName, out var tag)) - exceptTags.AddRange(tag.RecursiveNamespacedChildren.Values.Append(tag)); - - if (field?.GetCustomAttributes(typeof(TagSourceExcludeOnlyAttribute), false) is TagSourceExcludeOnlyAttribute[] excludeOnlyAttributes && excludeOnlyAttributes.Length is not 0) - foreach (var tagName in excludeOnlyAttributes.First().Values) - if (tags.TryGetValue(tagName, out var tag)) - exceptTags.Add(tag); + var includeTags = new List<KeyValuePair<string, ResolvedTag>>(); + var field = source.GetType().GetField(source.ToString())!; + var includeAttributes = field.GetCustomAttributes<TagSourceIncludeAttribute>(); + foreach (var tagName in includeAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + includeTags.AddRange(tag.RecursiveNamespacedChildren); + + var includeOnlyAttributes = field.GetCustomAttributes<TagSourceIncludeOnlyAttribute>(); + foreach (var tagName in includeOnlyAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + includeTags.Add(KeyValuePair.Create($"/{tag.Name}", tag)); + + var excludeAttributes = field.GetCustomAttributes<TagSourceExcludeAttribute>(); + foreach (var tagName in excludeAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + exceptTags.AddRange(tag.RecursiveNamespacedChildren.Values.Append(tag)); + + var excludeOnlyAttributes = field.GetCustomAttributes<TagSourceExcludeOnlyAttribute>(); + foreach (var tagName in excludeOnlyAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + exceptTags.Add(tag); includeTags = includeTags .DistinctBy(pair => $"{pair.Value.Source}:{pair.Value.Id}") From e82e62345aa0f876cb1bb4dd3e8f611f3cff5fb0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 17 Jun 2024 20:30:35 +0200 Subject: [PATCH 1067/1103] misc: update exception message - Updated the exception message for when we're unable to determine the series to use for the file based on it's location because the file resides within a mixed folder with multiple AniDB anime in it. They will either have to fix their file structure or use the VFS. --- Shokofin/API/ShokoAPIManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 99c885c7..678fb5f0 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -517,7 +517,7 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu return new(fileInfo, seasonInfo, showInfo); } - throw new Exception($"Unable to find the series to use for the file. (File={fileId})"); + throw new Exception($"Unable to determine the series to use for the file based on it's location because the file resides within a mixed folder with multiple AniDB anime in it. You will either have to fix your file structure or use the VFS to avoid this issue. (File={fileId})\nFile location; {path}"); } public async Task<FileInfo?> GetFileInfo(string fileId, string seriesId) From c76413a635a4c6f38a5a8ac78b1c980f4a4860c0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 17 Jun 2024 21:17:16 +0200 Subject: [PATCH 1068/1103] misc: add remove mapping button to settings - Added a button to remove the mapping for a media folder if it's unmapped, so it's easier to attempt to remap it without resorting to manual edits of the settings file or fiddling with the browser dev. tools. --- Shokofin/Configuration/configController.js | 27 ++++++++++++++++++++++ Shokofin/Configuration/configPage.html | 6 +++++ 2 files changed, 33 insertions(+) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index c7f4df2b..a424a0f7 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -177,6 +177,13 @@ async function loadMediaFolderConfig(form, mediaFolderId, config) { form.querySelector("#MediaFolderDefaultSettingsContainer").setAttribute("hidden", ""); form.querySelector("#MediaFolderPerFolderSettingsContainer").removeAttribute("hidden"); + if (mediaFolderConfig.IsMapped) { + form.querySelector("#MediaFolderDeleteContainer").setAttribute("hidden", ""); + } + else { + form.querySelector("#MediaFolderDeleteContainer").removeAttribute("hidden"); + } + Dashboard.hideLoadingMsg(); } @@ -562,6 +569,23 @@ async function unlinkUser(form) { return config; } +async function removeMediaFolder(form) { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + const mediaFolderId = form.querySelector("#MediaFolderSelector").value; + if (!mediaFolderId) return; + + const index = config.MediaFolders.findIndex((m) => m.MediaFolderId === mediaFolderId); + if (index !== -1) { + config.MediaFolders.splice(index, 1); + } + + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config) + form.querySelector("#MediaFolderSelector").value = ""; + + Dashboard.processPluginConfigurationUpdateResult(result); + return config; +} + async function syncMediaFolderSettings(form) { const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); const mediaFolderId = form.querySelector("#MediaFolderSelector").value; @@ -1044,6 +1068,9 @@ export default function (page) { .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; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 17e4d2a1..94df8a76 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1333,6 +1333,12 @@ <h3>Media Folder Settings</h3> </div> </div> </div> + <div id="MediaFolderDeleteContainer" 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> + <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"> <span>Save</span> </button> From 791c568f276ecdf8ec4fbc80c8c02fa556876b96 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 18 Jun 2024 00:55:54 +0200 Subject: [PATCH 1069/1103] fix: fix regression Fixes a regression introduced in 686785c4b004c29b02b267f8d35a9242f84f9b5b by accidentally removing _a few_ (4) critical checks before using some values --- Shokofin/Utils/TagFilter.cs | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/Shokofin/Utils/TagFilter.cs b/Shokofin/Utils/TagFilter.cs index 5ae52596..778fa185 100644 --- a/Shokofin/Utils/TagFilter.cs +++ b/Shokofin/Utils/TagFilter.cs @@ -438,24 +438,28 @@ private static HashSet<string> GetTagsFromSource(IReadOnlyDictionary<string, Res var includeTags = new List<KeyValuePair<string, ResolvedTag>>(); var field = source.GetType().GetField(source.ToString())!; var includeAttributes = field.GetCustomAttributes<TagSourceIncludeAttribute>(); - foreach (var tagName in includeAttributes.First().Values) - if (tags.TryGetValue(tagName, out var tag)) - includeTags.AddRange(tag.RecursiveNamespacedChildren); + if (includeAttributes.Length is 1) + foreach (var tagName in includeAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + includeTags.AddRange(tag.RecursiveNamespacedChildren); var includeOnlyAttributes = field.GetCustomAttributes<TagSourceIncludeOnlyAttribute>(); - foreach (var tagName in includeOnlyAttributes.First().Values) - if (tags.TryGetValue(tagName, out var tag)) - includeTags.Add(KeyValuePair.Create($"/{tag.Name}", tag)); + if (includeOnlyAttributes.Length is 1) + foreach (var tagName in includeOnlyAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + includeTags.Add(KeyValuePair.Create($"/{tag.Name}", tag)); var excludeAttributes = field.GetCustomAttributes<TagSourceExcludeAttribute>(); - foreach (var tagName in excludeAttributes.First().Values) - if (tags.TryGetValue(tagName, out var tag)) - exceptTags.AddRange(tag.RecursiveNamespacedChildren.Values.Append(tag)); + if (excludeAttributes.Length is 1) + foreach (var tagName in excludeAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + exceptTags.AddRange(tag.RecursiveNamespacedChildren.Values.Append(tag)); var excludeOnlyAttributes = field.GetCustomAttributes<TagSourceExcludeOnlyAttribute>(); - foreach (var tagName in excludeOnlyAttributes.First().Values) - if (tags.TryGetValue(tagName, out var tag)) - exceptTags.Add(tag); + if (excludeOnlyAttributes.Length is 1) + foreach (var tagName in excludeOnlyAttributes.First().Values) + if (tags.TryGetValue(tagName, out var tag)) + exceptTags.Add(tag); includeTags = includeTags .DistinctBy(pair => $"{pair.Value.Source}:{pair.Value.Id}") From ad954d089089ba2bb693c8a64b4951064bb6dd96 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 18 Jun 2024 16:39:19 +0200 Subject: [PATCH 1070/1103] fix: add even more tags to consider for content ratings --- .vscode/settings.json | 1 + Shokofin/Utils/ContentRating.cs | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 234a5bdb..0def0679 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,6 +16,7 @@ "eroge", "fanart", "fanarts", + "Gainax", "hentai", "imdb", "imdbid", diff --git a/Shokofin/Utils/ContentRating.cs b/Shokofin/Utils/ContentRating.cs index 42afade4..384a8f85 100644 --- a/Shokofin/Utils/ContentRating.cs +++ b/Shokofin/Utils/ContentRating.cs @@ -233,6 +233,18 @@ private static ProviderName[] GetOrderedProviders() // "Upgrade" the content rating if it contains any of these tags. if (contentRating is < TvRating.TvMA && tags.ContainsKey("/elements/ecchi/borderline porn")) contentRating = TvRating.TvMA; + if (contentRating is < TvRating.Tv14 && ( + tags.ContainsKey("/elements/ecchi/Gainax bounce") || + tags.ContainsKey("/elements/ecchi/breast fondling") || + tags.ContainsKey("/elements/ecchi/paper clothes") || + tags.ContainsKey("/elements/ecchi/skimpy clothing") + )) + contentRating = TvRating.Tv14; + if (contentRating is < TvRating.TvPG && ( + tags.ContainsKey("/elements/sexual humour") || + tags.ContainsKey("/technical aspects/very bloody wound in low-pg series") + )) + contentRating = TvRating.TvPG; if (tags.TryGetValue("/elements/ecchi", out tag)) { if (contentRating is < TvRating.Tv14 && tag.Weight is >= TagWeight.Four) contentRating = TvRating.Tv14; @@ -257,8 +269,6 @@ private static ProviderName[] GetOrderedProviders() if (contentRating is > TvRating.TvG && contentRating is < TvRating.TvY7 && tag.Weight is >= TagWeight.Two) contentRating = TvRating.TvY7; } - if (contentRating is < TvRating.TvPG && tags.ContainsKey("/technical aspects/very bloody wound in low-pg series")) - contentRating = TvRating.TvPG; if (contentRating is > TvRating.TvG && contentRating is < TvRating.TvY7 && tags.ContainsKey("/content indicators/violence/gore")) contentRating = TvRating.TvY7; From 4bdf2fda36d1f60d72a700bbf90bddda53529623 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 18 Jun 2024 22:35:54 +0200 Subject: [PATCH 1071/1103] fix: fix saving images from the image modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed saving images from the "Edit Images" modal for all users regardless of how they host Jellyfin and Shoko Server. Just don't ask _how_ it's fixed. Just know… that it is now fixed. --- Shokofin/API/Models/Image.cs | 10 ++------ Shokofin/API/ShokoAPIClient.cs | 10 +++++--- Shokofin/PluginServiceRegistrator.cs | 1 + Shokofin/Providers/ImageProvider.cs | 13 +++++----- Shokofin/Web/ImageHostUrl.cs | 38 ++++++++++++++++++++++++++++ Shokofin/Web/ShokoApiController.cs | 23 ++++++++++++++++- 6 files changed, 76 insertions(+), 19 deletions(-) create mode 100644 Shokofin/Web/ImageHostUrl.cs diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index 534ee7ac..5a9f9e2c 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -1,3 +1,4 @@ +using System; using System.Text.Json.Serialization; namespace Shokofin.API.Models; @@ -58,13 +59,6 @@ public class Image public virtual bool IsAvailable => !string.IsNullOrEmpty(LocalPath); - /// <summary> - /// The remote path to retrieve the image. - /// </summary> - [JsonIgnore] - public virtual string Path - => $"/api/v3/Image/{Source}/{Type}/{ID}"; - /// <summary> /// Get an URL to both download the image on the backend and preview it for /// the clients. @@ -75,7 +69,7 @@ public virtual string Path /// </remarks> /// <returns>The image URL</returns> public string ToURLString() - => string.Concat(Plugin.Instance.Configuration.PrettyUrl, Path); + => new Uri(new Uri(Web.ImageHostUrl.Value), $"/Plugin/Shokofin/Host/Image/{Source}/{Type}/{ID}").ToString(); } /// <summary> diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 1f6b6188..a5b900d5 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -103,13 +103,13 @@ private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, st ); } - private async Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null) + private async Task<HttpResponseMessage> Get(string url, HttpMethod method, string? apiKey = null, bool skipApiKey = false) { // Use the default key if no key was provided. apiKey ??= Plugin.Instance.Configuration.ApiKey; // Check if we have a key to use. - if (string.IsNullOrEmpty(apiKey)) + if (string.IsNullOrEmpty(apiKey) && !skipApiKey) throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); var version = Plugin.Instance.Configuration.ServerVersion; @@ -127,7 +127,8 @@ private async Task<HttpResponseMessage> Get(string url, HttpMethod method, strin using var requestMessage = new HttpRequestMessage(method, remoteUrl); requestMessage.Content = new StringContent(string.Empty); - requestMessage.Headers.Add("apikey", apiKey); + if (!string.IsNullOrEmpty(apiKey)) + requestMessage.Headers.Add("apikey", apiKey); var response = await _httpClient.SendAsync(requestMessage).ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.Unauthorized) throw new HttpRequestException("Invalid or expired API Token. Please reconnect the plugin to Shoko Server by resetting the connection or deleting and re-adding the user in the plugin settings.", null, HttpStatusCode.Unauthorized); @@ -243,6 +244,9 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method return null; } + public Task<HttpResponseMessage> GetImageAsync(ImageSource imageSource, ImageType imageType, int imageId) + => Get($"/api/v3/Image/{imageSource}/{imageType}/{imageId}", HttpMethod.Get, null, true); + public async Task<ImportFolder?> GetImportFolder(int id) { try { diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index e6836deb..16899b46 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -20,5 +20,6 @@ public void RegisterServices(IServiceCollection serviceCollection) serviceCollection.AddSingleton<Resolvers.VirtualFileSystemService>(); serviceCollection.AddSingleton<Events.EventDispatchService>(); serviceCollection.AddSingleton<SignalR.SignalRConnectionManager>(); + serviceCollection.AddControllers(options => options.Filters.Add<Web.ImageHostUrl>()); } } diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index b86e7e7a..43a77ad6 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -173,13 +173,12 @@ public IEnumerable<ImageType> GetSupportedImages(BaseItem item) public bool Supports(BaseItem item) => item is Series or Season or Episode or Movie or BoxSet; - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { - var internalUrl = Plugin.Instance.Configuration.Url; - var prettyUrl = Plugin.Instance.Configuration.PrettyUrl; - if (!string.Equals(internalUrl, prettyUrl) && url.StartsWith(prettyUrl)) - url = internalUrl + url[prettyUrl.Length..]; - - return HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); + var index = url.IndexOf("Plugin/Shokofin/Host"); + if (index is -1) + return new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest); + url = $"{Plugin.Instance.Configuration.Url}/api/v3{url[(index + 20)..]}"; + return await HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } diff --git a/Shokofin/Web/ImageHostUrl.cs b/Shokofin/Web/ImageHostUrl.cs new file mode 100644 index 00000000..fa6ade32 --- /dev/null +++ b/Shokofin/Web/ImageHostUrl.cs @@ -0,0 +1,38 @@ +using System; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Shokofin.Web; + +/// <summary> +/// Responsible for tracking the base url we need for the next set of images +/// to-be presented to a client. +/// </summary> +public class ImageHostUrl : IAsyncActionFilter +{ + /// <summary> + /// + /// </summary> + public static string Value { get; private set; } = "http://localhost:8096/"; + + private readonly object LockObj = new(); + + private static Regex RemoteImagesRegex = new(@"/Items/(?<itemId>[0-9a-fA-F]{32})/RemoteImages$", RegexOptions.Compiled); + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var request = context.HttpContext.Request; + 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] + "/"; + uriBuilder.Query = ""; + var uri = uriBuilder.ToString(); + lock (LockObj) + if (!string.Equals(uri, Value)) + Value = uri; + } + await next(); + } +} diff --git a/Shokofin/Web/ShokoApiController.cs b/Shokofin/Web/ShokoApiController.cs index 436434be..98a70e38 100644 --- a/Shokofin/Web/ShokoApiController.cs +++ b/Shokofin/Web/ShokoApiController.cs @@ -1,7 +1,7 @@ using System; +using System.ComponentModel.DataAnnotations; using System.Net.Http; using System.Net.Mime; -using System.Reflection; using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -77,6 +77,27 @@ public async Task<ActionResult<ApiKey>> GetApiKeyAsync([FromBody] ApiLoginReques return StatusCode(StatusCodes.Status500InternalServerError, ex.Message); } } + + /// <summary> + /// Simple forward to grab the image from Shoko Server. + /// </summary> + [ResponseCache(Duration = 3600 /* 1 hour in seconds */)] + [ProducesResponseType(typeof(FileStreamResult), 200)] + [ProducesResponseType(404)] + [HttpGet("Image/{ImageSource}/{ImageType}/{ImageId}")] + [HttpHead("Image/{ImageSource}/{ImageType}/{ImageId}")] + public async Task<ActionResult> GetImageAsync([FromRoute] ImageSource imageSource, [FromRoute] ImageType imageType, [FromRoute, Range(1, int.MaxValue)] int imageId + ) + { + var response = await APIClient.GetImageAsync(imageSource, imageType, imageId); + if (response.StatusCode is System.Net.HttpStatusCode.NotFound) + return NotFound(); + if (response.StatusCode is not System.Net.HttpStatusCode.OK) + return StatusCode((int)response.StatusCode); + var stream = await response.Content.ReadAsStreamAsync(); + var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/ocelot-stream"; + return File(stream, contentType); + } } public class ApiLoginRequest From f26e0dd2c6b51a8e79df3d6b6a915db87c7d3cb3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 18 Jun 2024 22:36:26 +0200 Subject: [PATCH 1072/1103] misc: add missing description [skip ci] --- 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 fa6ade32..edd93d7e 100644 --- a/Shokofin/Web/ImageHostUrl.cs +++ b/Shokofin/Web/ImageHostUrl.cs @@ -12,7 +12,7 @@ namespace Shokofin.Web; public class ImageHostUrl : IAsyncActionFilter { /// <summary> - /// + /// The current image host url base to use. /// </summary> public static string Value { get; private set; } = "http://localhost:8096/"; From 8adfeaf99ab299eb484c0636273e4324ff6f32ac Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 13 Jun 2024 08:12:54 +0200 Subject: [PATCH 1073/1103] fix: send config updated event --- Shokofin/API/ShokoAPIClient.cs | 6 +++--- Shokofin/Configuration/MediaFolderConfigurationService.cs | 4 ++-- Shokofin/Plugin.cs | 5 +++++ Shokofin/Tasks/VersionCheckTask.cs | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index a5b900d5..522a15da 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -118,7 +118,7 @@ private async Task<HttpResponseMessage> Get(string url, HttpMethod method, strin ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); Plugin.Instance.Configuration.ServerVersion = version; - Plugin.Instance.SaveConfiguration(); + Plugin.Instance.UpdateConfiguration(); } try { @@ -170,7 +170,7 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method ?? throw new HttpRequestException("Unable to call the API before an connection is established to Shoko Server!", null, HttpStatusCode.BadRequest); Plugin.Instance.Configuration.ServerVersion = version; - Plugin.Instance.SaveConfiguration(); + Plugin.Instance.UpdateConfiguration(); } try { @@ -208,7 +208,7 @@ private async Task<HttpResponseMessage> Post<Type>(string url, HttpMethod method ?? throw new HttpRequestException("Unable to connect to Shoko Server to read the version.", null, HttpStatusCode.BadGateway); Plugin.Instance.Configuration.ServerVersion = version; - Plugin.Instance.SaveConfiguration(); + Plugin.Instance.UpdateConfiguration(); } var postData = JsonSerializer.Serialize(new Dictionary<string, string> diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 75ec51c0..7f45cea7 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -92,7 +92,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) mediaFolderConfig.ImportFolderRelativePath ); Plugin.Instance.Configuration.MediaFolders.Remove(mediaFolderConfig); - Plugin.Instance.SaveConfiguration(); + Plugin.Instance.UpdateConfiguration(); MediaFolderChangeKeys.Remove(folder.Id); ConfigurationRemoved?.Invoke(null, new(mediaFolderConfig, folder)); @@ -168,7 +168,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold // Store and log the result. MediaFolderChangeKeys[mediaFolder.Id] = ConstructKey(mediaFolderConfig); config.MediaFolders.Add(mediaFolderConfig); - Plugin.Instance.SaveConfiguration(config); + 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})", diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 61b05115..61bcaa3f 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -76,6 +76,11 @@ public Plugin(ILoggerFactory loggerFactory, IApplicationPaths applicationPaths, Logger.LogDebug("Can create symbolic links; {Value}", CanCreateSymbolicLinks); } + public void UpdateConfiguration() + { + UpdateConfiguration(this.Configuration); + } + public void OnConfigChanged(object? sender, BasePluginConfiguration e) { if (e is not PluginConfiguration config) diff --git a/Shokofin/Tasks/VersionCheckTask.cs b/Shokofin/Tasks/VersionCheckTask.cs index e36610be..e071772e 100644 --- a/Shokofin/Tasks/VersionCheckTask.cs +++ b/Shokofin/Tasks/VersionCheckTask.cs @@ -94,7 +94,7 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can } } if (updated) { - Plugin.Instance.SaveConfiguration(); + Plugin.Instance.UpdateConfiguration(); } } } \ No newline at end of file From a8d83c65614e129ace00db4569e01d128073eeca Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 22 Jun 2024 18:55:20 +0200 Subject: [PATCH 1074/1103] refactor: create one VFS per library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactored the internals to create one VFS per library, and not per media folder. So if you attach multiple media libraries to a single library then they will share the same VFS. Just in case it wasn't obvious, but **THIS CHANGE WILL BREAK EXISTING VFSs.** 🙂 Luckily for you, all you need to do is to run the new cleanup task and refresh or recreate the library. - Added a cleanup task to clean up old VFS roots. --- Shokofin/API/ShokoAPIManager.cs | 71 +--- .../Configuration/MediaFolderConfiguration.cs | 16 +- .../MediaFolderConfigurationService.cs | 138 ++++-- Shokofin/Configuration/configController.js | 43 +- Shokofin/Events/EventDispatchService.cs | 252 +++++------ Shokofin/Resolvers/ShokoIgnoreRule.cs | 2 +- Shokofin/Resolvers/ShokoLibraryMonitor.cs | 2 +- .../Resolvers/VirtualFileSystemService.cs | 395 ++++++++++-------- Shokofin/Tasks/CleanupVirtualRootTask.cs | 83 ++++ Shokofin/Tasks/VersionCheckTask.cs | 33 +- 10 files changed, 618 insertions(+), 417 deletions(-) create mode 100644 Shokofin/Tasks/CleanupVirtualRootTask.cs diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 678fb5f0..88b7d3c9 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -76,71 +76,29 @@ private void OnTrackerStalled(object? sender, EventArgs eventArgs) public (Folder mediaFolder, string partialPath) FindMediaFolder(string path, Folder parent, Folder root) { Folder? mediaFolder = null; - if (path.StartsWith(Plugin.Instance.VirtualRoot + Path.DirectorySeparatorChar)) { - var mediaFolderId = Guid.Parse(path[(Plugin.Instance.VirtualRoot.Length + 1)..].Split(Path.DirectorySeparatorChar).First()); - mediaFolder = LibraryManager.GetItemById(mediaFolderId) as Folder; - if (mediaFolder != null) { - var mediaRootVirtualPath = mediaFolder.GetVirtualRoot(); - return (mediaFolder, path[mediaRootVirtualPath.Length..]); - } - return (root, path); - } - lock (MediaFolderListLock) { + lock (MediaFolderListLock) mediaFolder = MediaFolderList.FirstOrDefault((folder) => path.StartsWith(folder.Path + Path.DirectorySeparatorChar)); - } - if (mediaFolder != null) { + if (mediaFolder is not null) return (mediaFolder, path[mediaFolder.Path.Length..]); - } - - // Look for the root folder for the current item. - mediaFolder = parent; - while (!mediaFolder.ParentId.Equals(root.Id)) { - if (mediaFolder.GetParent() == null) { - break; - } - mediaFolder = (Folder)mediaFolder.GetParent(); - } - - lock (MediaFolderListLock) { - MediaFolderList.Add(mediaFolder); - } - return (mediaFolder, path[mediaFolder.Path.Length..]); + if (parent.GetTopParent() is not Folder topParent) + return (root, path); + lock (MediaFolderListLock) + MediaFolderList.Add(topParent); + return (topParent, path[topParent.Path.Length..]); } public string StripMediaFolder(string fullPath) { Folder? mediaFolder = null; - lock (MediaFolderListLock) { + lock (MediaFolderListLock) mediaFolder = MediaFolderList.FirstOrDefault((folder) => fullPath.StartsWith(folder.Path + Path.DirectorySeparatorChar)); - } - if (mediaFolder != null) { + if (mediaFolder is not null) return fullPath[mediaFolder.Path.Length..]; - } - - // Try to get the media folder by loading the parent and navigating upwards till we reach the root. - var directoryPath = System.IO.Path.GetDirectoryName(fullPath); - if (string.IsNullOrEmpty(directoryPath)) { - return fullPath; - } - - mediaFolder = (LibraryManager.FindByPath(directoryPath, true) as Folder); - if (mediaFolder == null || string.IsNullOrEmpty(mediaFolder?.Path)) { + if (Path.GetDirectoryName(fullPath) is not string directoryPath || LibraryManager.FindByPath(directoryPath, true)?.GetTopParent() is not Folder topParent) return fullPath; - } - - // Look for the root folder for the current item. - var root = LibraryManager.RootFolder; - while (!mediaFolder.ParentId.Equals(root.Id)) { - if (mediaFolder.GetParent() == null) { - break; - } - mediaFolder = (Folder)mediaFolder.GetParent(); - } - - lock (MediaFolderListLock) { - MediaFolderList.Add(mediaFolder); - } - return fullPath[mediaFolder.Path.Length..]; + lock (MediaFolderListLock) + MediaFolderList.Add(topParent); + return fullPath[topParent.Path.Length..]; } #endregion @@ -158,9 +116,8 @@ public void Clear() EpisodeIdToEpisodePathDictionary.Clear(); EpisodeIdToSeriesIdDictionary.Clear(); FileAndSeriesIdToEpisodeIdDictionary.Clear(); - lock (MediaFolderListLock) { + lock (MediaFolderListLock) MediaFolderList.Clear(); - } PathToEpisodeIdsDictionary.Clear(); PathToFileIdAndSeriesIdDictionary.Clear(); PathToSeriesIdDictionary.Clear(); diff --git a/Shokofin/Configuration/MediaFolderConfiguration.cs b/Shokofin/Configuration/MediaFolderConfiguration.cs index 119c821f..3f4d73a8 100644 --- a/Shokofin/Configuration/MediaFolderConfiguration.cs +++ b/Shokofin/Configuration/MediaFolderConfiguration.cs @@ -2,13 +2,27 @@ using System.IO; 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; public class MediaFolderConfiguration { + /// <summary> + /// The jellyfin library id. + /// </summary> + public Guid LibraryId { get; set; } + + /// <summary> + /// The Jellyfin library's name. Only for displaying on the plugin + /// configuration page. + /// </summary> + [XmlIgnore] + [JsonInclude] + public string? LibraryName => BaseItem.LibraryManager.GetItemById(LibraryId)?.Name; + /// <summary> /// The jellyfin media folder id. /// </summary> diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 7f45cea7..726e7fa0 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Emby.Naming.Common; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.IO; @@ -13,6 +14,26 @@ namespace Shokofin.Configuration; +public static class MediaFolderConfigurationExtensions +{ + public static Folder GetFolderForPath(this string mediaFolderPath) + => BaseItem.LibraryManager.GetItemById(mediaFolderPath) as Folder ?? + throw new Exception($"Unable to find folder by path \"{mediaFolderPath}\"."); + + public static IReadOnlyList<(int importFolderId, string importFolderSubPath, IReadOnlyList<string> mediaFolderPaths)> ToImportFolderList(this IEnumerable<MediaFolderConfiguration> mediaConfigs) + => mediaConfigs + .GroupBy(a => (a.ImportFolderId, a.ImportFolderRelativePath)) + .Select(g => (g.Key.ImportFolderId, g.Key.ImportFolderRelativePath, g.Select(a => a.MediaFolderPath).ToList() as IReadOnlyList<string>)) + .ToList(); + + public static IReadOnlyList<(string importFolderSubPath, bool vfsEnabled, IReadOnlyList<string> mediaFolderPaths)> ToImportFolderList(this IEnumerable<MediaFolderConfiguration> mediaConfigs, int importFolderId, string relativePath) + => mediaConfigs + .Where(a => a.ImportFolderId == importFolderId && a.IsEnabledForPath(relativePath)) + .GroupBy(a => (a.ImportFolderId, a.ImportFolderRelativePath, a.IsVirtualFileSystemEnabled)) + .Select(g => (g.Key.ImportFolderRelativePath, g.Key.IsVirtualFileSystemEnabled, g.Select(a => a.MediaFolderPath).ToList() as IReadOnlyList<string>)) + .ToList(); +} + public class MediaFolderConfigurationService { private readonly ILogger<MediaFolderConfigurationService> Logger; @@ -27,6 +48,8 @@ public class MediaFolderConfigurationService private readonly Dictionary<Guid, string> MediaFolderChangeKeys = new(); + private readonly object LockObj = new(); + public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationAdded; public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationUpdated; @@ -55,7 +78,6 @@ NamingOptions namingOptions ~MediaFolderConfigurationService() { - GC.SuppressFinalize(this); LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; MediaFolderChangeKeys.Clear(); @@ -83,19 +105,21 @@ private 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)) { - var mediaFolderConfig = Plugin.Instance.Configuration.MediaFolders.FirstOrDefault(c => c.MediaFolderId == folder.Id); - if (mediaFolderConfig != null) { - Logger.LogDebug( - "Removing stored configuration for folder at {Path} (ImportFolder={ImportFolderId},RelativePath={RelativePath})", - folder.Path, - mediaFolderConfig.ImportFolderId, - mediaFolderConfig.ImportFolderRelativePath - ); - Plugin.Instance.Configuration.MediaFolders.Remove(mediaFolderConfig); - Plugin.Instance.UpdateConfiguration(); - - MediaFolderChangeKeys.Remove(folder.Id); - ConfigurationRemoved?.Invoke(null, new(mediaFolderConfig, folder)); + lock (LockObj) { + var mediaFolderConfig = Plugin.Instance.Configuration.MediaFolders.FirstOrDefault(c => c.MediaFolderId == folder.Id); + if (mediaFolderConfig != null) { + Logger.LogDebug( + "Removing stored configuration for folder at {Path} (ImportFolder={ImportFolderId},RelativePath={RelativePath})", + folder.Path, + mediaFolderConfig.ImportFolderId, + mediaFolderConfig.ImportFolderRelativePath + ); + Plugin.Instance.Configuration.MediaFolders.Remove(mediaFolderConfig); + Plugin.Instance.UpdateConfiguration(); + + MediaFolderChangeKeys.Remove(folder.Id); + ConfigurationRemoved?.Invoke(null, new(mediaFolderConfig, folder)); + } } } } @@ -104,29 +128,83 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) #region Media Folder Mapping - public IReadOnlyList<(MediaFolderConfiguration config, Folder mediaFolder, string vfsPath)> GetAvailableMediaFolders(bool fileEvents = false, bool refreshEvents = false) - => Plugin.Instance.Configuration.MediaFolders - .Where(mediaFolder => mediaFolder.IsMapped && (!fileEvents || mediaFolder.IsFileEventsEnabled) && (!refreshEvents || mediaFolder.IsRefreshEventsEnabled)) - .Select(config => (config, mediaFolder: LibraryManager.GetItemById(config.MediaFolderId) as Folder)) - .OfType<(MediaFolderConfiguration config, Folder mediaFolder)>() - .Select(tuple => (tuple.config, tuple.mediaFolder, tuple.mediaFolder.GetVirtualRoot())) - .ToList(); + public IReadOnlyList<(string vfsPath, string? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList)> GetAvailableMediaFoldersForLibraries(Func<MediaFolderConfiguration, bool>? filter = null) + { + lock (LockObj) + return Plugin.Instance.Configuration.MediaFolders + .Where(config => config.IsMapped && (filter is null || filter(config)) && LibraryManager.GetItemById(config.MediaFolderId) is Folder) + .GroupBy(config => config.LibraryId) + .Select(groupBy => ( + libraryFolder: LibraryManager.GetItemById(groupBy.Key) as Folder, + mediaList: groupBy + .Where(config => LibraryManager.GetItemById(config.MediaFolderId) is Folder) + .ToList() as IReadOnlyList<MediaFolderConfiguration> + )) + .Where(tuple => tuple.libraryFolder is not null && tuple.mediaList.Count is > 0) + .Select(tuple => (tuple.libraryFolder!.GetVirtualRoot(), LibraryManager.GetConfiguredContentType(tuple.libraryFolder!) ?? null, tuple.mediaList)) + .ToList(); + } - public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFolder(Folder mediaFolder) + public (string? vfsPath, string? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList) GetAvailableMediaFoldersForLibrary(Folder mediaFolder, Func<MediaFolderConfiguration, bool>? filter = null) { - var config = Plugin.Instance.Configuration; - var mediaFolderConfig = config.MediaFolders.FirstOrDefault(c => c.MediaFolderId == mediaFolder.Id); - if (mediaFolderConfig != null) + var mediaFolderConfig = GetOrCreateConfigurationForMediaFolder(mediaFolder); + if (LibraryManager.GetItemById(mediaFolderConfig.LibraryId) is not Folder libraryFolder) + return (null, null, new List<MediaFolderConfiguration>()); + lock (LockObj) + return ( + libraryFolder.GetVirtualRoot(), + 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() + ); + } + + public MediaFolderConfiguration GetOrCreateConfigurationForMediaFolder(Folder mediaFolder) + { + 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 config = Plugin.Instance.Configuration; + var libraryConfig = config.MediaFolders.FirstOrDefault(c => c.LibraryId == libraryId); + var mediaFolderConfig = config.MediaFolders.FirstOrDefault(c => c.MediaFolderId == mediaFolder.Id) ?? + 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; + + if (config.MediaFolders.Find(c => string.Equals(mediaFolderPath, c.MediaFolderPath)) is {} mfc) + continue; + + 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(); + } + return mediaFolderConfig; + } + } + + private async Task<MediaFolderConfiguration> CreateConfigurationForPath(Guid libraryId, Folder mediaFolder, MediaFolderConfiguration? libraryConfig) + { // Check if we should introduce the VFS for the media folder. - mediaFolderConfig = new() { + var config = Plugin.Instance.Configuration; + var mediaFolderConfig = new MediaFolderConfiguration() { + LibraryId = libraryId, MediaFolderId = mediaFolder.Id, MediaFolderPath = mediaFolder.Path, - IsFileEventsEnabled = config.SignalR_FileEvents, - IsRefreshEventsEnabled = config.SignalR_RefreshEnabled, - IsVirtualFileSystemEnabled = config.VirtualFileSystem, - LibraryFilteringMode = config.LibraryFilteringMode, + IsFileEventsEnabled = libraryConfig?.IsFileEventsEnabled ?? config.SignalR_FileEvents, + IsRefreshEventsEnabled = libraryConfig?.IsRefreshEventsEnabled ?? config.SignalR_RefreshEnabled, + IsVirtualFileSystemEnabled = libraryConfig?.IsVirtualFileSystemEnabled ?? config.VirtualFileSystem, + LibraryFilteringMode = libraryConfig?.LibraryFilteringMode ?? config.LibraryFilteringMode, }; var start = DateTime.UtcNow; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index a424a0f7..f3028dda 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -177,7 +177,7 @@ async function loadMediaFolderConfig(form, mediaFolderId, config) { form.querySelector("#MediaFolderDefaultSettingsContainer").setAttribute("hidden", ""); form.querySelector("#MediaFolderPerFolderSettingsContainer").removeAttribute("hidden"); - if (mediaFolderConfig.IsMapped) { + if (mediaFolderConfig.IsMapped && mediaFolderConfig.LibraryName) { form.querySelector("#MediaFolderDeleteContainer").setAttribute("hidden", ""); } else { @@ -334,9 +334,11 @@ async function defaultSubmit(form) { let mediaFolderId = form.querySelector("#MediaFolderSelector").value; let mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; if (mediaFolderConfig) { - const filteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; - mediaFolderConfig.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; - mediaFolderConfig.LibraryFilteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; + 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.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; @@ -352,8 +354,11 @@ async function defaultSubmit(form) { mediaFolderId = form.querySelector("#SignalRMediaFolderSelector").value; mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; if (mediaFolderConfig) { - mediaFolderConfig.IsFileEventsEnabled = form.querySelector("#SignalRFileEvents").checked; - mediaFolderConfig.IsRefreshEventsEnabled = form.querySelector("#SignalRRefreshEvents").checked; + 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; @@ -579,8 +584,14 @@ async function removeMediaFolder(form) { config.MediaFolders.splice(index, 1); } - const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config) + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); form.querySelector("#MediaFolderSelector").value = ""; + form.querySelector("#MediaFolderSelector").innerHTML += config.MediaFolders + .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) + .join(""); + form.querySelector("#SignalRMediaFolderSelector").innerHTML += config.MediaFolders + .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) + .join(""); Dashboard.processPluginConfigurationUpdateResult(result); return config; @@ -591,8 +602,11 @@ async function syncMediaFolderSettings(form) { const mediaFolderId = form.querySelector("#MediaFolderSelector").value; const mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; if (mediaFolderConfig) { - mediaFolderConfig.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; - mediaFolderConfig.LibraryFilteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; + 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.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; @@ -617,8 +631,11 @@ async function syncSignalrSettings(form) { const mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; if (mediaFolderConfig) { - mediaFolderConfig.IsFileEventsEnabled = form.querySelector("#SignalRFileEvents").checked; - mediaFolderConfig.IsRefreshEventsEnabled = form.querySelector("#SignalRRefreshEvents").checked; + 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; @@ -998,7 +1015,7 @@ export default function (page) { ? config.VirtualFileSystem : true; form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; mediaFolderSelector.innerHTML += config.MediaFolders - .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.MediaFolderPath}</option>`) + .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) .join(""); // SignalR settings @@ -1006,7 +1023,7 @@ export default function (page) { form.querySelector("#SignalRAutoReconnectIntervals").value = config.SignalR_AutoReconnectInSeconds.join(", "); initSimpleList(form, "SignalREventSources", config.SignalR_EventSources); signalrMediaFolderSelector.innerHTML += config.MediaFolders - .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.MediaFolderPath}</option>`) + .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; diff --git a/Shokofin/Events/EventDispatchService.cs b/Shokofin/Events/EventDispatchService.cs index a5551fa0..ab1dd648 100644 --- a/Shokofin/Events/EventDispatchService.cs +++ b/Shokofin/Events/EventDispatchService.cs @@ -211,70 +211,72 @@ 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 mediaFolders = ConfigurationService.GetAvailableMediaFolders(fileEvents: true); + var libraries = ConfigurationService.GetAvailableMediaFoldersForLibraries(c => c.IsFileEventsEnabled); var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); if (reason is not UpdateReason.Removed) { Logger.LogTrace("Processing file changed. (File={FileId})", fileId); - foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { - if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) - continue; - - var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); - if (!File.Exists(sourceLocation)) - continue; - - // Let the core logic handle the rest. - if (!config.IsVirtualFileSystemEnabled) { - locationsToNotify.Add(sourceLocation); - continue; - } - - var result = new LinkGenerationResult(); - var topFolders = new HashSet<string>(); - var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, sourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) - .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) - .ToList(); - foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { - result += ResolveManager.GenerateSymbolicLinks(srcLoc, 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); - } - - // Remove old links for file. - var videos = LibraryManager - .GetItemList( - new() { - AncestorIds = new Guid[] { mediaFolder.Id }, - HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, - DtoOptions = new(true), - }, - true - ); - Logger.LogTrace("Found {Count} potential videos to remove", videos.Count); - foreach (var video in videos) { - if (string.IsNullOrEmpty(video.Path) || !video.Path.StartsWith(vfsPath) || result.Paths.Contains(video.Path)) { - Logger.LogTrace("Skipped a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); - continue; + foreach (var (vfsPath, collectionType, mediaConfigs) in libraries) { + foreach (var (importFolderSubPath, vfsEnabled, mediaFolderPaths) in mediaConfigs.ToImportFolderList(importFolderId, relativePath)) { + foreach (var mediaFolderPath in mediaFolderPaths) { + var sourceLocation = Path.Join(mediaFolderPath, relativePath[importFolderSubPath.Length..]); + if (!File.Exists(sourceLocation)) + continue; + + // Let the core logic handle the rest. + if (!vfsEnabled) { + locationsToNotify.Add(sourceLocation); + break; + } + + 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) + .ToList(); + foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(srcLoc, 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); + } + + // Remove old links for file. + var videos = LibraryManager + .GetItemList( + new() { + AncestorIds = mediaConfigs.Select(c => c.MediaFolderId).ToArray(), + HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, + DtoOptions = new(true), + }, + true + ); + Logger.LogTrace("Found {Count} potential videos to remove", videos.Count); + foreach (var video in videos) { + if (string.IsNullOrEmpty(video.Path) || !video.Path.StartsWith(vfsPath) || result.Paths.Contains(video.Path)) { + Logger.LogTrace("Skipped a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); + continue; + } + Logger.LogTrace("Found a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); + if (File.Exists(video.Path)) + File.Delete(video.Path); + topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); + locationsToNotify.Add(video.Path); + result.RemovedVideos++; + } + + result.Print(Logger, mediaFolderPath); + + // If all the "top-level-folders" exist, then let the core logic handle the rest. + if (topFolders.All(path => LibraryManager.FindByPath(path, true) is not null)) { + locationsToNotify.AddRange(vfsLocations.SelectMany(tuple => tuple.symbolicLinks)); + } + // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. + else { + var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolderPath, false).FirstOrDefault(); + if (!string.IsNullOrEmpty(fileOrFolder)) + mediaFoldersToNotify.TryAdd(mediaFolderPath, (fileOrFolder, mediaFolderPath.GetFolderForPath())); + } + break; } - Logger.LogTrace("Found a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); - if (File.Exists(video.Path)) - File.Delete(video.Path); - topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); - locationsToNotify.Add(video.Path); - result.RemovedVideos++; - } - - result.Print(Logger, mediaFolder.Path); - - // If all the "top-level-folders" exist, then let the core logic handle the rest. - if (topFolders.All(path => LibraryManager.FindByPath(path, true) is not null)) { - locationsToNotify.AddRange(vfsLocations.SelectMany(tuple => tuple.symbolicLinks)); - } - // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. - else { - var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); - if (!string.IsNullOrEmpty(fileOrFolder)) - mediaFoldersToNotify.TryAdd(mediaFolder.Path, (fileOrFolder, mediaFolder)); } } } @@ -283,68 +285,70 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int Logger.LogTrace("Processing file removed. (File={FileId})", fileId); relativePath = firstRemovedEvent.RelativePath; importFolderId = firstRemovedEvent.ImportFolderId; - foreach (var (config, mediaFolder, vfsPath) in mediaFolders) { - if (config.ImportFolderId != importFolderId || !config.IsEnabledForPath(relativePath)) - continue; - - // Let the core logic handle the rest. - if (!config.IsVirtualFileSystemEnabled) { - var sourceLocation = Path.Join(mediaFolder.Path, relativePath[config.ImportFolderRelativePath.Length..]); - locationsToNotify.Add(sourceLocation); - continue; - } - - // Check if we can use another location for the file. - var result = new LinkGenerationResult(); - var vfsSymbolicLinks = new HashSet<string>(); - var topFolders = new HashSet<string>(); - var newSourceLocation = await GetNewSourceLocation(config, fileId, relativePath, mediaFolder.Path); - if (!string.IsNullOrEmpty(newSourceLocation)) { - var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(mediaFolder, newSourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) - .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) - .ToList(); - foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { - result += ResolveManager.GenerateSymbolicLinks(srcLoc, 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); - } - vfsSymbolicLinks = vfsLocations.Select(tuple => tuple.sourceLocation).ToHashSet(); - } - - // Remove old links for file. - var videos = LibraryManager - .GetItemList( - new() { - HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, - DtoOptions = new(true), - }, - true - ); - Logger.LogTrace("Found {Count} potential videos to remove", videos.Count); - foreach (var video in videos) { - if (string.IsNullOrEmpty(video.Path) || !video.Path.StartsWith(vfsPath) || result.Paths.Contains(video.Path)) { - Logger.LogTrace("Skipped a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); - continue; + foreach (var (vfsPath, collectionType, mediaConfigs) in libraries) { + foreach (var (importFolderSubPath, vfsEnabled, mediaFolderPaths) in mediaConfigs.ToImportFolderList(importFolderId, relativePath)) { + foreach (var mediaFolderPath in mediaFolderPaths) { + // Let the core logic handle the rest. + if (!vfsEnabled) { + var sourceLocation = Path.Join(mediaFolderPath, relativePath[importFolderSubPath.Length..]); + locationsToNotify.Add(sourceLocation); + break; + } + + // Check if we can use another location for the file. + var result = new LinkGenerationResult(); + var vfsSymbolicLinks = new HashSet<string>(); + var topFolders = new HashSet<string>(); + 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) + .ToList(); + foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(srcLoc, 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); + } + vfsSymbolicLinks = vfsLocations.Select(tuple => tuple.sourceLocation).ToHashSet(); + } + + // Remove old links for file. + var videos = LibraryManager + .GetItemList( + new() { + HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, fileId.ToString() } }, + DtoOptions = new(true), + }, + true + ); + Logger.LogTrace("Found {Count} potential videos to remove", videos.Count); + foreach (var video in videos) { + if (string.IsNullOrEmpty(video.Path) || !video.Path.StartsWith(vfsPath) || result.Paths.Contains(video.Path)) { + Logger.LogTrace("Skipped a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); + continue; + } + Logger.LogTrace("Found a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); + if (File.Exists(video.Path)) + File.Delete(video.Path); + topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); + locationsToNotify.Add(video.Path); + result.RemovedVideos++; + } + + result.Print(Logger, mediaFolderPath); + + // If all the "top-level-folders" exist, then let the core logic handle the rest. + if (topFolders.All(path => LibraryManager.FindByPath(path, true) is not null)) { + locationsToNotify.AddRange(vfsSymbolicLinks); + } + // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. + else { + var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolderPath, false).FirstOrDefault(); + if (!string.IsNullOrEmpty(fileOrFolder)) + mediaFoldersToNotify.TryAdd(mediaFolderPath, (fileOrFolder, mediaFolderPath.GetFolderForPath())); + } + break; } - Logger.LogTrace("Found a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); - if (File.Exists(video.Path)) - File.Delete(video.Path); - topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); - locationsToNotify.Add(video.Path); - result.RemovedVideos++; - } - - result.Print(Logger, mediaFolder.Path); - - // If all the "top-level-folders" exist, then let the core logic handle the rest. - if (topFolders.All(path => LibraryManager.FindByPath(path, true) is not null)) { - locationsToNotify.AddRange(vfsSymbolicLinks); - } - // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. - else { - var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolder.Path, false).FirstOrDefault(); - if (!string.IsNullOrEmpty(fileOrFolder)) - mediaFoldersToNotify.TryAdd(mediaFolder.Path, (fileOrFolder, mediaFolder)); } } } @@ -408,18 +412,18 @@ private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEv return filteredSeriesIds.Count is 0 ? seriesIds : filteredSeriesIds; } - private async Task<string?> GetNewSourceLocation(MediaFolderConfiguration config, int fileId, string relativePath, string mediaFolderPath) + private async Task<string?> GetNewSourceLocation(int importFolderId, string importFolderSubPath, int fileId, string relativePath, string mediaFolderPath) { // Check if the file still exists, and if it has any other locations we can use. try { var file = await ApiClient.GetFile(fileId.ToString()); var usableLocation = file.Locations - .Where(loc => loc.ImportFolderId == config.ImportFolderId && config.IsEnabledForPath(loc.RelativePath) && loc.RelativePath != relativePath) + .Where(loc => loc.ImportFolderId == importFolderId && (string.IsNullOrEmpty(importFolderSubPath) || relativePath.StartsWith(importFolderSubPath + Path.DirectorySeparatorChar)) && loc.RelativePath != relativePath) .FirstOrDefault(); if (usableLocation is null) return null; - var sourceLocation = Path.Join(mediaFolderPath, usableLocation.RelativePath[config.ImportFolderRelativePath.Length..]); + var sourceLocation = Path.Join(mediaFolderPath, usableLocation.RelativePath[importFolderSubPath.Length..]); if (!File.Exists(sourceLocation)) return null; diff --git a/Shokofin/Resolvers/ShokoIgnoreRule.cs b/Shokofin/Resolvers/ShokoIgnoreRule.cs index 54b4da28..92321de7 100644 --- a/Shokofin/Resolvers/ShokoIgnoreRule.cs +++ b/Shokofin/Resolvers/ShokoIgnoreRule.cs @@ -88,7 +88,7 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent, root); // Ignore any media folders that aren't mapped to shoko. - var mediaFolderConfig = await ConfigurationService.GetOrCreateConfigurationForMediaFolder(mediaFolder); + var mediaFolderConfig = 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/ShokoLibraryMonitor.cs b/Shokofin/Resolvers/ShokoLibraryMonitor.cs index eae34dc1..b7ec1224 100644 --- a/Shokofin/Resolvers/ShokoLibraryMonitor.cs +++ b/Shokofin/Resolvers/ShokoLibraryMonitor.cs @@ -101,7 +101,7 @@ void IDisposable.Dispose() public void StartWatching() { // add blockers/watchers for every media folder with VFS enabled and real time monitoring enabled. - foreach (var mediaConfig in Plugin.Instance.Configuration.MediaFolders) { + foreach (var mediaConfig in Plugin.Instance.Configuration.MediaFolders.ToList()) { if (LibraryManager.GetItemById(mediaConfig.MediaFolderId) is not Folder mediaFolder) continue; diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 5a1880db..4216927b 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -108,10 +108,9 @@ 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 Folder folder && folder.ParentId == Guid.Empty && !string.IsNullOrEmpty(folder.Path) && !folder.Path.StartsWith(root.Path)) { - DataCache.Remove($"paths-for-media-folder:{folder.Path}"); - DataCache.Remove($"should-skip-media-folder:{folder.Path}"); + 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); @@ -133,9 +132,12 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) public async Task<string?> GenerateStructureInVFS(Folder mediaFolder, string path) { // Skip link generation if we've already generated for the media folder. - var vfsPath = mediaFolder.GetVirtualRoot(); - if (DataCache.TryGetValue<bool>($"should-skip-media-folder:{mediaFolder.Path}", out var shouldReturnPath)) - return shouldReturnPath ? vfsPath : null; + var (vfsPath, collectionType, mediaConfigs) = ConfigurationService.GetAvailableMediaFoldersForLibrary(mediaFolder, config => config.IsVirtualFileSystemEnabled); + if (string.IsNullOrEmpty(vfsPath) || mediaConfigs.Count is 0) + return null; + + 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."); // Check full path and all parent directories if they have been indexed. if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { @@ -149,26 +151,15 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) } // Only do this once. - var key = path.StartsWith(mediaFolder.Path) - ? $"should-skip-media-folder:{mediaFolder.Path}" + var key = mediaConfigs.Any(config => path.StartsWith(config.MediaFolderPath)) + ? $"should-skip-vfs-path:{vfsPath}" : $"should-skip-vfs-path:{path}"; - shouldReturnPath = await DataCache.GetOrCreateAsync<bool>(key, async (__) => { - var mediaConfig = await ConfigurationService.GetOrCreateConfigurationForMediaFolder(mediaFolder); - if (!mediaConfig.IsMapped) - return false; - - // Return early if we're not going to generate them. - if (!mediaConfig.IsVirtualFileSystemEnabled) - return 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 shouldReturnPath = await DataCache.GetOrCreateAsync<bool>(key, async (__) => { // Iterate the files already in the VFS. string? pathToClean = null; IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { - var allPaths = GetPathsForMediaFolder(mediaFolder); + var allPaths = GetPathsForMediaFolder(mediaConfigs); var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar); switch (pathSegments.Length) { // show/movie-folder level @@ -183,13 +174,13 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) break; pathToClean = path; - allFiles = GetFilesForMovie(episodeId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + allFiles = GetFilesForMovie(episodeId, seriesId, mediaConfigs, allPaths); break; } // show pathToClean = path; - allFiles = GetFilesForShow(seriesId, null, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + allFiles = GetFilesForShow(seriesId, null, mediaConfigs, allPaths); break; } @@ -207,7 +198,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) if (!seasonOrMovieName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) break; - allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path); + allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfigs, allPaths); break; } @@ -216,7 +207,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) break; pathToClean = path; - allFiles = GetFilesForShow(seriesId, seasonNumber, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + allFiles = GetFilesForShow(seriesId, seasonNumber, mediaConfigs, allPaths); break; } @@ -235,29 +226,29 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) if (!episodeName.TryGetAttributeValue(ShokoFileId.Name, out var fileId) || !int.TryParse(fileId, out _)) break; - allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path); + allFiles = GetFilesForEpisode(fileId, seriesId, mediaConfigs, allPaths); break; } } } // Iterate files in the "real" media folder. - else if (path.StartsWith(mediaFolder.Path)) { - var allPaths = GetPathsForMediaFolder(mediaFolder); + else if (mediaConfigs.Any(config => path.StartsWith(config.MediaFolderPath))) { + var allPaths = GetPathsForMediaFolder(mediaConfigs); pathToClean = vfsPath; - allFiles = GetFilesForImportFolder(mediaConfig.ImportFolderId, mediaConfig.ImportFolderRelativePath, mediaFolder.Path, allPaths); + allFiles = GetFilesForImportFolder(mediaConfigs, allPaths); } if (allFiles is null) return false; // Generate and cleanup the structure in the VFS. - var result = await GenerateStructure(mediaFolder, vfsPath, allFiles); + var result = await GenerateStructure(collectionType, vfsPath, allFiles); if (!string.IsNullOrEmpty(pathToClean)) result += CleanupStructure(vfsPath, pathToClean, result.Paths.ToArray()); // Save which paths we've already generated so we can skip generation // for them and their sub-paths later, and also print the result. - result.Print(Logger, path.StartsWith(mediaFolder.Path) ? mediaFolder.Path : path); + result.Print(Logger, mediaConfigs.Any(config => path.StartsWith(config.MediaFolderPath)) ? vfsPath : path); return true; }); @@ -265,57 +256,79 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) return shouldReturnPath ? vfsPath : null; } - private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) - { - Logger.LogDebug("Looking for files in folder at {Path}", mediaFolder.Path); - var start = DateTime.UtcNow; - var paths = FileSystem.GetFilePaths(mediaFolder.Path, true) - .Where(path => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) - .ToHashSet(); - Logger.LogDebug("Found {FileCount} files in folder at {Path} in {TimeSpan}.", paths.Count, mediaFolder.Path, DateTime.UtcNow - start); - return paths; - } + private HashSet<string> GetPathsForMediaFolder(IReadOnlyList<MediaFolderConfiguration> mediaConfigs) + => DataCache.GetOrCreate( + $"path-set-for-library:{mediaConfigs[0].LibraryId}", + (_) => { + var libraryId = mediaConfigs[0].LibraryId; + Logger.LogDebug("Looking for files in library across {Count} folders. (Library={LibraryId})", mediaConfigs.Count, libraryId); + var start = DateTime.UtcNow; + var paths = new HashSet<string>(); + foreach (var mediaConfig in mediaConfigs) { + Logger.LogDebug("Looking for files in folder at {Path}. (Library={LibraryId})", mediaConfig.MediaFolderPath, libraryId); + var folderStart = DateTime.UtcNow; + var before = paths.Count; + paths.UnionWith( + FileSystem.GetFilePaths(mediaConfig.MediaFolderPath, true) + .Where(path => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + ); + Logger.LogDebug("Found {FileCount} files in folder at {Path} in {TimeSpan}. (Library={LibraryId})", paths.Count - before, mediaConfig.MediaFolderPath, DateTime.UtcNow - folderStart, libraryId); + } - private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForEpisode(string fileId, string seriesId, int importFolderId, string importFolderSubPath, string mediaFolderPath) + Logger.LogDebug("Found {FileCount} files in library across {Count} in {TimeSpan}. (Library={LibraryId})", paths.Count, mediaConfigs.Count, DateTime.UtcNow - start, libraryId); + return paths; + } + ); + + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForEpisode(string fileId, string seriesId, IReadOnlyList<MediaFolderConfiguration> mediaConfigs, HashSet<string> fileSet) { + var totalFiles = 0; var start = DateTime.UtcNow; var file = ApiClient.GetFile(fileId).ConfigureAwait(false).GetAwaiter().GetResult(); if (file is null || !file.CrossReferences.Any(xref => xref.Series.ToString() == seriesId)) yield break; Logger.LogDebug( - "Iterating 1 file to potentially use within media folder at {Path} (File={FileId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", - mediaFolderPath, + "Iterating files to potentially use within {Count} media folders. (File={FileId},Series={SeriesId},Library={LibraryId})", + mediaConfigs.Count, fileId, seriesId, - importFolderId, - importFolderSubPath + mediaConfigs[0].LibraryId ); - var location = file.Locations - .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length is 0 || location.RelativePath.StartsWith(importFolderSubPath))) - .FirstOrDefault(); - if (location is null || file.CrossReferences.Count is 0) - yield break; + foreach (var (importFolderId, importFolderSubPath, mediaFolderPaths) in mediaConfigs.ToImportFolderList()) { + var location = file.Locations + .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length is 0 || location.RelativePath.StartsWith(importFolderSubPath))) + .FirstOrDefault(); + if (location is null) + continue; - var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); - if (!File.Exists(sourceLocation)) - yield break; + foreach (var mediaFolderPath in mediaFolderPaths) { + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, fileId, seriesId); + goto forLoopBreak; + } - yield return (sourceLocation, fileId, seriesId); + continue; + forLoopBreak: break; + } var timeSpent = DateTime.UtcNow - start; Logger.LogDebug( - "Iterated 1 file to potentially use within media folder at {Path} in {TimeSpan} (File={FileId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", - mediaFolderPath, + "Iterated {Count} file(s) to potentially use within {Count} media folders in {TimeSpan} (File={FileId},Series={SeriesId},Library={LibraryId})", + totalFiles, + mediaConfigs.Count, timeSpent, fileId, seriesId, - importFolderId, - importFolderSubPath + mediaConfigs[0].LibraryId ); } - private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForMovie(string episodeId, string seriesId, int importFolderId, string importFolderSubPath, string mediaFolderPath, IReadOnlySet<string> fileSet) + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForMovie(string episodeId, string seriesId, IReadOnlyList<MediaFolderConfiguration> mediaConfigs, HashSet<string> fileSet) { var start = DateTime.UtcNow; var totalFiles = 0; @@ -323,12 +336,11 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) if (seasonInfo is null) yield break; Logger.LogDebug( - "Iterating files to potentially use within media folder at {Path} (Episode={EpisodeId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", - mediaFolderPath, + "Iterating files to potentially use within {Count} media folders. (Episode={EpisodeId},Series={SeriesId},Library={LibraryId})", + mediaConfigs.Count, episodeId, seriesId, - importFolderId, - importFolderSubPath + mediaConfigs[0].LibraryId ); var episodeIds = seasonInfo.ExtrasList.Select(episode => episode.Id).Append(episodeId).ToHashSet(); @@ -338,46 +350,54 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) .ToList(); foreach (var (file, fileSeriesId, location) in fileLocations) { - if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) - continue; + foreach (var (importFolderId, importFolderSubPath, mediaFolderPaths) in mediaConfigs.ToImportFolderList()) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) + continue; - var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); - if (!fileSet.Contains(sourceLocation)) - continue; + foreach (var mediaFolderPath in mediaFolderPaths) { + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, file.Id.ToString(), fileSeriesId); + goto forLoopBreak; + } - totalFiles++; - yield return (sourceLocation, file.Id.ToString(), fileSeriesId); + continue; + forLoopBreak: break; + } } + var timeSpent = DateTime.UtcNow - start; Logger.LogDebug( - "Iterated {FileCount} file to potentially use within media folder at {Path} in {TimeSpan} (Episode={EpisodeId},Series={SeriesId},ImportFolder={FolderId},RelativePath={RelativePath})", + "Iterated {Count} file(s) to potentially use within {Count} media folders in {TimeSpan} (Episode={EpisodeId},Series={SeriesId},Library={LibraryId})", totalFiles, - mediaFolderPath, + mediaConfigs.Count, timeSpent, episodeId, seriesId, - importFolderId, - importFolderSubPath + mediaConfigs[0].LibraryId ); } - private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForShow(string seriesId, int? seasonNumber, int importFolderId, string importFolderSubPath, string mediaFolderPath, HashSet<string> fileSet) + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForShow(string seriesId, int? seasonNumber, IReadOnlyList<MediaFolderConfiguration> mediaConfigs, HashSet<string> fileSet) { var start = DateTime.UtcNow; var showInfo = ApiManager.GetShowInfoForSeries(seriesId).ConfigureAwait(false).GetAwaiter().GetResult(); if (showInfo is null) yield break; Logger.LogDebug( - "Iterating files to potentially use within media folder at {Path} (Series={SeriesId},Season={SeasonNumber},ImportFolder={FolderId},RelativePath={RelativePath})", - mediaFolderPath, + "Iterating files to potentially use within {Count} media folders. (Series={SeriesId},Season={SeasonNumber},Library={LibraryId})", + mediaConfigs.Count, seriesId, seasonNumber, - importFolderId, - importFolderSubPath + mediaConfigs[0].LibraryId ); // Only return the files for the given season. var totalFiles = 0; + var configList = mediaConfigs.ToImportFolderList(); if (seasonNumber.HasValue) { // Special handling of specials (pun intended) if (seasonNumber.Value is 0) { @@ -389,15 +409,23 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) .ToList(); foreach (var (file, fileSeriesId, location) in fileLocations) { - if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) - continue; + foreach (var (importFolderId, importFolderSubPath, mediaFolderPaths) in configList) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) + continue; + + foreach (var mediaFolderPath in mediaFolderPaths) { + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, file.Id.ToString(), fileSeriesId); + goto forLoopBreak; + } - var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); - if (!fileSet.Contains(sourceLocation)) continue; - - totalFiles++; - yield return (sourceLocation, file.Id.ToString(), fileSeriesId); + forLoopBreak: break; + } } } } @@ -414,15 +442,23 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) .ToList(); foreach (var (file, fileSeriesId, location) in fileLocations) { - if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) - continue; + foreach (var (importFolderId, importFolderSubPath, mediaFolderPaths) in configList) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) + continue; + + foreach (var mediaFolderPath in mediaFolderPaths) { + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + totalFiles++; + yield return (sourceLocation, file.Id.ToString(), fileSeriesId); + goto forLoopBreak; + } - var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); - if (!fileSet.Contains(sourceLocation)) continue; - - totalFiles++; - yield return (sourceLocation, file.Id.ToString(), fileSeriesId); + forLoopBreak: break; + } } } } @@ -435,101 +471,113 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) .SelectMany(tuple => tuple.file.Locations.Select(location => (tuple.file, tuple.seriesId, location))) .ToList(); foreach (var (file, fileSeriesId, location) in fileLocations) { - if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) - continue; + foreach (var (importFolderId, importFolderSubPath, mediaFolderPaths) in configList) { + if (location.ImportFolderId != importFolderId || importFolderSubPath.Length != 0 && !location.RelativePath.StartsWith(importFolderSubPath)) + continue; - var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); - if (!fileSet.Contains(sourceLocation)) - continue; + foreach (var mediaFolderPath in mediaFolderPaths) { + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; - totalFiles++; - yield return (sourceLocation, file.Id.ToString(), fileSeriesId); + totalFiles++; + yield return (sourceLocation, file.Id.ToString(), fileSeriesId); + goto forLoopBreak; + } + + continue; + forLoopBreak: break; + } } } } var timeSpent = DateTime.UtcNow - start; Logger.LogDebug( - "Iterated {FileCount} files to potentially use within media folder at {Path} in {TimeSpan} (Series={SeriesId},Season={SeasonNumber},ImportFolder={FolderId},RelativePath={RelativePath})", + "Iterated {FileCount} files to potentially use within {Count} media folders in {TimeSpan} (Series={SeriesId},Season={SeasonNumber},Library={LibraryId})", totalFiles, - mediaFolderPath, + mediaConfigs.Count, timeSpent, seriesId, seasonNumber, - importFolderId, - importFolderSubPath + mediaConfigs[0].LibraryId ); } - private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForImportFolder(int importFolderId, string importFolderSubPath, string mediaFolderPath, HashSet<string> fileSet) + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForImportFolder(IReadOnlyList<MediaFolderConfiguration> mediaConfigs, HashSet<string> fileSet) { var start = DateTime.UtcNow; - var firstPage = ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath); - var pageData = firstPage - .ConfigureAwait(false) - .GetAwaiter() - .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})", - fileSet.Count, - mediaFolderPath, - pageData.Total, - importFolderId, - importFolderSubPath, - pageData.List.Count == pageData.Total ? null : pageData.List.Count, - totalPages - ); - - // Ensure at most 5 pages are in-flight at any given time, until we're done fetching the pages. - var semaphore = new SemaphoreSlim(5); - var pages = new List<Task<ListResult<API.Models.File>>>() { firstPage }; - for (var page = 2; page <= totalPages; page++) - pages.Add(GetImportFolderFilesPage(importFolderId, importFolderSubPath, page, semaphore)); - var singleSeriesIds = new HashSet<int>(); var multiSeriesFiles = new List<(API.Models.File, string)>(); var totalSingleSeriesFiles = 0; - do { - var task = Task.WhenAny(pages).ConfigureAwait(false).GetAwaiter().GetResult(); - pages.Remove(task); - semaphore.Release(); - pageData = task.Result; - - Logger.LogTrace( - "Iterating page {PageNumber} with size {PageSize} (ImportFolder={FolderId},RelativePath={RelativePath})", - totalPages - pages.Count, - pageData.List.Count, + foreach (var (importFolderId, importFolderSubPath, mediaFolderPaths) in mediaConfigs.ToImportFolderList()) { + var firstPage = ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath); + var pageData = firstPage + .ConfigureAwait(false) + .GetAwaiter() + .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})", + fileSet.Count, + mediaFolderPaths, + pageData.Total, importFolderId, - importFolderSubPath + importFolderSubPath, + pageData.List.Count == pageData.Total ? null : pageData.List.Count, + totalPages ); - foreach (var file in pageData.List) { - if (file.CrossReferences.Count is 0) - continue; - var location = file.Locations - .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length is 0 || location.RelativePath.StartsWith(importFolderSubPath))) - .FirstOrDefault(); - if (location is null) - continue; + // Ensure at most 5 pages are in-flight at any given time, until we're done fetching the pages. + var semaphore = new SemaphoreSlim(5); + var pages = new List<Task<ListResult<API.Models.File>>>() { firstPage }; + for (var page = 2; page <= totalPages; page++) + pages.Add(GetImportFolderFilesPage(importFolderId, importFolderSubPath, page, semaphore)); - var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); - if (!fileSet.Contains(sourceLocation)) - continue; + do { + var task = Task.WhenAny(pages).ConfigureAwait(false).GetAwaiter().GetResult(); + pages.Remove(task); + semaphore.Release(); + pageData = task.Result; + + Logger.LogTrace( + "Iterating page {PageNumber} with size {PageSize} (ImportFolder={FolderId},RelativePath={RelativePath})", + totalPages - pages.Count, + pageData.List.Count, + importFolderId, + importFolderSubPath + ); + foreach (var file in pageData.List) { + if (file.CrossReferences.Count is 0) + continue; - // Yield all single-series files now, and offset the processing of all multi-series files for later. - var seriesIds = file.CrossReferences.Where(x => x.Series.Shoko.HasValue && x.Episodes.All(e => e.Shoko.HasValue)).Select(x => x.Series.Shoko!.Value).ToHashSet(); - if (seriesIds.Count is 1) { - totalSingleSeriesFiles++; - singleSeriesIds.Add(seriesIds.First()); - foreach (var seriesId in seriesIds) - yield return (sourceLocation, file.Id.ToString(), seriesId.ToString()); - } - else if (seriesIds.Count > 1) { - multiSeriesFiles.Add((file, sourceLocation)); + var location = file.Locations + .Where(location => location.ImportFolderId == importFolderId && (importFolderSubPath.Length is 0 || location.RelativePath.StartsWith(importFolderSubPath))) + .FirstOrDefault(); + if (location is null) + continue; + + foreach (var mediaFolderPath in mediaFolderPaths) { + var sourceLocation = Path.Join(mediaFolderPath, location.RelativePath[importFolderSubPath.Length..]); + if (!fileSet.Contains(sourceLocation)) + continue; + + // Yield all single-series files now, and offset the processing of all multi-series files for later. + var seriesIds = file.CrossReferences.Where(x => x.Series.Shoko.HasValue && x.Episodes.All(e => e.Shoko.HasValue)).Select(x => x.Series.Shoko!.Value).ToHashSet(); + if (seriesIds.Count is 1) { + totalSingleSeriesFiles++; + singleSeriesIds.Add(seriesIds.First()); + foreach (var seriesId in seriesIds) + yield return (sourceLocation, file.Id.ToString(), seriesId.ToString()); + } + else if (seriesIds.Count > 1) { + multiSeriesFiles.Add((file, sourceLocation)); + } + break; + } } - } - } while (pages.Count > 0); + } while (pages.Count > 0); + } // Check which series of the multiple series we have, and only yield // the paths for the series we have. This will fail if an OVA episode is @@ -565,14 +613,13 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) var timeSpent = DateTime.UtcNow - start; Logger.LogDebug( - "Iterated {FileCount} ({MultiFileCount}→{MultiFileCount}) files to potentially use within media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath})", + "Iterated {FileCount} ({MultiFileCount}→{MultiFileCount}) files to potentially use within {Count} media folders in {TimeSpan} (Library={LibraryId})", totalSingleSeriesFiles, multiSeriesFiles.Count, totalMultiSeriesFiles, - mediaFolderPath, + mediaConfigs.Count, timeSpent, - importFolderId, - importFolderSubPath + mediaConfigs[0].LibraryId ); } @@ -582,10 +629,9 @@ private HashSet<string> GetPathsForMediaFolder(Folder mediaFolder) return await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath, page).ConfigureAwait(false); } - private async Task<LinkGenerationResult> GenerateStructure(Folder mediaFolder, string vfsPath, IEnumerable<(string sourceLocation, string fileId, string seriesId)> allFiles) + private async Task<LinkGenerationResult> GenerateStructure(string? collectionType, string vfsPath, IEnumerable<(string sourceLocation, string fileId, string seriesId)> allFiles) { var result = new LinkGenerationResult(); - var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); await Task.WhenAll(allFiles.Select(async (tuple) => { await semaphore.WaitAsync().ConfigureAwait(false); @@ -593,7 +639,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { try { Logger.LogTrace("Generating links for {Path} (File={FileId},Series={SeriesId})", tuple.sourceLocation, tuple.fileId, tuple.seriesId); - var (sourceLocation, symbolicLinks, importedAt) = await GenerateLocationsForFile(vfsPath, collectionType, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); + var (sourceLocation, symbolicLinks, importedAt) = await GenerateLocationsForFile(collectionType, vfsPath, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); // Skip any source files we weren't meant to have in the library. if (string.IsNullOrEmpty(sourceLocation) || !importedAt.HasValue) @@ -615,14 +661,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { return result; } - public async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(Folder mediaFolder, string sourceLocation, string fileId, string seriesId) - { - var vfsPath = mediaFolder.GetVirtualRoot(); - var collectionType = LibraryManager.GetInheritedContentType(mediaFolder); - return await GenerateLocationsForFile(vfsPath, collectionType, sourceLocation, fileId, seriesId); - } - - private async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(string vfsPath, string? collectionType, string sourceLocation, string fileId, string seriesId) + public async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(string? collectionType, string vfsPath, string sourceLocation, string fileId, string seriesId) { var season = await ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); if (season is null) diff --git a/Shokofin/Tasks/CleanupVirtualRootTask.cs b/Shokofin/Tasks/CleanupVirtualRootTask.cs new file mode 100644 index 00000000..6cf4f437 --- /dev/null +++ b/Shokofin/Tasks/CleanupVirtualRootTask.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; +using Shokofin.Utils; + +namespace Shokofin.Tasks; + +/// <summary> +/// Cleanup any old VFS roots leftover from an outdated install or failed removal of the roots. +/// </summary> +public class CleanupVirtualRootTask : IScheduledTask, IConfigurableScheduledTask +{ + /// <inheritdoc /> + public string Name => "Cleanup Virtual File System Roots"; + + /// <inheritdoc /> + public string Description => "Cleanup any old VFS roots leftover from an outdated install or failed removal of the roots."; + + /// <inheritdoc /> + public string Category => "Shokofin"; + + /// <inheritdoc /> + public string Key => "ShokoCleanupVirtualRoot"; + + /// <inheritdoc /> + public bool IsHidden => false; + + /// <inheritdoc /> + public bool IsEnabled => false; + + /// <inheritdoc /> + public bool IsLogged => true; + + private readonly ILogger<CleanupVirtualRootTask> Logger; + + private readonly IFileSystem FileSystem; + + private readonly LibraryScanWatcher ScanWatcher; + + public CleanupVirtualRootTask(ILogger<CleanupVirtualRootTask> logger, IFileSystem fileSystem, LibraryScanWatcher scanWatcher) + { + Logger = logger; + FileSystem = fileSystem; + ScanWatcher = scanWatcher; + } + + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + => Array.Empty<TaskTriggerInfo>(); + + public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + if (ScanWatcher.IsScanRunning) + return Task.CompletedTask; + + var start = DateTime.Now; + var mediaFolders = 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) + .ToList(); + Logger.LogInformation("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); + Directory.Delete(vfsRoot.FullName, true); + var perFolderDeltaTime = DateTime.Now - folderStart; + Logger.LogInformation("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); + + return Task.CompletedTask; + } +} diff --git a/Shokofin/Tasks/VersionCheckTask.cs b/Shokofin/Tasks/VersionCheckTask.cs index e071772e..b7a2675a 100644 --- a/Shokofin/Tasks/VersionCheckTask.cs +++ b/Shokofin/Tasks/VersionCheckTask.cs @@ -3,6 +3,8 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; using Shokofin.API; @@ -39,23 +41,22 @@ public class VersionCheckTask : IScheduledTask, IConfigurableScheduledTask private readonly ILogger<VersionCheckTask> Logger; + private readonly ILibraryManager LibraryManager; + private readonly ShokoAPIClient ApiClient; - public VersionCheckTask(ILogger<VersionCheckTask> logger, ShokoAPIClient apiClient) + public VersionCheckTask(ILogger<VersionCheckTask> logger, ILibraryManager libraryManager, ShokoAPIClient apiClient) { Logger = logger; + LibraryManager = libraryManager; ApiClient = apiClient; } public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - => new TaskTriggerInfo[2] { + => new TaskTriggerInfo[1] { new() { Type = TaskTriggerInfo.TriggerStartup, }, - new() { - Type = TaskTriggerInfo.TriggerDaily, - TimeOfDayTicks = new TimeSpan(3, 0, 0).Ticks, - }, }; public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) @@ -71,7 +72,7 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can updated = true; } - var mediaFolders = Plugin.Instance.Configuration.MediaFolders; + var mediaFolders = Plugin.Instance.Configuration.MediaFolders.ToList(); var importFolderNameMap = await Task .WhenAll( mediaFolders @@ -83,13 +84,21 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can ) .ContinueWith(task => task.Result.OfType<ImportFolder>().ToDictionary(i => i.Id, i => i.Name)) .ConfigureAwait(false); - foreach (var mediaFolder in mediaFolders) { - if (!importFolderNameMap.TryGetValue(mediaFolder.ImportFolderId, out var importFolderName)) + foreach (var mediaFolderConfig in mediaFolders) { + if (!importFolderNameMap.TryGetValue(mediaFolderConfig.ImportFolderId, out var importFolderName)) importFolderName = null; - if (!string.Equals(mediaFolder.ImportFolderName, importFolderName)) { - Logger.LogInformation("Found new name for import folder; {name} (ImportFolder={ImportFolderId})", importFolderName, mediaFolder.ImportFolderId); - mediaFolder.ImportFolderName = importFolderName; + 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); + 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); + mediaFolderConfig.ImportFolderName = importFolderName; updated = true; } } From 36244afd9ff4177a5656786fe4440868feda77ea Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 22 Jun 2024 18:55:41 +0200 Subject: [PATCH 1075/1103] fix: don't run collection reconstruction if we don't have any libraries --- Shokofin/Collections/CollectionManager.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index 5d4e3f6c..751cd008 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -106,6 +106,8 @@ await LibraryManager.AddVirtualFolder(name, CollectionTypeOptions.BoxSets, libra public async Task ReconstructCollections(IProgress<double> progress, CancellationToken cancellationToken) { try { + // This check is to prevent creating the collections root if we don't have any libraries yet. + if (LibraryManager.GetVirtualFolders().Count is 0) return; switch (Plugin.Instance.Configuration.CollectionGrouping) { default: From 7a4d4318dc309d1b92b69dbf675435c41fd5e1b6 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 22 Jun 2024 18:56:02 +0200 Subject: [PATCH 1076/1103] misc: reduce stall time from 60 to 10 seconds --- 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 98db5395..b85fdc1f 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -548,7 +548,7 @@ public PluginConfiguration() SignalR_EventSources = new[] { ProviderName.Shoko, ProviderName.AniDB, ProviderName.TMDB }; SignalR_RefreshEnabled = false; SignalR_FileEvents = false; - UsageTracker_StalledTimeInSeconds = 60; + UsageTracker_StalledTimeInSeconds = 10; EXPERIMENTAL_AutoMergeVersions = true; EXPERIMENTAL_SplitThenMergeMovies = true; EXPERIMENTAL_SplitThenMergeEpisodes = false; From a99e97afb18505255a5843fa1f1f15455de6a532 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 22 Jun 2024 18:56:38 +0200 Subject: [PATCH 1077/1103] fix: only track enabled items in resolvers and ignore rule --- Shokofin/Resolvers/ShokoIgnoreRule.cs | 6 ++++-- Shokofin/Resolvers/ShokoResolver.cs | 12 ++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Shokofin/Resolvers/ShokoIgnoreRule.cs b/Shokofin/Resolvers/ShokoIgnoreRule.cs index 92321de7..5ff41915 100644 --- a/Shokofin/Resolvers/ShokoIgnoreRule.cs +++ b/Shokofin/Resolvers/ShokoIgnoreRule.cs @@ -68,12 +68,13 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file if (fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) return false; - var trackerId = Plugin.Instance.Tracker.Add($"Should ignore path \"{fileInfo.FullName}\"."); + Guid? trackerId = null; try { // Enable the scanner if we selected to use the Shoko provider for any metadata type on the current root folder. if (!Lookup.IsEnabledForItem(parent, out var isSoleProvider)) return false; + trackerId = Plugin.Instance.Tracker.Add($"Should ignore path \"{fileInfo.FullName}\"."); if (fileInfo.IsDirectory && Plugin.Instance.IgnoredFolders.Contains(Path.GetFileName(fileInfo.FullName).ToLowerInvariant())) { Logger.LogDebug("Skipped excluded folder at path {Path}", fileInfo.FullName); return true; @@ -118,7 +119,8 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file throw; } finally { - Plugin.Instance.Tracker.Remove(trackerId); + if (trackerId.HasValue) + Plugin.Instance.Tracker.Remove(trackerId.Value); } } diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs index 1c39424c..57e4781a 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -65,7 +65,7 @@ NamingOptions namingOptions if (root is null || parent == root) return null; - var trackerId = Plugin.Instance.Tracker.Add($"Resolve path \"{fileInfo.FullName}\"."); + Guid? trackerId = null; try { if (!Lookup.IsEnabledForItem(parent)) return null; @@ -77,6 +77,7 @@ NamingOptions namingOptions if (parent.GetTopParent() is not Folder mediaFolder) return null; + trackerId = Plugin.Instance.Tracker.Add($"Resolve path \"{fileInfo.FullName}\"."); var vfsPath = await ResolveManager.GenerateStructureInVFS(mediaFolder, fileInfo.FullName).ConfigureAwait(false); if (string.IsNullOrEmpty(vfsPath)) return null; @@ -97,7 +98,8 @@ NamingOptions namingOptions throw; } finally { - Plugin.Instance.Tracker.Remove(trackerId); + if (trackerId.HasValue) + Plugin.Instance.Tracker.Remove(trackerId.Value); } } @@ -110,7 +112,7 @@ NamingOptions namingOptions if (root is null || parent == root) return null; - var trackerId = Plugin.Instance.Tracker.Add($"Resolve children of \"{parent.Path}\". (Children={fileInfoList.Count})"); + Guid? trackerId = null; try { if (!Lookup.IsEnabledForItem(parent)) return null; @@ -118,6 +120,7 @@ NamingOptions namingOptions if (parent.GetTopParent() is not Folder mediaFolder) return null; + trackerId = Plugin.Instance.Tracker.Add($"Resolve children of \"{parent.Path}\". (Children={fileInfoList.Count})"); var vfsPath = await ResolveManager.GenerateStructureInVFS(mediaFolder, parent.Path).ConfigureAwait(false); if (string.IsNullOrEmpty(vfsPath)) return null; @@ -195,7 +198,8 @@ NamingOptions namingOptions throw; } finally { - Plugin.Instance.Tracker.Remove(trackerId); + if (trackerId.HasValue) + Plugin.Instance.Tracker.Remove(trackerId.Value); } } From 3c129a8507d88820f38943efdf97a3641584050e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 22 Jun 2024 23:01:54 +0200 Subject: [PATCH 1078/1103] fix: don't attempt to look up library id if it's empty --- Shokofin/Configuration/MediaFolderConfiguration.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Configuration/MediaFolderConfiguration.cs b/Shokofin/Configuration/MediaFolderConfiguration.cs index 3f4d73a8..a99b3f80 100644 --- a/Shokofin/Configuration/MediaFolderConfiguration.cs +++ b/Shokofin/Configuration/MediaFolderConfiguration.cs @@ -21,7 +21,7 @@ public class MediaFolderConfiguration /// </summary> [XmlIgnore] [JsonInclude] - public string? LibraryName => BaseItem.LibraryManager.GetItemById(LibraryId)?.Name; + public string? LibraryName => LibraryId == Guid.Empty ? null : BaseItem.LibraryManager.GetItemById(LibraryId)?.Name; /// <summary> /// The jellyfin media folder id. From 54862f5fc50223b0d04e25d9035d12e8174025b3 Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Mon, 24 Jun 2024 10:07:48 +0000 Subject: [PATCH 1079/1103] fix: fix episode order when using season merging --- Shokofin/API/Info/SeasonInfo.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 2dc9e792..2bb412db 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -161,14 +161,21 @@ 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 => e.AniDB.EpisodeNumber) + .OrderBy(e => seriesIdOrder.IndexOf(e.Shoko.IDs.Series.ToString())) + .ThenBy(e => e.AniDB.Type) + .ThenBy(e => e.AniDB.EpisodeNumber) .ToList(); specialsList = specialsList - .OrderBy(e => e.AniDB.EpisodeNumber) + .OrderBy(e => seriesIdOrder.IndexOf(e.Shoko.IDs.Series.ToString())) + .ThenBy(e => e.AniDB.Type) + .ThenBy(e => e.AniDB.EpisodeNumber) .ToList(); altEpisodesList = altEpisodesList - .OrderBy(e => e.AniDB.EpisodeNumber) + .OrderBy(e => seriesIdOrder.IndexOf(e.Shoko.IDs.Series.ToString())) + .ThenBy(e => e.AniDB.Type) + .ThenBy(e => e.AniDB.EpisodeNumber) .ToList(); // Replace the normal episodes if we've hidden all the normal episodes and we have at least one From 552a39e0405076dd25a767c85008705c2de3777f Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Mon, 24 Jun 2024 10:12:11 +0000 Subject: [PATCH 1080/1103] fix: fix regression for link generation - Add back the check for if the VFS has already been generated for the ~~media folder~~ library. --- Shokofin/Resolvers/VirtualFileSystemService.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 4216927b..f1ad5d73 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -131,7 +131,6 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) /// <returns>The VFS path, if it succeeded.</returns> public async Task<string?> GenerateStructureInVFS(Folder mediaFolder, string path) { - // Skip link generation if we've already generated for the media folder. var (vfsPath, collectionType, mediaConfigs) = ConfigurationService.GetAvailableMediaFoldersForLibrary(mediaFolder, config => config.IsVirtualFileSystemEnabled); if (string.IsNullOrEmpty(vfsPath) || mediaConfigs.Count is 0) return null; @@ -139,6 +138,10 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) 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."); + // 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; + // Check full path and all parent directories if they have been indexed. if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).Prepend(vfsPath).ToArray(); @@ -154,7 +157,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) var key = mediaConfigs.Any(config => path.StartsWith(config.MediaFolderPath)) ? $"should-skip-vfs-path:{vfsPath}" : $"should-skip-vfs-path:{path}"; - var shouldReturnPath = await DataCache.GetOrCreateAsync<bool>(key, async (__) => { + shouldReturnPath = await DataCache.GetOrCreateAsync<bool>(key, async (__) => { // Iterate the files already in the VFS. string? pathToClean = null; IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; From d4301cc1a46fd73a63216165b8a4d866b56e62dd Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Tue, 25 Jun 2024 18:59:42 +0000 Subject: [PATCH 1081/1103] fix: use one media folder for vfs - If the library contains multiple media folders then the VFS will only be attached to the first media folder, so the other media folders will "virtually" be empty. This is a technical change to prevent giving the same children to multiple base items causing errors internally in Jellyfin. --- .../MediaFolderConfigurationService.cs | 38 ++++++++++++------- Shokofin/Events/EventDispatchService.cs | 12 +++--- Shokofin/Resolvers/ShokoResolver.cs | 8 ++-- .../Resolvers/VirtualFileSystemService.cs | 20 ++++++---- 4 files changed, 47 insertions(+), 31 deletions(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 726e7fa0..3e22333b 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -128,36 +128,46 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) #region Media Folder Mapping - public IReadOnlyList<(string vfsPath, string? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList)> GetAvailableMediaFoldersForLibraries(Func<MediaFolderConfiguration, bool>? filter = null) + public IReadOnlyList<(string vfsPath, string mainMediaFolderPath, string? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList)> GetAvailableMediaFoldersForLibraries(Func<MediaFolderConfiguration, bool>? filter = null) { - lock (LockObj) + + 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) .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> )) - .Where(tuple => tuple.libraryFolder is not null && tuple.mediaList.Count is > 0) - .Select(tuple => (tuple.libraryFolder!.GetVirtualRoot(), LibraryManager.GetConfiguredContentType(tuple.libraryFolder!) ?? null, tuple.mediaList)) + .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!) ?? null, tuple.mediaList)) .ToList(); + } } - public (string? vfsPath, string? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList) GetAvailableMediaFoldersForLibrary(Folder mediaFolder, Func<MediaFolderConfiguration, bool>? filter = null) + public (string vfsPath, string mainMediaFolderPath, string? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList) GetAvailableMediaFoldersForLibrary(Folder mediaFolder, Func<MediaFolderConfiguration, bool>? filter = null) { var mediaFolderConfig = GetOrCreateConfigurationForMediaFolder(mediaFolder); - if (LibraryManager.GetItemById(mediaFolderConfig.LibraryId) is not Folder libraryFolder) - return (null, null, new List<MediaFolderConfiguration>()); - lock (LockObj) + lock (LockObj) { + if (LibraryManager.GetItemById(mediaFolderConfig.LibraryId) is not Folder libraryFolder) + return (string.Empty, string.Empty, null, new List<MediaFolderConfiguration>()); + 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 ( - libraryFolder.GetVirtualRoot(), - 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() - ); + libraryFolder.GetVirtualRoot(), + virtualFolder.Locations[0], + 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() + ); + } } public MediaFolderConfiguration GetOrCreateConfigurationForMediaFolder(Folder mediaFolder) diff --git a/Shokofin/Events/EventDispatchService.cs b/Shokofin/Events/EventDispatchService.cs index ab1dd648..fa9244c6 100644 --- a/Shokofin/Events/EventDispatchService.cs +++ b/Shokofin/Events/EventDispatchService.cs @@ -215,7 +215,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); if (reason is not UpdateReason.Removed) { Logger.LogTrace("Processing file changed. (File={FileId})", fileId); - foreach (var (vfsPath, collectionType, mediaConfigs) in libraries) { + foreach (var (vfsPath, mainMediaFolderPath, collectionType, mediaConfigs) in libraries) { foreach (var (importFolderSubPath, vfsEnabled, mediaFolderPaths) in mediaConfigs.ToImportFolderList(importFolderId, relativePath)) { foreach (var mediaFolderPath in mediaFolderPaths) { var sourceLocation = Path.Join(mediaFolderPath, relativePath[importFolderSubPath.Length..]); @@ -271,9 +271,9 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int } // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. else { - var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolderPath, false).FirstOrDefault(); + var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mainMediaFolderPath, false).FirstOrDefault(); if (!string.IsNullOrEmpty(fileOrFolder)) - mediaFoldersToNotify.TryAdd(mediaFolderPath, (fileOrFolder, mediaFolderPath.GetFolderForPath())); + mediaFoldersToNotify.TryAdd(mainMediaFolderPath, (fileOrFolder, mainMediaFolderPath.GetFolderForPath())); } break; } @@ -285,7 +285,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int Logger.LogTrace("Processing file removed. (File={FileId})", fileId); relativePath = firstRemovedEvent.RelativePath; importFolderId = firstRemovedEvent.ImportFolderId; - foreach (var (vfsPath, collectionType, mediaConfigs) in libraries) { + foreach (var (vfsPath, mainMediaFolderPath, collectionType, mediaConfigs) in libraries) { foreach (var (importFolderSubPath, vfsEnabled, mediaFolderPaths) in mediaConfigs.ToImportFolderList(importFolderId, relativePath)) { foreach (var mediaFolderPath in mediaFolderPaths) { // Let the core logic handle the rest. @@ -343,9 +343,9 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int } // Else give the core logic _any_ file or folder placed directly in the media folder, so it will schedule the media folder to be refreshed. else { - var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mediaFolderPath, false).FirstOrDefault(); + var fileOrFolder = FileSystem.GetFileSystemEntryPaths(mainMediaFolderPath, false).FirstOrDefault(); if (!string.IsNullOrEmpty(fileOrFolder)) - mediaFoldersToNotify.TryAdd(mediaFolderPath, (fileOrFolder, mediaFolderPath.GetFolderForPath())); + mediaFoldersToNotify.TryAdd(mainMediaFolderPath, (fileOrFolder, mainMediaFolderPath.GetFolderForPath())); } break; } diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs index 57e4781a..819bf674 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -78,8 +78,8 @@ NamingOptions namingOptions return null; trackerId = Plugin.Instance.Tracker.Add($"Resolve path \"{fileInfo.FullName}\"."); - var vfsPath = await ResolveManager.GenerateStructureInVFS(mediaFolder, fileInfo.FullName).ConfigureAwait(false); - if (string.IsNullOrEmpty(vfsPath)) + var (vfsPath, shouldContinue) = await ResolveManager.GenerateStructureInVFS(mediaFolder, fileInfo.FullName).ConfigureAwait(false); + if (string.IsNullOrEmpty(vfsPath) || !shouldContinue) return null; if (parent.Id == mediaFolder.Id && fileInfo.IsDirectory) { @@ -121,8 +121,8 @@ NamingOptions namingOptions return null; trackerId = Plugin.Instance.Tracker.Add($"Resolve children of \"{parent.Path}\". (Children={fileInfoList.Count})"); - var vfsPath = await ResolveManager.GenerateStructureInVFS(mediaFolder, parent.Path).ConfigureAwait(false); - if (string.IsNullOrEmpty(vfsPath)) + var (vfsPath, shouldContinue) = await ResolveManager.GenerateStructureInVFS(mediaFolder, parent.Path).ConfigureAwait(false); + if (string.IsNullOrEmpty(vfsPath) || !shouldContinue) return null; // Redirect children of a VFS managed media folder to the VFS. diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index f1ad5d73..73a42c87 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -129,18 +129,21 @@ 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?> GenerateStructureInVFS(Folder mediaFolder, string path) + public async Task<(string?, bool)> GenerateStructureInVFS(Folder mediaFolder, string path) { - var (vfsPath, collectionType, mediaConfigs) = ConfigurationService.GetAvailableMediaFoldersForLibrary(mediaFolder, config => config.IsVirtualFileSystemEnabled); - if (string.IsNullOrEmpty(vfsPath) || mediaConfigs.Count is 0) - return null; + var (vfsPath, mainMediaFolderPath, collectionType, mediaConfigs) = ConfigurationService.GetAvailableMediaFoldersForLibrary(mediaFolder, 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."); // 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; + return ( + shouldReturnPath ? vfsPath : null, + path.StartsWith(vfsPath + Path.DirectorySeparatorChar) || path == mainMediaFolderPath + ); // Check full path and all parent directories if they have been indexed. if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { @@ -148,7 +151,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) while (pathSegments.Length > 1) { var subPath = Path.Join(pathSegments); if (DataCache.TryGetValue<bool>($"should-skip-vfs-path:{subPath}", out _)) - return vfsPath; + return (vfsPath, true); pathSegments = pathSegments.SkipLast(1).ToArray(); } } @@ -256,7 +259,10 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) return true; }); - return shouldReturnPath ? vfsPath : null; + return ( + shouldReturnPath ? vfsPath : null, + path.StartsWith(vfsPath + Path.DirectorySeparatorChar) || path == mainMediaFolderPath + ); } private HashSet<string> GetPathsForMediaFolder(IReadOnlyList<MediaFolderConfiguration> mediaConfigs) From 9d5de34ee72fdfb87521a62ef34adfcb847fbe1a Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Wed, 26 Jun 2024 09:43:41 +0000 Subject: [PATCH 1082/1103] fix: make file events great again! - Fix the the extension method to get the media folder from a path to refresh the library itself when adding files for new series not in the media library. --- 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 3e22333b..1043cee4 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -17,7 +17,7 @@ namespace Shokofin.Configuration; public static class MediaFolderConfigurationExtensions { public static Folder GetFolderForPath(this string mediaFolderPath) - => BaseItem.LibraryManager.GetItemById(mediaFolderPath) as Folder ?? + => BaseItem.LibraryManager.FindByPath(mediaFolderPath, true) as Folder ?? throw new Exception($"Unable to find folder by path \"{mediaFolderPath}\"."); public static IReadOnlyList<(int importFolderId, string importFolderSubPath, IReadOnlyList<string> mediaFolderPaths)> ToImportFolderList(this IEnumerable<MediaFolderConfiguration> mediaConfigs) From 848012cf3083ed40ef57eb5af45441df02195456 Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Sat, 29 Jun 2024 14:28:42 +0000 Subject: [PATCH 1083/1103] feat: add more optional metadata to VFS paths - Added two _optional_ pieces of metadata to add to the VFS path purely for user QoL, but with some drawbacks, which is why they're **opt-in** and not opt-out. Read the warnings if you want to know more. --- .../MediaFolderConfigurationService.cs | 2 +- Shokofin/Configuration/PluginConfiguration.cs | 22 +++++- Shokofin/Configuration/configController.js | 75 ++++++++++--------- Shokofin/Configuration/configPage.html | 16 +++- .../Resolvers/VirtualFileSystemService.cs | 17 ++++- 5 files changed, 87 insertions(+), 45 deletions(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 1043cee4..c9ff9716 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -213,7 +213,7 @@ private async Task<MediaFolderConfiguration> CreateConfigurationForPath(Guid lib MediaFolderPath = mediaFolder.Path, IsFileEventsEnabled = libraryConfig?.IsFileEventsEnabled ?? config.SignalR_FileEvents, IsRefreshEventsEnabled = libraryConfig?.IsRefreshEventsEnabled ?? config.SignalR_RefreshEnabled, - IsVirtualFileSystemEnabled = libraryConfig?.IsVirtualFileSystemEnabled ?? config.VirtualFileSystem, + IsVirtualFileSystemEnabled = libraryConfig?.IsVirtualFileSystemEnabled ?? config.VFS_Enabled, LibraryFilteringMode = libraryConfig?.LibraryFilteringMode ?? config.LibraryFilteringMode, }; diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index b85fdc1f..9ffc0f5e 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -348,12 +348,24 @@ public virtual string PrettyUrl /// <summary> /// Enable/disable the VFS for new media-folders/libraries. /// </summary> - public bool VirtualFileSystem { get; set; } + [XmlElement("VirtualFileSystem")] + public bool VFS_Enabled { get; set; } /// <summary> /// Number of threads to concurrently generate links for the VFS. /// </summary> - public int VirtualFileSystemThreads { get; set; } + [XmlElement("VirtualFileSystemThreads")] + public int VFS_Threads { get; set; } + + /// <summary> + /// Add release group to the file name of VFS entries. + /// </summary> + public bool VFS_AddReleaseGroup { get; set; } + + /// <summary> + /// Add resolution to the file name of VFS entries. + /// </summary> + public bool VFS_AddResolution { get; set; } /// <summary> /// Enable/disable the filtering for new media-folders/libraries. @@ -524,8 +536,10 @@ public PluginConfiguration() DescriptionProvider.TvDB, DescriptionProvider.TMDB, }; - VirtualFileSystem = CanCreateSymbolicLinks; - VirtualFileSystemThreads = 4; + VFS_Enabled = CanCreateSymbolicLinks; + VFS_Threads = 4; + VFS_AddReleaseGroup = false; + VFS_AddResolution = false; UseGroupsForShows = false; SeparateMovies = false; MovieSpecialsAsExtraFeaturettes = false; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index f3028dda..8156e0e1 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -333,6 +333,10 @@ async function defaultSubmit(form) { // Media Folder settings let mediaFolderId = form.querySelector("#MediaFolderSelector").value; let mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : 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; if (mediaFolderConfig) { const libraryId = mediaFolderConfig.LibraryId; for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { @@ -341,7 +345,7 @@ async function defaultSubmit(form) { } } else { - config.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; + config.VFS_Enabled = form.querySelector("#VFS_Enabled").checked; config.LibraryFilteringMode = form.querySelector("#LibraryFilteringMode").value; } @@ -365,10 +369,6 @@ async function defaultSubmit(form) { config.SignalR_RefreshEnabled = form.querySelector("#SignalRDefaultRefreshEvents").checked; } - // Advanced settings - config.PublicUrl = publicUrl; - config.IgnoredFolders = ignoredFolders; - form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); // Experimental settings config.EXPERIMENTAL_AutoMergeVersions = form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked; @@ -439,11 +439,18 @@ async function defaultSubmit(form) { if (url.endsWith("/")) { url = url.slice(0, -1); } + let publicUrl = form.querySelector("#PublicUrl").value; + if (publicUrl.endsWith("/")) { + publicUrl = publicUrl.slice(0, -1); + form.querySelector("#PublicUrl").value = publicUrl; + } + config.PublicUrl = publicUrl; // Update the url if needed. - if (config.Url !== url) { + if (config.Url !== url || config.PublicUrl !== publicUrl) { config.Url = url; form.querySelector("#Url").value = url; + form.querySelector("#PublicUrl").value = publicUrl; let result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); Dashboard.processPluginConfigurationUpdateResult(result); } @@ -487,12 +494,6 @@ async function resetConnectionSettings(form) { async function syncSettings(form) { const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - 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 config.TitleMainOverride = form.querySelector("#TitleMainOverride").checked; @@ -541,11 +542,6 @@ async function syncSettings(form) { config.AddCreditsAsSpecialFeatures = form.querySelector("#AddCreditsAsSpecialFeatures").checked; config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; - // Advanced settings - config.PublicUrl = publicUrl; - config.IgnoredFolders = ignoredFolders; - form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); - // Experimental settings config.EXPERIMENTAL_AutoMergeVersions = form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked; config.EXPERIMENTAL_SplitThenMergeMovies = form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked; @@ -601,6 +597,12 @@ 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 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; if (mediaFolderConfig) { const libraryId = mediaFolderConfig.LibraryId; for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { @@ -609,7 +611,7 @@ async function syncMediaFolderSettings(form) { } } else { - config.VirtualFileSystem = form.querySelector("#VirtualFileSystem").checked; + config.VFS_Enabled = form.querySelector("#VFS_Enabled").checked; config.LibraryFilteringMode = form.querySelector("#LibraryFilteringMode").value; } @@ -721,6 +723,7 @@ export default function (page) { } 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", ""); @@ -736,6 +739,7 @@ export default function (page) { } 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", ""); @@ -891,6 +895,7 @@ export default function (page) { // Connection settings form.querySelector("#Url").value = config.Url; + form.querySelector("#PublicUrl").value = config.PublicUrl; form.querySelector("#Username").value = config.Username; form.querySelector("#Password").value = ""; @@ -988,7 +993,7 @@ export default function (page) { form.querySelector("#AddTMDBId").checked = config.AddTMDBId; // Library settings - if (form.querySelector("#UseGroupsForShows").checked = config.UseGroupsForShows || false) { + if (form.querySelector("#UseGroupsForShows").checked = config.UseGroupsForShows) { form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); form.querySelector("#SeasonOrdering").disabled = false; } @@ -997,22 +1002,22 @@ export default function (page) { form.querySelector("#SeasonOrdering").disabled = true; } form.querySelector("#SeasonOrdering").value = config.SeasonOrdering; - form.querySelector("#CollectionGrouping").value = config.CollectionGrouping || "Default"; - form.querySelector("#CollectionMinSizeOfTwo").checked = config.CollectionMinSizeOfTwo != null - ? config.CollectionMinSizeOfTwo : true; - form.querySelector("#SeparateMovies").checked = config.SeparateMovies != null - ? config.SeparateMovies : true; - form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" - ? "AfterSeason" : config.SpecialsPlacement; - form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked = config.MovieSpecialsAsExtraFeaturettes || false; - form.querySelector("#AddTrailers").checked = config.AddTrailers || false; - form.querySelector("#AddCreditsAsThemeVideos").checked = config.AddCreditsAsThemeVideos || false; - form.querySelector("#AddCreditsAsSpecialFeatures").checked = config.AddCreditsAsSpecialFeatures || false; - form.querySelector("#AddMissingMetadata").checked = config.AddMissingMetadata || false; + form.querySelector("#CollectionGrouping").value = config.CollectionGrouping; + form.querySelector("#CollectionMinSizeOfTwo").checked = config.CollectionMinSizeOfTwo; + form.querySelector("#SeparateMovies").checked = config.SeparateMovies; + 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("#VirtualFileSystem").checked = config.VirtualFileSystem != null - ? config.VirtualFileSystem : true; + + 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_Enabled").checked = config.VFS_Enabled; form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; mediaFolderSelector.innerHTML += config.MediaFolders .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) @@ -1031,10 +1036,6 @@ export default function (page) { // User settings userSelector.innerHTML += users.map((user) => `<option value="${user.Id}">${user.Name}</option>`).join(""); - // Advanced settings - form.querySelector("#PublicUrl").value = config.PublicUrl; - form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); - // Experimental settings form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked = config.EXPERIMENTAL_AutoMergeVersions || false; form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked = config.EXPERIMENTAL_SplitThenMergeMovies != null diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 94df8a76..91b50b58 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1240,6 +1240,20 @@ <h3>Media Folder Settings</h3> <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> + </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 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> <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"> @@ -1250,7 +1264,7 @@ <h3>Media Folder Settings</h3> <div id="MediaFolderDefaultSettingsContainer"> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="VirtualFileSystem" /> + <input is="emby-checkbox" type="checkbox" id="VFS_Enabled" /> <span>Virtual File System™</span> </label> <div class="fieldDescription checkboxFieldDescription"> diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 73a42c87..4b6e3120 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -641,7 +641,7 @@ private HashSet<string> GetPathsForMediaFolder(IReadOnlyList<MediaFolderConfigur private async Task<LinkGenerationResult> GenerateStructure(string? collectionType, string vfsPath, IEnumerable<(string sourceLocation, string fileId, string seriesId)> allFiles) { var result = new LinkGenerationResult(); - var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VirtualFileSystemThreads); + var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VFS_Threads); await Task.WhenAll(allFiles.Select(async (tuple) => { await semaphore.WaitAsync().ConfigureAwait(false); @@ -765,7 +765,20 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { } } - var fileName = $"{episodeName} [{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{Path.GetExtension(sourceLocation)}"; + var extraDetails = new List<string>(); + 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 + : $"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 symbolicLinks = folders .Select(folderPath => Path.Join(folderPath, fileName)) .ToArray(); From 37f53f84c0fa6c316dfc12616c53e499480d3f82 Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Sat, 29 Jun 2024 14:29:52 +0000 Subject: [PATCH 1084/1103] refactor: disable caching of paths again - Disabled the caching of paths in the media folders again, since we once again know that the VFS will only run once for the top-most folder we're scanning. --- .../Resolvers/VirtualFileSystemService.cs | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 4b6e3120..e4a50545 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -266,28 +266,25 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) } private HashSet<string> GetPathsForMediaFolder(IReadOnlyList<MediaFolderConfiguration> mediaConfigs) - => DataCache.GetOrCreate( - $"path-set-for-library:{mediaConfigs[0].LibraryId}", - (_) => { - var libraryId = mediaConfigs[0].LibraryId; - Logger.LogDebug("Looking for files in library across {Count} folders. (Library={LibraryId})", mediaConfigs.Count, libraryId); - var start = DateTime.UtcNow; - var paths = new HashSet<string>(); - foreach (var mediaConfig in mediaConfigs) { - Logger.LogDebug("Looking for files in folder at {Path}. (Library={LibraryId})", mediaConfig.MediaFolderPath, libraryId); - var folderStart = DateTime.UtcNow; - var before = paths.Count; - paths.UnionWith( - FileSystem.GetFilePaths(mediaConfig.MediaFolderPath, true) - .Where(path => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) - ); - Logger.LogDebug("Found {FileCount} files in folder at {Path} in {TimeSpan}. (Library={LibraryId})", paths.Count - before, mediaConfig.MediaFolderPath, DateTime.UtcNow - folderStart, libraryId); - } + { + var libraryId = mediaConfigs[0].LibraryId; + Logger.LogDebug("Looking for files in library across {Count} folders. (Library={LibraryId})", mediaConfigs.Count, libraryId); + var start = DateTime.UtcNow; + var paths = new HashSet<string>(); + foreach (var mediaConfig in mediaConfigs) { + Logger.LogDebug("Looking for files in folder at {Path}. (Library={LibraryId})", mediaConfig.MediaFolderPath, libraryId); + var folderStart = DateTime.UtcNow; + var before = paths.Count; + paths.UnionWith( + FileSystem.GetFilePaths(mediaConfig.MediaFolderPath, true) + .Where(path => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + ); + Logger.LogDebug("Found {FileCount} files in folder at {Path} in {TimeSpan}. (Library={LibraryId})", paths.Count - before, mediaConfig.MediaFolderPath, DateTime.UtcNow - folderStart, libraryId); + } - Logger.LogDebug("Found {FileCount} files in library across {Count} in {TimeSpan}. (Library={LibraryId})", paths.Count, mediaConfigs.Count, DateTime.UtcNow - start, libraryId); - return paths; - } - ); + Logger.LogDebug("Found {FileCount} files in library across {Count} in {TimeSpan}. (Library={LibraryId})", paths.Count, mediaConfigs.Count, DateTime.UtcNow - start, libraryId); + return paths; + } private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForEpisode(string fileId, string seriesId, IReadOnlyList<MediaFolderConfiguration> mediaConfigs, HashSet<string> fileSet) { From 349544d01ed0cfaca513452f0c97b6958b1f55d5 Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Sat, 29 Jun 2024 14:30:14 +0000 Subject: [PATCH 1085/1103] misc: update comment --- 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 819bf674..e311ad9c 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -179,7 +179,7 @@ NamingOptions namingOptions .OfType<BaseItem>() .ToList(); - // TODO: uncomment the code snippet once the PR is in stable JF. + // TODO: uncomment the code snippet once we reach JF 10.10. // return new() { Items = items, ExtraFiles = new() }; // TODO: Remove these two hacks once we have proper support for adding multiple series at once. From fd226f3ddbd3076abb21e1ce056f3c9f6d1a4d98 Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Sat, 29 Jun 2024 14:31:43 +0000 Subject: [PATCH 1086/1103] fix: throw if we can't find the media folder - Throw if we can't find the media folder during scanning for non-VFS libraries, instead of silently continuing while using the root folder (which is not a media folder!). - Added doc-comments for the find media folder and strip media folder methods. --- Shokofin/API/ShokoAPIManager.cs | 22 ++++++++++++++++++++-- Shokofin/Resolvers/ShokoIgnoreRule.cs | 2 +- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 88b7d3c9..5f952d41 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -73,7 +73,19 @@ private void OnTrackerStalled(object? sender, EventArgs eventArgs) #region Ignore rule - public (Folder mediaFolder, string partialPath) FindMediaFolder(string path, Folder parent, Folder root) + /// <summary> + /// We'll let the ignore rule "scan" for the media folder, and populate our + /// dictionary for later use, then we'll use said dictionary to lookup the + /// media folder by path later in the ignore rule and when stripping the + /// media folder from the path to get the relative path in + /// <see cref="StripMediaFolder"/>. + /// </summary> + /// <param name="path">The path to find the media folder for.</param> + /// <param name="parent">The parent folder of <paramref name="path"/>. + /// </param> + /// <returns>The media folder and partial string within said folder for + /// <paramref name="path"/>.</returns> + public (Folder mediaFolder, string partialPath) FindMediaFolder(string path, Folder parent) { Folder? mediaFolder = null; lock (MediaFolderListLock) @@ -81,12 +93,18 @@ private void OnTrackerStalled(object? sender, EventArgs eventArgs) if (mediaFolder is not null) return (mediaFolder, path[mediaFolder.Path.Length..]); if (parent.GetTopParent() is not Folder topParent) - return (root, path); + throw new Exception($"Unable to find media folder for path \"{path}\""); lock (MediaFolderListLock) MediaFolderList.Add(topParent); return (topParent, path[topParent.Path.Length..]); } + /// <summary> + /// Strip the media folder from the full path, leaving only the partial + /// path to use when searching Shoko for a match. + /// </summary> + /// <param name="fullPath">The full path to strip.</param> + /// <returns>The partial path, void of the media folder.</returns> public string StripMediaFolder(string fullPath) { Folder? mediaFolder = null; diff --git a/Shokofin/Resolvers/ShokoIgnoreRule.cs b/Shokofin/Resolvers/ShokoIgnoreRule.cs index 5ff41915..d2cea26e 100644 --- a/Shokofin/Resolvers/ShokoIgnoreRule.cs +++ b/Shokofin/Resolvers/ShokoIgnoreRule.cs @@ -86,7 +86,7 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file } var fullPath = fileInfo.FullName; - var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent, root); + var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent); // Ignore any media folders that aren't mapped to shoko. var mediaFolderConfig = ConfigurationService.GetOrCreateConfigurationForMediaFolder(mediaFolder); From 2d55c8448531c89ad1998747e74113db4563c55c Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Sat, 29 Jun 2024 14:35:55 +0000 Subject: [PATCH 1087/1103] fix: add better removal of symbolic links in event dispatcher --- Shokofin/Events/EventDispatchService.cs | 35 ++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/Shokofin/Events/EventDispatchService.cs b/Shokofin/Events/EventDispatchService.cs index fa9244c6..3c735544 100644 --- a/Shokofin/Events/EventDispatchService.cs +++ b/Shokofin/Events/EventDispatchService.cs @@ -256,8 +256,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int continue; } Logger.LogTrace("Found a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); - if (File.Exists(video.Path)) - File.Delete(video.Path); + RemoveSymbolicLink(video.Path); topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); locationsToNotify.Add(video.Path); result.RemovedVideos++; @@ -328,8 +327,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int continue; } Logger.LogTrace("Found a {Kind} to remove with path {Path}", video.GetBaseItemKind(), video.Path); - if (File.Exists(video.Path)) - File.Delete(video.Path); + RemoveSymbolicLink(video.Path); topFolders.Add(Path.Join(vfsPath, video.Path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())); locationsToNotify.Add(video.Path); result.RemovedVideos++; @@ -434,6 +432,35 @@ private async Task<IReadOnlySet<string>> GetSeriesIdsForFile(int fileId, IFileEv } } + private void RemoveSymbolicLink(string filePath) + { + // TODO: If this works better, the move it to an utility and also use it in the VFS if needed, or remove this comment if it's not needed. + try { + var fileExists = File.Exists(filePath); + var fileInfo = new System.IO.FileInfo(filePath); + var fileInfoExists = fileInfo.Exists; + var reparseFlag = fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint); + Logger.LogTrace( + "Result for if file is a reparse point; {FilePath} (Exists1={FileExists},Exists2={FileInfoExists},ReparsePoint={IsReparsePoint},Attributes={AllAttributes})", + filePath, + fileExists, + fileInfoExists, + reparseFlag, + fileInfo.Attributes + ); + + try { + File.Delete(filePath); + } + catch (Exception ex) { + Logger.LogError(ex, "Unable to remove symbolic link at path {Path}; {ErrorMessage}", filePath, ex.Message); + } + } + catch (Exception ex) { + Logger.LogTrace(ex, "Unable to check if file path exists and is a reparse point; {FilePath}", filePath); + } + } + private async Task ReportMediaFolderChanged(Folder mediaFolder, string pathToReport) { if (LibraryManager.GetLibraryOptions(mediaFolder) is not LibraryOptions libraryOptions || !libraryOptions.EnableRealtimeMonitor) { From 16a02f2e0aa87952d54324e52dfd447a32734556 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 2 Jul 2024 23:22:05 +0200 Subject: [PATCH 1088/1103] fix: fix VFS resolver when series in shoko doesn't exist - Fixed the VFS media folder resolution when the series in shoko has been removed. --- Shokofin/API/ShokoAPIManager.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 5f952d41..fff9c704 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -659,7 +659,13 @@ public bool TryGetSeriesIdForEpisodeId(string episodeId, out string? seriesId) if (string.IsNullOrEmpty(seriesId)) return null; - var series = await APIClient.GetSeries(seriesId).ConfigureAwait(false); + Series series; + try { + series = await APIClient.GetSeries(seriesId).ConfigureAwait(false); + } + catch (ApiException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound) { + return null; + } return await CreateSeasonInfo(series).ConfigureAwait(false); } From 7aefcb43fc70e119b2cd32aec5fee67ffe36f44a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 2 Jul 2024 23:22:48 +0200 Subject: [PATCH 1089/1103] misc: add missing trace logging for method --- Shokofin/API/ShokoAPIManager.cs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index fff9c704..ff244ddb 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -785,7 +785,19 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) private Task<(string primaryId, List<string> extraIds)> GetSeriesIdsForSeason(Series series) => DataCache.GetOrCreateAsync( $"season-series-ids:{series.IDs.Shoko}", - (tuple) => Logger.LogTrace(""), + (tuple) => { + var config = Plugin.Instance.Configuration; + if (!config.EXPERIMENTAL_MergeSeasons) + return; + + if (!config.EXPERIMENTAL_MergeSeasonsTypes.Contains(GetCustomSeriesType(series.IDs.Shoko.ToString()).ConfigureAwait(false).GetAwaiter().GetResult() ?? series.AniDBEntity.Type)) + return; + + if (series.AniDBEntity.AirDate is null) + return; + + Logger.LogTrace("Reusing existing series-to-season mapping for series. (Series={SeriesId},ExtraSeries={ExtraIds})", tuple.primaryId, tuple.extraIds); + }, async (cacheEntry) => { var primaryId = series.IDs.Shoko.ToString(); var extraIds = new List<string>(); @@ -799,6 +811,8 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) if (series.AniDBEntity.AirDate is null) return (primaryId, extraIds); + Logger.LogTrace("Creating new series-to-season mapping for series. (Series={SeriesId})", primaryId); + // We potentially have a "follow-up" season candidate, so look for the "primary" season candidate, then jump into that. var relations = await APIClient.GetSeriesRelations(primaryId).ConfigureAwait(false); var mainTitle = series.AniDBEntity.Titles.First(title => title.Type == TitleType.Main).Value; @@ -834,8 +848,10 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) var prequelMainTitle = prequelSeries.AniDBEntity.Titles.First(title => title.Type == TitleType.Main).Value; var prequelResult = YearRegex.Match(prequelMainTitle); if (!prequelResult.Success) { - if (string.Equals(adjustedMainTitle, prequelMainTitle, StringComparison.InvariantCultureIgnoreCase)) - return await GetSeriesIdsForSeason(prequelSeries); + if (string.Equals(adjustedMainTitle, prequelMainTitle, StringComparison.InvariantCultureIgnoreCase)) { + (primaryId, extraIds) = await GetSeriesIdsForSeason(prequelSeries); + goto breakPrequelWhileLoop; + } continue; } @@ -846,7 +862,7 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) goto continuePrequelWhileLoop; } } - break; + breakPrequelWhileLoop: break; continuePrequelWhileLoop: continue; } } @@ -895,6 +911,8 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) } } + Logger.LogTrace("Created new series-to-season mapping for series. (Series={SeriesId},ExtraSeries={ExtraIds})", primaryId, extraIds); + return (primaryId, extraIds); } ); From a0afed5b62cd1e4e8fcfe4a0793b7719761d3b81 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 3 Jul 2024 00:02:40 +0200 Subject: [PATCH 1090/1103] fix: add basic cleanup of removed series in the VFS - Added some basic cleanup logic to remove series and movies that have been removed from shoko upon a refresh or scan of the library. --- Shokofin/Resolvers/ShokoResolver.cs | 41 +++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs index e311ad9c..53de0c54 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -15,6 +16,7 @@ using Shokofin.API.Models; using Shokofin.ExternalIds; +using File = System.IO.File; using IDirectoryService = MediaBrowser.Controller.Providers.IDirectoryService; using TvSeries = MediaBrowser.Controller.Entities.TV.Series; @@ -128,6 +130,7 @@ NamingOptions namingOptions // Redirect children of a VFS managed media folder to the VFS. if (parent.IsTopParent) { var createMovies = collectionType is CollectionType.Movies || (collectionType is null && Plugin.Instance.Configuration.SeparateMovies); + var pathsToRemoveBag = new ConcurrentBag<(string, bool)>(); var items = FileSystem.GetDirectories(vfsPath) .AsParallel() .SelectMany(dirInfo => { @@ -138,8 +141,10 @@ NamingOptions namingOptions .ConfigureAwait(false) .GetAwaiter() .GetResult(); - if (season is null) + if (season is null) { + pathsToRemoveBag.Add((dirInfo.FullName, true)); return Array.Empty<BaseItem>(); + } if (createMovies && season.Type is SeriesType.Movie) { return FileSystem.GetFiles(dirInfo.FullName) @@ -160,7 +165,13 @@ NamingOptions namingOptions .GetResult(); // Abort if the file was not recognized. - if (file is null || file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode))) + if (file is null) { + pathsToRemoveBag.Add((fileInfo.FullName, false)); + return null; + } + + // Or if it's a recognized extra. + if (file.EpisodeList.Any(eI => season.IsExtraEpisode(eI.Episode))) return null; return new Movie() { @@ -179,6 +190,32 @@ NamingOptions namingOptions .OfType<BaseItem>() .ToList(); + if (!pathsToRemoveBag.IsEmpty) { + var start = DateTime.Now; + var pathsToRemove = pathsToRemoveBag.ToArray().DistinctBy(tuple => tuple.Item1).ToList(); + Logger.LogDebug("Cleaning up {Count} removed entries in {Path}", pathsToRemove.Count, mediaFolder.Path); + foreach (var (pathToRemove, isDirectory) in pathsToRemove) { + try { + if (isDirectory) { + Logger.LogTrace("Removing directory: {Path}", pathToRemove); + Directory.Delete(pathToRemove, true); + Logger.LogTrace("Removed directory: {Path}", pathToRemove); + + } + else { + Logger.LogTrace("Removing file: {Path}", pathToRemove); + File.Delete(pathToRemove); + Logger.LogTrace("Removed file: {Path}", pathToRemove); + } + } + catch (Exception ex) { + Logger.LogTrace(ex, "Failed to remove "); + } + } + var deltaTime = DateTime.Now - start; + Logger.LogDebug("Cleaned up {Count} removed entries in {Time}", pathsToRemove.Count, deltaTime); + } + // TODO: uncomment the code snippet once we reach JF 10.10. // return new() { Items = items, ExtraFiles = new() }; From b01f9e3cb8d335bcb2418e153b9d51bef6ba6698 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Tue, 9 Apr 2024 17:33:27 +0530 Subject: [PATCH 1091/1103] refactor: make changes for JF 10.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed `PersonType` → `PersonKind` - Changed the target abi from `10.8.0.0` → `10.9.0.0` - Added a dev container for people to develop for the plugin even if they don't have dotnet8 installed. - Tweaked the series merging id to use the new `MetadataProvider.Custom` instead of the ol' hacky ImDB id hack. - Fixed dotnet8 compiler complaints. Co-authored-by: Mikal Stordal <mikalstordal@gmail.com> --- .config/dotnet-tools.json | 12 ++++ .devcontainer/devcontainer.json | 28 ++++++++++ .vscode/settings.json | 1 + Shokofin/API/Info/SeasonInfo.cs | 14 ++--- .../MediaFolderConfigurationService.cs | 6 +- Shokofin/MergeVersions/MergeVersionManager.cs | 17 ++---- Shokofin/PluginServiceRegistrator.cs | 5 +- Shokofin/Providers/SeriesProvider.cs | 4 +- Shokofin/Resolvers/ShokoIgnoreRule.cs | 8 +-- Shokofin/Resolvers/ShokoLibraryMonitor.cs | 11 ++-- Shokofin/Resolvers/ShokoResolver.cs | 14 ++--- .../Resolvers/VirtualFileSystemService.cs | 55 ++++++++++--------- Shokofin/Shokofin.csproj | 10 ++-- Shokofin/SignalR/SignalREntryPoint.cs | 19 +++---- Shokofin/Sync/UserDataSyncManager.cs | 1 + Shokofin/Tasks/PostScanTask.cs | 4 +- build.yaml | 2 +- 17 files changed, 121 insertions(+), 90 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 .devcontainer/devcontainer.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..c6670e9f --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "8.0.3", + "commands": [ + "dotnet-ef" + ] + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..c7cc7196 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +{ + "name": "Development Shokofin Server", + "image":"mcr.microsoft.com/devcontainers/dotnet:8.0-jammy", + // restores nuget packages, installs the dotnet workloads and installs the dev https certificate + "postStartCommand": "dotnet restore; dotnet workload update; dotnet dev-certs https --trust", + // reads the extensions list and installs them + "postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension", + "features": { + "ghcr.io/devcontainers/features/dotnet:2": { + "version": "none", + "dotnetRuntimeVersions": "8.0", + "aspNetCoreRuntimeVersions": "8.0" + }, + "ghcr.io/devcontainers-contrib/features/apt-packages:1": { + "preserve_apt_list": false, + "packages": ["libfontconfig1"] + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "dockerDashComposeVersion": "v2" + }, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/eitsupi/devcontainer-features/jq-likes:2": {} + }, + "hostRequirements": { + "memory": "8gb", + "cpus": 4 + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 0def0679..ef19470d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -46,6 +46,7 @@ "signalr", "tmdb", "tvshow", + "tvshows", "viewshow", "webui", "whitespaces" diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 2bb412db..581a7933 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -4,7 +4,7 @@ using Shokofin.API.Models; using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo; -using PersonType = MediaBrowser.Model.Entities.PersonType; +using PersonKind = Jellyfin.Data.Enums.PersonKind; namespace Shokofin.API.Info; @@ -273,41 +273,41 @@ public bool IsEmpty(int offset = 0) { CreatorRoleType.Director => new PersonInfo { - Type = PersonType.Director, + Type = PersonKind.Director, Name = role.Staff.Name, Role = role.Name, ImageUrl = GetImagePath(role.Staff.Image), }, CreatorRoleType.Producer => new PersonInfo { - Type = PersonType.Producer, + Type = PersonKind.Producer, Name = role.Staff.Name, Role = role.Name, ImageUrl = GetImagePath(role.Staff.Image), }, CreatorRoleType.Music => new PersonInfo { - Type = PersonType.Lyricist, + Type = PersonKind.Lyricist, Name = role.Staff.Name, Role = role.Name, ImageUrl = GetImagePath(role.Staff.Image), }, CreatorRoleType.SourceWork => new PersonInfo { - Type = PersonType.Writer, + Type = PersonKind.Writer, Name = role.Staff.Name, Role = role.Name, ImageUrl = GetImagePath(role.Staff.Image), }, CreatorRoleType.SeriesComposer => new PersonInfo { - Type = PersonType.Composer, + Type = PersonKind.Composer, Name = role.Staff.Name, ImageUrl = GetImagePath(role.Staff.Image), }, CreatorRoleType.Seiyuu => new PersonInfo { - Type = PersonType.Actor, + Type = PersonKind.Actor, Name = role.Staff.Name, // The character will always be present if the role is a VA. // We make it a conditional check since otherwise will the compiler complain. diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index c9ff9716..af4da12f 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -128,7 +128,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) #region Media Folder Mapping - public IReadOnlyList<(string vfsPath, string mainMediaFolderPath, string? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList)> GetAvailableMediaFoldersForLibraries(Func<MediaFolderConfiguration, bool>? filter = null) + public IReadOnlyList<(string vfsPath, string mainMediaFolderPath, CollectionType? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList)> GetAvailableMediaFoldersForLibraries(Func<MediaFolderConfiguration, bool>? filter = null) { lock (LockObj) { @@ -144,12 +144,12 @@ 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!) ?? null, tuple.mediaList)) + .Select(tuple => (tuple.libraryFolder!.GetVirtualRoot(), tuple.virtualFolder!.Locations[0], LibraryManager.GetConfiguredContentType(tuple.libraryFolder!), tuple.mediaList)) .ToList(); } } - public (string vfsPath, string mainMediaFolderPath, string? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList) GetAvailableMediaFoldersForLibrary(Folder mediaFolder, Func<MediaFolderConfiguration, bool>? filter = null) + public (string vfsPath, string mainMediaFolderPath, CollectionType? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList) GetAvailableMediaFoldersForLibrary(Folder mediaFolder, Func<MediaFolderConfiguration, bool>? filter = null) { var mediaFolderConfig = GetOrCreateConfigurationForMediaFolder(mediaFolder); lock (LockObj) { diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index dad8168e..f2d46d62 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -10,7 +10,6 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; -using MediaBrowser.Common.Progress; using Shokofin.ExternalIds; namespace Shokofin.MergeVersions; @@ -20,7 +19,7 @@ namespace Shokofin.MergeVersions; /// single UI element (by linking the videos together and letting Jellyfin /// handle the rest). /// </summary> -/// +/// /// Based upon; /// https://github.com/danieladov/jellyfin-plugin-mergeversions public class MergeVersionsManager @@ -62,16 +61,14 @@ public async Task MergeAll(IProgress<double> progress, CancellationToken cancell double episodeProgressValue = 0d, movieProgressValue = 0d; // Setup the movie task. - var movieProgress = new ActionableProgress<double>(); - movieProgress.RegisterAction(value => { + var movieProgress = new Progress<double>(value => { movieProgressValue = value / 2d; progress?.Report(movieProgressValue + episodeProgressValue); }); var movieTask = MergeAllMovies(movieProgress, cancellationToken); // Setup the episode task. - var episodeProgress = new ActionableProgress<double>(); - episodeProgress.RegisterAction(value => { + var episodeProgress = new Progress<double>(value => { episodeProgressValue = value / 2d; progress?.Report(movieProgressValue + episodeProgressValue); }); @@ -94,16 +91,14 @@ public async Task SplitAll(IProgress<double> progress, CancellationToken cancell double episodeProgressValue = 0d, movieProgressValue = 0d; // Setup the movie task. - var movieProgress = new ActionableProgress<double>(); - movieProgress.RegisterAction(value => { + var movieProgress = new Progress<double>(value => { movieProgressValue = value / 2d; progress?.Report(movieProgressValue + episodeProgressValue); }); var movieTask = SplitAllMovies(movieProgress, cancellationToken); // Setup the episode task. - var episodeProgress = new ActionableProgress<double>(); - episodeProgress.RegisterAction(value => { + var episodeProgress = new Progress<double>(value => { episodeProgressValue = value / 2d; progress?.Report(movieProgressValue + episodeProgressValue); progress?.Report(50d + (value / 2d)); @@ -205,7 +200,7 @@ public async Task SplitAllMovies(IProgress<double> progress, CancellationToken c } /// <summary> - /// + /// /// </summary> /// <param name="progress">Progress indicator.</param> /// <param name="cancellationToken">Cancellation token.</param> diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index 16899b46..5ac91ec2 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -1,4 +1,5 @@ -using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Plugins; using Microsoft.Extensions.DependencyInjection; namespace Shokofin; @@ -7,7 +8,7 @@ namespace Shokofin; public class PluginServiceRegistrator : IPluginServiceRegistrator { /// <inheritdoc /> - public void RegisterServices(IServiceCollection serviceCollection) + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) { serviceCollection.AddSingleton<Utils.LibraryScanWatcher>(); serviceCollection.AddSingleton<API.ShokoAPIClient>(); diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index b2b6078b..b6dbe198 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -105,9 +105,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat public static void AddProviderIds(IHasProviderIds item, string seriesId, string? groupId = null, string? anidbId = null, string? tmdbId = null) { - // NOTE: These next line will remain here till _someone_ fix the series merging for providers other then TvDB and ImDB in Jellyfin. - // NOTE: #2 Will fix this once JF 10.9 is out, as it contains a change that will help in this situation. - item.SetProviderId(MetadataProvider.Imdb, $"INVALID-BUT-DO-NOT-TOUCH:{seriesId}"); + item.SetProviderId(MetadataProvider.Custom, $"shoko://shoko-series={seriesId}"); var config = Plugin.Instance.Configuration; item.SetProviderId(ShokoSeriesId.Name, seriesId); diff --git a/Shokofin/Resolvers/ShokoIgnoreRule.cs b/Shokofin/Resolvers/ShokoIgnoreRule.cs index d2cea26e..ffe2391e 100644 --- a/Shokofin/Resolvers/ShokoIgnoreRule.cs +++ b/Shokofin/Resolvers/ShokoIgnoreRule.cs @@ -3,10 +3,10 @@ using System.Linq; using System.Threading.Tasks; using Emby.Naming.Common; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Resolvers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using Shokofin.API; @@ -124,7 +124,7 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file } } - private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPath, string? collectionType, bool shouldIgnore) + private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPath, CollectionType? collectionType, bool shouldIgnore) { var season = await ApiManager.GetSeasonInfoByPath(fullPath).ConfigureAwait(false); @@ -157,13 +157,13 @@ private async Task<bool> ShouldFilterDirectory(string partialPath, string fullPa // Filter library if we enabled the option. var isMovieSeason = season.Type is SeriesType.Movie; switch (collectionType) { - case CollectionType.TvShows: + case CollectionType.tvshows: if (isMovieSeason && Plugin.Instance.Configuration.SeparateMovies) { Logger.LogInformation("Found movie in show library and library separation is enabled, ignoring shoko series. (Series={SeriesId},ExtraSeries={ExtraIds})", season.Id, season.ExtraIds); return true; } break; - case CollectionType.Movies: + case CollectionType.movies: if (!isMovieSeason) { Logger.LogInformation("Found show in movie library, ignoring shoko series. (Series={SeriesId},ExtraSeries={ExtraIds})", season.Id, season.ExtraIds); return true; diff --git a/Shokofin/Resolvers/ShokoLibraryMonitor.cs b/Shokofin/Resolvers/ShokoLibraryMonitor.cs index b7ec1224..e662252d 100644 --- a/Shokofin/Resolvers/ShokoLibraryMonitor.cs +++ b/Shokofin/Resolvers/ShokoLibraryMonitor.cs @@ -2,11 +2,12 @@ using System.Collections.Concurrent; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Emby.Naming.Common; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Configuration; @@ -22,7 +23,7 @@ namespace Shokofin.Resolvers; -public class ShokoLibraryMonitor : IServerEntryPoint, IDisposable +public class ShokoLibraryMonitor : IHostedService { private readonly ILogger<ShokoLibraryMonitor> Logger; @@ -86,16 +87,16 @@ NamingOptions namingOptions LibraryScanWatcher.ValueChanged -= OnLibraryScanRunningChanged; } - Task IServerEntryPoint.RunAsync() + Task IHostedService.StartAsync(CancellationToken cancellationToken) { StartWatching(); return Task.CompletedTask; } - void IDisposable.Dispose() + Task IHostedService.StopAsync(CancellationToken cancellationToken) { - GC.SuppressFinalize(this); StopWatching(); + return Task.CompletedTask; } public void StartWatching() diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs index 53de0c54..9c5fc7eb 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -5,11 +5,11 @@ using System.Linq; using System.Threading.Tasks; using Emby.Naming.Common; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Resolvers; -using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using Shokofin.API; @@ -58,9 +58,9 @@ NamingOptions namingOptions NamingOptions = namingOptions; } - public async Task<BaseItem?> ResolveSingle(Folder? parent, string? collectionType, FileSystemMetadata fileInfo) + public async Task<BaseItem?> ResolveSingle(Folder? parent, CollectionType? collectionType, FileSystemMetadata fileInfo) { - if (!(collectionType is CollectionType.TvShows or CollectionType.Movies or null) || parent is null || fileInfo is null) + if (!(collectionType is CollectionType.tvshows or CollectionType.movies or null) || parent is null || fileInfo is null) return null; var root = LibraryManager.RootFolder; @@ -105,9 +105,9 @@ NamingOptions namingOptions } } - public async Task<MultiItemResolverResult?> ResolveMultiple(Folder? parent, string? collectionType, List<FileSystemMetadata> fileInfoList) + public async Task<MultiItemResolverResult?> ResolveMultiple(Folder? parent, CollectionType? collectionType, List<FileSystemMetadata> fileInfoList) { - if (!(collectionType is CollectionType.TvShows or CollectionType.Movies or null) || parent is null) + if (!(collectionType is CollectionType.tvshows or CollectionType.movies or null) || parent is null) return null; var root = LibraryManager.RootFolder; @@ -129,7 +129,7 @@ NamingOptions namingOptions // Redirect children of a VFS managed media folder to the VFS. if (parent.IsTopParent) { - var createMovies = collectionType is CollectionType.Movies || (collectionType is null && Plugin.Instance.Configuration.SeparateMovies); + var createMovies = collectionType is CollectionType.movies || (collectionType is null && Plugin.Instance.Configuration.SeparateMovies); var pathsToRemoveBag = new ConcurrentBag<(string, bool)>(); var items = FileSystem.GetDirectories(vfsPath) .AsParallel() @@ -254,7 +254,7 @@ NamingOptions namingOptions #region IMultiItemResolver - MultiItemResolverResult? IMultiItemResolver.ResolveMultiple(Folder parent, List<FileSystemMetadata> files, string? collectionType, IDirectoryService directoryService) + MultiItemResolverResult? IMultiItemResolver.ResolveMultiple(Folder parent, List<FileSystemMetadata> files, CollectionType? collectionType, IDirectoryService directoryService) => ResolveMultiple(parent, collectionType, files) .ConfigureAwait(false) .GetAwaiter() diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index e4a50545..5b219403 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Emby.Naming.Common; using Emby.Naming.ExternalFiles; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; @@ -47,7 +48,7 @@ public class VirtualFileSystemService // Note: Out of the 14k entries in my test shoko database, then only **319** entries have a title longer than 100 characters. private const int NameCutOff = 64; - private static readonly HashSet<string> IgnoreFolderNames = new() { + private static readonly HashSet<string> IgnoreFolderNames = [ "backdrops", "behind the scenes", "deleted scenes", @@ -60,7 +61,7 @@ public class VirtualFileSystemService "other", "extras", "trailers", - }; + ]; public VirtualFileSystemService( ShokoAPIManager apiManager, @@ -635,7 +636,7 @@ private HashSet<string> GetPathsForMediaFolder(IReadOnlyList<MediaFolderConfigur return await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath, page).ConfigureAwait(false); } - private async Task<LinkGenerationResult> GenerateStructure(string? 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) { var result = new LinkGenerationResult(); var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VFS_Threads); @@ -667,33 +668,33 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { return result; } - public async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(string? collectionType, string vfsPath, string sourceLocation, string fileId, string seriesId) + public async Task<(string sourceLocation, 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, Array.Empty<string>(), null); + return (string.Empty, [], null); var isMovieSeason = season.Type is SeriesType.Movie; var config = Plugin.Instance.Configuration; var shouldAbort = collectionType switch { - CollectionType.TvShows => isMovieSeason && config.SeparateMovies, - CollectionType.Movies => !isMovieSeason, + CollectionType.tvshows => isMovieSeason && config.SeparateMovies, + CollectionType.movies => !isMovieSeason, _ => false, }; if (shouldAbort) - return (string.Empty, Array.Empty<string>(), null); + return (string.Empty, [], null); var show = await ApiManager.GetShowInfoForSeries(season.Id).ConfigureAwait(false); if (show is null) - return (string.Empty, Array.Empty<string>(), null); + return (string.Empty, [], null); var file = await ApiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false); - var (episode, episodeXref, _) = (file?.EpisodeList ?? new()).FirstOrDefault(); + var (episode, episodeXref, _) = (file?.EpisodeList ?? []).FirstOrDefault(); if (file is null || episode is null) - return (string.Empty, Array.Empty<string>(), null); + return (string.Empty, [], null); if (season is null || episode is null) - return (string.Empty, Array.Empty<string>(), null); + return (string.Empty, [], null); var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters() ?? $"Shoko Series {show.Id}"; var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); @@ -709,29 +710,29 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { var folders = new List<string>(); var extrasFolders = file.ExtraType switch { null => isExtra ? new string[] { "extras" } : null, - ExtraType.ThemeSong => new string[] { "theme-music" }, + ExtraType.ThemeSong => ["theme-music"], ExtraType.ThemeVideo => config.AddCreditsAsThemeVideos && config.AddCreditsAsSpecialFeatures - ? new string[] { "backdrops", "extras" } + ? ["backdrops", "extras"] : config.AddCreditsAsThemeVideos - ? new string[] { "backdrops" } + ? ["backdrops"] : config.AddCreditsAsSpecialFeatures - ? new string[] { "extras" } - : new string[] { }, + ? ["extras"] + : [], ExtraType.Trailer => config.AddTrailers - ? new string[] { "trailers" } - : new string[] { }, - ExtraType.BehindTheScenes => new string[] { "behind the scenes" }, - ExtraType.DeletedScene => new string[] { "deleted scenes" }, - ExtraType.Clip => new string[] { "clips" }, - ExtraType.Interview => new string[] { "interviews" }, - ExtraType.Scene => new string[] { "scenes" }, - ExtraType.Sample => new string[] { "samples" }, - _ => new string[] { "extras" }, + ? ["trailers"] + : [], + ExtraType.BehindTheScenes => ["behind the scenes"], + ExtraType.DeletedScene => ["deleted scenes"], + ExtraType.Clip => ["clips"], + ExtraType.Interview => ["interviews"], + ExtraType.Scene => ["scenes"], + ExtraType.Sample => ["samples"], + _ => ["extras"], }; 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 (isMovieSeason && collectionType is not CollectionType.tvshows) { if (extrasFolders != null) { foreach (var extrasFolder in extrasFolders) foreach (var episodeInfo in season.EpisodeList) diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj index 2347a53c..e321cec3 100644 --- a/Shokofin/Shokofin.csproj +++ b/Shokofin/Shokofin.csproj @@ -1,15 +1,15 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>net6.0</TargetFramework> + <TargetFramework>net8.0</TargetFramework> <OutputType>Library</OutputType> - <SignalRVersion>6.0.28</SignalRVersion> + <SignalRVersion>8.0.3</SignalRVersion> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> - <PackageReference Include="Jellyfin.Controller" Version="10.8.0" /> + <PackageReference Include="Jellyfin.Controller" Version="10.9.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="$(SignalRVersion)" /> - <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> + <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" /> </ItemGroup> <Target Name="CopySignalRDLLsToOutputPath" AfterTargets="Build"> @@ -29,4 +29,4 @@ <EmbeddedResource Include="Configuration\configController.js" /> <EmbeddedResource Include="Configuration\configPage.html" /> </ItemGroup> -</Project> \ No newline at end of file +</Project> diff --git a/Shokofin/SignalR/SignalREntryPoint.cs b/Shokofin/SignalR/SignalREntryPoint.cs index da8862d3..9b4f9c22 100644 --- a/Shokofin/SignalR/SignalREntryPoint.cs +++ b/Shokofin/SignalR/SignalREntryPoint.cs @@ -1,24 +1,19 @@ -using System; +using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.Hosting; namespace Shokofin.SignalR; -public class SignalREntryPoint : IServerEntryPoint +public class SignalREntryPoint : IHostedService { private readonly SignalRConnectionManager ConnectionManager; public SignalREntryPoint(SignalRConnectionManager connectionManager) => ConnectionManager = connectionManager; - public void Dispose() - { - GC.SuppressFinalize(this); - ConnectionManager.StopAsync() - .GetAwaiter() - .GetResult(); - } + public Task StopAsync(CancellationToken cancellationToken) + => ConnectionManager.StopAsync(); - public Task RunAsync() + public Task StartAsync(CancellationToken cancellationToken) => ConnectionManager.RunAsync(); -} \ No newline at end of file +} diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index e566b74a..0fe70faf 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs index 70ab991b..caa668b2 100644 --- a/Shokofin/Tasks/PostScanTask.cs +++ b/Shokofin/Tasks/PostScanTask.cs @@ -1,7 +1,6 @@ using System; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Progress; using MediaBrowser.Controller.Library; using Shokofin.API; using Shokofin.Collections; @@ -28,8 +27,7 @@ public async Task Run(IProgress<double> progress, CancellationToken token) if (Plugin.Instance.Configuration.EXPERIMENTAL_AutoMergeVersions) { // Setup basic progress tracking var baseProgress = 0d; - var simpleProgress = new ActionableProgress<double>(); - simpleProgress.RegisterAction(value => progress.Report(baseProgress + (value / 2d))); + var simpleProgress = new Progress<double>(value => progress.Report(baseProgress + (value / 2d))); // Merge versions. await VersionsManager.MergeAll(simpleProgress, token); diff --git a/build.yaml b/build.yaml index 1fa90ea2..896ddd40 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.8.0.0" +targetAbi: "10.9.0.0" owner: "ShokoAnime" overview: "Manage your anime from Jellyfin using metadata from Shoko" description: > From e484934c7394e2cd7bbaf8034ba3401da3327774 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 20 Apr 2024 18:14:13 +0000 Subject: [PATCH 1092/1103] fix: enable import order for VFS again --- .../Resolvers/VirtualFileSystemService.cs | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 5b219403..4c99521d 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -786,10 +786,7 @@ file.Shoko.AniDBData is not null return (sourceLocation, symbolicLinks, (file.Shoko.ImportedAt ?? file.Shoko.CreatedAt).ToLocalTime()); } -// TODO: Remove this for 10.9 -#pragma warning disable IDE0060 public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, DateTime importedAt) -#pragma warning restore IDE0060 { try { var result = new LinkGenerationResult(); @@ -812,9 +809,8 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ if (!File.Exists(symbolicLink)) throw; } - // TODO: Uncomment this for 10.9 - // // Mock the creation date to fake the "date added" order in Jellyfin. - // File.SetCreationTime(symbolicLink, importedAt); + // Mock the creation date to fake the "date added" order in Jellyfin. + File.SetCreationTime(symbolicLink, importedAt); } else { var shouldFix = false; @@ -825,13 +821,12 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", symbolicLink, sourceLocation, nextTarget?.FullName); } - // TODO: Uncomment this for 10.9 - // var date = File.GetCreationTime(symbolicLink); - // if (date != importedAt) { - // shouldFix = true; - // - // Logger.LogWarning("Fixing broken symbolic link {Link} with incorrect date.", symbolicLink); - // } + var date = File.GetCreationTime(symbolicLink); + if (date != importedAt) { + shouldFix = true; + + 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); @@ -847,9 +842,8 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ if (!File.Exists(symbolicLink)) throw; } - // TODO: Uncomment this for 10.9 - // // Mock the creation date to fake the "date added" order in Jellyfin. - // File.SetCreationTime(symbolicLink, importedAt); + // Mock the creation date to fake the "date added" order in Jellyfin. + File.SetCreationTime(symbolicLink, importedAt); result.FixedVideos++; } else { From d2482b36fd58b49cea9d97a51b4e9c2043f17e0d Mon Sep 17 00:00:00 2001 From: Mark Cilia Vincenti <markciliavincenti@gmail.com> Date: Sun, 5 May 2024 20:15:25 +0200 Subject: [PATCH 1093/1103] fix: fix potential race condition + more - Fixed some potential race conditions in the semaphore locking by switching over to the `AsyncKeyedLock` to handle the locking. This should also reduces memory allocations as a side-effect of the change. --- Shokofin/Shokofin.csproj | 1 + Shokofin/Utils/GuardedMemoryCache.cs | 61 +++++----------------------- 2 files changed, 12 insertions(+), 50 deletions(-) diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj index e321cec3..88e66e47 100644 --- a/Shokofin/Shokofin.csproj +++ b/Shokofin/Shokofin.csproj @@ -7,6 +7,7 @@ </PropertyGroup> <ItemGroup> + <PackageReference Include="AsyncKeyedLock" Version="6.4.2" /> <PackageReference Include="Jellyfin.Controller" Version="10.9.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="$(SignalRVersion)" /> <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" /> diff --git a/Shokofin/Utils/GuardedMemoryCache.cs b/Shokofin/Utils/GuardedMemoryCache.cs index fe6e79bf..19feae5e 100644 --- a/Shokofin/Utils/GuardedMemoryCache.cs +++ b/Shokofin/Utils/GuardedMemoryCache.cs @@ -1,8 +1,7 @@ using System; -using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; -using System.Threading; using System.Threading.Tasks; +using AsyncKeyedLock; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -18,7 +17,9 @@ sealed class GuardedMemoryCache : IDisposable, IMemoryCache private IMemoryCache Cache; - private readonly ConcurrentDictionary<object, SemaphoreSlim> Semaphores = new(); + private static AsyncKeyedLockOptions AsyncKeyedLockOptions = new() { PoolSize = 20, PoolInitialFill = 1 }; + + private AsyncKeyedLocker<object> Semaphores = new(AsyncKeyedLockOptions); public GuardedMemoryCache(ILogger logger, MemoryCacheOptions options, MemoryCacheEntryOptions? cacheEntryOptions = null) { @@ -33,7 +34,8 @@ public void Clear() Logger.LogDebug("Clearing cache…"); var cache = Cache; Cache = new MemoryCache(CacheOptions); - Semaphores.Clear(); + Semaphores.Dispose(); + Semaphores = new AsyncKeyedLocker<object>(AsyncKeyedLockOptions); cache.Dispose(); } @@ -44,11 +46,7 @@ public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICac return value; } - var semaphore = GetSemaphore(key); - - semaphore.Wait(); - - try { + using (Semaphores.Lock(key)) { if (TryGetValue(key, out value)) { foundAction(value); return value; @@ -63,10 +61,6 @@ public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICac entry.Value = value; return value; } - finally { - RemoveSemaphore(key); - semaphore.Release(); - } } public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> foundAction, Func<ICacheEntry, Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) @@ -76,11 +70,7 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> found return value; } - var semaphore = GetSemaphore(key); - - await semaphore.WaitAsync(); - - try { + using (await Semaphores.LockAsync(key).ConfigureAwait(false)) { if (TryGetValue(key, out value)) { foundAction(value); return value; @@ -95,10 +85,6 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> found entry.Value = value; return value; } - finally { - RemoveSemaphore(key); - semaphore.Release(); - } } public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFactory, MemoryCacheEntryOptions? createOptions = null) @@ -106,11 +92,7 @@ public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFacto if (TryGetValue<TItem>(key, out var value)) return value; - var semaphore = GetSemaphore(key); - - semaphore.Wait(); - - try { + using (Semaphores.Lock(key)) { if (TryGetValue(key, out value)) return value; @@ -123,10 +105,6 @@ public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFacto entry.Value = value; return value; } - finally { - RemoveSemaphore(key); - semaphore.Release(); - } } public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) @@ -134,11 +112,7 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, T if (TryGetValue<TItem>(key, out var value)) return value; - var semaphore = GetSemaphore(key); - - await semaphore.WaitAsync(); - - try { + using (await Semaphores.LockAsync(key).ConfigureAwait(false)) { if (TryGetValue(key, out value)) return value; @@ -151,27 +125,14 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, T entry.Value = value; return value; } - finally { - RemoveSemaphore(key); - semaphore.Release(); - } } public void Dispose() { - foreach (var semaphore in Semaphores.Values) - semaphore.Release(); + Semaphores.Dispose(); Cache.Dispose(); } - SemaphoreSlim GetSemaphore(object key) - => Semaphores.GetOrAdd(key, _ => new SemaphoreSlim(1)); - - void RemoveSemaphore(object key) - { - Semaphores.TryRemove(key, out var _); - } - public ICacheEntry CreateEntry(object key) => Cache.CreateEntry(key); From 4ab621773f3f4d5f7b1e31216c94c4ccec94f520 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 6 May 2024 01:06:34 +0200 Subject: [PATCH 1094/1103] revert: "fix: expect the unexpected" This reverts commit 9efa117e23da1da1fe472e972ece4fe9e456317a. --- .../Resolvers/VirtualFileSystemService.cs | 40 +++---------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 4c99521d..053a3b8e 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -801,14 +801,7 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ if (!File.Exists(symbolicLink)) { result.CreatedVideos++; Logger.LogDebug("Linking {Link} → {LinkTarget}", symbolicLink, sourceLocation); - // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. - try { - File.CreateSymbolicLink(symbolicLink, sourceLocation); - } - catch { - if (!File.Exists(symbolicLink)) - throw; - } + File.CreateSymbolicLink(symbolicLink, sourceLocation); // Mock the creation date to fake the "date added" order in Jellyfin. File.SetCreationTime(symbolicLink, importedAt); } @@ -833,15 +826,8 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ shouldFix = true; } if (shouldFix) { - // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. - try { - File.Delete(symbolicLink); - File.CreateSymbolicLink(symbolicLink, sourceLocation); - } - catch { - if (!File.Exists(symbolicLink)) - throw; - } + 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++; @@ -861,14 +847,7 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ if (!File.Exists(subtitleLink)) { result.CreatedSubtitles++; Logger.LogDebug("Linking {Link} → {LinkTarget}", subtitleLink, subtitleSource); - // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. - try { - File.CreateSymbolicLink(subtitleLink, subtitleSource); - } - catch { - if (!File.Exists(subtitleLink)) - throw; - } + File.CreateSymbolicLink(subtitleLink, subtitleSource); } else { var shouldFix = false; @@ -885,15 +864,8 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ shouldFix = true; } if (shouldFix) { - // In case Jellyfin decided to run the resolver in parallel for whatever reason, then check again. - try { - File.Delete(subtitleLink); - File.CreateSymbolicLink(subtitleLink, subtitleSource); - } - catch { - if (!File.Exists(subtitleLink)) - throw; - } + File.Delete(subtitleLink); + File.CreateSymbolicLink(subtitleLink, subtitleSource); result.FixedSubtitles++; } else { From ce20d427fe6d1fa0c768597d221a9df4367376f1 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 May 2024 03:18:29 +0200 Subject: [PATCH 1095/1103] misc: update read-me --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fb086aa6..d04bc9fa 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,7 @@ compatible with what. | `1.x.x` | `10.7` | `4.1.0-4.1.2` | | `2.x.x` | `10.8` | `4.1.2` | | `3.x.x` | `10.8` | `4.2.0` | -| `unstable` | `10.8` | `4.2.2` | -| `N/A` | `10.9` | `N/A` | +| `unstable` | `10.9` | `4.2.2` | ### Official Repository @@ -91,7 +90,7 @@ compatible with what. $ dotnet publish -c Release Shokofin/Shokofin.csproj ``` 4. **Copy Built Files:** - - After building, go to the `bin/Release/net6.0/` directory. + - After building, go to the `bin/Release/net8.0/` directory. - Copy all `.dll` files to a folder named `Shoko`. - Place this `Shoko` folder in the `plugins` directory of your Jellyfin program data directory or inside the portable install directory. For help From a497634b4ab01c3d58ac6ede5a7288b2ac31960b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 May 2024 22:00:46 +0000 Subject: [PATCH 1096/1103] misc: tweak async keyed lock options --- Shokofin/Utils/GuardedMemoryCache.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/Utils/GuardedMemoryCache.cs b/Shokofin/Utils/GuardedMemoryCache.cs index 19feae5e..f0fb62a5 100644 --- a/Shokofin/Utils/GuardedMemoryCache.cs +++ b/Shokofin/Utils/GuardedMemoryCache.cs @@ -17,7 +17,7 @@ sealed class GuardedMemoryCache : IDisposable, IMemoryCache private IMemoryCache Cache; - private static AsyncKeyedLockOptions AsyncKeyedLockOptions = new() { PoolSize = 20, PoolInitialFill = 1 }; + private static readonly AsyncKeyedLockOptions AsyncKeyedLockOptions = new() { PoolSize = 50 }; private AsyncKeyedLocker<object> Semaphores = new(AsyncKeyedLockOptions); @@ -35,7 +35,7 @@ public void Clear() var cache = Cache; Cache = new MemoryCache(CacheOptions); Semaphores.Dispose(); - Semaphores = new AsyncKeyedLocker<object>(AsyncKeyedLockOptions); + Semaphores = new(AsyncKeyedLockOptions); cache.Dispose(); } @@ -145,6 +145,6 @@ public bool TryGetValue(object key, [NotNullWhen(true)] out object? value) public bool TryGetValue<TItem>(object key, [NotNullWhen(true)] out TItem? value) => Cache.TryGetValue(key, out value); - public TItem? Set<TItem>(object key, [NotNullIfNotNull("value")] TItem? value, MemoryCacheEntryOptions? createOptions = null) + 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 328b06dd0962d7895e3f22a5800286747c1846cf Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 12 May 2024 23:21:54 +0000 Subject: [PATCH 1097/1103] misc: use local time for time checks --- 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 053a3b8e..769b6b46 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -814,7 +814,7 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", symbolicLink, sourceLocation, nextTarget?.FullName); } - var date = File.GetCreationTime(symbolicLink); + var date = File.GetCreationTime(symbolicLink).ToLocalTime(); if (date != importedAt) { shouldFix = true; From 8a9ea318bd507635ba4482a8b95df270065b6ba7 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <gharshitmohan@ymail.com> Date: Mon, 13 May 2024 10:39:02 +0530 Subject: [PATCH 1098/1103] fix: fix SignalR auto-connect --- Shokofin/PluginServiceRegistrator.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index 5ac91ec2..8a5c0e92 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -21,6 +21,7 @@ public void RegisterServices(IServiceCollection serviceCollection, IServerApplic serviceCollection.AddSingleton<Resolvers.VirtualFileSystemService>(); serviceCollection.AddSingleton<Events.EventDispatchService>(); serviceCollection.AddSingleton<SignalR.SignalRConnectionManager>(); + serviceCollection.AddHostedService<SignalR.SignalREntryPoint>(); serviceCollection.AddControllers(options => options.Filters.Add<Web.ImageHostUrl>()); } } From a9becbe2a6a176849966ea5508177f3ff905964d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 20 May 2024 22:02:26 +0200 Subject: [PATCH 1099/1103] misc: replace get collections impl. with native impl. --- Shokofin/Collections/CollectionManager.cs | 58 +---------------------- 1 file changed, 2 insertions(+), 56 deletions(-) diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index 751cd008..018d3d54 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -4,38 +4,24 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; -using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; -using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.API.Info; using Shokofin.ExternalIds; using Shokofin.Utils; -using Directory = System.IO.Directory; -using Path = System.IO.Path; - namespace Shokofin.Collections; public class CollectionManager { - private readonly IApplicationPaths ApplicationPaths; - private readonly ILibraryManager LibraryManager; - private readonly IFileSystem FileSystem; - private readonly ICollectionManager Collection; - - private readonly ILocalizationManager LocalizationManager; private readonly ILogger<CollectionManager> Logger; @@ -46,62 +32,22 @@ public class CollectionManager private static int MinCollectionSize => Plugin.Instance.Configuration.CollectionMinSizeOfTwo ? 1 : 0; public CollectionManager( - IApplicationPaths applicationPaths, ILibraryManager libraryManager, - IFileSystem fileSystem, ICollectionManager collectionManager, - ILocalizationManager localizationManager, ILogger<CollectionManager> logger, IIdLookup lookup, ShokoAPIManager apiManager ) { - ApplicationPaths = applicationPaths; LibraryManager = libraryManager; - FileSystem = fileSystem; Collection = collectionManager; - LocalizationManager = localizationManager; Logger = logger; Lookup = lookup; ApiManager = apiManager; } - // TODO: Replace this temp. impl. with the native impl on 10.9 after the migration. - public async Task<Folder?> GetCollectionsFolder(bool createIfNeeded) - { - var path = Path.Combine(ApplicationPaths.DataPath, "collections"); - var collectionRoot = LibraryManager - .RootFolder - .Children - .OfType<Folder>() - .Where(i => FileSystem.AreEqual(path, i.Path) || FileSystem.ContainsSubPath(i.Path, path)) - .FirstOrDefault(); - if (collectionRoot is not null) - return collectionRoot; - - if (!createIfNeeded) - return null; - - Directory.CreateDirectory(path); - - var libraryOptions = new LibraryOptions { - PathInfos = new[] { new MediaPathInfo(path) }, - EnableRealtimeMonitor = false, - SaveLocalMetadata = true - }; - - var name = LocalizationManager.GetLocalizedString("Collections"); - - await LibraryManager.AddVirtualFolder(name, CollectionTypeOptions.BoxSets, libraryOptions, true) - .ConfigureAwait(false); - - return LibraryManager - .RootFolder - .Children - .OfType<Folder>() - .Where(i => FileSystem.AreEqual(path, i.Path) || FileSystem.ContainsSubPath(i.Path, path)) - .FirstOrDefault(); - } + public Task<Folder?> GetCollectionsFolder(bool createIfNeeded) + => Collection.GetCollectionsFolder(createIfNeeded); public async Task ReconstructCollections(IProgress<double> progress, CancellationToken cancellationToken) { From 3119d5b7b2dfe759aa5c4b64bd218ac492b6364c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 7 Jun 2024 21:00:39 +0200 Subject: [PATCH 1100/1103] fix: register shoko library monitor as a service --- Shokofin/PluginServiceRegistrator.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index 8a5c0e92..5cc3702d 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -22,6 +22,7 @@ public void RegisterServices(IServiceCollection serviceCollection, IServerApplic serviceCollection.AddSingleton<Events.EventDispatchService>(); serviceCollection.AddSingleton<SignalR.SignalRConnectionManager>(); serviceCollection.AddHostedService<SignalR.SignalREntryPoint>(); + serviceCollection.AddHostedService<Resolvers.ShokoLibraryMonitor>(); serviceCollection.AddControllers(options => options.Filters.Add<Web.ImageHostUrl>()); } } From a33699b7eca0670656773e335d25e48ae3b1c629 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 18 Jun 2024 22:54:08 +0200 Subject: [PATCH 1101/1103] fix: catch semaphore full exceptions in guarded memory cache --- Shokofin/Utils/GuardedMemoryCache.cs | 145 ++++++++++++++++++++------- 1 file changed, 108 insertions(+), 37 deletions(-) diff --git a/Shokofin/Utils/GuardedMemoryCache.cs b/Shokofin/Utils/GuardedMemoryCache.cs index f0fb62a5..6069d699 100644 --- a/Shokofin/Utils/GuardedMemoryCache.cs +++ b/Shokofin/Utils/GuardedMemoryCache.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; using Microsoft.Extensions.Caching.Memory; @@ -17,7 +18,7 @@ sealed class GuardedMemoryCache : IDisposable, IMemoryCache private IMemoryCache Cache; - private static readonly AsyncKeyedLockOptions AsyncKeyedLockOptions = new() { PoolSize = 50 }; + private static readonly AsyncKeyedLockOptions AsyncKeyedLockOptions = new() { MaxCount = 1, PoolSize = 50 }; private AsyncKeyedLocker<object> Semaphores = new(AsyncKeyedLockOptions); @@ -46,20 +47,38 @@ public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICac return value; } - using (Semaphores.Lock(key)) { + try { + using (Semaphores.Lock(key)) { + if (TryGetValue(key, out value)) { + foundAction(value); + return value; + } + + using var entry = Cache.CreateEntry(key); + createOptions ??= CacheEntryOptions; + if (createOptions != null) + entry.SetOptions(createOptions); + + value = createFactory(entry); + entry.Value = value; + return value; + } + } + catch (SemaphoreFullException) { + Logger.LogWarning("Got a semaphore full exception for key: {Key}", key); + + if (value is not null) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was assigned for key: {Key}", key); + return value; + } + if (TryGetValue(key, out value)) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was in the cache for key: {Key}", key); foundAction(value); return value; } - using var entry = Cache.CreateEntry(key); - createOptions ??= CacheEntryOptions; - if (createOptions != null) - entry.SetOptions(createOptions); - - value = createFactory(entry); - entry.Value = value; - return value; + throw; } } @@ -70,20 +89,38 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> found return value; } - using (await Semaphores.LockAsync(key).ConfigureAwait(false)) { + try { + using (await Semaphores.LockAsync(key).ConfigureAwait(false)) { + if (TryGetValue(key, out value)) { + foundAction(value); + return value; + } + + using var entry = Cache.CreateEntry(key); + createOptions ??= CacheEntryOptions; + if (createOptions != null) + entry.SetOptions(createOptions); + + value = await createFactory(entry).ConfigureAwait(false); + entry.Value = value; + return value; + } + } + catch (SemaphoreFullException) { + Logger.LogWarning("Got a semaphore full exception for key: {Key}", key); + + if (value is not null) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was assigned for key: {Key}", key); + return value; + } + if (TryGetValue(key, out value)) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was in the cache for key: {Key}", key); foundAction(value); return value; } - using var entry = Cache.CreateEntry(key); - createOptions ??= CacheEntryOptions; - if (createOptions != null) - entry.SetOptions(createOptions); - - value = await createFactory(entry).ConfigureAwait(false); - entry.Value = value; - return value; + throw; } } @@ -92,18 +129,35 @@ public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFacto if (TryGetValue<TItem>(key, out var value)) return value; - using (Semaphores.Lock(key)) { - if (TryGetValue(key, out value)) + try { + using (Semaphores.Lock(key)) { + if (TryGetValue(key, out value)) + return value; + + using var entry = Cache.CreateEntry(key); + createOptions ??= CacheEntryOptions; + if (createOptions != null) + entry.SetOptions(createOptions); + + value = createFactory(entry); + entry.Value = value; return value; + } + } + catch (SemaphoreFullException) { + Logger.LogWarning("Got a semaphore full exception for key: {Key}", key); - using var entry = Cache.CreateEntry(key); - createOptions ??= CacheEntryOptions; - if (createOptions != null) - entry.SetOptions(createOptions); + if (value is not null) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was assigned for key: {Key}", key); + return value; + } - value = createFactory(entry); - entry.Value = value; - return value; + if (TryGetValue(key, out value)) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was in the cache for key: {Key}", key); + return value; + } + + throw; } } @@ -112,18 +166,35 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, T if (TryGetValue<TItem>(key, out var value)) return value; - using (await Semaphores.LockAsync(key).ConfigureAwait(false)) { - if (TryGetValue(key, out value)) + try { + using (await Semaphores.LockAsync(key).ConfigureAwait(false)) { + if (TryGetValue(key, out value)) + return value; + + using var entry = Cache.CreateEntry(key); + createOptions ??= CacheEntryOptions; + if (createOptions != null) + entry.SetOptions(createOptions); + + value = await createFactory(entry).ConfigureAwait(false); + entry.Value = value; return value; + } + } + catch (SemaphoreFullException) { + Logger.LogWarning("Got a semaphore full exception for key: {Key}", key); - using var entry = Cache.CreateEntry(key); - createOptions ??= CacheEntryOptions; - if (createOptions != null) - entry.SetOptions(createOptions); + if (value is not null) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was assigned for key: {Key}", key); + return value; + } - value = await createFactory(entry).ConfigureAwait(false); - entry.Value = value; - return value; + if (TryGetValue(key, out value)) { + Logger.LogInformation("Recovered from the semaphore full exception because the value was in the cache for key: {Key}", key); + return value; + } + + throw; } } From 0aef15b820a27f1d43a4aeb7ec6a17437880c17b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 19 Jun 2024 17:22:38 +0200 Subject: [PATCH 1102/1103] cleanup: remove unused parameter in GMC methods --- Shokofin/API/ShokoAPIClient.cs | 2 +- Shokofin/API/ShokoAPIManager.cs | 20 +++++++++---------- Shokofin/Resolvers/ShokoLibraryMonitor.cs | 3 ++- .../Resolvers/VirtualFileSystemService.cs | 2 +- Shokofin/Utils/GuardedMemoryCache.cs | 16 +++++++-------- 5 files changed, 22 insertions(+), 21 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 522a15da..cebe3456 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -89,7 +89,7 @@ private async Task<ReturnType> Get<ReturnType>(string url, HttpMethod method, st return await _cache.GetOrCreateAsync( $"apiKey={apiKey ?? "default"},method={method},url={url},object", (_) => Logger.LogTrace("Reusing object for {Method} {URL}", method, url), - async (_) => { + async () => { Logger.LogTrace("Creating object for {Method} {URL}", method, url); var response = await Get(url, method, apiKey).ConfigureAwait(false); if (response.StatusCode != HttpStatusCode.OK) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index ff244ddb..e76fb083 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -153,7 +153,7 @@ public void Clear() public Task<IReadOnlyDictionary<string, ResolvedTag>> GetNamespacedTagsForSeries(string seriesId) => DataCache.GetOrCreateAsync( $"series-linked-tags:{seriesId}", - async (_) => { + async () => { var nextUserTagId = 1; var hasCustomTags = false; var rootTags = new List<Tag>(); @@ -362,7 +362,7 @@ public async Task<HashSet<string>> GetLocalEpisodeIdsForSeason(SeasonInfo season private Task<(HashSet<string>, HashSet<string>)> GetPathSetAndLocalEpisodeIdsForSeries(string seriesId) => DataCache.GetOrCreateAsync( $"series-path-set-and-episode-ids:${seriesId}", - async (_) => { + async () => { var pathSet = new HashSet<string>(); var episodeIds = new HashSet<string>(); foreach (var file in await APIClient.GetFilesForSeries(seriesId).ConfigureAwait(false)) { @@ -521,7 +521,7 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu private Task<FileInfo> CreateFileInfo(File file, string fileId, string seriesId) => DataCache.GetOrCreateAsync( $"file:{fileId}:{seriesId}", - async (_) => { + async () => { Logger.LogTrace("Creating info object for file. (File={FileId},Series={SeriesId})", fileId, seriesId); // Find the cross-references for the selected series. @@ -587,7 +587,7 @@ public bool TryGetFileIdForPath(string path, out string? fileId) private EpisodeInfo CreateEpisodeInfo(Episode episode, string episodeId) => DataCache.GetOrCreate( $"episode:{episodeId}", - (cachedEntry) => { + () => { Logger.LogTrace("Creating info object for episode {EpisodeName}. (Episode={EpisodeId})", episode.Name, episodeId); return new EpisodeInfo(episode); @@ -688,7 +688,7 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) return await DataCache.GetOrCreateAsync( $"season:{seriesId}", (seasonInfo) => Logger.LogTrace("Reusing info object for season {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seriesId), - async (cachedEntry) => { + async () => { // We updated the "primary" series id for the merge group, so fetch the new series details from the client cache. if (!string.Equals(series.IDs.Shoko.ToString(), seriesId, StringComparison.Ordinal)) series = await APIClient.GetSeries(seriesId); @@ -763,7 +763,7 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) private Task<(DateTime?, DateTime?)> GetEarliestImportedAtForSeries(string seriesId) => DataCache.GetOrCreateAsync<(DateTime?, DateTime?)>( $"series-earliest-imported-at:${seriesId}", - async (_) => { + async () => { var files = await APIClient.GetFilesForSeries(seriesId).ConfigureAwait(false); if (!files.Any(f => f.ImportedAt.HasValue)) return (null, null); @@ -798,7 +798,7 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) Logger.LogTrace("Reusing existing series-to-season mapping for series. (Series={SeriesId},ExtraSeries={ExtraIds})", tuple.primaryId, tuple.extraIds); }, - async (cacheEntry) => { + async () => { var primaryId = series.IDs.Shoko.ToString(); var extraIds = new List<string>(); var config = Plugin.Instance.Configuration; @@ -1051,7 +1051,7 @@ public bool TryGetDefaultSeriesIdForSeriesId(string seriesId, [NotNullWhen(true) => DataCache.GetOrCreateAsync( $"show:by-group-id:{groupId}", (showInfo) => Logger.LogTrace("Reusing info object for show {GroupName}. (Group={GroupId})", showInfo?.Name, groupId), - async (cachedEntry) => { + async () => { Logger.LogTrace("Creating info object for show {GroupName}. (Group={GroupId})", group.Name, groupId); var seriesInGroup = await APIClient.GetSeriesInGroup(groupId).ConfigureAwait(false); @@ -1088,7 +1088,7 @@ private ShowInfo GetOrCreateShowInfoForSeasonInfo(SeasonInfo seasonInfo, string? => DataCache.GetOrCreate( $"show:by-series-id:{seasonInfo.Id}", (showInfo) => Logger.LogTrace("Reusing info object for show {GroupName}. (Series={SeriesId})", showInfo.Name, seasonInfo.Id), - (cachedEntry) => { + () => { Logger.LogTrace("Creating info object for show {SeriesName}. (Series={SeriesId})", seasonInfo.Shoko.Name, seasonInfo.Id); var showInfo = new ShowInfo(seasonInfo, collectionId); @@ -1139,7 +1139,7 @@ private Task<CollectionInfo> CreateCollectionInfo(Group group, string groupId) => DataCache.GetOrCreateAsync( $"collection:by-group-id:{groupId}", (collectionInfo) => Logger.LogTrace("Reusing info object for collection {GroupName}. (Group={GroupId})", collectionInfo.Name, groupId), - async (cachedEntry) => { + async () => { Logger.LogTrace("Creating info object for collection {GroupName}. (Group={GroupId})", group.Name, groupId); Logger.LogTrace("Fetching show info objects for collection {GroupName}. (Group={GroupId})", group.Name, groupId); diff --git a/Shokofin/Resolvers/ShokoLibraryMonitor.cs b/Shokofin/Resolvers/ShokoLibraryMonitor.cs index e662252d..1aedf429 100644 --- a/Shokofin/Resolvers/ShokoLibraryMonitor.cs +++ b/Shokofin/Resolvers/ShokoLibraryMonitor.cs @@ -267,10 +267,11 @@ public async Task ReportFileSystemChanged(MediaFolderConfiguration mediaConfig, return; } + // Using a "cache" here is more to ensure we only run for the same path once in a given time span. await Cache.GetOrCreateAsync( path, (_) => Logger.LogTrace("Skipped path because it was handled within a second ago; {Path}", path), - async (_) => { + async () => { string? fileId = null; IFileEventArgs eventArgs; var reason = changeTypes is WatcherChangeTypes.Deleted ? UpdateReason.Removed : changeTypes is WatcherChangeTypes.Created ? UpdateReason.Added : UpdateReason.Updated; diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 769b6b46..16f23060 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -161,7 +161,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) var key = mediaConfigs.Any(config => path.StartsWith(config.MediaFolderPath)) ? $"should-skip-vfs-path:{vfsPath}" : $"should-skip-vfs-path:{path}"; - shouldReturnPath = await DataCache.GetOrCreateAsync<bool>(key, async (__) => { + shouldReturnPath = await DataCache.GetOrCreateAsync<bool>(key, async () => { // Iterate the files already in the VFS. string? pathToClean = null; IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; diff --git a/Shokofin/Utils/GuardedMemoryCache.cs b/Shokofin/Utils/GuardedMemoryCache.cs index 6069d699..b6d00352 100644 --- a/Shokofin/Utils/GuardedMemoryCache.cs +++ b/Shokofin/Utils/GuardedMemoryCache.cs @@ -40,7 +40,7 @@ public void Clear() cache.Dispose(); } - public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICacheEntry, TItem> createFactory, MemoryCacheEntryOptions? createOptions = null) + public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<TItem> createFactory, MemoryCacheEntryOptions? createOptions = null) { if (TryGetValue<TItem>(key, out var value)) { foundAction(value); @@ -59,7 +59,7 @@ public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICac if (createOptions != null) entry.SetOptions(createOptions); - value = createFactory(entry); + value = createFactory(); entry.Value = value; return value; } @@ -82,7 +82,7 @@ public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<ICac } } - public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> foundAction, Func<ICacheEntry, Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) + public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> foundAction, Func<Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) { if (TryGetValue<TItem>(key, out var value)) { foundAction(value); @@ -101,7 +101,7 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> found if (createOptions != null) entry.SetOptions(createOptions); - value = await createFactory(entry).ConfigureAwait(false); + value = await createFactory().ConfigureAwait(false); entry.Value = value; return value; } @@ -124,7 +124,7 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> found } } - public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFactory, MemoryCacheEntryOptions? createOptions = null) + public TItem GetOrCreate<TItem>(object key, Func<TItem> createFactory, MemoryCacheEntryOptions? createOptions = null) { if (TryGetValue<TItem>(key, out var value)) return value; @@ -139,7 +139,7 @@ public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFacto if (createOptions != null) entry.SetOptions(createOptions); - value = createFactory(entry); + value = createFactory(); entry.Value = value; return value; } @@ -161,7 +161,7 @@ public TItem GetOrCreate<TItem>(object key, Func<ICacheEntry, TItem> createFacto } } - public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) + public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) { if (TryGetValue<TItem>(key, out var value)) return value; @@ -176,7 +176,7 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<ICacheEntry, T if (createOptions != null) entry.SetOptions(createOptions); - value = await createFactory(entry).ConfigureAwait(false); + value = await createFactory().ConfigureAwait(false); entry.Value = value; return value; } From 5afe58a7af81b3c32755e56d8263fe125dc49791 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 4 Jul 2024 14:43:17 +0200 Subject: [PATCH 1103/1103] fix: update release.yml [skip ci] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a8ac7eb..62272267 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: - name: Setup .Net uses: actions/setup-dotnet@v1 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Restore Nuget Packages run: dotnet restore Shokofin/Shokofin.csproj