diff --git a/.gitignore b/.gitignore index bd8aba6..65cc20e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ artifacts **/.DS_Store metashark/ manifest_cn.json -manifest.json \ No newline at end of file +manifest.json +.vscode diff --git a/Jellyfin.Plugin.MetaShark.Test/DoubanApiTest.cs b/Jellyfin.Plugin.MetaShark.Test/DoubanApiTest.cs index 0f616c1..fdba052 100644 --- a/Jellyfin.Plugin.MetaShark.Test/DoubanApiTest.cs +++ b/Jellyfin.Plugin.MetaShark.Test/DoubanApiTest.cs @@ -59,6 +59,28 @@ namespace Jellyfin.Plugin.MetaShark.Test } + [TestMethod] + public void TestSearchBySuggest() + { + var keyword = "重返少年时"; + var api = new DoubanApi(loggerFactory); + + Task.Run(async () => + { + try + { + var result = await api.SearchBySuggestAsync(keyword, CancellationToken.None); + var str = result.ToJson(); + TestContext.WriteLine(result.ToJson()); + } + catch (Exception ex) + { + TestContext.WriteLine(ex.Message); + } + }).GetAwaiter().GetResult(); + } + + [TestMethod] public void TestGetVideoBySidAsync() { diff --git a/Jellyfin.Plugin.MetaShark.Test/SeriesProviderTest.cs b/Jellyfin.Plugin.MetaShark.Test/SeriesProviderTest.cs index ebdcc21..07f3398 100644 --- a/Jellyfin.Plugin.MetaShark.Test/SeriesProviderTest.cs +++ b/Jellyfin.Plugin.MetaShark.Test/SeriesProviderTest.cs @@ -50,5 +50,29 @@ namespace Jellyfin.Plugin.MetaShark.Test }).GetAwaiter().GetResult(); } + [TestMethod] + public void TestGetAnimeMetadata() + { + var info = new SeriesInfo() { Name = "命运-冠位嘉年华" }; + var doubanApi = new DoubanApi(loggerFactory); + var tmdbApi = new TmdbApi(loggerFactory); + var omdbApi = new OmdbApi(loggerFactory); + var httpClientFactory = new DefaultHttpClientFactory(); + var libraryManagerStub = new Mock(); + var httpContextAccessorStub = new Mock(); + + Task.Run(async () => + { + var provider = new SeriesProvider(httpClientFactory, loggerFactory, libraryManagerStub.Object, httpContextAccessorStub.Object, doubanApi, tmdbApi, omdbApi); + var result = await provider.GetMetadata(info, CancellationToken.None); + Assert.AreEqual(result.Item.Name, "命运/冠位指定嘉年华 公元2020奥林匹亚英灵限界大祭"); + Assert.AreEqual(result.Item.OriginalTitle, "Fate/Grand Carnival"); + Assert.IsNotNull(result); + + var str = result.ToJson(); + Console.WriteLine(result.ToJson()); + }).GetAwaiter().GetResult(); + } + } } diff --git a/Jellyfin.Plugin.MetaShark/Api/DoubanApi.cs b/Jellyfin.Plugin.MetaShark/Api/DoubanApi.cs index 3d82683..d3d3d5a 100644 --- a/Jellyfin.Plugin.MetaShark/Api/DoubanApi.cs +++ b/Jellyfin.Plugin.MetaShark/Api/DoubanApi.cs @@ -31,6 +31,8 @@ using Jellyfin.Plugin.MetaShark.Core; using System.Data; using TMDbLib.Objects.Movies; using System.Xml.Linq; +using RateLimiter; +using ComposableAsync; namespace Jellyfin.Plugin.MetaShark.Api { @@ -50,6 +52,8 @@ namespace Jellyfin.Plugin.MetaShark.Api Regex regSid = new Regex(@"sid: (\d+?),", RegexOptions.Compiled); Regex regCat = new Regex(@"\[(.+?)\]", RegexOptions.Compiled); Regex regYear = new Regex(@"(\d{4})", RegexOptions.Compiled); + Regex regTitle = new Regex(@"([\w\W]+?)", RegexOptions.Compiled); + Regex regKeywordMeta = new Regex(@" /// Initializes a new instance of the class. /// @@ -86,9 +98,9 @@ namespace Jellyfin.Plugin.MetaShark.Api var handler = new HttpClientHandlerEx(); this._cookieContainer = handler.CookieContainer; - httpClient = new HttpClient(handler, true); + httpClient = new HttpClient(handler); httpClient.Timeout = TimeSpan.FromSeconds(10); - httpClient.DefaultRequestHeaders.Add("user-agent", HTTP_USER_AGENT); + httpClient.DefaultRequestHeaders.Add("User-Agent", HTTP_USER_AGENT); httpClient.DefaultRequestHeaders.Add("Origin", "https://movie.douban.com"); httpClient.DefaultRequestHeaders.Add("Referer", "https://movie.douban.com/"); } @@ -162,7 +174,7 @@ namespace Jellyfin.Plugin.MetaShark.Api EnsureLoadDoubanCookie(); - // LimitRequestFrequently(2000); + await LimitRequestFrequently(); var encodedKeyword = HttpUtility.UrlEncode(keyword); var url = $"https://www.douban.com/search?cat=1002&q={encodedKeyword}"; @@ -220,6 +232,56 @@ namespace Jellyfin.Plugin.MetaShark.Api return list; } + public async Task> SearchBySuggestAsync(string keyword, CancellationToken cancellationToken) + { + var list = new List(); + if (string.IsNullOrEmpty(keyword)) + { + return list; + } + + EnsureLoadDoubanCookie(); + await LimitRequestFrequently(); + + var encodedKeyword = HttpUtility.UrlEncode(keyword); + var url = $"https://www.douban.com/j/search_suggest?q={encodedKeyword}"; + + using (var requestMessage = new HttpRequestMessage(HttpMethod.Get, url)) + { + requestMessage.Headers.Add("Origin", "https://www.douban.com"); + requestMessage.Headers.Add("Referer", "https://www.douban.com/"); + + var response = await httpClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + this._logger.LogWarning("douban suggest请求失败. keyword: {0} statusCode: {1}", keyword, response.StatusCode); + return list; + } + + JsonSerializerOptions? serializeOptions = null; + var result = await response.Content.ReadFromJsonAsync(serializeOptions, cancellationToken).ConfigureAwait(false); + + if (result != null && result.Cards != null) + { + foreach (var suggest in result.Cards) + { + if (suggest.Type != "movie") + { + continue; + } + + var movie = new DoubanSubject(); + movie.Sid = suggest.Sid; + movie.Name = suggest.Title; + movie.Year = suggest.Year.ToInt(); + list.Add(movie); + } + } + } + + return list; + } + public async Task GetMovieAsync(string sid, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(sid)) @@ -236,7 +298,7 @@ namespace Jellyfin.Plugin.MetaShark.Api } EnsureLoadDoubanCookie(); - // LimitRequestFrequently(); + await LimitRequestFrequently(); var url = $"https://movie.douban.com/subject/{sid}/"; var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); @@ -253,14 +315,8 @@ namespace Jellyfin.Plugin.MetaShark.Api if (contentNode != null) { var nameStr = contentNode.GetText("h1>span:first-child") ?? string.Empty; - var match = this.regNameMath.Match(nameStr); - var name = string.Empty; - var orginalName = string.Empty; - if (match.Success && match.Groups.Count == 3) - { - name = match.Groups[1].Value; - orginalName = match.Groups[2].Value; - } + var name = GetTitle(body); + var orginalName = nameStr.Replace(name, "").Trim(); var yearStr = contentNode.GetText("h1>span.year") ?? string.Empty; var year = yearStr.GetMatchGroup(this.regYear); var rating = contentNode.GetText("div.rating_self strong.rating_num") ?? "0"; @@ -347,7 +403,7 @@ namespace Jellyfin.Plugin.MetaShark.Api } EnsureLoadDoubanCookie(); - // LimitRequestFrequently(); + await LimitRequestFrequently(); var list = new List(); var url = $"https://movie.douban.com/subject/{sid}/celebrities"; @@ -493,7 +549,6 @@ namespace Jellyfin.Plugin.MetaShark.Api EnsureLoadDoubanCookie(); - // LimitRequestFrequently(); keyword = HttpUtility.UrlEncode(keyword); @@ -547,7 +602,7 @@ namespace Jellyfin.Plugin.MetaShark.Api } EnsureLoadDoubanCookie(); - // LimitRequestFrequently(); + await LimitRequestFrequently(); var list = new List(); var url = $"https://movie.douban.com/subject/{sid}/photos?type=W&start=0&sortby=size&size=a&subtype=a"; @@ -598,24 +653,105 @@ namespace Jellyfin.Plugin.MetaShark.Api return list; } - - protected void LimitRequestFrequently(int interval = 1000) + public async Task CheckLoginAsync(CancellationToken cancellationToken) { - var diff = 0; - lock (_lock) - { - var ts = DateTime.Now - lastRequestTime; - diff = (int)(interval - ts.TotalMilliseconds); - if (diff > 0) - { - this._logger.LogInformation("请求太频繁,等待{0}毫秒后继续执行...", diff); - Thread.Sleep(diff); - } + EnsureLoadDoubanCookie(); - lastRequestTime = DateTime.Now; + var url = "https://www.douban.com/mine/"; + var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + var requestUrl = response.RequestMessage?.RequestUri?.ToString(); + if (requestUrl == null || requestUrl.Contains("login") || requestUrl.Contains("sec.douban.com")) + { + return false; } + + return true; } + protected async Task LimitRequestFrequently() + { + if (IsEnableAvoidRiskControl()) + { + var configCookie = Plugin.Instance?.Configuration.DoubanCookies.Trim() ?? string.Empty; + if (!string.IsNullOrEmpty(configCookie)) + { + await this._loginedTimeConstraint; + } + else + { + await this._guestTimeConstraint; + } + } + else + { + await this._defaultTimeConstraint; + } + + // var diff = 0; + // Double interval = 0.0; + // if (IsEnableAvoidRiskControl()) + // { + // var configCookie = Plugin.Instance?.Configuration.DoubanCookies.Trim() ?? string.Empty; + // if (string.IsNullOrEmpty(configCookie)) + // { + // interval = 3000; + // } + // else + // { + // interval = 6000; + // } + // // // 启用防止封禁 + // // this._logger.LogWarning("thread开始等待." + Thread.CurrentThread.ManagedThreadId); + // // await this._limitTimeConstraint; + // // this._logger.LogWarning("thread等待结束." + Thread.CurrentThread.ManagedThreadId); + // } + // else + // { + // interval = 1000; + // // 默认限制 + // // await this._defaultTimeConstraint; + // } + + // this._logger.LogWarning("thread进入." + Thread.CurrentThread.ManagedThreadId); + // lock (_lock) + // { + // this._logger.LogWarning("thread开始等待." + Thread.CurrentThread.ManagedThreadId); + + // lastRequestTime = lastRequestTime.AddMilliseconds(interval); + // diff = (int)(lastRequestTime - DateTime.Now).TotalMilliseconds; + // if (diff <= 0) + // { + // lastRequestTime = DateTime.Now; + // } + // } + + // if (diff > 0) + // { + // this._logger.LogInformation("请求太频繁,等待{0}毫秒后继续执行..." + Thread.CurrentThread.ManagedThreadId, diff); + // // Thread.Sleep(diff); + // await Task.Delay(diff); + // } + + // this._logger.LogWarning("thread等待结束." + Thread.CurrentThread.ManagedThreadId); + } + + private string GetTitle(string body) + { + var title = string.Empty; + + var keyword = Match(body, regKeywordMeta); + if (!string.IsNullOrEmpty(keyword)) + { + title = keyword.Split(",").FirstOrDefault(); + if (!string.IsNullOrEmpty(title)) + { + return title.Trim(); + } + } + + title = Match(body, regTitle); + return title.Replace("(豆瓣)", "").Trim(); + } private string? GetText(IElement el, string css) { @@ -650,6 +786,11 @@ namespace Jellyfin.Plugin.MetaShark.Api return string.Empty; } + private bool IsEnableAvoidRiskControl() + { + return Plugin.Instance?.Configuration.EnableDoubanAvoidRiskControl ?? false; + } + public void Dispose() { @@ -661,6 +802,7 @@ namespace Jellyfin.Plugin.MetaShark.Api { if (disposing) { + httpClient.Dispose(); _memoryCache.Dispose(); } } diff --git a/Jellyfin.Plugin.MetaShark/Api/Http/LoggingHandler.cs b/Jellyfin.Plugin.MetaShark/Api/Http/LoggingHandler.cs new file mode 100644 index 0000000..d35ea98 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Api/Http/LoggingHandler.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.MetaShark.Api.Http +{ + public class LoggingHandler : DelegatingHandler + { + private readonly ILogger _logger; + public LoggingHandler(HttpMessageHandler innerHandler, ILoggerFactory loggerFactory) + : base(innerHandler) + { + _logger = loggerFactory.CreateLogger(); + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _logger.LogInformation((request.RequestUri?.ToString() ?? string.Empty)); + + return await base.SendAsync(request, cancellationToken); + } + } + +} \ No newline at end of file diff --git a/Jellyfin.Plugin.MetaShark/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.MetaShark/Configuration/PluginConfiguration.cs index 40e8b35..983a65c 100644 --- a/Jellyfin.Plugin.MetaShark/Configuration/PluginConfiguration.cs +++ b/Jellyfin.Plugin.MetaShark/Configuration/PluginConfiguration.cs @@ -25,7 +25,12 @@ public enum SomeOptions /// public class PluginConfiguration : BasePluginConfiguration { - public string Version { get; } = Assembly.GetExecutingAssembly().GetName().Version.ToString(); + public string Version { get; } = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? string.Empty; + + public string DoubanCookies { get; set; } = string.Empty; + + public bool EnableDoubanAvoidRiskControl { get; set; } = false; + public bool EnableTmdb { get; set; } = true; public bool EnableTmdbSearch { get; set; } = false; @@ -34,7 +39,7 @@ public class PluginConfiguration : BasePluginConfiguration public string TmdbHost { get; set; } = string.Empty; - public string DoubanCookies { get; set; } = string.Empty; + public int MaxCastMembers { get; set; } = 15; diff --git a/Jellyfin.Plugin.MetaShark/Configuration/configPage.html b/Jellyfin.Plugin.MetaShark/Configuration/configPage.html index 4378b0e..064e6c8 100644 --- a/Jellyfin.Plugin.MetaShark/Configuration/configPage.html +++ b/Jellyfin.Plugin.MetaShark/Configuration/configPage.html @@ -22,12 +22,33 @@
-
- - -
可为空,填写可搜索到需登录访问的影片,使用分号“;”分隔格式cookie.
-
+
+ +

豆瓣

+
+
+ + +
可为空,填写可搜索到需登录访问的影片,使用(www.douban.com)分号“;”分隔格式cookie.
+
+
+ +
勾选后,刮削会变慢,适合刮削大量影片时使用,建议搭配网站cookie一起使用
+
+
@@ -80,10 +101,14 @@ $('#current_version').text("v" + config.Version); document.querySelector('#DoubanCookies').value = config.DoubanCookies; + document.querySelector('#EnableDoubanAvoidRiskControl').checked = config.EnableDoubanAvoidRiskControl; document.querySelector('#EnableTmdb').checked = config.EnableTmdb; document.querySelector('#EnableTmdbSearch').checked = config.EnableTmdbSearch; document.querySelector('#TmdbApiKey').value = config.TmdbApiKey; document.querySelector('#TmdbHost').value = config.TmdbHost; + + checkDoubanLogin(); + Dashboard.hideLoadingMsg(); }); }); @@ -93,18 +118,38 @@ Dashboard.showLoadingMsg(); ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) { config.DoubanCookies = document.querySelector('#DoubanCookies').value; + config.EnableDoubanAvoidRiskControl = document.querySelector('#EnableDoubanAvoidRiskControl').checked; config.EnableTmdb = document.querySelector('#EnableTmdb').checked; config.EnableTmdbSearch = document.querySelector('#EnableTmdbSearch').checked; config.TmdbApiKey = document.querySelector('#TmdbApiKey').value; config.TmdbHost = document.querySelector('#TmdbHost').value; ApiClient.updatePluginConfiguration(TemplateConfig.pluginUniqueId, config).then(function (result) { Dashboard.processPluginConfigurationUpdateResult(result); + + checkDoubanLogin(); }); }); e.preventDefault(); return false; }); + + function checkDoubanLogin() { + let cookie = document.querySelector('#DoubanCookies').value + if (!cookie || !$.trim(cookie)) { + $('#login_invalid').hide(); + return; + } + + + $.getJSON("/plugin/metashark/douban/checklogin", function (resp) { + if (resp && resp.code != 1) { + $('#login_invalid').show(); + } else { + $('#login_invalid').hide(); + } + }) + } diff --git a/Jellyfin.Plugin.MetaShark/Controllers/MetaBotController.cs b/Jellyfin.Plugin.MetaShark/Controllers/MetaBotController.cs index 0b5c4ce..2f6b105 100644 --- a/Jellyfin.Plugin.MetaShark/Controllers/MetaBotController.cs +++ b/Jellyfin.Plugin.MetaShark/Controllers/MetaBotController.cs @@ -1,3 +1,4 @@ +using System.Threading; using System; using System.Collections.Generic; using System.Linq; @@ -19,6 +20,8 @@ using System.Runtime.InteropServices; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Common.Net; +using Jellyfin.Plugin.MetaShark.Api; +using Jellyfin.Plugin.MetaShark.Model; namespace Jellyfin.Plugin.MetaShark.Controllers { @@ -27,15 +30,17 @@ namespace Jellyfin.Plugin.MetaShark.Controllers [Route("/plugin/metashark")] public class MetaSharkController : ControllerBase { + private readonly DoubanApi _doubanApi; private readonly IHttpClientFactory _httpClientFactory; /// /// Initializes a new instance of the class. /// /// The . - public MetaSharkController(IHttpClientFactory httpClientFactory) + public MetaSharkController(IHttpClientFactory httpClientFactory, DoubanApi doubanApi) { - _httpClientFactory = httpClientFactory; + this._httpClientFactory = httpClientFactory; + this._doubanApi = doubanApi; } @@ -71,6 +76,17 @@ namespace Jellyfin.Plugin.MetaShark.Controllers return stream; } + /// + /// 代理访问图片. + /// + [Route("douban/checklogin")] + [HttpGet] + public async Task CheckDoubanLogin() + { + var isLogin = await _doubanApi.CheckLoginAsync(CancellationToken.None); + return new ApiResult(isLogin ? 1 : 0, isLogin ? "logined" : "not login"); + } + private HttpClient GetHttpClient() { var client = _httpClientFactory.CreateClient(NamedClient.Default); diff --git a/Jellyfin.Plugin.MetaShark/Core/NameParser.cs b/Jellyfin.Plugin.MetaShark/Core/NameParser.cs index 3e91ebb..c818e26 100644 --- a/Jellyfin.Plugin.MetaShark/Core/NameParser.cs +++ b/Jellyfin.Plugin.MetaShark/Core/NameParser.cs @@ -75,7 +75,7 @@ namespace Jellyfin.Plugin.MetaShark.Core } // 假如Anitomy解析不到year,尝试使用jellyfin默认parser,看能不能解析成功 - if (parseResult.Year == null) + if (parseResult.Year == null && !IsAnime(fileName)) { var nativeParseResult = ParseMovie(fileName); if (nativeParseResult.Year != null) @@ -165,6 +165,11 @@ namespace Jellyfin.Plugin.MetaShark.Core return true; } + if (Regex.Match(name, @"\[.+\].*?\[.+?\]", RegexOptions.IgnoreCase).Success) + { + return true; + } + return false; } } diff --git a/Jellyfin.Plugin.MetaShark/Jellyfin.Plugin.MetaShark.csproj b/Jellyfin.Plugin.MetaShark/Jellyfin.Plugin.MetaShark.csproj index 59e9fc8..05b734d 100644 --- a/Jellyfin.Plugin.MetaShark/Jellyfin.Plugin.MetaShark.csproj +++ b/Jellyfin.Plugin.MetaShark/Jellyfin.Plugin.MetaShark.csproj @@ -1,5 +1,4 @@ - - + net6.0 Jellyfin.Plugin.MetaShark @@ -8,35 +7,28 @@ enable AllEnabledByDefault - False - False - - - - - - + \ No newline at end of file diff --git a/Jellyfin.Plugin.MetaShark/Model/ApiResult.cs b/Jellyfin.Plugin.MetaShark/Model/ApiResult.cs new file mode 100644 index 0000000..4c23925 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Model/ApiResult.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.MetaShark.Model; + +public class ApiResult +{ + [JsonPropertyName("code")] + public int Code { get; set; } + [JsonPropertyName("msg")] + public string Msg { get; set; } = string.Empty; + + public ApiResult(int code, string msg = "") + { + this.Code = code; + this.Msg = msg; + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.MetaShark/Model/DoubanSuggest.cs b/Jellyfin.Plugin.MetaShark/Model/DoubanSuggest.cs new file mode 100644 index 0000000..1e36f72 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Model/DoubanSuggest.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Jellyfin.Plugin.MetaShark.Core; + +namespace Jellyfin.Plugin.MetaShark.Model; + +public class DoubanSuggest +{ + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + [JsonPropertyName("year")] + public string Year { get; set; } = string.Empty; + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + + public string Sid + { + get + { + var regSid = new Regex(@"subject\/(\d+?)\/", RegexOptions.Compiled); + return this.Url.GetMatchGroup(regSid); + } + } +} diff --git a/Jellyfin.Plugin.MetaShark/Model/DoubanSuggestResult.cs b/Jellyfin.Plugin.MetaShark/Model/DoubanSuggestResult.cs new file mode 100644 index 0000000..28b4f6f --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Model/DoubanSuggestResult.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.MetaShark.Model; + +public class DoubanSuggestResult +{ + [JsonPropertyName("cards")] + public List? Cards { get; set; } +} diff --git a/Jellyfin.Plugin.MetaShark/Providers/BaseProvider.cs b/Jellyfin.Plugin.MetaShark/Providers/BaseProvider.cs index 5e44532..0df6e77 100644 --- a/Jellyfin.Plugin.MetaShark/Providers/BaseProvider.cs +++ b/Jellyfin.Plugin.MetaShark/Providers/BaseProvider.cs @@ -82,36 +82,51 @@ namespace Jellyfin.Plugin.MetaShark.Providers this.Log($"GuessByDouban of [name]: {info.Name} [file_name]: {fileName} [year]: {info.Year} [search name]: {searchName}"); - var result = await this._doubanApi.SearchAsync(searchName, cancellationToken).ConfigureAwait(false); - var jw = new JaroWinkler(); - foreach (var item in result) + List result; + DoubanSubject? item; + + // 假如存在年份,先通过suggest接口查找,减少搜索页访问次数,避免封禁(suggest没法区分电影或电视剧,排序也比搜索页差些) + if (config.EnableDoubanAvoidRiskControl) { - if (info is MovieInfo && item.Category != "电影") + if (info.Year != null && info.Year > 0) { - continue; + result = await this._doubanApi.SearchBySuggestAsync(searchName, cancellationToken).ConfigureAwait(false); + item = result.Where(x => x.Year == info.Year && x.Name == searchName).FirstOrDefault(); + if (item != null) + { + this.Log($"GuessByDouban of [name]: {searchName} found Sid: {item.Sid} (suggest)"); + return item.Sid; + } + item = result.Where(x => x.Year == info.Year).FirstOrDefault(); + if (item != null) + { + this.Log($"GuessByDouban of [name]: {searchName} found Sid: {item.Sid} (suggest)"); + return item.Sid; + } } + } - if (info is SeriesInfo && item.Category != "电视剧") + // 通过搜索页面查找 + result = await this._doubanApi.SearchAsync(searchName, cancellationToken).ConfigureAwait(false); + var cat = info is MovieInfo ? "电影" : "电视剧"; + + // 优先返回对应年份的电影 + if (info.Year != null && info.Year > 0) + { + item = result.Where(x => x.Category == cat && x.Year == info.Year).FirstOrDefault(); + if (item != null) { - continue; - } - - - // bt种子都是英文名,但电影是中日韩泰印法地区时,都不适用相似匹配,去掉限制 - - // 不存在年份需要比较时,直接返回 - if (info.Year == null || info.Year == 0) - { - this.Log($"GuessByDouban of [name] found Sid: {item.Sid}"); - return item.Sid; - } - - if (info.Year == item.Year) - { - this.Log($"GuessByDouban of [name] found Sid: {item.Sid}"); + this.Log($"GuessByDouban of [name]: {searchName} found Sid: {item.Sid}"); return item.Sid; } + } + // 不存在年份时,返回第一个 + item = result.Where(x => x.Category == cat).FirstOrDefault(); + if (item != null) + { + this.Log($"GuessByDouban of [name]: {searchName} found Sid: {item.Sid}"); + return item.Sid; } return null; @@ -125,24 +140,35 @@ namespace Jellyfin.Plugin.MetaShark.Providers } this.Log($"GuestDoubanSeasonByYear of [name]: {name} [year]: {year}"); - var result = await this._doubanApi.SearchAsync(name, cancellationToken).ConfigureAwait(false); - var jw = new JaroWinkler(); - foreach (var item in result) + + // 先通过suggest接口查找,减少搜索页访问次数,避免封禁(suggest没法区分电影或电视剧,排序也比搜索页差些) + if (config.EnableDoubanAvoidRiskControl) { - if (item.Category != "电视剧") + var suggestResult = await this._doubanApi.SearchBySuggestAsync(name, cancellationToken).ConfigureAwait(false); + var suggestItem = suggestResult.Where(x => x.Year == year && x.Name == name).FirstOrDefault(); + if (suggestItem != null) { - continue; + this.Log($"GuestDoubanSeasonByYear of [name] found Sid: \"{suggestItem.Sid}\" (suggest)"); + return suggestItem.Sid; } - - // bt种子都是英文名,但电影是中日韩泰印法地区时,都不适用相似匹配,去掉限制 - - if (year == item.Year) + suggestItem = suggestResult.Where(x => x.Year == year).FirstOrDefault(); + if (suggestItem != null) { - this.Log($"GuestDoubanSeasonByYear of [name] found Sid: \"{item.Sid}\""); - return item.Sid; + this.Log($"GuestDoubanSeasonByYear of [name] found Sid: \"{suggestItem.Sid}\" (suggest)"); + return suggestItem.Sid; } } + + // 通过搜索页面查找 + var result = await this._doubanApi.SearchAsync(name, cancellationToken).ConfigureAwait(false); + var item = result.Where(x => x.Category == "电视剧" && x.Year == year).FirstOrDefault(); + if (item != null && !string.IsNullOrEmpty(item.Sid)) + { + this.Log($"GuestDoubanSeasonByYear of [name] found Sid: \"{item.Sid}\""); + return item.Sid; + } + return null; } @@ -158,24 +184,24 @@ namespace Jellyfin.Plugin.MetaShark.Providers this.Log($"GuestByTmdb of [name]: {info.Name} [file_name]: {fileName} [year]: {info.Year} [search name]: {searchName}"); - var jw = new JaroWinkler(); - switch (info) { case MovieInfo: var movieResults = await this._tmdbApi.SearchMovieAsync(searchName, info.Year ?? 0, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); - foreach (var item in movieResults) + var movieItem = movieResults.FirstOrDefault(); + if (movieItem != null) { // bt种子都是英文名,但电影是中日韩泰印法地区时,都不适用相似匹配,去掉限制 - return item.Id.ToString(CultureInfo.InvariantCulture); + return movieItem.Id.ToString(CultureInfo.InvariantCulture); } break; case SeriesInfo: var seriesResults = await this._tmdbApi.SearchSeriesAsync(searchName, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); - foreach (var item in seriesResults) + var seriesItem = seriesResults.FirstOrDefault(); + if (seriesItem != null) { // bt种子都是英文名,但电影是中日韩泰印法地区时,都不适用相似匹配,去掉限制 - return item.Id.ToString(CultureInfo.InvariantCulture); + return seriesItem.Id.ToString(CultureInfo.InvariantCulture); } break; } diff --git a/Jellyfin.Plugin.MetaShark/Providers/MovieImageProvider.cs b/Jellyfin.Plugin.MetaShark/Providers/MovieImageProvider.cs index ec66166..4508d4c 100644 --- a/Jellyfin.Plugin.MetaShark/Providers/MovieImageProvider.cs +++ b/Jellyfin.Plugin.MetaShark/Providers/MovieImageProvider.cs @@ -58,7 +58,7 @@ namespace Jellyfin.Plugin.MetaShark.Providers if (metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid)) { var primary = await this._doubanApi.GetMovieAsync(sid, cancellationToken); - if (primary == null) + if (primary == null || string.IsNullOrEmpty(primary.ImgMiddle)) { return Enumerable.Empty(); } diff --git a/Jellyfin.Plugin.MetaShark/Providers/SeriesImageProvider.cs b/Jellyfin.Plugin.MetaShark/Providers/SeriesImageProvider.cs index d815c4b..349d20a 100644 --- a/Jellyfin.Plugin.MetaShark/Providers/SeriesImageProvider.cs +++ b/Jellyfin.Plugin.MetaShark/Providers/SeriesImageProvider.cs @@ -58,7 +58,7 @@ namespace Jellyfin.Plugin.MetaShark.Providers if (metaSource != MetaSource.Tmdb && !string.IsNullOrEmpty(sid)) { var primary = await this._doubanApi.GetMovieAsync(sid, cancellationToken); - if (primary == null) + if (primary == null || string.IsNullOrEmpty(primary.ImgMiddle)) { return Enumerable.Empty(); } @@ -152,7 +152,7 @@ namespace Jellyfin.Plugin.MetaShark.Providers return list; } - return photo.Where(x => x.Width > x.Height * 1.3).Select(x => + return photo.Where(x => x.Width > x.Height * 1.3 && !string.IsNullOrEmpty(x.Large)).Select(x => { return new RemoteImageInfo { diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/AssemblyInfo.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/AssemblyInfo.cs new file mode 100644 index 0000000..1563afa --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ComposableAsync.Core.Test")] + diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Awaitable/DispatcherAwaiter.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Awaitable/DispatcherAwaiter.cs new file mode 100644 index 0000000..061840a --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Awaitable/DispatcherAwaiter.cs @@ -0,0 +1,43 @@ +using System; +using System.Runtime.CompilerServices; +using System.Security; + +namespace ComposableAsync +{ + /// + /// Dispatcher awaiter, making a dispatcher awaitable + /// + public struct DispatcherAwaiter : INotifyCompletion + { + /// + /// Dispatcher never is synchronous + /// + public bool IsCompleted => false; + + private readonly IDispatcher _Dispatcher; + + /// + /// Construct a NotifyCompletion fom a dispatcher + /// + /// + public DispatcherAwaiter(IDispatcher dispatcher) + { + _Dispatcher = dispatcher; + } + + /// + /// Dispatch on complete + /// + /// + [SecuritySafeCritical] + public void OnCompleted(Action continuation) + { + _Dispatcher.Dispatch(continuation); + } + + /// + /// No Result + /// + public void GetResult() { } + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DelegatingHandler/DispatcherDelegatingHandler.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DelegatingHandler/DispatcherDelegatingHandler.cs new file mode 100644 index 0000000..dbd8b49 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DelegatingHandler/DispatcherDelegatingHandler.cs @@ -0,0 +1,30 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace ComposableAsync +{ + /// + /// A implementation based on + /// + internal class DispatcherDelegatingHandler : DelegatingHandler + { + private readonly IDispatcher _Dispatcher; + + /// + /// Build an from a + /// + /// + public DispatcherDelegatingHandler(IDispatcher dispatcher) + { + _Dispatcher = dispatcher; + InnerHandler = new HttpClientHandler(); + } + + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + return _Dispatcher.Enqueue(() => base.SendAsync(request, cancellationToken), cancellationToken); + } + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Dispatcher/ComposedDispatcher.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Dispatcher/ComposedDispatcher.cs new file mode 100644 index 0000000..aed2434 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Dispatcher/ComposedDispatcher.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace ComposableAsync +{ + internal class ComposedDispatcher : IDispatcher, IAsyncDisposable + { + private readonly IDispatcher _First; + private readonly IDispatcher _Second; + + public ComposedDispatcher(IDispatcher first, IDispatcher second) + { + _First = first; + _Second = second; + } + + public void Dispatch(Action action) + { + _First.Dispatch(() => _Second.Dispatch(action)); + } + + public async Task Enqueue(Action action) + { + await _First.Enqueue(() => _Second.Enqueue(action)); + } + + public async Task Enqueue(Func action) + { + return await _First.Enqueue(() => _Second.Enqueue(action)); + } + + public async Task Enqueue(Func action) + { + await _First.Enqueue(() => _Second.Enqueue(action)); + } + + public async Task Enqueue(Func> action) + { + return await _First.Enqueue(() => _Second.Enqueue(action)); + } + + public async Task Enqueue(Func action, CancellationToken cancellationToken) + { + await _First.Enqueue(() => _Second.Enqueue(action, cancellationToken), cancellationToken); + } + + public async Task Enqueue(Func> action, CancellationToken cancellationToken) + { + return await _First.Enqueue(() => _Second.Enqueue(action, cancellationToken), cancellationToken); + } + + public async Task Enqueue(Func action, CancellationToken cancellationToken) + { + return await _First.Enqueue(() => _Second.Enqueue(action, cancellationToken), cancellationToken); + } + + public async Task Enqueue(Action action, CancellationToken cancellationToken) + { + await _First.Enqueue(() => _Second.Enqueue(action, cancellationToken), cancellationToken); + } + + public IDispatcher Clone() => new ComposedDispatcher(_First, _Second); + + public Task DisposeAsync() + { + return Task.WhenAll(DisposeAsync(_First), DisposeAsync(_Second)); + } + + private static Task DisposeAsync(IDispatcher disposable) => (disposable as IAsyncDisposable)?.DisposeAsync() ?? Task.CompletedTask; + + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Dispatcher/DispatcherAdapter.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Dispatcher/DispatcherAdapter.cs new file mode 100644 index 0000000..d6532ad --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Dispatcher/DispatcherAdapter.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace ComposableAsync +{ + internal class DispatcherAdapter : IDispatcher + { + private readonly IBasicDispatcher _BasicDispatcher; + + public DispatcherAdapter(IBasicDispatcher basicDispatcher) + { + _BasicDispatcher = basicDispatcher; + } + + public IDispatcher Clone() => new DispatcherAdapter(_BasicDispatcher.Clone()); + + public void Dispatch(Action action) + { + _BasicDispatcher.Enqueue(action, CancellationToken.None); + } + + public Task Enqueue(Action action) + { + return _BasicDispatcher.Enqueue(action, CancellationToken.None); + } + + public Task Enqueue(Func action) + { + return _BasicDispatcher.Enqueue(action, CancellationToken.None); + } + + public Task Enqueue(Func action) + { + return _BasicDispatcher.Enqueue(action, CancellationToken.None); + } + + public Task Enqueue(Func> action) + { + return _BasicDispatcher.Enqueue(action, CancellationToken.None); + } + + public Task Enqueue(Func action, CancellationToken cancellationToken) + { + return _BasicDispatcher.Enqueue(action, cancellationToken); + } + + public Task Enqueue(Action action, CancellationToken cancellationToken) + { + return _BasicDispatcher.Enqueue(action, cancellationToken); + } + + public Task Enqueue(Func action, CancellationToken cancellationToken) + { + return _BasicDispatcher.Enqueue(action, cancellationToken); + } + + public Task Enqueue(Func> action, CancellationToken cancellationToken) + { + return _BasicDispatcher.Enqueue(action, cancellationToken); + } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Dispatcher/NullDispatcher.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Dispatcher/NullDispatcher.cs new file mode 100644 index 0000000..4882f9e --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Dispatcher/NullDispatcher.cs @@ -0,0 +1,74 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace ComposableAsync +{ + /// + /// that run actions synchronously + /// + public sealed class NullDispatcher: IDispatcher + { + private NullDispatcher() { } + + /// + /// Returns a static null dispatcher + /// + public static IDispatcher Instance { get; } = new NullDispatcher(); + + /// + public void Dispatch(Action action) + { + action(); + } + + /// + public Task Enqueue(Action action) + { + action(); + return Task.CompletedTask; + } + + /// + public Task Enqueue(Func action) + { + return Task.FromResult(action()); + } + + /// + public async Task Enqueue(Func action) + { + await action(); + } + + /// + public async Task Enqueue(Func> action) + { + return await action(); + } + + public Task Enqueue(Func action, CancellationToken cancellationToken) + { + return Task.FromResult(action()); + } + + public Task Enqueue(Action action, CancellationToken cancellationToken) + { + action(); + return Task.CompletedTask; + } + + public async Task Enqueue(Func action, CancellationToken cancellationToken) + { + await action(); + } + + public async Task Enqueue(Func> action, CancellationToken cancellationToken) + { + return await action(); + } + + /// + public IDispatcher Clone() => Instance; + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DispatcherExtension.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DispatcherExtension.cs new file mode 100644 index 0000000..ef29912 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DispatcherExtension.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; + +namespace ComposableAsync +{ + /// + /// extension methods provider + /// + public static class DispatcherExtension + { + /// + /// Returns awaitable to enter in the dispatcher context + /// This extension method make a dispatcher awaitable + /// + /// + /// + public static DispatcherAwaiter GetAwaiter(this IDispatcher dispatcher) + { + return new DispatcherAwaiter(dispatcher); + } + + /// + /// Returns a composed dispatcher applying the given dispatcher + /// after the first one + /// + /// + /// + /// + public static IDispatcher Then(this IDispatcher dispatcher, IDispatcher other) + { + if (dispatcher == null) + throw new ArgumentNullException(nameof(dispatcher)); + + if (other == null) + throw new ArgumentNullException(nameof(other)); + + return new ComposedDispatcher(dispatcher, other); + } + + /// + /// Returns a composed dispatcher applying the given dispatchers sequentially + /// + /// + /// + /// + public static IDispatcher Then(this IDispatcher dispatcher, params IDispatcher[] others) + { + return dispatcher.Then((IEnumerable)others); + } + + /// + /// Returns a composed dispatcher applying the given dispatchers sequentially + /// + /// + /// + /// + public static IDispatcher Then(this IDispatcher dispatcher, IEnumerable others) + { + if (dispatcher == null) + throw new ArgumentNullException(nameof(dispatcher)); + + if (others == null) + throw new ArgumentNullException(nameof(others)); + + return others.Aggregate(dispatcher, (cum, val) => cum.Then(val)); + } + + /// + /// Create a from an + /// + /// + /// + public static DelegatingHandler AsDelegatingHandler(this IDispatcher dispatcher) + { + return new DispatcherDelegatingHandler(dispatcher); + } + + /// + /// Create a from an + /// + /// + /// + public static IDispatcher ToFullDispatcher(this IBasicDispatcher @basicDispatcher) + { + return new DispatcherAdapter(@basicDispatcher); + } + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DispatcherManager/IDispatcherManager.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DispatcherManager/IDispatcherManager.cs new file mode 100644 index 0000000..66c52d5 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DispatcherManager/IDispatcherManager.cs @@ -0,0 +1,19 @@ +namespace ComposableAsync +{ + /// + /// Dispatcher manager + /// + public interface IDispatcherManager : IAsyncDisposable + { + /// + /// true if the Dispatcher should be released + /// + bool DisposeDispatcher { get; } + + /// + /// Returns a consumable Dispatcher + /// + /// + IDispatcher GetDispatcher(); + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DispatcherManager/MonoDispatcherManager.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DispatcherManager/MonoDispatcherManager.cs new file mode 100644 index 0000000..f26ef5c --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DispatcherManager/MonoDispatcherManager.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; + +namespace ComposableAsync +{ + /// + /// implementation based on single + /// + public sealed class MonoDispatcherManager : IDispatcherManager + { + /// + public bool DisposeDispatcher { get; } + + /// + public IDispatcher GetDispatcher() => _Dispatcher; + + private readonly IDispatcher _Dispatcher; + + /// + /// Create + /// + /// + /// + public MonoDispatcherManager(IDispatcher dispatcher, bool shouldDispose = false) + { + _Dispatcher = dispatcher; + DisposeDispatcher = shouldDispose; + } + + /// + public Task DisposeAsync() + { + return DisposeDispatcher && (_Dispatcher is IAsyncDisposable disposable) ? + disposable.DisposeAsync() : Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DispatcherProviderExtension.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DispatcherProviderExtension.cs new file mode 100644 index 0000000..f7046a1 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/DispatcherProviderExtension.cs @@ -0,0 +1,18 @@ +namespace ComposableAsync +{ + /// + /// extension + /// + public static class DispatcherProviderExtension + { + /// + /// Returns the underlying + /// + /// + /// + public static IDispatcher GetAssociatedDispatcher(this IDispatcherProvider dispatcherProvider) + { + return dispatcherProvider?.Dispatcher ?? NullDispatcher.Instance; + } + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Disposable/ComposableAsyncDisposable.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Disposable/ComposableAsyncDisposable.cs new file mode 100644 index 0000000..541d906 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Disposable/ComposableAsyncDisposable.cs @@ -0,0 +1,48 @@ +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; + +namespace ComposableAsync +{ + /// + /// implementation aggregating other + /// + public sealed class ComposableAsyncDisposable : IAsyncDisposable + { + private readonly ConcurrentQueue _Disposables; + + /// + /// Build an empty ComposableAsyncDisposable + /// + public ComposableAsyncDisposable() + { + _Disposables = new ConcurrentQueue(); + } + + /// + /// Add an to the ComposableAsyncDisposable + /// and returns it + /// + /// + /// + /// + public T Add(T disposable) where T: IAsyncDisposable + { + if (disposable == null) + return default(T); + + _Disposables.Enqueue(disposable); + return disposable; + } + + /// + /// Dispose all the resources asynchronously + /// + /// + public Task DisposeAsync() + { + var tasks = _Disposables.ToArray().Select(disposable => disposable.DisposeAsync()).ToArray(); + return Task.WhenAll(tasks); + } + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Disposable/IAsyncDisposable.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Disposable/IAsyncDisposable.cs new file mode 100644 index 0000000..3517491 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/Disposable/IAsyncDisposable.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; + +namespace ComposableAsync +{ + /// + /// Asynchronous version of IDisposable + /// For reference see discussion: https://github.com/dotnet/roslyn/issues/114 + /// + public interface IAsyncDisposable + { + /// + /// Performs asynchronously application-defined tasks associated with freeing, + /// releasing, or resetting unmanaged resources. + /// + Task DisposeAsync(); + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/IBasicDispatcher.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/IBasicDispatcher.cs new file mode 100644 index 0000000..6e054dd --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/IBasicDispatcher.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace ComposableAsync +{ + /// + /// Simplified version of that can be converted + /// to a using the ToFullDispatcher extension method + /// + public interface IBasicDispatcher + { + /// + /// Clone dispatcher + /// + /// + IBasicDispatcher Clone(); + + /// + /// Enqueue the function and return a task corresponding + /// to the execution of the task + /// /// + /// + /// + /// + /// + Task Enqueue(Func action, CancellationToken cancellationToken); + + /// + /// Enqueue the action and return a task corresponding + /// to the execution of the task + /// + /// + /// + /// + Task Enqueue(Action action, CancellationToken cancellationToken); + + /// + /// Enqueue the task and return a task corresponding + /// to the execution of the task + /// + /// + /// + /// + Task Enqueue(Func action, CancellationToken cancellationToken); + + /// + /// Enqueue the task and return a task corresponding + /// to the execution of the original task + /// + /// + /// + /// + /// + Task Enqueue(Func> action, CancellationToken cancellationToken); + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/IDispatcher.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/IDispatcher.cs new file mode 100644 index 0000000..d7eb14f --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/IDispatcher.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace ComposableAsync +{ + /// + /// Dispatcher executes an action or a function + /// on its own context + /// + public interface IDispatcher + { + /// + /// Execute action on dispatcher context in a + /// none-blocking way + /// + /// + void Dispatch(Action action); + + /// + /// Enqueue the action and return a task corresponding to + /// the completion of the action + /// + /// + /// + Task Enqueue(Action action); + + /// + /// Enqueue the function and return a task corresponding to + /// the result of the function + /// + /// + /// + /// + Task Enqueue(Func action); + + /// + /// Enqueue the task and return a task corresponding to + /// the completion of the task + /// + /// + /// + Task Enqueue(Func action); + + /// + /// Enqueue the task and return a task corresponding + /// to the execution of the original task + /// + /// + /// + /// + Task Enqueue(Func> action); + + /// + /// Enqueue the function and return a task corresponding + /// to the execution of the task + /// /// + /// + /// + /// + /// + Task Enqueue(Func action, CancellationToken cancellationToken); + + /// + /// Enqueue the action and return a task corresponding + /// to the execution of the task + /// + /// + /// + /// + Task Enqueue(Action action, CancellationToken cancellationToken); + + /// + /// Enqueue the task and return a task corresponding + /// to the execution of the task + /// + /// + /// + /// + Task Enqueue(Func action, CancellationToken cancellationToken); + + /// + /// Enqueue the task and return a task corresponding + /// to the execution of the original task + /// + /// + /// + /// + /// + Task Enqueue(Func> action, CancellationToken cancellationToken); + + /// + /// Clone dispatcher + /// + /// + IDispatcher Clone(); + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/IDispatcherProvider.cs b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/IDispatcherProvider.cs new file mode 100644 index 0000000..fb4c760 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/ComposableAsync.Core/IDispatcherProvider.cs @@ -0,0 +1,13 @@ +namespace ComposableAsync +{ + /// + /// Returns the fiber associated with an actor + /// + public interface IDispatcherProvider + { + /// + /// Returns the corresponding + /// + IDispatcher Dispatcher { get; } + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/AwaitableConstraintExtension.cs b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/AwaitableConstraintExtension.cs new file mode 100644 index 0000000..0a2dd65 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/AwaitableConstraintExtension.cs @@ -0,0 +1,22 @@ +namespace RateLimiter +{ + /// + /// Provides extension to interface + /// + public static class AwaitableConstraintExtension + { + /// + /// Compose two awaitable constraint in a new one + /// + /// + /// + /// + public static IAwaitableConstraint Compose(this IAwaitableConstraint awaitableConstraint1, IAwaitableConstraint awaitableConstraint2) + { + if (awaitableConstraint1 == awaitableConstraint2) + return awaitableConstraint1; + + return new ComposedAwaitableConstraint(awaitableConstraint1, awaitableConstraint2); + } + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/ComposedAwaitableConstraint.cs b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/ComposedAwaitableConstraint.cs new file mode 100644 index 0000000..278de53 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/ComposedAwaitableConstraint.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RateLimiter +{ + internal class ComposedAwaitableConstraint : IAwaitableConstraint + { + private readonly IAwaitableConstraint _AwaitableConstraint1; + private readonly IAwaitableConstraint _AwaitableConstraint2; + private readonly SemaphoreSlim _Semaphore = new SemaphoreSlim(1, 1); + + internal ComposedAwaitableConstraint(IAwaitableConstraint awaitableConstraint1, IAwaitableConstraint awaitableConstraint2) + { + _AwaitableConstraint1 = awaitableConstraint1; + _AwaitableConstraint2 = awaitableConstraint2; + } + + public IAwaitableConstraint Clone() + { + return new ComposedAwaitableConstraint(_AwaitableConstraint1.Clone(), _AwaitableConstraint2.Clone()); + } + + public async Task WaitForReadiness(CancellationToken cancellationToken) + { + await _Semaphore.WaitAsync(cancellationToken); + IDisposable[] disposables; + try + { + disposables = await Task.WhenAll(_AwaitableConstraint1.WaitForReadiness(cancellationToken), _AwaitableConstraint2.WaitForReadiness(cancellationToken)); + } + catch (Exception) + { + _Semaphore.Release(); + throw; + } + return new DisposeAction(() => + { + foreach (var disposable in disposables) + { + disposable.Dispose(); + } + _Semaphore.Release(); + }); + } + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/CountByIntervalAwaitableConstraint.cs b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/CountByIntervalAwaitableConstraint.cs new file mode 100644 index 0000000..460be4f --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/CountByIntervalAwaitableConstraint.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace RateLimiter +{ + /// + /// Provide an awaitable constraint based on number of times per duration + /// + public class CountByIntervalAwaitableConstraint : IAwaitableConstraint + { + /// + /// List of the last time stamps + /// + public IReadOnlyList TimeStamps => _TimeStamps.ToList(); + + /// + /// Stack of the last time stamps + /// + protected LimitedSizeStack _TimeStamps { get; } + + private int _Count { get; } + private TimeSpan _TimeSpan { get; } + private SemaphoreSlim _Semaphore { get; } = new SemaphoreSlim(1, 1); + private ITime _Time { get; } + + /// + /// Constructs a new AwaitableConstraint based on number of times per duration + /// + /// + /// + public CountByIntervalAwaitableConstraint(int count, TimeSpan timeSpan) : this(count, timeSpan, TimeSystem.StandardTime) + { + } + + internal CountByIntervalAwaitableConstraint(int count, TimeSpan timeSpan, ITime time) + { + if (count <= 0) + throw new ArgumentException("count should be strictly positive", nameof(count)); + + if (timeSpan.TotalMilliseconds <= 0) + throw new ArgumentException("timeSpan should be strictly positive", nameof(timeSpan)); + + _Count = count; + _TimeSpan = timeSpan; + _TimeStamps = new LimitedSizeStack(_Count); + _Time = time; + } + + /// + /// returns a task that will complete once the constraint is fulfilled + /// + /// + /// Cancel the wait + /// + /// + /// A disposable that should be disposed upon task completion + /// + public async Task WaitForReadiness(CancellationToken cancellationToken) + { + await _Semaphore.WaitAsync(cancellationToken); + var count = 0; + var now = _Time.GetNow(); + var target = now - _TimeSpan; + LinkedListNode element = _TimeStamps.First, last = null; + while ((element != null) && (element.Value > target)) + { + last = element; + element = element.Next; + count++; + } + + if (count < _Count) + return new DisposeAction(OnEnded); + + Debug.Assert(element == null); + Debug.Assert(last != null); + var timeToWait = last.Value.Add(_TimeSpan) - now; + try + { + await _Time.GetDelay(timeToWait, cancellationToken); + } + catch (Exception) + { + _Semaphore.Release(); + throw; + } + + return new DisposeAction(OnEnded); + } + + /// + /// Clone CountByIntervalAwaitableConstraint + /// + /// + public IAwaitableConstraint Clone() + { + return new CountByIntervalAwaitableConstraint(_Count, _TimeSpan, _Time); + } + + private void OnEnded() + { + var now = _Time.GetNow(); + _TimeStamps.Push(now); + OnEnded(now); + _Semaphore.Release(); + } + + /// + /// Called when action has been executed + /// + /// + protected virtual void OnEnded(DateTime now) + { + } + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/DisposeAction.cs b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/DisposeAction.cs new file mode 100644 index 0000000..8fd44c0 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/DisposeAction.cs @@ -0,0 +1,20 @@ +using System; + +namespace RateLimiter +{ + internal class DisposeAction : IDisposable + { + private Action _Act; + + public DisposeAction(Action act) + { + _Act = act; + } + + public void Dispose() + { + _Act?.Invoke(); + _Act = null; + } + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/IAwaitableConstraint.cs b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/IAwaitableConstraint.cs new file mode 100644 index 0000000..a9d13d3 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/IAwaitableConstraint.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RateLimiter +{ + /// + /// Represents a time constraints that can be awaited + /// + public interface IAwaitableConstraint + { + /// + /// returns a task that will complete once the constraint is fulfilled + /// + /// + /// Cancel the wait + /// + /// + /// A disposable that should be disposed upon task completion + /// + Task WaitForReadiness(CancellationToken cancellationToken); + + /// + /// Returns a new IAwaitableConstraint with same constraints but unused + /// + /// + IAwaitableConstraint Clone(); + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/ITime.cs b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/ITime.cs new file mode 100644 index 0000000..0faa0cd --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/ITime.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RateLimiter +{ + /// + /// Time abstraction + /// + internal interface ITime + { + /// + /// Return Now DateTime + /// + /// + DateTime GetNow(); + + /// + /// Returns a task delay + /// + /// + /// + /// + Task GetDelay(TimeSpan timespan, CancellationToken cancellationToken); + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/LimitedSizeStack.cs b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/LimitedSizeStack.cs new file mode 100644 index 0000000..bf1fe86 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/LimitedSizeStack.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; + +namespace RateLimiter +{ + /// + /// LinkedList with a limited size + /// If the size exceeds the limit older entry are removed + /// + /// + public class LimitedSizeStack: LinkedList + { + private readonly int _MaxSize; + + /// + /// Construct the LimitedSizeStack with the given limit + /// + /// + public LimitedSizeStack(int maxSize) + { + _MaxSize = maxSize; + } + + /// + /// Push new entry. If he size exceeds the limit, the oldest entry is removed + /// + /// + public void Push(T item) + { + AddFirst(item); + + if (Count > _MaxSize) + RemoveLast(); + } + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/PersistentCountByIntervalAwaitableConstraint.cs b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/PersistentCountByIntervalAwaitableConstraint.cs new file mode 100644 index 0000000..cda1e92 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/PersistentCountByIntervalAwaitableConstraint.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; + +namespace RateLimiter +{ + /// + /// that is able to save own state. + /// + public sealed class PersistentCountByIntervalAwaitableConstraint : CountByIntervalAwaitableConstraint + { + private readonly Action _SaveStateAction; + + /// + /// Create an instance of . + /// + /// Maximum actions allowed per time interval. + /// Time interval limits are applied for. + /// Action is used to save state. + /// Initial timestamps. + public PersistentCountByIntervalAwaitableConstraint(int count, TimeSpan timeSpan, + Action saveStateAction, IEnumerable initialTimeStamps) : base(count, timeSpan) + { + _SaveStateAction = saveStateAction; + + if (initialTimeStamps == null) + return; + + foreach (var timeStamp in initialTimeStamps) + { + _TimeStamps.Push(timeStamp); + } + } + + /// + /// Save state + /// + protected override void OnEnded(DateTime now) + { + _SaveStateAction(now); + } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/TimeLimiter.cs b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/TimeLimiter.cs new file mode 100644 index 0000000..e36b8e9 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/TimeLimiter.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ComposableAsync; + +namespace RateLimiter +{ + /// + /// TimeLimiter implementation + /// + public class TimeLimiter : IDispatcher + { + private readonly IAwaitableConstraint _AwaitableConstraint; + + internal TimeLimiter(IAwaitableConstraint awaitableConstraint) + { + _AwaitableConstraint = awaitableConstraint; + } + + /// + /// Perform the given task respecting the time constraint + /// returning the result of given function + /// + /// + /// + public Task Enqueue(Func perform) + { + return Enqueue(perform, CancellationToken.None); + } + + /// + /// Perform the given task respecting the time constraint + /// returning the result of given function + /// + /// + /// + /// + public Task Enqueue(Func> perform) + { + return Enqueue(perform, CancellationToken.None); + } + + /// + /// Perform the given task respecting the time constraint + /// + /// + /// + /// + public async Task Enqueue(Func perform, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using (await _AwaitableConstraint.WaitForReadiness(cancellationToken)) + { + await perform(); + } + } + + /// + /// Perform the given task respecting the time constraint + /// returning the result of given function + /// + /// + /// + /// + /// + public async Task Enqueue(Func> perform, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using (await _AwaitableConstraint.WaitForReadiness(cancellationToken)) + { + return await perform(); + } + } + + public IDispatcher Clone() => new TimeLimiter(_AwaitableConstraint.Clone()); + + private static Func Transform(Action act) + { + return () => { act(); return Task.FromResult(0); }; + } + + /// + /// Perform the given task respecting the time constraint + /// returning the result of given function + /// + /// + /// + /// + private static Func> Transform(Func compute) + { + return () => Task.FromResult(compute()); + } + + /// + /// Perform the given task respecting the time constraint + /// + /// + /// + public Task Enqueue(Action perform) + { + var transformed = Transform(perform); + return Enqueue(transformed); + } + + /// + /// Perform the given task respecting the time constraint + /// + /// + public void Dispatch(Action action) + { + Enqueue(action); + } + + /// + /// Perform the given task respecting the time constraint + /// returning the result of given function + /// + /// + /// + /// + public Task Enqueue(Func perform) + { + var transformed = Transform(perform); + return Enqueue(transformed); + } + + /// + /// Perform the given task respecting the time constraint + /// returning the result of given function + /// + /// + /// + /// + /// + public Task Enqueue(Func perform, CancellationToken cancellationToken) + { + var transformed = Transform(perform); + return Enqueue(transformed, cancellationToken); + } + + /// + /// Perform the given task respecting the time constraint + /// + /// + /// + /// + public Task Enqueue(Action perform, CancellationToken cancellationToken) + { + var transformed = Transform(perform); + return Enqueue(transformed, cancellationToken); + } + + /// + /// Returns a TimeLimiter based on a maximum number of times + /// during a given period + /// + /// + /// + /// + public static TimeLimiter GetFromMaxCountByInterval(int maxCount, TimeSpan timeSpan) + { + return new TimeLimiter(new CountByIntervalAwaitableConstraint(maxCount, timeSpan)); + } + + /// + /// Create that will save state using action passed through parameter. + /// + /// Maximum actions allowed per time interval. + /// Time interval limits are applied for. + /// Action is used to save state. + /// instance with . + public static TimeLimiter GetPersistentTimeLimiter(int maxCount, TimeSpan timeSpan, + Action saveStateAction) + { + return GetPersistentTimeLimiter(maxCount, timeSpan, saveStateAction, null); + } + + /// + /// Create with initial timestamps that will save state using action passed through parameter. + /// + /// Maximum actions allowed per time interval. + /// Time interval limits are applied for. + /// Action is used to save state. + /// Initial timestamps. + /// instance with . + public static TimeLimiter GetPersistentTimeLimiter(int maxCount, TimeSpan timeSpan, + Action saveStateAction, IEnumerable initialTimeStamps) + { + return new TimeLimiter(new PersistentCountByIntervalAwaitableConstraint(maxCount, timeSpan, saveStateAction, initialTimeStamps)); + } + + /// + /// Compose various IAwaitableConstraint in a TimeLimiter + /// + /// + /// + public static TimeLimiter Compose(params IAwaitableConstraint[] constraints) + { + var composed = constraints.Aggregate(default(IAwaitableConstraint), + (accumulated, current) => (accumulated == null) ? current : accumulated.Compose(current)); + return new TimeLimiter(composed); + } + } +} diff --git a/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/TimeSystem.cs b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/TimeSystem.cs new file mode 100644 index 0000000..81b46e1 --- /dev/null +++ b/Jellyfin.Plugin.MetaShark/Vendor/RateLimiter/TimeSystem.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RateLimiter +{ + internal class TimeSystem : ITime + { + public static ITime StandardTime { get; } + + static TimeSystem() + { + StandardTime = new TimeSystem(); + } + + private TimeSystem() + { + } + + DateTime ITime.GetNow() + { + return DateTime.Now; + } + + Task ITime.GetDelay(TimeSpan timespan, CancellationToken cancellationToken) + { + return Task.Delay(timespan, cancellationToken); + } + } +} diff --git a/README.md b/README.md index c3c42af..e32918e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ jellyfin电影元数据插件,影片信息只要从豆瓣获取,并由TheMov 3. 识别时默认不返回TheMovieDb结果,有需要可以到插件配置中打开 4. 假如网络原因访问TheMovieDb比较慢,可以到插件配置中关闭从TheMovieDb获取数据(关闭后不会再获取剧集信息) -> 🚨建议一次不要刮削太多电影,要不然会触发豆瓣风控,导致被封IP(封IP需要等6小时左右才能恢复访问) +> 🚨假如需要刮削大量电影,请到插件配置中打开防封禁功能,避免频繁请求豆瓣导致被封IP(封IP需要等6小时左右才能恢复访问) ## How to build