Add youku danmu

This commit is contained in:
cxfksword
2023-02-02 21:55:54 +08:00
parent fb5f2a0649
commit 1a7a684f89
48 changed files with 2427 additions and 31 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
Jellyfin.Plugin.Danmu/Vendor/** linguist-vendored

View File

@@ -1,23 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Moq" Version="4.18.2" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.8" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.8" />
<PackageReference Include="coverlet.collector" Version="3.1.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Jellyfin.Plugin.Danmu\Jellyfin.Plugin.Danmu.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="Moq" Version="4.18.2" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.8" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.8" />
<PackageReference Include="coverlet.collector" Version="3.1.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Jellyfin.Plugin.Danmu\Jellyfin.Plugin.Danmu.csproj" />
</ItemGroup>
</Project>

View File

@@ -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();
}
}
}

View File

@@ -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;
// }
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();

View File

@@ -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<HttpResponseMessage> 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());
}
}
}
}
}

View File

@@ -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<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
base.SendAsync(request, cancellationToken);
}
}

View File

@@ -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<AnimeResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && result.Success)
{

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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<YoukuComment> Result { get; set; }
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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<YoukuEpisode> Videos { get; set; } = new List<YoukuEpisode>();
}
}

View File

@@ -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
{
/// <inheritdoc />
public class EpisodeExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => Youku.ScraperProviderName;
/// <inheritdoc />
public string Key => Youku.ScraperProviderId;
/// <inheritdoc />
public ExternalIdMediaType? Type => ExternalIdMediaType.Episode;
/// <inheritdoc />
public string UrlFormatString => "https://v.youku.com/v_show/id_{0}.html";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Episode;
}
}

View File

@@ -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
{
/// <inheritdoc />
public class MovieExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => Youku.ScraperProviderName;
/// <inheritdoc />
public string Key => Youku.ScraperProviderId;
/// <inheritdoc />
public ExternalIdMediaType? Type => null;
/// <inheritdoc />
public string UrlFormatString => "https://v.youku.com/v_show/id_{0}.html";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Movie;
}
}

View File

@@ -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
{
/// <inheritdoc />
public class SeasonExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => Youku.ScraperProviderName;
/// <inheritdoc />
public string Key => Youku.ScraperProviderId;
/// <inheritdoc />
public ExternalIdMediaType? Type => null;
/// <inheritdoc />
public string UrlFormatString => "https://v.youku.com/v_nextstage/id_{0}.html";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Season;
}
}

View File

@@ -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<Youku>())
{
_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<string?> 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<ScraperMedia?> 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<ScraperEpisode?> GetMediaEpisode(string id)
{
if (string.IsNullOrEmpty(id))
{
return null;
}
return new ScraperEpisode() { Id = id, CommentId = id };
}
public override async Task<ScraperDanmaku?> 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<YoukuCommentProperty>(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*第.季", "");
}
}

View File

@@ -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(@"<a.*?h5-show-card.*?>([\w\W]+?)</a>", RegexOptions.Compiled);
private static readonly Regex trackInfoReg = new Regex(@"data-trackinfo=""(\{[\w\W]+?\})""", RegexOptions.Compiled);
private static readonly Regex featureReg = new Regex(@"<div.*?show-feature.*?>([\w\W]+?)</div>", 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;
/// <summary>
/// Initializes a new instance of the <see cref="YoukuApi"/> class.
/// </summary>
/// <param name="loggerFactory">The <see cref="ILoggerFactory"/>.</param>
public YoukuApi(ILoggerFactory loggerFactory)
: base(loggerFactory.CreateLogger<YoukuApi>())
{
httpClient.DefaultRequestHeaders.Add("user-agent", HTTP_USER_AGENT);
}
public async Task<List<YoukuTrackInfo>> SearchAsync(string keyword, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(keyword))
{
return new List<YoukuTrackInfo>();
}
var cacheKey = $"search_{keyword}";
var expiredOption = new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) };
if (_memoryCache.TryGetValue<List<YoukuTrackInfo>>(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<YoukuTrackInfo>();
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<YoukuTrackInfo>(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<List<YoukuTrackInfo>>(cacheKey, result, expiredOption);
return result;
}
public async Task<YoukuVideo?> 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<YoukuVideo?>(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<YoukuVideo>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null)
{
_memoryCache.Set<YoukuVideo?>(cacheKey, result, expiredOption);
return result;
}
_memoryCache.Set<YoukuVideo?>(cacheKey, null, expiredOption);
return null;
}
public async Task<YoukuEpisode?> 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<YoukuEpisode?>(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<YoukuEpisode>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null)
{
_memoryCache.Set<YoukuEpisode?>(cacheKey, result, expiredOption);
return result;
}
_memoryCache.Set<YoukuEpisode?>(cacheKey, null, expiredOption);
return null;
}
public async Task<List<YoukuComment>> GetDanmuContentAsync(string vid, CancellationToken cancellationToken)
{
var danmuList = new List<YoukuComment>();
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<List<YoukuComment>> GetDanmuContentByMatAsync(string vid, int mat, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(vid))
{
return new List<YoukuComment>();
}
await EnsureTokenCookie(cancellationToken);
var ctime = new DateTimeOffset(DateTime.UtcNow).ToUnixTimeMilliseconds();
var msg = new Dictionary<string, object>() {
{"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<string, string>("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<YoukuRpcResult>(_jsonOptions, cancellationToken).ConfigureAwait(false);
if (result != null && !string.IsNullOrEmpty(result.Data.Result))
{
var commentResult = JsonSerializer.Deserialize<YoukuCommentResult>(result.Data.Result);
if (commentResult != null && commentResult.Data != null)
{
return commentResult.Data.Result;
}
}
return new List<YoukuComment>();
}
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();
}
}

View File

@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ComposableAsync.Core.Test")]

View File

@@ -0,0 +1,43 @@
using System;
using System.Runtime.CompilerServices;
using System.Security;
namespace ComposableAsync
{
/// <summary>
/// Dispatcher awaiter, making a dispatcher awaitable
/// </summary>
public struct DispatcherAwaiter : INotifyCompletion
{
/// <summary>
/// Dispatcher never is synchronous
/// </summary>
public bool IsCompleted => false;
private readonly IDispatcher _Dispatcher;
/// <summary>
/// Construct a NotifyCompletion fom a dispatcher
/// </summary>
/// <param name="dispatcher"></param>
public DispatcherAwaiter(IDispatcher dispatcher)
{
_Dispatcher = dispatcher;
}
/// <summary>
/// Dispatch on complete
/// </summary>
/// <param name="continuation"></param>
[SecuritySafeCritical]
public void OnCompleted(Action continuation)
{
_Dispatcher.Dispatch(continuation);
}
/// <summary>
/// No Result
/// </summary>
public void GetResult() { }
}
}

View File

@@ -0,0 +1,30 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace ComposableAsync
{
/// <summary>
/// A <see cref="DelegatingHandler"/> implementation based on <see cref="IDispatcher"/>
/// </summary>
internal class DispatcherDelegatingHandler : DelegatingHandler
{
private readonly IDispatcher _Dispatcher;
/// <summary>
/// Build an <see cref="DelegatingHandler"/> from a <see cref="IDispatcher"/>
/// </summary>
/// <param name="dispatcher"></param>
public DispatcherDelegatingHandler(IDispatcher dispatcher)
{
_Dispatcher = dispatcher;
InnerHandler = new HttpClientHandler();
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
return _Dispatcher.Enqueue(() => base.SendAsync(request, cancellationToken), cancellationToken);
}
}
}

View File

@@ -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<T> Enqueue<T>(Func<T> action)
{
return await _First.Enqueue(() => _Second.Enqueue(action));
}
public async Task Enqueue(Func<Task> action)
{
await _First.Enqueue(() => _Second.Enqueue(action));
}
public async Task<T> Enqueue<T>(Func<Task<T>> action)
{
return await _First.Enqueue(() => _Second.Enqueue(action));
}
public async Task Enqueue(Func<Task> action, CancellationToken cancellationToken)
{
await _First.Enqueue(() => _Second.Enqueue(action, cancellationToken), cancellationToken);
}
public async Task<T> Enqueue<T>(Func<Task<T>> action, CancellationToken cancellationToken)
{
return await _First.Enqueue(() => _Second.Enqueue(action, cancellationToken), cancellationToken);
}
public async Task<T> Enqueue<T>(Func<T> 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;
}
}

View File

@@ -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<T> Enqueue<T>(Func<T> action)
{
return _BasicDispatcher.Enqueue(action, CancellationToken.None);
}
public Task Enqueue(Func<Task> action)
{
return _BasicDispatcher.Enqueue(action, CancellationToken.None);
}
public Task<T> Enqueue<T>(Func<Task<T>> action)
{
return _BasicDispatcher.Enqueue(action, CancellationToken.None);
}
public Task<T> Enqueue<T>(Func<T> action, CancellationToken cancellationToken)
{
return _BasicDispatcher.Enqueue(action, cancellationToken);
}
public Task Enqueue(Action action, CancellationToken cancellationToken)
{
return _BasicDispatcher.Enqueue(action, cancellationToken);
}
public Task Enqueue(Func<Task> action, CancellationToken cancellationToken)
{
return _BasicDispatcher.Enqueue(action, cancellationToken);
}
public Task<T> Enqueue<T>(Func<Task<T>> action, CancellationToken cancellationToken)
{
return _BasicDispatcher.Enqueue(action, cancellationToken);
}
}
}

View File

@@ -0,0 +1,74 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ComposableAsync
{
/// <summary>
/// <see cref="IDispatcher"/> that run actions synchronously
/// </summary>
public sealed class NullDispatcher: IDispatcher
{
private NullDispatcher() { }
/// <summary>
/// Returns a static null dispatcher
/// </summary>
public static IDispatcher Instance { get; } = new NullDispatcher();
/// <inheritdoc />
public void Dispatch(Action action)
{
action();
}
/// <inheritdoc />
public Task Enqueue(Action action)
{
action();
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<T> Enqueue<T>(Func<T> action)
{
return Task.FromResult(action());
}
/// <inheritdoc />
public async Task Enqueue(Func<Task> action)
{
await action();
}
/// <inheritdoc />
public async Task<T> Enqueue<T>(Func<Task<T>> action)
{
return await action();
}
public Task<T> Enqueue<T>(Func<T> action, CancellationToken cancellationToken)
{
return Task.FromResult(action());
}
public Task Enqueue(Action action, CancellationToken cancellationToken)
{
action();
return Task.CompletedTask;
}
public async Task Enqueue(Func<Task> action, CancellationToken cancellationToken)
{
await action();
}
public async Task<T> Enqueue<T>(Func<Task<T>> action, CancellationToken cancellationToken)
{
return await action();
}
/// <inheritdoc />
public IDispatcher Clone() => Instance;
}
}

View File

@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
namespace ComposableAsync
{
/// <summary>
/// <see cref="IDispatcher"/> extension methods provider
/// </summary>
public static class DispatcherExtension
{
/// <summary>
/// Returns awaitable to enter in the dispatcher context
/// This extension method make a dispatcher awaitable
/// </summary>
/// <param name="dispatcher"></param>
/// <returns></returns>
public static DispatcherAwaiter GetAwaiter(this IDispatcher dispatcher)
{
return new DispatcherAwaiter(dispatcher);
}
/// <summary>
/// Returns a composed dispatcher applying the given dispatcher
/// after the first one
/// </summary>
/// <param name="dispatcher"></param>
/// <param name="other"></param>
/// <returns></returns>
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);
}
/// <summary>
/// Returns a composed dispatcher applying the given dispatchers sequentially
/// </summary>
/// <param name="dispatcher"></param>
/// <param name="others"></param>
/// <returns></returns>
public static IDispatcher Then(this IDispatcher dispatcher, params IDispatcher[] others)
{
return dispatcher.Then((IEnumerable<IDispatcher>)others);
}
/// <summary>
/// Returns a composed dispatcher applying the given dispatchers sequentially
/// </summary>
/// <param name="dispatcher"></param>
/// <param name="others"></param>
/// <returns></returns>
public static IDispatcher Then(this IDispatcher dispatcher, IEnumerable<IDispatcher> 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));
}
/// <summary>
/// Create a <see cref="DelegatingHandler"/> from an <see cref="IDispatcher"/>
/// </summary>
/// <param name="dispatcher"></param>
/// <returns></returns>
public static DelegatingHandler AsDelegatingHandler(this IDispatcher dispatcher)
{
return new DispatcherDelegatingHandler(dispatcher);
}
/// <summary>
/// Create a <see cref="IDispatcher"/> from an <see cref="IBasicDispatcher"/>
/// </summary>
/// <param name="basicDispatcher"></param>
/// <returns></returns>
public static IDispatcher ToFullDispatcher(this IBasicDispatcher @basicDispatcher)
{
return new DispatcherAdapter(@basicDispatcher);
}
}
}

View File

@@ -0,0 +1,19 @@
namespace ComposableAsync
{
/// <summary>
/// Dispatcher manager
/// </summary>
public interface IDispatcherManager : IAsyncDisposable
{
/// <summary>
/// true if the Dispatcher should be released
/// </summary>
bool DisposeDispatcher { get; }
/// <summary>
/// Returns a consumable Dispatcher
/// </summary>
/// <returns></returns>
IDispatcher GetDispatcher();
}
}

View File

@@ -0,0 +1,36 @@
using System.Threading.Tasks;
namespace ComposableAsync
{
/// <summary>
/// <see cref="IDispatcherManager"/> implementation based on single <see cref="IDispatcher"/>
/// </summary>
public sealed class MonoDispatcherManager : IDispatcherManager
{
/// <inheritdoc cref="IDispatcherManager"/>
public bool DisposeDispatcher { get; }
/// <inheritdoc cref="IDispatcherManager"/>
public IDispatcher GetDispatcher() => _Dispatcher;
private readonly IDispatcher _Dispatcher;
/// <summary>
/// Create
/// </summary>
/// <param name="dispatcher"></param>
/// <param name="shouldDispose"></param>
public MonoDispatcherManager(IDispatcher dispatcher, bool shouldDispose = false)
{
_Dispatcher = dispatcher;
DisposeDispatcher = shouldDispose;
}
/// <inheritdoc cref="IDispatcherManager"/>
public Task DisposeAsync()
{
return DisposeDispatcher && (_Dispatcher is IAsyncDisposable disposable) ?
disposable.DisposeAsync() : Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,18 @@
namespace ComposableAsync
{
/// <summary>
/// <see cref="IDispatcherProvider"/> extension
/// </summary>
public static class DispatcherProviderExtension
{
/// <summary>
/// Returns the underlying <see cref="IDispatcher"/>
/// </summary>
/// <param name="dispatcherProvider"></param>
/// <returns></returns>
public static IDispatcher GetAssociatedDispatcher(this IDispatcherProvider dispatcherProvider)
{
return dispatcherProvider?.Dispatcher ?? NullDispatcher.Instance;
}
}
}

View File

@@ -0,0 +1,48 @@
using System.Collections.Concurrent;
using System.Linq;
using System.Threading.Tasks;
namespace ComposableAsync
{
/// <summary>
/// <see cref="IAsyncDisposable"/> implementation aggregating other <see cref="IAsyncDisposable"/>
/// </summary>
public sealed class ComposableAsyncDisposable : IAsyncDisposable
{
private readonly ConcurrentQueue<IAsyncDisposable> _Disposables;
/// <summary>
/// Build an empty ComposableAsyncDisposable
/// </summary>
public ComposableAsyncDisposable()
{
_Disposables = new ConcurrentQueue<IAsyncDisposable>();
}
/// <summary>
/// Add an <see cref="IAsyncDisposable"/> to the ComposableAsyncDisposable
/// and returns it
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="disposable"></param>
/// <returns></returns>
public T Add<T>(T disposable) where T: IAsyncDisposable
{
if (disposable == null)
return default(T);
_Disposables.Enqueue(disposable);
return disposable;
}
/// <summary>
/// Dispose all the resources asynchronously
/// </summary>
/// <returns></returns>
public Task DisposeAsync()
{
var tasks = _Disposables.ToArray().Select(disposable => disposable.DisposeAsync()).ToArray();
return Task.WhenAll(tasks);
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
namespace ComposableAsync
{
/// <summary>
/// Asynchronous version of IDisposable
/// For reference see discussion: https://github.com/dotnet/roslyn/issues/114
/// </summary>
public interface IAsyncDisposable
{
/// <summary>
/// Performs asynchronously application-defined tasks associated with freeing,
/// releasing, or resetting unmanaged resources.
/// </summary>
Task DisposeAsync();
}
}

View File

@@ -0,0 +1,57 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ComposableAsync
{
/// <summary>
/// Simplified version of <see cref="IDispatcher"/> that can be converted
/// to a <see cref="IDispatcher"/> using the ToFullDispatcher extension method
/// </summary>
public interface IBasicDispatcher
{
/// <summary>
/// Clone dispatcher
/// </summary>
/// <returns></returns>
IBasicDispatcher Clone();
/// <summary>
/// Enqueue the function and return a task corresponding
/// to the execution of the task
/// /// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="action"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<T> Enqueue<T>(Func<T> action, CancellationToken cancellationToken);
/// <summary>
/// Enqueue the action and return a task corresponding
/// to the execution of the task
/// </summary>
/// <param name="action"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task Enqueue(Action action, CancellationToken cancellationToken);
/// <summary>
/// Enqueue the task and return a task corresponding
/// to the execution of the task
/// </summary>
/// <param name="action"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task Enqueue(Func<Task> action, CancellationToken cancellationToken);
/// <summary>
/// Enqueue the task and return a task corresponding
/// to the execution of the original task
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="action"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<T> Enqueue<T>(Func<Task<T>> action, CancellationToken cancellationToken);
}
}

View File

@@ -0,0 +1,98 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ComposableAsync
{
/// <summary>
/// Dispatcher executes an action or a function
/// on its own context
/// </summary>
public interface IDispatcher
{
/// <summary>
/// Execute action on dispatcher context in a
/// none-blocking way
/// </summary>
/// <param name="action"></param>
void Dispatch(Action action);
/// <summary>
/// Enqueue the action and return a task corresponding to
/// the completion of the action
/// </summary>
/// <param name="action"></param>
/// <returns></returns>
Task Enqueue(Action action);
/// <summary>
/// Enqueue the function and return a task corresponding to
/// the result of the function
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="action"></param>
/// <returns></returns>
Task<T> Enqueue<T>(Func<T> action);
/// <summary>
/// Enqueue the task and return a task corresponding to
/// the completion of the task
/// </summary>
/// <param name="action"></param>
/// <returns></returns>
Task Enqueue(Func<Task> action);
/// <summary>
/// Enqueue the task and return a task corresponding
/// to the execution of the original task
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="action"></param>
/// <returns></returns>
Task<T> Enqueue<T>(Func<Task<T>> action);
/// <summary>
/// Enqueue the function and return a task corresponding
/// to the execution of the task
/// /// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="action"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<T> Enqueue<T>(Func<T> action, CancellationToken cancellationToken);
/// <summary>
/// Enqueue the action and return a task corresponding
/// to the execution of the task
/// </summary>
/// <param name="action"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task Enqueue(Action action, CancellationToken cancellationToken);
/// <summary>
/// Enqueue the task and return a task corresponding
/// to the execution of the task
/// </summary>
/// <param name="action"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task Enqueue(Func<Task> action, CancellationToken cancellationToken);
/// <summary>
/// Enqueue the task and return a task corresponding
/// to the execution of the original task
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="action"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<T> Enqueue<T>(Func<Task<T>> action, CancellationToken cancellationToken);
/// <summary>
/// Clone dispatcher
/// </summary>
/// <returns></returns>
IDispatcher Clone();
}
}

View File

@@ -0,0 +1,13 @@
namespace ComposableAsync
{
/// <summary>
/// Returns the fiber associated with an actor
/// </summary>
public interface IDispatcherProvider
{
/// <summary>
/// Returns the corresponding <see cref="IDispatcher"/>
/// </summary>
IDispatcher Dispatcher { get; }
}
}

View File

@@ -0,0 +1,22 @@
namespace RateLimiter
{
/// <summary>
/// Provides extension to interface <see cref="IAwaitableConstraint"/>
/// </summary>
public static class AwaitableConstraintExtension
{
/// <summary>
/// Compose two awaitable constraint in a new one
/// </summary>
/// <param name="awaitableConstraint1"></param>
/// <param name="awaitableConstraint2"></param>
/// <returns></returns>
public static IAwaitableConstraint Compose(this IAwaitableConstraint awaitableConstraint1, IAwaitableConstraint awaitableConstraint2)
{
if (awaitableConstraint1 == awaitableConstraint2)
return awaitableConstraint1;
return new ComposedAwaitableConstraint(awaitableConstraint1, awaitableConstraint2);
}
}
}

View File

@@ -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<IDisposable> 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();
});
}
}
}

View File

@@ -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
{
/// <summary>
/// Provide an awaitable constraint based on number of times per duration
/// </summary>
public class CountByIntervalAwaitableConstraint : IAwaitableConstraint
{
/// <summary>
/// List of the last time stamps
/// </summary>
public IReadOnlyList<DateTime> TimeStamps => _TimeStamps.ToList();
/// <summary>
/// Stack of the last time stamps
/// </summary>
protected LimitedSizeStack<DateTime> _TimeStamps { get; }
private int _Count { get; }
private TimeSpan _TimeSpan { get; }
private SemaphoreSlim _Semaphore { get; } = new SemaphoreSlim(1, 1);
private ITime _Time { get; }
/// <summary>
/// Constructs a new AwaitableConstraint based on number of times per duration
/// </summary>
/// <param name="count"></param>
/// <param name="timeSpan"></param>
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<DateTime>(_Count);
_Time = time;
}
/// <summary>
/// returns a task that will complete once the constraint is fulfilled
/// </summary>
/// <param name="cancellationToken">
/// Cancel the wait
/// </param>
/// <returns>
/// A disposable that should be disposed upon task completion
/// </returns>
public async Task<IDisposable> WaitForReadiness(CancellationToken cancellationToken)
{
await _Semaphore.WaitAsync(cancellationToken);
var count = 0;
var now = _Time.GetNow();
var target = now - _TimeSpan;
LinkedListNode<DateTime> 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);
}
/// <summary>
/// Clone CountByIntervalAwaitableConstraint
/// </summary>
/// <returns></returns>
public IAwaitableConstraint Clone()
{
return new CountByIntervalAwaitableConstraint(_Count, _TimeSpan, _Time);
}
private void OnEnded()
{
var now = _Time.GetNow();
_TimeStamps.Push(now);
OnEnded(now);
_Semaphore.Release();
}
/// <summary>
/// Called when action has been executed
/// </summary>
/// <param name="now"></param>
protected virtual void OnEnded(DateTime now)
{
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace RateLimiter
{
/// <summary>
/// Represents a time constraints that can be awaited
/// </summary>
public interface IAwaitableConstraint
{
/// <summary>
/// returns a task that will complete once the constraint is fulfilled
/// </summary>
/// <param name="cancellationToken">
/// Cancel the wait
/// </param>
/// <returns>
/// A disposable that should be disposed upon task completion
/// </returns>
Task<IDisposable> WaitForReadiness(CancellationToken cancellationToken);
/// <summary>
/// Returns a new IAwaitableConstraint with same constraints but unused
/// </summary>
/// <returns></returns>
IAwaitableConstraint Clone();
}
}

View File

@@ -0,0 +1,26 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace RateLimiter
{
/// <summary>
/// Time abstraction
/// </summary>
internal interface ITime
{
/// <summary>
/// Return Now DateTime
/// </summary>
/// <returns></returns>
DateTime GetNow();
/// <summary>
/// Returns a task delay
/// </summary>
/// <param name="timespan"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task GetDelay(TimeSpan timespan, CancellationToken cancellationToken);
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Generic;
namespace RateLimiter
{
/// <summary>
/// LinkedList with a limited size
/// If the size exceeds the limit older entry are removed
/// </summary>
/// <typeparam name="T"></typeparam>
public class LimitedSizeStack<T>: LinkedList<T>
{
private readonly int _MaxSize;
/// <summary>
/// Construct the LimitedSizeStack with the given limit
/// </summary>
/// <param name="maxSize"></param>
public LimitedSizeStack(int maxSize)
{
_MaxSize = maxSize;
}
/// <summary>
/// Push new entry. If he size exceeds the limit, the oldest entry is removed
/// </summary>
/// <param name="item"></param>
public void Push(T item)
{
AddFirst(item);
if (Count > _MaxSize)
RemoveLast();
}
}
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
namespace RateLimiter
{
/// <summary>
/// <see cref="CountByIntervalAwaitableConstraint"/> that is able to save own state.
/// </summary>
public sealed class PersistentCountByIntervalAwaitableConstraint : CountByIntervalAwaitableConstraint
{
private readonly Action<DateTime> _SaveStateAction;
/// <summary>
/// Create an instance of <see cref="PersistentCountByIntervalAwaitableConstraint"/>.
/// </summary>
/// <param name="count">Maximum actions allowed per time interval.</param>
/// <param name="timeSpan">Time interval limits are applied for.</param>
/// <param name="saveStateAction">Action is used to save state.</param>
/// <param name="initialTimeStamps">Initial timestamps.</param>
public PersistentCountByIntervalAwaitableConstraint(int count, TimeSpan timeSpan,
Action<DateTime> saveStateAction, IEnumerable<DateTime> initialTimeStamps) : base(count, timeSpan)
{
_SaveStateAction = saveStateAction;
if (initialTimeStamps == null)
return;
foreach (var timeStamp in initialTimeStamps)
{
_TimeStamps.Push(timeStamp);
}
}
/// <summary>
/// Save state
/// </summary>
protected override void OnEnded(DateTime now)
{
_SaveStateAction(now);
}
}
}

View File

@@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ComposableAsync;
namespace RateLimiter
{
/// <summary>
/// TimeLimiter implementation
/// </summary>
public class TimeLimiter : IDispatcher
{
private readonly IAwaitableConstraint _AwaitableConstraint;
internal TimeLimiter(IAwaitableConstraint awaitableConstraint)
{
_AwaitableConstraint = awaitableConstraint;
}
/// <summary>
/// Perform the given task respecting the time constraint
/// returning the result of given function
/// </summary>
/// <param name="perform"></param>
/// <returns></returns>
public Task Enqueue(Func<Task> perform)
{
return Enqueue(perform, CancellationToken.None);
}
/// <summary>
/// Perform the given task respecting the time constraint
/// returning the result of given function
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="perform"></param>
/// <returns></returns>
public Task<T> Enqueue<T>(Func<Task<T>> perform)
{
return Enqueue(perform, CancellationToken.None);
}
/// <summary>
/// Perform the given task respecting the time constraint
/// </summary>
/// <param name="perform"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task Enqueue(Func<Task> perform, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
using (await _AwaitableConstraint.WaitForReadiness(cancellationToken))
{
await perform();
}
}
/// <summary>
/// Perform the given task respecting the time constraint
/// returning the result of given function
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="perform"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<T> Enqueue<T>(Func<Task<T>> perform, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
using (await _AwaitableConstraint.WaitForReadiness(cancellationToken))
{
return await perform();
}
}
public IDispatcher Clone() => new TimeLimiter(_AwaitableConstraint.Clone());
private static Func<Task> Transform(Action act)
{
return () => { act(); return Task.FromResult(0); };
}
/// <summary>
/// Perform the given task respecting the time constraint
/// returning the result of given function
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="compute"></param>
/// <returns></returns>
private static Func<Task<T>> Transform<T>(Func<T> compute)
{
return () => Task.FromResult(compute());
}
/// <summary>
/// Perform the given task respecting the time constraint
/// </summary>
/// <param name="perform"></param>
/// <returns></returns>
public Task Enqueue(Action perform)
{
var transformed = Transform(perform);
return Enqueue(transformed);
}
/// <summary>
/// Perform the given task respecting the time constraint
/// </summary>
/// <param name="action"></param>
public void Dispatch(Action action)
{
Enqueue(action);
}
/// <summary>
/// Perform the given task respecting the time constraint
/// returning the result of given function
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="perform"></param>
/// <returns></returns>
public Task<T> Enqueue<T>(Func<T> perform)
{
var transformed = Transform(perform);
return Enqueue(transformed);
}
/// <summary>
/// Perform the given task respecting the time constraint
/// returning the result of given function
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="perform"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public Task<T> Enqueue<T>(Func<T> perform, CancellationToken cancellationToken)
{
var transformed = Transform(perform);
return Enqueue(transformed, cancellationToken);
}
/// <summary>
/// Perform the given task respecting the time constraint
/// </summary>
/// <param name="perform"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public Task Enqueue(Action perform, CancellationToken cancellationToken)
{
var transformed = Transform(perform);
return Enqueue(transformed, cancellationToken);
}
/// <summary>
/// Returns a TimeLimiter based on a maximum number of times
/// during a given period
/// </summary>
/// <param name="maxCount"></param>
/// <param name="timeSpan"></param>
/// <returns></returns>
public static TimeLimiter GetFromMaxCountByInterval(int maxCount, TimeSpan timeSpan)
{
return new TimeLimiter(new CountByIntervalAwaitableConstraint(maxCount, timeSpan));
}
/// <summary>
/// Create <see cref="TimeLimiter"/> that will save state using action passed through <paramref name="saveStateAction"/> parameter.
/// </summary>
/// <param name="maxCount">Maximum actions allowed per time interval.</param>
/// <param name="timeSpan">Time interval limits are applied for.</param>
/// <param name="saveStateAction">Action is used to save state.</param>
/// <returns><see cref="TimeLimiter"/> instance with <see cref="PersistentCountByIntervalAwaitableConstraint"/>.</returns>
public static TimeLimiter GetPersistentTimeLimiter(int maxCount, TimeSpan timeSpan,
Action<DateTime> saveStateAction)
{
return GetPersistentTimeLimiter(maxCount, timeSpan, saveStateAction, null);
}
/// <summary>
/// Create <see cref="TimeLimiter"/> with initial timestamps that will save state using action passed through <paramref name="saveStateAction"/> parameter.
/// </summary>
/// <param name="maxCount">Maximum actions allowed per time interval.</param>
/// <param name="timeSpan">Time interval limits are applied for.</param>
/// <param name="saveStateAction">Action is used to save state.</param>
/// <param name="initialTimeStamps">Initial timestamps.</param>
/// <returns><see cref="TimeLimiter"/> instance with <see cref="PersistentCountByIntervalAwaitableConstraint"/>.</returns>
public static TimeLimiter GetPersistentTimeLimiter(int maxCount, TimeSpan timeSpan,
Action<DateTime> saveStateAction, IEnumerable<DateTime> initialTimeStamps)
{
return new TimeLimiter(new PersistentCountByIntervalAwaitableConstraint(maxCount, timeSpan, saveStateAction, initialTimeStamps));
}
/// <summary>
/// Compose various IAwaitableConstraint in a TimeLimiter
/// </summary>
/// <param name="constraints"></param>
/// <returns></returns>
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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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