diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a5a5a58 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +Jellyfin.Plugin.Danmu/Vendor/** linguist-vendored diff --git a/Jellyfin.Plugin.Danmu.Test/Jellyfin.Plugin.Danmu.Test.csproj b/Jellyfin.Plugin.Danmu.Test/Jellyfin.Plugin.Danmu.Test.csproj index 3807020..cfe2e6a 100644 --- a/Jellyfin.Plugin.Danmu.Test/Jellyfin.Plugin.Danmu.Test.csproj +++ b/Jellyfin.Plugin.Danmu.Test/Jellyfin.Plugin.Danmu.Test.csproj @@ -1,23 +1,18 @@ - - - - net6.0 - enable - enable - - false - - - - - - - - - - - - - - - + + + net6.0 + enable + enable + false + + + + + + + + + + + + \ No newline at end of file diff --git a/Jellyfin.Plugin.Danmu.Test/YoukuApiTest.cs b/Jellyfin.Plugin.Danmu.Test/YoukuApiTest.cs new file mode 100644 index 0000000..b5d25ea --- /dev/null +++ b/Jellyfin.Plugin.Danmu.Test/YoukuApiTest.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Jellyfin.Plugin.Danmu.Model; +using Jellyfin.Plugin.Danmu.Scrapers.Youku; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.Danmu.Test +{ + + [TestClass] + public class YoukuApiTest + { + ILoggerFactory loggerFactory = LoggerFactory.Create(builder => + builder.AddSimpleConsole(options => + { + options.IncludeScopes = true; + options.SingleLine = true; + options.TimestampFormat = "hh:mm:ss "; + })); + + [TestMethod] + public void TestSearch() + { + var keyword = "夏洛特"; + var api = new YoukuApi(loggerFactory); + + Task.Run(async () => + { + try + { + var result = await api.SearchAsync(keyword, CancellationToken.None); + Console.WriteLine(result); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + }).GetAwaiter().GetResult(); + } + + + [TestMethod] + public void TestGetVideo() + { + var api = new YoukuApi(loggerFactory); + + Task.Run(async () => + { + try + { + var vid = "0b39c5b6569311e5b2ad"; + var result = await api.GetVideoAsync(vid, CancellationToken.None); + Console.WriteLine(result); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + }).GetAwaiter().GetResult(); + } + + + [TestMethod] + public void TestGetDanmuContentByMat() + { + var api = new YoukuApi(loggerFactory); + + Task.Run(async () => + { + try + { + var vid = "XMTM1MTc4MDU3Ng=="; + var result = await api.GetDanmuContentByMatAsync(vid, 0, CancellationToken.None); + Console.WriteLine(result); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + }).GetAwaiter().GetResult(); + } + + [TestMethod] + public void TestGetDanmu() + { + var api = new YoukuApi(loggerFactory); + + Task.Run(async () => + { + try + { + var vid = "XMTM1MTc4MDU3Ng=="; + var result = await api.GetDanmuContentAsync(vid, CancellationToken.None); + Console.WriteLine(result); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + }).GetAwaiter().GetResult(); + } + + } +} diff --git a/Jellyfin.Plugin.Danmu/Core/Extensions/ElementExtension.cs b/Jellyfin.Plugin.Danmu/Core/Extensions/ElementExtension.cs new file mode 100644 index 0000000..f4dfd53 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Core/Extensions/ElementExtension.cs @@ -0,0 +1,58 @@ +// using AngleSharp.Dom; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.Danmu.Core.Extensions +{ + public static class ElementExtension + { + // public static string? GetText(this IElement el, string css) + // { + // var node = el.QuerySelector(css); + // if (node != null) + // { + // return node.Text().Trim(); + // } + + // return null; + // } + + // public static string GetTextOrDefault(this IElement el, string css, string defaultVal = "") + // { + // var node = el.QuerySelector(css); + // if (node != null) + // { + // return node.Text().Trim(); + // } + + // return defaultVal; + // } + + // public static string? GetAttr(this IElement el, string css, string attr) + // { + // var node = el.QuerySelector(css); + // if (node != null) + // { + // var attrVal = node.GetAttribute(attr); + // return attrVal != null ? attrVal.Trim() : null; + // } + + // return null; + // } + + // public static string? GetAttrOrDefault(this IElement el, string css, string attr, string defaultVal = "") + // { + // var node = el.QuerySelector(css); + // if (node != null) + // { + // var attrVal = node.GetAttribute(attr); + // return attrVal != null ? attrVal.Trim() : defaultVal; + // } + + // return defaultVal; + // } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Danmu/Core/Extensions/JsonExtension.cs b/Jellyfin.Plugin.Danmu/Core/Extensions/JsonExtension.cs index e1cd123..159fcd6 100644 --- a/Jellyfin.Plugin.Danmu/Core/Extensions/JsonExtension.cs +++ b/Jellyfin.Plugin.Danmu/Core/Extensions/JsonExtension.cs @@ -13,7 +13,10 @@ namespace Jellyfin.Plugin.Danmu.Core.Extensions { if (obj == null) return string.Empty; - return JsonSerializer.Serialize(obj); + // 不指定UnsafeRelaxedJsonEscaping,+号会被转码为unicode字符,和js/java的序列化不一致 + var jso = new JsonSerializerOptions(); + jso.Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping; + return JsonSerializer.Serialize(obj, jso); } } } diff --git a/Jellyfin.Plugin.Danmu/Core/Extensions/RegexExtension.cs b/Jellyfin.Plugin.Danmu/Core/Extensions/RegexExtension.cs new file mode 100644 index 0000000..57a66e5 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Core/Extensions/RegexExtension.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Eventing.Reader; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.Danmu.Core.Extensions +{ + public static class RegexExtension + { + public static string FirstMatch(this Regex reg, string text, string defaultVal = "") + { + var match = reg.Match(text); + if (match.Success) + { + return match.Value; + } + + return defaultVal; + } + + public static string FirstMatchGroup(this Regex reg, string text, string defaultVal = "") + { + var match = reg.Match(text); + if (match.Success && match.Groups.Count > 1) + { + return match.Groups[1].Value.Trim(); + } + + return defaultVal; + } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Danmu/Core/Extensions/StringExtension.cs b/Jellyfin.Plugin.Danmu/Core/Extensions/StringExtension.cs index 7737198..30a1ba3 100644 --- a/Jellyfin.Plugin.Danmu/Core/Extensions/StringExtension.cs +++ b/Jellyfin.Plugin.Danmu/Core/Extensions/StringExtension.cs @@ -43,6 +43,36 @@ namespace Jellyfin.Plugin.Danmu.Core.Extensions return 0.0f; } + public static double ToDouble(this string s) + { + double val; + if (double.TryParse(s, out val)) + { + return val; + } + + return 0.0; + } + + public static string ToMD5(this string str) + { + using (var cryptoMD5 = System.Security.Cryptography.MD5.Create()) + { + //將字串編碼成 UTF8 位元組陣列 + var bytes = Encoding.UTF8.GetBytes(str); + + //取得雜湊值位元組陣列 + var hash = cryptoMD5.ComputeHash(bytes); + + //取得 MD5 + var md5 = BitConverter.ToString(hash) + .Replace("-", String.Empty) + .ToUpper(); + + return md5; + } + } + public static double Distance(this string s1, string s2) { var jw = new JaroWinkler(); diff --git a/Jellyfin.Plugin.Danmu/Core/Http/HttpLoggingHandler.cs b/Jellyfin.Plugin.Danmu/Core/Http/HttpLoggingHandler.cs new file mode 100644 index 0000000..94a1ba3 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Core/Http/HttpLoggingHandler.cs @@ -0,0 +1,84 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +// ReSharper disable TemplateIsNotCompileTimeConstantProblem + +namespace Jellyfin.Plugin.Danmu.Core.Http +{ + [SuppressMessage("Usage", "CA2254:模板应为静态表达式", Justification = "<挂起>")] + public class HttpLoggingHandler : DelegatingHandler + { + private readonly ILogger _logger; + + public HttpLoggingHandler(HttpMessageHandler innerHandler, ILogger logger) + : base(innerHandler) + { + _logger = logger; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + HttpResponseMessage? response = null; + try + { + response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + + await LogRequestAndResponse(request, response, null, cancellationToken).ConfigureAwait(false); + + + return response; + } + catch (Exception exception) + { + await LogRequestAndResponse(request, response, exception, cancellationToken).ConfigureAwait(false); + throw; + } + } + + private async Task LogRequestAndResponse(HttpRequestMessage? request, HttpResponseMessage? response, Exception? exception, CancellationToken cancellationToken) + { + var log = new StringBuilder(); + try + { + if (request != null) + { + log.Append("Request: ").Append(request).Append('\n'); + } + + if (request?.Content != null) + { + log.Append("Content: ").Append(await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)).Append('\n'); + } + + if (response != null) + { + log.Append("Response: ").Append(response).Append('\n'); + } + + if (response?.Content != null) + { + log.Append("Content: ").Append(await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)).Append('\n'); + } + } + catch + { + } + finally + { + if (exception == null) + { + _logger.LogWarning(log.ToString()); + } + else + { + _logger.LogError(exception, log.ToString()); + } + } + } + } +} diff --git a/Jellyfin.Plugin.Danmu/Core/Http/HttpRetryMessageHandler.cs b/Jellyfin.Plugin.Danmu/Core/Http/HttpRetryMessageHandler.cs new file mode 100644 index 0000000..c499b3a --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Core/Http/HttpRetryMessageHandler.cs @@ -0,0 +1,19 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.Danmu.Core.Http +{ + public class HttpRetryMessageHandler : DelegatingHandler + { + public HttpRetryMessageHandler(HttpMessageHandler innerHandler) : base(innerHandler) + { + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) => + base.SendAsync(request, cancellationToken); + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs index ad1c51d..f8cca88 100644 --- a/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs +++ b/Jellyfin.Plugin.Danmu/Scrapers/Dandan/DandanApi.cs @@ -98,7 +98,6 @@ public class DandanApi : AbstractApi var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - var ddd = response.Content.ToString(); var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); if (result != null && result.Success) { diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuComment.cs b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuComment.cs new file mode 100644 index 0000000..ef22c8a --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuComment.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Youku.Entity +{ + public class YoukuComment + { + // "mat": 22, + // "createtime": 1673844600000, + // "ver": 1, + // "propertis": "{\"size\":2,\"color\":16524894,\"pos\":3,\"alpha\":1}", + // "iid": "XMTM1MTc4MDU3Ng==", + // "level": 0, + // "lid": 0, + // "type": 1, + // "content": "打自己一拳呗", + // "extFields": { + // "grade": 3, + // "voteUp": 0 + // }, + // "ct": 10004, + // "uid": "UNTYzMTg5NzAzNg==", + // "uid2": "1407974259", + // "ouid": "UMTM2OTY2ODYwMA==", + // "playat": 1320797, + // "id": 5559881890, + // "aid": 300707, + // "status": 99 + + [JsonPropertyName("id")] + public Int64 ID { get; set; } + + [JsonPropertyName("content")] + public string Content { get; set; } + + // 毫秒 + [JsonPropertyName("playat")] + public Int64 Playat { get; set; } + + // "{\"size\":2,\"color\":16524894,\"pos\":3,\"alpha\":1}", + [JsonPropertyName("propertis")] + public string Propertis { get; set; } + + [JsonPropertyName("uid")] + public string Uid { get; set; } + + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuCommentProperty.cs b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuCommentProperty.cs new file mode 100644 index 0000000..aeb2404 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuCommentProperty.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Youku.Entity +{ + public class YoukuCommentProperty + { + + [JsonPropertyName("size")] + public int Size { get; set; } + + [JsonPropertyName("color")] + public uint Color { get; set; } + + [JsonPropertyName("pos")] + public int Pos { get; set; } + + [JsonPropertyName("alpha")] + public int Alpha { get; set; } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuCommentResult.cs b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuCommentResult.cs new file mode 100644 index 0000000..ced768d --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuCommentResult.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Youku.Entity +{ + public class YoukuCommentResult + { + [JsonPropertyName("data")] + public YoukuCommentData Data { get; set; } + } + + public class YoukuCommentData + { + [JsonPropertyName("result")] + public List Result { get; set; } + } + +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuEpisode.cs b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuEpisode.cs new file mode 100644 index 0000000..1aaa554 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuEpisode.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Jellyfin.Plugin.Danmu.Core.Extensions; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Youku.Entity +{ + public class YoukuEpisode + { + [JsonPropertyName("id")] + public string ID { get; set; } + + [JsonPropertyName("seq")] + public string Seq { get; set; } + + [JsonPropertyName("duration")] + public string Duration { get; set; } + + [JsonPropertyName("title")] + public string Title { get; set; } + + [JsonPropertyName("link")] + public string Link { get; set; } + + + public int TotalMat + { + get + { + var duration = Duration.ToDouble(); + return (int)Math.Floor(duration / 60) + 1; + } + + } + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuRpcResult.cs b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuRpcResult.cs new file mode 100644 index 0000000..90a4280 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuRpcResult.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Youku.Entity +{ + public class YoukuRpcResult + { + [JsonPropertyName("data")] + public YoukuRpcData Data { get; set; } + } + + public class YoukuRpcData + { + [JsonPropertyName("result")] + public string Result { get; set; } + } +} \ No newline at end of file diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuTrackInfo.cs b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuTrackInfo.cs new file mode 100644 index 0000000..c249424 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuTrackInfo.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Youku.Entity +{ + public class YoukuTrackInfo + { + [JsonPropertyName("group_id")] + public string GroupID { get; set; } + + [JsonPropertyName("object_type")] + public int ObjectType { get; set; } + + [JsonPropertyName("object_title")] + public string ObjectTitle { get; set; } + + [JsonPropertyName("object_url")] + public string ObjectUrl { get; set; } + + [JsonIgnore] + public int? Year { get; set; } + + [JsonIgnore] + public string Type { get; set; } + + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuVideo.cs b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuVideo.cs new file mode 100644 index 0000000..74fa86b --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Entity/YoukuVideo.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Youku.Entity +{ + public class YoukuVideo + { + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("videos")] + public List Videos { get; set; } = new List(); + + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Youku/ExternalId/EpisodeExternalId.cs b/Jellyfin.Plugin.Danmu/Scrapers/Youku/ExternalId/EpisodeExternalId.cs new file mode 100644 index 0000000..f8cc77a --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Youku/ExternalId/EpisodeExternalId.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Youku.ExternalId +{ + /// + public class EpisodeExternalId : IExternalId + { + /// + public string ProviderName => Youku.ScraperProviderName; + + /// + public string Key => Youku.ScraperProviderId; + + /// + public ExternalIdMediaType? Type => ExternalIdMediaType.Episode; + + /// + public string UrlFormatString => "https://v.youku.com/v_show/id_{0}.html"; + + /// + public bool Supports(IHasProviderIds item) => item is Episode; + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Youku/ExternalId/MovieExternalId.cs b/Jellyfin.Plugin.Danmu/Scrapers/Youku/ExternalId/MovieExternalId.cs new file mode 100644 index 0000000..61efe96 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Youku/ExternalId/MovieExternalId.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Youku.ExternalId +{ + /// + public class MovieExternalId : IExternalId + { + /// + public string ProviderName => Youku.ScraperProviderName; + + /// + public string Key => Youku.ScraperProviderId; + + /// + public ExternalIdMediaType? Type => null; + + /// + public string UrlFormatString => "https://v.youku.com/v_show/id_{0}.html"; + + /// + public bool Supports(IHasProviderIds item) => item is Movie; + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Youku/ExternalId/SeasonExternalId.cs b/Jellyfin.Plugin.Danmu/Scrapers/Youku/ExternalId/SeasonExternalId.cs new file mode 100644 index 0000000..6582901 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Youku/ExternalId/SeasonExternalId.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Providers; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Youku.ExternalId +{ + + /// + public class SeasonExternalId : IExternalId + { + /// + public string ProviderName => Youku.ScraperProviderName; + + /// + public string Key => Youku.ScraperProviderId; + + /// + public ExternalIdMediaType? Type => null; + + /// + public string UrlFormatString => "https://v.youku.com/v_nextstage/id_{0}.html"; + + /// + public bool Supports(IHasProviderIds item) => item is Season; + } + +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Youku/Youku.cs b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Youku.cs new file mode 100644 index 0000000..08f0021 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Youku/Youku.cs @@ -0,0 +1,170 @@ +using System.Linq; +using System; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.Danmu.Core; +using MediaBrowser.Controller.Entities; +using Microsoft.Extensions.Logging; +using Jellyfin.Plugin.Danmu.Scrapers.Entity; +using System.Collections.Generic; +using System.Xml; +using Jellyfin.Plugin.Danmu.Core.Extensions; +using System.Text.Json; +using Jellyfin.Plugin.Danmu.Scrapers.Youku.Entity; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Youku; + +public class Youku : AbstractScraper +{ + public const string ScraperProviderName = "优酷"; + public const string ScraperProviderId = "YoukuID"; + + private readonly YoukuApi _api; + + public Youku(ILoggerFactory logManager) + : base(logManager.CreateLogger()) + { + _api = new YoukuApi(logManager); + } + + public override int DefaultOrder => 3; + + public override bool DefaultEnable => false; + + public override string Name => "优酷"; + + public override string ProviderName => ScraperProviderName; + + public override string ProviderId => ScraperProviderId; + + public override async Task GetMatchMediaId(BaseItem item) + { + var searchName = this.NormalizeSearchName(item.Name); + var videos = await this._api.SearchAsync(searchName, CancellationToken.None).ConfigureAwait(false); + foreach (var video in videos) + { + var videoId = video.GroupID; + var title = video.ObjectTitle; + var pubYear = video.Year; + var isMovieItemType = item is MediaBrowser.Controller.Entities.Movies.Movie; + + if (isMovieItemType && video.Type != "movie") + { + continue; + } + + if (!isMovieItemType && video.Type == "movie") + { + continue; + } + + // 检测标题是否相似(越大越相似) + var score = searchName.Distance(title); + if (score < 0.7) + { + log.LogInformation("[{0}] 标题差异太大,忽略处理. 搜索词:{1}, score: {2}", title, searchName, score); + continue; + } + + // 检测年份是否一致 + var itemPubYear = item.ProductionYear ?? 0; + if (itemPubYear > 0 && pubYear > 0 && itemPubYear != pubYear) + { + log.LogInformation("[{0}] 发行年份不一致,忽略处理. Youku:{1} jellyfin: {2}", title, pubYear, itemPubYear); + continue; + } + + return $"{videoId}"; + } + + return null; + } + + + public override async Task GetMedia(string id) + { + if (string.IsNullOrEmpty(id)) + { + return null; + } + + var video = await _api.GetVideoAsync(id, CancellationToken.None).ConfigureAwait(false); + if (video == null) + { + log.LogInformation("[{0}]获取不到视频信息:id={1}", this.Name, id); + return null; + } + + var media = new ScraperMedia(); + media.Id = id; + media.Name = "no name"; + if (video.Videos != null && video.Videos.Count > 0) + { + foreach (var item in video.Videos) + { + media.Episodes.Add(new ScraperEpisode() { Id = $"{item.ID}", CommentId = $"{item.ID}" }); + } + } + + return media; + } + + public override async Task GetMediaEpisode(string id) + { + if (string.IsNullOrEmpty(id)) + { + return null; + } + + return new ScraperEpisode() { Id = id, CommentId = id }; + } + + public override async Task GetDanmuContent(string commentId) + { + if (string.IsNullOrEmpty(commentId)) + { + return null; + } + + var comments = await _api.GetDanmuContentAsync(commentId, CancellationToken.None).ConfigureAwait(false); + var danmaku = new ScraperDanmaku(); + danmaku.ChatId = 1000; + danmaku.ChatServer = "acs.youku.com"; + foreach (var item in comments) + { + try + { + var danmakuText = new ScraperDanmakuText(); + danmakuText.Progress = (int)item.Playat; + danmakuText.Mode = 1; + danmakuText.MidHash = item.Uid; + danmakuText.Id = item.ID; + danmakuText.Content = item.Content; + + var property = JsonSerializer.Deserialize(item.Propertis); + if (property != null) + { + danmakuText.Color = property.Color; + } + + danmaku.Items.Add(danmakuText); + } + catch (Exception ex) + { + + } + + } + + return danmaku; + } + + + private string NormalizeSearchName(string name) + { + // 去掉可能存在的季名称 + return Regex.Replace(name, @"\s*第.季", ""); + } +} diff --git a/Jellyfin.Plugin.Danmu/Scrapers/Youku/YoukuApi.cs b/Jellyfin.Plugin.Danmu/Scrapers/Youku/YoukuApi.cs new file mode 100644 index 0000000..7329505 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Scrapers/Youku/YoukuApi.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using ComposableAsync; +using Jellyfin.Plugin.Danmu.Core.Extensions; +using Jellyfin.Plugin.Danmu.Scrapers.Entity; +using Jellyfin.Plugin.Danmu.Scrapers.Youku.Entity; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using RateLimiter; + +namespace Jellyfin.Plugin.Danmu.Scrapers.Youku; + +public class YoukuApi : AbstractApi +{ + const string HTTP_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1 Edg/93.0.4577.63"; + private static readonly object _lock = new object(); + private static readonly Regex yearReg = new Regex(@"[12][890][0-9][0-9]", RegexOptions.Compiled); + private static readonly Regex moviesReg = new Regex(@"([\w\W]+?)", RegexOptions.Compiled); + private static readonly Regex trackInfoReg = new Regex(@"data-trackinfo=""(\{[\w\W]+?\})""", RegexOptions.Compiled); + private static readonly Regex featureReg = new Regex(@"([\w\W]+?)", RegexOptions.Compiled); + private static readonly Regex unusedReg = new Regex(@"\[.+?\]|\(.+?\)|【.+?】", RegexOptions.Compiled); + + private DateTime lastRequestTime = DateTime.Now.AddDays(-1); + + private TimeLimiter _timeConstraint = TimeLimiter.GetFromMaxCountByInterval(1, TimeSpan.FromMilliseconds(1000)); + + protected string _cna = string.Empty; + protected string _token = string.Empty; + protected string _tokenEnc = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + /// The . + public YoukuApi(ILoggerFactory loggerFactory) + : base(loggerFactory.CreateLogger()) + { + httpClient.DefaultRequestHeaders.Add("user-agent", HTTP_USER_AGENT); + } + + + public async Task> SearchAsync(string keyword, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(keyword)) + { + return new List(); + } + + var cacheKey = $"search_{keyword}"; + var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }; + if (_memoryCache.TryGetValue>(cacheKey, out var searchResult)) + { + return searchResult; + } + + await this.LimitRequestFrequently(); + + keyword = HttpUtility.UrlEncode(keyword); + var url = $"https://search.youku.com/search_video?keyword={keyword}"; + var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + var result = new List(); + var matchs = moviesReg.Matches(body); + foreach (Match match in matchs) + { + var text = HttpUtility.HtmlDecode(match.Groups[1].Value); + var trackInfoJson = trackInfoReg.FirstMatchGroup(text); + try + { + if (string.IsNullOrEmpty(trackInfoJson)) + { + continue; + } + + var trackInfo = JsonSerializer.Deserialize(trackInfoJson); + if (trackInfo != null && trackInfo.ObjectType == 101 && !trackInfo.ObjectTitle.Contains("中配版")) + { + var featureInfo = featureReg.FirstMatchGroup(text); + var year = yearReg.FirstMatch(featureInfo).ToInt(); + trackInfo.Year = year > 0 ? year : null; + trackInfo.Type = featureInfo.Contains("电影") ? "movie" : "tv"; + trackInfo.ObjectTitle = unusedReg.Replace(trackInfo.ObjectTitle, ""); + result.Add(trackInfo); + } + } + catch (Exception ex) + { + + } + } + + _memoryCache.Set>(cacheKey, result, expiredOption); + return result; + } + + public async Task GetVideoAsync(string id, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(id)) + { + return null; + } + + var cacheKey = $"media_{id}"; + var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }; + if (_memoryCache.TryGetValue(cacheKey, out var video)) + { + return video; + } + + var url = $"https://openapi.youku.com/v2/shows/videos.json?client_id=53e6cc67237fc59a&package=com.huawei.hwvplayer.youku&ext=show&show_id={id}"; + var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (result != null) + { + _memoryCache.Set(cacheKey, result, expiredOption); + return result; + } + + _memoryCache.Set(cacheKey, null, expiredOption); + return null; + } + + + public async Task GetEpisodeAsync(string vid, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(vid)) + { + return null; + } + + var cacheKey = $"episode_{vid}"; + var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }; + if (_memoryCache.TryGetValue(cacheKey, out var episode)) + { + return episode; + } + + // 文档:https://cloud.youku.com/docs?id=46 + var url = $"https://openapi.youku.com/v2/videos/show_basic.json?client_id=53e6cc67237fc59a&package=com.huawei.hwvplayer.youku&video_id={vid}"; + var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (result != null) + { + _memoryCache.Set(cacheKey, result, expiredOption); + return result; + } + + _memoryCache.Set(cacheKey, null, expiredOption); + return null; + } + + public async Task> GetDanmuContentAsync(string vid, CancellationToken cancellationToken) + { + var danmuList = new List(); + if (string.IsNullOrEmpty(vid)) + { + return danmuList; + } + + await EnsureTokenCookie(cancellationToken); + + + var episode = await this.GetEpisodeAsync(vid, cancellationToken); + if (episode == null) + { + return danmuList; + } + + var totalMat = episode.TotalMat; + for (int mat = 0; mat < totalMat; mat++) + { + var comments = await this.GetDanmuContentByMatAsync(vid, mat, cancellationToken); + danmuList.AddRange(comments); + } + + return danmuList; + } + + // mat从0开始,视频分钟数 + public async Task> GetDanmuContentByMatAsync(string vid, int mat, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(vid)) + { + return new List(); + } + + await EnsureTokenCookie(cancellationToken); + + + var ctime = new DateTimeOffset(DateTime.UtcNow).ToUnixTimeMilliseconds(); + var msg = new Dictionary() { + {"pid", 0}, + {"ctype", 10004}, + {"sver", "3.1.0"}, + {"cver", "v1.0"}, + {"ctime" , ctime}, + {"guid", this._cna}, + {"vid", vid}, + {"mat", mat}, + {"mcount", 1}, + {"type", 1} + }; + + // 需key按字母排序 + var msgOrdered = msg.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value).ToJson(); + var msgEnc = Convert.ToBase64String(Encoding.UTF8.GetBytes(msgOrdered)); + var sign = this.generateMsgSign(msgEnc); + msg.Add("msg", msgEnc); + msg.Add("sign", sign); + + + var appKey = "24679788"; + var data = msg.ToJson(); + var t = Convert.ToString(new DateTimeOffset(DateTime.UtcNow).ToUnixTimeMilliseconds()); + var param = HttpUtility.ParseQueryString(string.Empty); + param["jsv"] = "2.7.0"; + param["appKey"] = appKey; + param["t"] = t; + param["sign"] = this.generateTokenSign(t, appKey, data); + param["api"] = "mopen.youku.danmu.list"; + param["v"] = "1.0"; + param["type"] = "originaljson"; + param["dataType"] = "jsonp"; + param["timeout"] = "20000"; + param["jsonpIncPrefix"] = "utility"; + + var builder = new UriBuilder("https://acs.youku.com/h5/mopen.youku.danmu.list/1.0/"); + builder.Query = param.ToString(); + HttpResponseMessage response; + var formContent = new FormUrlEncodedContent(new[]{ + new KeyValuePair("data", data) + }); + using (var requestMessage = new HttpRequestMessage(HttpMethod. + Post, builder.Uri.ToString()) + { Content = formContent }) + { + requestMessage.Headers.Add("Referer", "https://v.youku.com"); + + response = await httpClient.SendAsync(requestMessage); + } + response.EnsureSuccessStatusCode(); + + var dd = await response.Content.ReadAsStringAsync(); + var result = await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (result != null && !string.IsNullOrEmpty(result.Data.Result)) + { + var commentResult = JsonSerializer.Deserialize(result.Data.Result); + if (commentResult != null && commentResult.Data != null) + { + return commentResult.Data.Result; + } + } + + return new List(); + } + + protected async Task LimitRequestFrequently() + { + await this._timeConstraint; + } + + protected async Task EnsureTokenCookie(CancellationToken cancellationToken) + { + var cookies = _cookieContainer.GetCookies(new Uri("https://mmstat.com", UriKind.Absolute)); + var cookie = cookies.FirstOrDefault(x => x.Name == "cna"); + if (cookie == null) + { + var url = "https://log.mmstat.com/eg.js"; + var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + // 重新读取最新 + cookies = _cookieContainer.GetCookies(new Uri("https://mmstat.com", UriKind.Absolute)); + cookie = cookies.FirstOrDefault(x => x.Name == "cna"); + } + if (cookie != null) + { + _cna = cookie.Value; + } + + + cookies = _cookieContainer.GetCookies(new Uri("https://youku.com", UriKind.Absolute)); + var tokenCookie = cookies.FirstOrDefault(x => x.Name == "_m_h5_tk"); + var tokenEncCookie = cookies.FirstOrDefault(x => x.Name == "_m_h5_tk_enc"); + if (tokenCookie == null || tokenEncCookie == null) + { + var url = "https://acs.youku.com/h5/mtop.com.youku.aplatform.weakget/1.0/?jsv=2.5.1&appKey=24679788"; + var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + // 重新读取最新 + cookies = _cookieContainer.GetCookies(new Uri("https://youku.com", UriKind.Absolute)); + tokenCookie = cookies.FirstOrDefault(x => x.Name == "_m_h5_tk"); + tokenEncCookie = cookies.FirstOrDefault(x => x.Name == "_m_h5_tk_enc"); + } + + if (tokenCookie != null) + { + _token = tokenCookie.Value; + } + if (tokenEncCookie != null) + { + _tokenEnc = tokenEncCookie.Value; + } + } + + + protected string generateMsgSign(string msgEnc) + { + return (msgEnc + "MkmC9SoIw6xCkSKHhJ7b5D2r51kBiREr").ToMD5().ToLower(); + } + + protected string generateTokenSign(string t, string appKey, string data) + { + var arr = new string[] { this._token.Substring(0, 32), t, appKey, data }; + return string.Join('&', arr).ToMD5().ToLower(); + } +} + diff --git a/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/AssemblyInfo.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/AssemblyInfo.cs new file mode 100644 index 0000000..1563afa --- /dev/null +++ b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ComposableAsync.Core.Test")] + diff --git a/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/Awaitable/DispatcherAwaiter.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/Awaitable/DispatcherAwaiter.cs new file mode 100644 index 0000000..061840a --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/ComposableAsync.Core/DelegatingHandler/DispatcherDelegatingHandler.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/DelegatingHandler/DispatcherDelegatingHandler.cs new file mode 100644 index 0000000..dbd8b49 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/ComposableAsync.Core/Dispatcher/ComposedDispatcher.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/Dispatcher/ComposedDispatcher.cs new file mode 100644 index 0000000..aed2434 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/ComposableAsync.Core/Dispatcher/DispatcherAdapter.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/Dispatcher/DispatcherAdapter.cs new file mode 100644 index 0000000..d6532ad --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/ComposableAsync.Core/Dispatcher/NullDispatcher.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/Dispatcher/NullDispatcher.cs new file mode 100644 index 0000000..4882f9e --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/ComposableAsync.Core/DispatcherExtension.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/DispatcherExtension.cs new file mode 100644 index 0000000..ef29912 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/ComposableAsync.Core/DispatcherManager/IDispatcherManager.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/DispatcherManager/IDispatcherManager.cs new file mode 100644 index 0000000..66c52d5 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/ComposableAsync.Core/DispatcherManager/MonoDispatcherManager.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/DispatcherManager/MonoDispatcherManager.cs new file mode 100644 index 0000000..f26ef5c --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/ComposableAsync.Core/DispatcherProviderExtension.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/DispatcherProviderExtension.cs new file mode 100644 index 0000000..f7046a1 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/ComposableAsync.Core/Disposable/ComposableAsyncDisposable.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/Disposable/ComposableAsyncDisposable.cs new file mode 100644 index 0000000..541d906 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/ComposableAsync.Core/Disposable/IAsyncDisposable.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/Disposable/IAsyncDisposable.cs new file mode 100644 index 0000000..3517491 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/ComposableAsync.Core/IBasicDispatcher.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/IBasicDispatcher.cs new file mode 100644 index 0000000..6e054dd --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/ComposableAsync.Core/IDispatcher.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/IDispatcher.cs new file mode 100644 index 0000000..d7eb14f --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/ComposableAsync.Core/IDispatcherProvider.cs b/Jellyfin.Plugin.Danmu/Vendor/ComposableAsync.Core/IDispatcherProvider.cs new file mode 100644 index 0000000..fb4c760 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/RateLimiter/AwaitableConstraintExtension.cs b/Jellyfin.Plugin.Danmu/Vendor/RateLimiter/AwaitableConstraintExtension.cs new file mode 100644 index 0000000..0a2dd65 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/RateLimiter/ComposedAwaitableConstraint.cs b/Jellyfin.Plugin.Danmu/Vendor/RateLimiter/ComposedAwaitableConstraint.cs new file mode 100644 index 0000000..278de53 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/RateLimiter/CountByIntervalAwaitableConstraint.cs b/Jellyfin.Plugin.Danmu/Vendor/RateLimiter/CountByIntervalAwaitableConstraint.cs new file mode 100644 index 0000000..460be4f --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/RateLimiter/DisposeAction.cs b/Jellyfin.Plugin.Danmu/Vendor/RateLimiter/DisposeAction.cs new file mode 100644 index 0000000..8fd44c0 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/RateLimiter/IAwaitableConstraint.cs b/Jellyfin.Plugin.Danmu/Vendor/RateLimiter/IAwaitableConstraint.cs new file mode 100644 index 0000000..a9d13d3 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/RateLimiter/ITime.cs b/Jellyfin.Plugin.Danmu/Vendor/RateLimiter/ITime.cs new file mode 100644 index 0000000..0faa0cd --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/RateLimiter/LimitedSizeStack.cs b/Jellyfin.Plugin.Danmu/Vendor/RateLimiter/LimitedSizeStack.cs new file mode 100644 index 0000000..bf1fe86 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/RateLimiter/PersistentCountByIntervalAwaitableConstraint.cs b/Jellyfin.Plugin.Danmu/Vendor/RateLimiter/PersistentCountByIntervalAwaitableConstraint.cs new file mode 100644 index 0000000..cda1e92 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/RateLimiter/TimeLimiter.cs b/Jellyfin.Plugin.Danmu/Vendor/RateLimiter/TimeLimiter.cs new file mode 100644 index 0000000..e36b8e9 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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.Danmu/Vendor/RateLimiter/TimeSystem.cs b/Jellyfin.Plugin.Danmu/Vendor/RateLimiter/TimeSystem.cs new file mode 100644 index 0000000..81b46e1 --- /dev/null +++ b/Jellyfin.Plugin.Danmu/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 2582528..a640d65 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # jellyfin-plugin-danmu [![Danmu](https://img.shields.io/github/v/release/cxfksword/jellyfin-plugin-danmu)](https://github.com/cxfksword/jellyfin-plugin-danmu/releases) -[![Danmu](https://img.shields.io/badge/jellyfin-10.8.x-lightgrey)](https://github.com/cxfksword/jellyfin-plugin-danmu/releases) +[![Danmu](https://img.shields.io/badge/jellyfin-10.8.x-lightgrey?logo=jellyfin)](https://github.com/cxfksword/jellyfin-plugin-danmu/releases) [![Danmu](https://img.shields.io/github/license/cxfksword/jellyfin-plugin-danmu)](https://github.com/cxfksword/jellyfin-plugin-danmu/main/LICENSE) -jellyfin的弹幕自动下载插件,已支持的弹幕来源:b站,弹弹play。 +jellyfin的弹幕自动下载插件,已支持的弹幕来源:b站,弹弹play,优酷。 支持功能: @@ -82,7 +82,3 @@ $ dotnet publish Jellyfin.Plugin.Danmu/Jellyfin.Plugin.Danmu.csproj [downkyi](https://github.com/leiurayer/downkyi) - -## License - -Apache License Version 2.0