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
[](https://github.com/cxfksword/jellyfin-plugin-danmu/releases)
-[](https://github.com/cxfksword/jellyfin-plugin-danmu/releases)
+[](https://github.com/cxfksword/jellyfin-plugin-danmu/releases)
[](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