Merge branch 'feature/scripting' into dev

This commit is contained in:
genteure 2022-05-14 22:28:31 +08:00
commit effa694a65
20 changed files with 877 additions and 28 deletions

View File

@ -37,7 +37,8 @@ namespace BililiveRecorder.Cli.Configure
TimingWatchdogTimeout,
RecordDanmakuFlushInterval,
NetworkTransportUseSystemProxy,
NetworkTransportAllowedAddressFamily
NetworkTransportAllowedAddressFamily,
UserScript
}
public enum RoomConfigProperties
{
@ -86,6 +87,7 @@ namespace BililiveRecorder.Cli.Configure
GlobalConfig.Add(GlobalConfigProperties.RecordDanmakuFlushInterval, new ConfigInstruction<GlobalConfig, uint>(config => config.HasRecordDanmakuFlushInterval = false, (config, value) => config.RecordDanmakuFlushInterval = value) { Name = "RecordDanmakuFlushInterval", CanBeOptional = true });
GlobalConfig.Add(GlobalConfigProperties.NetworkTransportUseSystemProxy, new ConfigInstruction<GlobalConfig, bool>(config => config.HasNetworkTransportUseSystemProxy = false, (config, value) => config.NetworkTransportUseSystemProxy = value) { Name = "NetworkTransportUseSystemProxy", CanBeOptional = true });
GlobalConfig.Add(GlobalConfigProperties.NetworkTransportAllowedAddressFamily, new ConfigInstruction<GlobalConfig, AllowedAddressFamily>(config => config.HasNetworkTransportAllowedAddressFamily = false, (config, value) => config.NetworkTransportAllowedAddressFamily = value) { Name = "NetworkTransportAllowedAddressFamily", CanBeOptional = true });
GlobalConfig.Add(GlobalConfigProperties.UserScript, new ConfigInstruction<GlobalConfig, string>(config => config.HasUserScript = false, (config, value) => config.UserScript = value) { Name = "UserScript", CanBeOptional = true });
RoomConfig.Add(RoomConfigProperties.RoomId, new ConfigInstruction<RoomConfig, int>(config => config.HasRoomId = false, (config, value) => config.RoomId = value) { Name = "RoomId", CanBeOptional = false });
RoomConfig.Add(RoomConfigProperties.AutoRecord, new ConfigInstruction<RoomConfig, bool>(config => config.HasAutoRecord = false, (config, value) => config.AutoRecord = value) { Name = "AutoRecord", CanBeOptional = false });

View File

@ -5,10 +5,15 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<DebugType>portable</DebugType>
<DebugSymbols>true</DebugSymbols>
<RestoreAdditionalProjectSources>
https://api.nuget.org/v3/index.json;
https://www.myget.org/F/jint/api/v3/index.json;
</RestoreAdditionalProjectSources>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Fluid.Core" Version="2.2.8" />
<PackageReference Include="Jint" Version="3.0.0-preview-275" />
<PackageReference Include="JsonSubTypes" Version="1.8.0" />
<PackageReference Include="HierarchicalPropertyDefault" Version="0.1.4-beta-g75fdf624b1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />

View File

@ -176,6 +176,11 @@ namespace BililiveRecorder.Core.Config.V3
/// </summary>
public AllowedAddressFamily NetworkTransportAllowedAddressFamily => this.GetPropertyValue<AllowedAddressFamily>();
/// <summary>
/// 自定义脚本
/// </summary>
public string UserScript => this.GetPropertyValue<string>();
}
[JsonObject(MemberSerialization.OptIn)]
@ -373,6 +378,14 @@ namespace BililiveRecorder.Core.Config.V3
[JsonProperty(nameof(NetworkTransportAllowedAddressFamily)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<AllowedAddressFamily> OptionalNetworkTransportAllowedAddressFamily { get => this.GetPropertyValueOptional<AllowedAddressFamily>(nameof(this.NetworkTransportAllowedAddressFamily)); set => this.SetPropertyValueOptional(value, nameof(this.NetworkTransportAllowedAddressFamily)); }
/// <summary>
/// 自定义脚本
/// </summary>
public string UserScript { get => this.GetPropertyValue<string>(); set => this.SetPropertyValue(value); }
public bool HasUserScript { get => this.GetPropertyHasValue(nameof(this.UserScript)); set => this.SetPropertyHasValue<string>(value, nameof(this.UserScript)); }
[JsonProperty(nameof(UserScript)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<string> OptionalUserScript { get => this.GetPropertyValueOptional<string>(nameof(this.UserScript)); set => this.SetPropertyValueOptional(value, nameof(this.UserScript)); }
}
public sealed partial class DefaultConfig
@ -428,6 +441,8 @@ namespace BililiveRecorder.Core.Config.V3
public AllowedAddressFamily NetworkTransportAllowedAddressFamily => AllowedAddressFamily.Any;
public string UserScript => string.Empty;
}
}

View File

@ -5,6 +5,7 @@ using BililiveRecorder.Core.Api.Http;
using BililiveRecorder.Core.Config.V3;
using BililiveRecorder.Core.Danmaku;
using BililiveRecorder.Core.Recording;
using BililiveRecorder.Core.Scripting;
using BililiveRecorder.Core.Templating;
using BililiveRecorder.Flv;
using Microsoft.Extensions.DependencyInjection;
@ -27,6 +28,7 @@ namespace BililiveRecorder.DependencyInjection
.AddSingleton<IRecorder, Recorder>()
.AddSingleton<IRoomFactory, RoomFactory>()
.AddScoped<IBasicDanmakuWriter, BasicDanmakuWriter>()
.AddSingleton<UserScriptRunner>()
;
private static IServiceCollection AddRecorderPollyPolicy(this IServiceCollection services) => services

View File

@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using BililiveRecorder.Core.Api;
using BililiveRecorder.Core.Event;
using BililiveRecorder.Core.Scripting;
using BililiveRecorder.Core.Templating;
using Serilog;
@ -16,11 +17,13 @@ namespace BililiveRecorder.Core.Recording
public RawDataRecordTask(IRoom room,
ILogger logger,
IApiClient apiClient,
FileNameGenerator fileNameGenerator)
FileNameGenerator fileNameGenerator,
UserScriptRunner userScriptRunner)
: base(room: room,
logger: logger?.ForContext<RawDataRecordTask>().ForContext(LoggingContext.RoomId, room.RoomConfig.RoomId)!,
apiClient: apiClient,
fileNameGenerator: fileNameGenerator)
fileNameGenerator: fileNameGenerator,
userScriptRunner: userScriptRunner)
{
}

View File

@ -10,6 +10,7 @@ using System.Timers;
using BililiveRecorder.Core.Api;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Event;
using BililiveRecorder.Core.Scripting;
using BililiveRecorder.Core.Templating;
using Serilog;
using Timer = System.Timers.Timer;
@ -33,6 +34,7 @@ namespace BililiveRecorder.Core.Recording
protected readonly ILogger logger;
protected readonly IApiClient apiClient;
private readonly FileNameGenerator fileNameGenerator;
private readonly UserScriptRunner userScriptRunner;
protected string? streamHost;
protected bool started = false;
@ -51,12 +53,13 @@ namespace BililiveRecorder.Core.Recording
private DateTimeOffset ioStatsLastTrigger;
private TimeSpan durationSinceNoDataReceived;
protected RecordTaskBase(IRoom room, ILogger logger, IApiClient apiClient, FileNameGenerator fileNameGenerator)
protected RecordTaskBase(IRoom room, ILogger logger, IApiClient apiClient, FileNameGenerator fileNameGenerator, UserScriptRunner userScriptRunner)
{
this.room = room ?? throw new ArgumentNullException(nameof(room));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
this.fileNameGenerator = fileNameGenerator ?? throw new ArgumentNullException(nameof(fileNameGenerator));
this.userScriptRunner = userScriptRunner ?? throw new ArgumentNullException(nameof(userScriptRunner));
this.ct = this.cts.Token;
this.timer.Elapsed += this.Timer_Elapsed_TriggerIOStats;
@ -95,6 +98,7 @@ namespace BililiveRecorder.Core.Recording
this.streamHost = new Uri(fullUrl).Host;
var qnDesc = qn switch
{
30000 => "杜比",
20000 => "4K",
10000 => "原画",
401 => "蓝光(杜比)",
@ -102,6 +106,7 @@ namespace BililiveRecorder.Core.Recording
250 => "超清",
150 => "高清",
80 => "流畅",
-1 => "录播姬用户脚本",
_ => "未知"
};
this.logger.Information("连接直播服务器 {Host} 录制画质 {Qn} ({QnDescription})", this.streamHost, qn, qnDesc);
@ -218,6 +223,19 @@ namespace BililiveRecorder.Core.Recording
protected async Task<(string url, int qn)> FetchStreamUrlAsync(int roomid)
{
var qns = this.room.RoomConfig.RecordingQuality?.Split(new[] { ',', '', '、', ' ' }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => int.TryParse(x, out var num) ? num : -1)
.Where(x => x > 0)
.ToArray()
?? Array.Empty<int>();
// 优先使用用户脚本获取直播流地址
if (this.userScriptRunner.CallOnFetchStreamUrl(this.logger, roomid, qns) is { } urlFromScript)
{
this.logger.Information("使用用户脚本返回的直播流地址 {Url}", urlFromScript);
return (urlFromScript, -1);
}
const int DefaultQn = 10000;
var selected_qn = DefaultQn;
var codecItem = await this.apiClient.GetCodecItemInStreamUrlAsync(roomid: roomid, qn: DefaultQn).ConfigureAwait(false);
@ -225,12 +243,6 @@ namespace BililiveRecorder.Core.Recording
if (codecItem is null)
throw new Exception("no supported stream url, qn: " + DefaultQn);
var qns = this.room.RoomConfig.RecordingQuality?.Split(new[] { ',', '', '、', ' ' }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => int.TryParse(x, out var num) ? num : -1)
.Where(x => x > 0)
.ToArray()
?? Array.Empty<int>();
// Select first avaiable qn
foreach (var qn in qns)
{
@ -280,10 +292,37 @@ namespace BililiveRecorder.Core.Recording
while (true)
{
var originalUri = new Uri(fullUrl);
var allowedAddressFamily = this.room.RoomConfig.NetworkTransportAllowedAddressFamily;
HttpRequestMessage request;
if (this.userScriptRunner.CallOnTransformStreamUrl(this.logger, fullUrl) is { } scriptResult)
{
var (scriptUrl, scriptIp) = scriptResult;
this.logger.Debug("用户脚本重定向了直播流地址 {NewUrl}, 旧地址 {OldUrl}", scriptUrl, fullUrl);
fullUrl = scriptUrl;
if (scriptIp is not null)
{
this.logger.Debug("用户脚本指定了服务器 IP {IP}", scriptIp);
request = new HttpRequestMessage(HttpMethod.Get, fullUrl);
var uri = new Uri(fullUrl);
var builder = new UriBuilder(uri)
{
Host = scriptIp
};
request = new HttpRequestMessage(HttpMethod.Get, builder.Uri);
request.Headers.Host = uri.IsDefaultPort ? uri.Host : uri.Host + ":" + uri.Port;
goto sendRequest;
}
}
var originalUri = new Uri(fullUrl);
if (allowedAddressFamily == AllowedAddressFamily.System)
{
this.logger.Debug("NetworkTransportAllowedAddressFamily is System");
@ -319,6 +358,8 @@ namespace BililiveRecorder.Core.Recording
request.Headers.Host = originalUri.IsDefaultPort ? originalUri.Host : originalUri.Host + ":" + originalUri.Port;
}
sendRequest:
var resp = await client.SendAsync(request,
HttpCompletionOption.ResponseHeadersRead,
new CancellationTokenSource(timeout).Token)

View File

@ -9,6 +9,7 @@ using BililiveRecorder.Core.Api;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Event;
using BililiveRecorder.Core.ProcessingRules;
using BililiveRecorder.Core.Scripting;
using BililiveRecorder.Core.Templating;
using BililiveRecorder.Flv;
using BililiveRecorder.Flv.Amf;
@ -42,11 +43,13 @@ namespace BililiveRecorder.Core.Recording
IFlvTagReaderFactory flvTagReaderFactory,
ITagGroupReaderFactory tagGroupReaderFactory,
IFlvProcessingContextWriterFactory writerFactory,
FileNameGenerator fileNameGenerator)
FileNameGenerator fileNameGenerator,
UserScriptRunner userScriptRunner)
: base(room: room,
logger: logger?.ForContext<StandardRecordTask>().ForContext(LoggingContext.RoomId, room.RoomConfig.RoomId)!,
apiClient: apiClient,
fileNameGenerator: fileNameGenerator)
fileNameGenerator: fileNameGenerator,
userScriptRunner: userScriptRunner)
{
this.flvTagReaderFactory = flvTagReaderFactory ?? throw new ArgumentNullException(nameof(flvTagReaderFactory));
this.tagGroupReaderFactory = tagGroupReaderFactory ?? throw new ArgumentNullException(nameof(tagGroupReaderFactory));

View File

@ -0,0 +1,211 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using Jint;
using Jint.Native;
using Jint.Native.Json;
using Jint.Native.Object;
using Jint.Runtime;
using Jint.Runtime.Interop;
using Serilog;
using Serilog.Events;
namespace BililiveRecorder.Core.Scripting.Runtime
{
public class JintConsole : ObjectInstance
{
private readonly ILogger logger;
private static readonly IReadOnlyList<string> templateMessageMap;
private const int MaxTemplateSlotCount = 8;
private readonly Dictionary<string, int> counters = new();
private readonly Dictionary<string, Stopwatch> timers = new();
static JintConsole()
{
var map = new List<string>();
templateMessageMap = map;
var b = new StringBuilder("[Script]");
for (var i = 1; i <= MaxTemplateSlotCount; i++)
{
b.Append(" {Message");
b.Append(i);
b.Append("}");
map.Add(b.ToString());
}
}
public JintConsole(Engine engine, ILogger logger) : base(engine)
{
this.logger = logger?.ForContext<JintConsole>() ?? throw new ArgumentNullException(nameof(logger));
}
protected override void Initialize()
{
Add("assert", this.Assert);
Add("clear", this.Clear);
Add("count", this.Count);
Add("countReset", this.CountReset);
Add("debug", this.BuildLogFunc(LogEventLevel.Debug));
Add("error", this.BuildLogFunc(LogEventLevel.Error));
Add("info", this.BuildLogFunc(LogEventLevel.Information));
Add("log", this.BuildLogFunc(LogEventLevel.Information));
Add("time", this.Time);
Add("timeEnd", this.TimeEnd);
Add("timeLog", this.TimeLog);
Add("trace", this.BuildLogFunc(LogEventLevel.Information));
Add("warn", this.BuildLogFunc(LogEventLevel.Warning));
[MethodImpl(MethodImplOptions.AggressiveInlining)]
void Add(string name, Func<JsValue, JsValue[], JsValue> func)
{
this.FastAddProperty(name, new ClrFunctionInstance(this._engine, name, func), false, false, false);
}
}
private string[] FormatToString(ReadOnlySpan<JsValue> values)
{
var result = new string[values.Length];
var jsonSerializer = new Lazy<JsonSerializer>(() => new JsonSerializer(this._engine));
for (var i = 0; i < values.Length; i++)
{
var value = values[i];
var text = value switch
{
JsString jsString => jsString.ToString(),
JsBoolean or JsNumber or JsBigInt or JsNull or JsUndefined => value.ToString(),
_ => jsonSerializer.Value.Serialize(values[i], Undefined, Undefined).ToString()
};
result[i] = text;
}
return result;
}
// TODO: Add call stack support
// Workaround: use `new Error().stack` in js side
// ref: https://github.com/sebastienros/jint/discussions/1115
private Func<JsValue, JsValue[], JsValue> BuildLogFunc(LogEventLevel level)
{
return Log;
JsValue Log(JsValue thisObject, JsValue[] arguments)
{
var messages = this.FormatToString(arguments);
if (messages.Length > 0 && messages.Length <= MaxTemplateSlotCount)
{
// Serilog quote "Catch a common pitfall when a single non-object array is cast to object[]"
// ref: https://github.com/serilog/serilog/blob/fabc2cbe637c9ddfa2d1ddc9f502df120f444acd/src/Serilog/Core/Logger.cs#L368
var values = messages.Cast<object>().ToArray();
// Note: this is the non-generic, `params object[]` version
// void Write(LogEventLevel level, string messageTemplate, params object[] propertyValues);
this.logger.Write(level: level, messageTemplate: templateMessageMap[messages.Length - 1], propertyValues: values);
}
else
{
// void Write<T>(LogEventLevel level, string messageTemplate, T propertyValue);
this.logger.Write(level: level, messageTemplate: "[Script] {Messages}", propertyValue: messages);
}
return Undefined;
}
}
private JsValue Assert(JsValue thisObject, JsValue[] arguments)
{
if (!arguments.At(0).IsLooselyEqual(true))
{
string[] messages;
if (arguments.Length < 2)
{
messages = Array.Empty<string>();
}
else
{
messages = this.FormatToString(arguments.AsSpan(1));
}
this.logger.Error("[Script] Assertion failed: {Messages}", messages);
}
return Undefined;
}
private JsValue Clear(JsValue thisObject, JsValue[] arguments) => Undefined; // noop
private JsValue Count(JsValue thisObject, JsValue[] arguments)
{
var name = arguments.Length > 0 ? arguments[0].ToString() : "default";
if (this.counters.TryGetValue(name, out var count))
{
count++;
}
else
{
count = 1;
}
this.counters[name] = count;
this.logger.Information("[Script] {CounterName}: {Count}", name, count);
return Undefined;
}
private JsValue CountReset(JsValue thisObject, JsValue[] arguments)
{
var name = arguments.Length > 0 ? arguments[0].ToString() : "default";
this.counters.Remove(name);
this.logger.Information("[Script] {CounterName}: {Count}", name, 0);
return Undefined;
}
private JsValue Time(JsValue thisObject, JsValue[] arguments)
{
var name = arguments.Length > 0 ? arguments[0].ToString() : "default";
if (this.timers.ContainsKey(name))
{
this.logger.Warning("[Script] Timer {TimerName} already exists", name);
}
else
{
this.timers[name] = Stopwatch.StartNew();
}
return Undefined;
}
private JsValue TimeEnd(JsValue thisObject, JsValue[] arguments)
{
var name = arguments.Length > 0 ? arguments[0].ToString() : "default";
if (this.timers.TryGetValue(name, out var timer))
{
timer.Stop();
this.timers.Remove(name);
this.logger.Information("[Script] {TimerName}: {ElapsedMilliseconds} ms", name, timer.ElapsedMilliseconds);
}
else
{
this.logger.Warning("[Script] Timer {TimerName} does not exist", name);
}
return Undefined;
}
private JsValue TimeLog(JsValue thisObject, JsValue[] arguments)
{
var name = arguments.Length > 0 ? arguments[0].ToString() : "default";
if (this.timers.TryGetValue(name, out var timer))
{
this.logger.Information("[Script] {TimerName}: {ElapsedMilliseconds} ms", name, timer.ElapsedMilliseconds);
}
else
{
this.logger.Warning("[Script] Timer {TimerName} does not exist", name);
}
return Undefined;
}
}
}

View File

@ -0,0 +1,45 @@
using System;
using System.Linq;
using System.Net;
using System.Runtime.CompilerServices;
using Jint;
using Jint.Native;
using Jint.Native.Object;
using Jint.Runtime;
using Jint.Runtime.Interop;
namespace BililiveRecorder.Core.Scripting.Runtime
{
public class JintDns : ObjectInstance
{
public JintDns(Engine engine) : base(engine)
{
}
protected override void Initialize()
{
Add("lookup", this.Lookup);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
void Add(string name, Func<JsValue, JsValue[], JsValue> func)
{
this.FastAddProperty(name, new ClrFunctionInstance(this._engine, name, func), false, false, false);
}
}
private JsValue Lookup(JsValue thisObject, JsValue[] arguments)
{
string[] result;
try
{
result = Dns.GetHostAddresses(arguments.At(0).AsString()).Select(x => x.ToString()).ToArray();
}
catch (Exception)
{
result = Array.Empty<string>();
}
return FromObject(this._engine, result);
}
}
}

View File

@ -0,0 +1,39 @@
using System;
using System.Collections.Specialized;
using System.Net;
using System.Net.Http;
using System.Web;
using Jint;
using Jint.Native.Object;
using Jint.Runtime.Interop;
namespace BililiveRecorder.Core.Scripting.Runtime
{
public class JintDotnet : ObjectInstance
{
public JintDotnet(Engine engine) : base(engine)
{
}
protected override void Initialize()
{
this.FastAddProperty("Dns", TypeReference.CreateTypeReference(this._engine, typeof(Dns)), false, false, false);
AddTypeAsProperty<Uri>();
AddTypeAsProperty<UriBuilder>();
AddTypeAsProperty<HttpUtility>();
AddTypeAsProperty<NameValueCollection>();
AddTypeAsProperty<HttpClient>();
AddTypeAsProperty<HttpClientHandler>();
AddTypeAsProperty<HttpCompletionOption>();
AddTypeAsProperty<HttpRequestMessage>();
AddTypeAsProperty<HttpMethod>();
AddTypeAsProperty<ByteArrayContent>();
AddTypeAsProperty<StringContent>();
AddTypeAsProperty<FormUrlEncodedContent>();
void AddTypeAsProperty<T>() => this.FastAddProperty(typeof(T).Name, TypeReference.CreateTypeReference<T>(this._engine), false, false, false);
}
}
}

View File

@ -0,0 +1,195 @@
using System.Net;
using System.Net.Http;
using Jint;
using Jint.Native;
using Jint.Native.Array;
using Jint.Native.Function;
using Jint.Native.Object;
using Jint.Runtime;
namespace BililiveRecorder.Core.Scripting.Runtime
{
public class JintFetchSync : FunctionInstance
{
private static readonly JsString functionName = new JsString("fetchSync");
public JintFetchSync(Engine engine) : base(engine, engine.Realm, functionName)
{
}
protected override JsValue Call(JsValue thisObject, JsValue[] arguments)
{
if (arguments.Length == 0)
throw new JavaScriptException(this._engine.Realm.Intrinsics.Error, "1 argument required, but only 0 present.");
if (arguments[0] is not JsString urlString)
throw new JavaScriptException(this._engine.Realm.Intrinsics.Error, "Only url string is supported as the 1st argument.");
ObjectInstance? initObject = null;
if (arguments.Length > 1)
initObject = arguments[1] is not ObjectInstance arg1
? throw new JavaScriptException(this._engine.Realm.Intrinsics.Error, "The provided value is not of type 'RequestInit'.")
: arg1;
HttpClientHandler handler = new HttpClientHandler();
HttpClient? httpClient = new HttpClient(handler);
var requestMessage = new HttpRequestMessage(HttpMethod.Get, urlString.ToString());
bool throwOnRedirect = false;
if (initObject is not null)
{
foreach (var kv in initObject.GetOwnProperties())
{
var key = kv.Key;
var value = kv.Value;
if (!key.IsString())
continue;
switch (key.AsString())
{
case "body":
this.SetRequestBody(requestMessage, value.Value);
break;
case "headers":
this.SetRequestHeader(requestMessage, value.Value);
break;
case "method":
this.SetRequestMethod(requestMessage, value.Value);
break;
case "redirect":
{
var redirect = value.Value;
if (redirect is JsNull or JsUndefined)
break;
switch (redirect.ToString())
{
case "follow":
handler.AllowAutoRedirect = true;
break;
case "manual":
handler.AllowAutoRedirect = false;
break;
case "error":
handler.AllowAutoRedirect = false;
throwOnRedirect = true;
break;
default:
throw new JavaScriptException(this._engine.Realm.Intrinsics.Error, $"'{redirect}' is not a valid value for 'redirect'.");
}
break;
}
case "referrer":
{
var referrer = value.Value;
if (referrer is JsNull or JsUndefined)
break;
requestMessage.Headers.Referrer = new System.Uri(referrer.ToString());
break;
}
case "cache":
case "credentials":
case "integrity":
case "keepalive":
case "mode":
case "referrerPolicy":
case "signal":
default:
break;
}
}
}
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
#pragma warning disable VSTHRD104 // Offer async methods
var resp = httpClient.SendAsync(requestMessage).Result;
if (throwOnRedirect && (resp.StatusCode is (HttpStatusCode)301 or (HttpStatusCode)302 or (HttpStatusCode)303 or (HttpStatusCode)307 or (HttpStatusCode)308))
{
throw new JavaScriptException(this._engine.Realm.Intrinsics.Error, $"'Failed to fetch, Status code: {(int)resp.StatusCode}.");
}
var respString = resp.Content.ReadAsStringAsync().Result;
#pragma warning restore VSTHRD104 // Offer async methods
#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits
var respHeaders = new ObjectInstance(this._engine);
foreach (var respHeader in resp.Headers)
respHeaders.Set(respHeader.Key, string.Join(", ", respHeader.Value));
var result = new ObjectInstance(this._engine);
result.Set("body", respString);
result.Set("headers", respHeaders);
result.Set("ok", resp.IsSuccessStatusCode);
result.Set("status", (int)resp.StatusCode);
result.Set("statusText", resp.ReasonPhrase);
return result;
}
private void SetRequestMethod(HttpRequestMessage requestMessage, JsValue value)
{
if (value is JsNull or JsUndefined)
return;
var method = value.ToString();
requestMessage.Method = method.ToUpperInvariant() switch
{
"HEAD" => HttpMethod.Head,
"GET" => HttpMethod.Get,
"POST" => HttpMethod.Post,
"PUT" => HttpMethod.Put,
"DELETE" => HttpMethod.Delete,
"OPTIONS" => HttpMethod.Options,
"TRACE" => HttpMethod.Trace,
_ => new HttpMethod(method),
};
}
private void SetRequestHeader(HttpRequestMessage requestMessage, JsValue value)
{
if (value is JsNull or JsUndefined)
return;
if (value is ObjectInstance objectInstance)
{
foreach (var header in objectInstance.GetOwnProperties())
{
var headerName = header.Key.ToString();
var headerValue = header.Value.Value.ToString();
requestMessage.Headers.Remove(headerName);
requestMessage.Headers.TryAddWithoutValidation(headerName, headerValue);
}
}
else if (value is ArrayInstance arrayInstance)
{
foreach (ArrayInstance header in arrayInstance)
{
if (header.Length != 2)
throw new JavaScriptException(this._engine.Realm.Intrinsics.Error, "The header object must contain exactly two elements.");
var headerName = header[0].ToString();
var headerValue = header[1].ToString();
requestMessage.Headers.Remove(headerName);
requestMessage.Headers.TryAddWithoutValidation(headerName, headerValue);
}
}
else
{
throw new JavaScriptException(this._engine.Realm.Intrinsics.Error, "Only object or array is supported for 'header'.");
}
}
private void SetRequestBody(HttpRequestMessage requestMessage, JsValue value)
{
if (value is JsNull or JsUndefined)
return;
if (value is not JsString jsString)
throw new JavaScriptException(this._engine.Realm.Intrinsics.Error, "Only string is supported for 'body'.");
requestMessage.Content = new StringContent(jsString.ToString());
}
}
}

View File

@ -0,0 +1,226 @@
using System;
using System.Threading;
using BililiveRecorder.Core.Config.V3;
using BililiveRecorder.Core.Scripting.Runtime;
using Esprima;
using Esprima.Ast;
using Jint;
using Jint.Native;
using Jint.Native.Function;
using Jint.Native.Object;
using Jint.Runtime.Interop;
using Serilog;
namespace BililiveRecorder.Core.Scripting
{
public class UserScriptRunner
{
private const string RecorderEvents = "recorderEvents";
private static readonly JsValue RecorderEventsString = RecorderEvents;
private static int ExecutionId = 0;
private readonly GlobalConfig config;
private readonly Options jintOptions;
private static readonly Script setupScript;
private string? cachedScriptSource;
private Script? cachedScript;
static UserScriptRunner()
{
setupScript = new JavaScriptParser(@"
globalThis.recorderEvents = {};
", new ParserOptions(@"internalSetup.js")).ParseScript();
}
public UserScriptRunner(GlobalConfig config)
{
this.config = config ?? throw new ArgumentNullException(nameof(config));
this.jintOptions = new Options()
.CatchClrExceptions()
.LimitRecursion(100)
.RegexTimeoutInterval(TimeSpan.FromSeconds(2))
.Configure(engine =>
{
engine.Realm.GlobalObject.FastAddProperty("dns", new JintDns(engine), writable: false, enumerable: false, configurable: false);
engine.Realm.GlobalObject.FastAddProperty("dotnet", new JintDotnet(engine), writable: false, enumerable: false, configurable: false);
engine.Realm.GlobalObject.FastAddProperty("fetchSync", new JintFetchSync(engine), writable: false, enumerable: false, configurable: false);
});
}
private Script? GetParsedScript()
{
var source = this.config.UserScript;
if (this.cachedScript is not null)
{
if (string.IsNullOrWhiteSpace(source))
{
this.cachedScript = null;
this.cachedScriptSource = null;
return null;
}
else if (this.cachedScriptSource == source)
{
return this.cachedScript;
}
}
if (string.IsNullOrWhiteSpace(source))
{
return null;
}
var parser = new JavaScriptParser(source, new ParserOptions("userscript.js"));
var script = parser.ParseScript();
this.cachedScript = script;
this.cachedScriptSource = source;
return script;
}
private Engine CreateJintEngine(ILogger logger)
{
var engine = new Engine(this.jintOptions);
engine.Realm.GlobalObject.FastAddProperty("console", new JintConsole(engine, logger), writable: false, enumerable: false, configurable: false);
engine.Execute(setupScript);
return engine;
}
private static ILogger BuildLogger(ILogger logger)
{
var id = Interlocked.Increment(ref ExecutionId);
return logger.ForContext<UserScriptRunner>().ForContext(nameof(ExecutionId), id);
}
private FunctionInstance? ExecuteScriptThenGetEventHandler(ILogger logger, string functionName)
{
var script = this.GetParsedScript();
if (script is null)
return null;
var engine = this.CreateJintEngine(logger);
engine.Execute(script);
if (engine.Realm.GlobalObject.Get(RecorderEventsString) is not ObjectInstance events)
{
logger.Warning("[Script] recorderEvents 被修改为非 object");
return null;
}
return events.Get(functionName) as FunctionInstance;
}
public void CallOnTest(ILogger logger, Action<string>? alert)
{
const string callbackName = "onTest";
var log = BuildLogger(logger);
try
{
var func = this.ExecuteScriptThenGetEventHandler(log, callbackName);
if (func is null) return;
_ = func.Engine.Call(func, new DelegateWrapper(func.Engine, alert ?? delegate { }));
}
catch (Exception ex)
{
log.Error(ex, $"执行脚本 {callbackName} 时发生错误");
return;
}
}
/// <summary>
/// 获取直播流 URL
/// </summary>
/// <param name="logger">logger</param>
/// <param name="roomid">房间号</param>
/// <returns>直播流 URL</returns>
public string? CallOnFetchStreamUrl(ILogger logger, int roomid, int[] qnSetting)
{
const string callbackName = "onFetchStreamUrl";
var log = BuildLogger(logger);
try
{
var func = this.ExecuteScriptThenGetEventHandler(log, callbackName);
if (func is null) return null;
var input = new ObjectInstance(func.Engine);
input.Set("roomid", roomid);
input.Set("qn", JsValue.FromObject(func.Engine, qnSetting));
var result = func.Engine.Call(func, input);
switch (result)
{
case JsString jsString:
return jsString.ToString();
case JsUndefined or JsNull:
return null;
default:
log.Warning($"{RecorderEvents}.{callbackName}() 返回了不支持的类型: {{ValueType}}", result.Type);
return null;
}
}
catch (Exception ex)
{
log.Error(ex, $"执行脚本 {callbackName} 时发生错误");
return null;
}
}
/// <summary>
/// 在发送请求之前修改直播流 URL
/// </summary>
/// <param name="logger">logger</param>
/// <param name="originalUrl">原直播流地址</param>
/// <returns>url: 新直播流地址<br/>ip: 可选的强制使用的 IP 地址</returns>
public (string url, string? ip)? CallOnTransformStreamUrl(ILogger logger, string originalUrl)
{
const string callbackName = "onTransformStreamUrl";
var log = BuildLogger(logger);
try
{
var func = this.ExecuteScriptThenGetEventHandler(log, callbackName);
if (func is null) return null;
var result = func.Engine.Call(func, originalUrl);
switch (result)
{
case JsString jsString:
return (jsString.ToString(), null);
case ObjectInstance obj:
{
var url = obj.Get("url");
if (url is not JsString urlString)
{
log.Warning($"{RecorderEvents}.{callbackName}() 返回的 object 缺少 url 属性");
return null;
}
var ip = obj.Get("ip") as JsString;
return (urlString.ToString(), ip?.ToString());
}
case JsUndefined or JsNull:
return null;
default:
log.Warning($"{RecorderEvents}.{callbackName}() 返回了不支持的类型: {{ValueType}}", result.Type);
return null;
}
}
catch (Exception ex)
{
log.Error(ex, $"执行脚本 {callbackName} 时发生错误");
return null;
}
}
}
}

View File

@ -48,6 +48,14 @@
<ui:NumberBox Minimum="0" SmallChange="1" Text="{Binding RecordDanmakuFlushInterval,UpdateSourceTrigger=PropertyChanged}"/>
</c:SettingWithDefault>
</GroupBox>
<GroupBox Header="Scripting">
<StackPanel>
<Button Click="TestScript_Click" Content="Test"/>
<c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasUserScript}" Header="请自由发挥((">
<TextBox Text="{Binding UserScript, UpdateSourceTrigger=LostFocus}" AcceptsReturn="True" MinHeight="70" MaxHeight="130"/>
</c:SettingWithDefault>
</StackPanel>
</GroupBox>
<GroupBox Header="Timing">
<ui:SimpleStackPanel Spacing="10">
<c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasTimingStreamRetry}" Header="录制重试间隔">

View File

@ -3,6 +3,7 @@ using System.Runtime.Serialization;
using System.Threading.Tasks;
using System.Windows;
using BililiveRecorder.Core.Api.Http;
using BililiveRecorder.Core.Scripting;
using Newtonsoft.Json.Linq;
using Serilog;
@ -16,16 +17,21 @@ namespace BililiveRecorder.WPF.Pages
{
private static readonly ILogger logger = Log.ForContext<AdvancedSettingsPage>();
private readonly HttpApiClient? httpApiClient;
private readonly UserScriptRunner? userScriptRunner;
public AdvancedSettingsPage(HttpApiClient? httpApiClient)
public AdvancedSettingsPage(HttpApiClient? httpApiClient, UserScriptRunner? userScriptRunner)
{
this.InitializeComponent();
this.httpApiClient = httpApiClient;
this.userScriptRunner = userScriptRunner;
}
public AdvancedSettingsPage() : this((HttpApiClient?)(RootPage.ServiceProvider?.GetService(typeof(HttpApiClient))))
{
}
public AdvancedSettingsPage()
: this(
(HttpApiClient?)(RootPage.ServiceProvider?.GetService(typeof(HttpApiClient))),
(UserScriptRunner?)(RootPage.ServiceProvider?.GetService(typeof(UserScriptRunner)))
)
{ }
private void Crash_Click(object sender, RoutedEventArgs e) => throw new TestException("test crash triggered");
@ -75,5 +81,10 @@ namespace BililiveRecorder.WPF.Pages
MessageBox.Show("User: " + jo["data"]?["uname"]?.ToObject<string>(), "Cookie Test - Successed", MessageBoxButton.OK, MessageBoxImage.Information);
}
private void TestScript_Click(object sender, RoutedEventArgs e)
{
_ = Task.Run(() => this.userScriptRunner?.CallOnTest(Log.Logger, str => MessageBox.Show(str)));
}
}
}

View File

@ -62,16 +62,6 @@ namespace BililiveRecorder.WPF.Pages
this.Model = new RootModel();
this.DataContext = this.Model;
{
var services = new ServiceCollection();
services
.AddFlv()
.AddRecorder()
;
this.serviceProvider = services.BuildServiceProvider();
}
this.InitializeComponent();
this.AdvancedSettingsPageItem.Visibility = Visibility.Hidden;

View File

@ -4,6 +4,7 @@ using System.CommandLine.Invocation;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Runtime.ExceptionServices;
using System.Runtime.InteropServices;
using System.Security;
@ -11,7 +12,10 @@ using System.Threading;
using System.Threading.Tasks;
using System.Windows.Threading;
using BililiveRecorder.ToolBox;
using Esprima;
using Jint.Runtime;
using Sentry;
using Sentry.Extensibility;
using Serilog;
using Serilog.Core;
using Serilog.Exceptions;
@ -237,7 +241,11 @@ namespace BililiveRecorder.WPF
o.IsGlobalModeEnabled = true;
o.DisableAppDomainUnhandledExceptionCapture();
o.DisableTaskUnobservedTaskExceptionCapture();
o.AddExceptionFilterForType<System.Net.Http.HttpRequestException>();
o.AddExceptionFilterForType<HttpRequestException>();
o.AddExceptionFilterForType<OutOfMemoryException>();
o.AddExceptionFilterForType<JintException>();
o.AddExceptionFilterForType<ParserException>();
o.AddEventProcessor(new SentryEventProcessor());
o.TextFormatter = new MessageTemplateTextFormatter("[{RoomId}] {Message}{NewLine}{Exception}{@ExceptionDetail:j}");
@ -269,5 +277,12 @@ namespace BililiveRecorder.WPF
[HandleProcessCorruptedStateExceptions, SecurityCritical]
private static void App_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) =>
logger.Fatal(e.Exception, "Unhandled exception from Application.DispatcherUnhandledException");
private class SentryEventProcessor : ISentryEventProcessor
{
private static readonly string JintConsole = typeof(Core.Scripting.Runtime.JintConsole).FullName;
private static readonly string UserScriptRunner = typeof(Core.Scripting.UserScriptRunner).FullName;
public SentryEvent? Process(SentryEvent e) => (e?.Logger == JintConsole || e?.Logger == UserScriptRunner) ? null : e;
}
}
}

View File

@ -64,6 +64,7 @@ namespace BililiveRecorder.Web.Models
public Optional<uint>? OptionalRecordDanmakuFlushInterval { get; set; }
public Optional<bool>? OptionalNetworkTransportUseSystemProxy { get; set; }
public Optional<AllowedAddressFamily>? OptionalNetworkTransportAllowedAddressFamily { get; set; }
public Optional<string>? OptionalUserScript { get; set; }
public void ApplyTo(GlobalConfig config)
{
@ -91,6 +92,7 @@ namespace BililiveRecorder.Web.Models
if (this.OptionalRecordDanmakuFlushInterval.HasValue) config.OptionalRecordDanmakuFlushInterval = this.OptionalRecordDanmakuFlushInterval.Value;
if (this.OptionalNetworkTransportUseSystemProxy.HasValue) config.OptionalNetworkTransportUseSystemProxy = this.OptionalNetworkTransportUseSystemProxy.Value;
if (this.OptionalNetworkTransportAllowedAddressFamily.HasValue) config.OptionalNetworkTransportAllowedAddressFamily = this.OptionalNetworkTransportAllowedAddressFamily.Value;
if (this.OptionalUserScript.HasValue) config.OptionalUserScript = this.OptionalUserScript.Value;
}
}
@ -138,6 +140,7 @@ namespace BililiveRecorder.Web.Models.Rest
public Optional<uint> OptionalRecordDanmakuFlushInterval { get; set; }
public Optional<bool> OptionalNetworkTransportUseSystemProxy { get; set; }
public Optional<AllowedAddressFamily> OptionalNetworkTransportAllowedAddressFamily { get; set; }
public Optional<string> OptionalUserScript { get; set; }
}
}
@ -190,6 +193,7 @@ namespace BililiveRecorder.Web.Models.Graphql
this.Field(x => x.OptionalRecordDanmakuFlushInterval, type: typeof(HierarchicalOptionalType<uint>));
this.Field(x => x.OptionalNetworkTransportUseSystemProxy, type: typeof(HierarchicalOptionalType<bool>));
this.Field(x => x.OptionalNetworkTransportAllowedAddressFamily, type: typeof(HierarchicalOptionalType<AllowedAddressFamily>));
this.Field(x => x.OptionalUserScript, type: typeof(HierarchicalOptionalType<string>));
}
}
@ -221,6 +225,7 @@ namespace BililiveRecorder.Web.Models.Graphql
this.Field(x => x.RecordDanmakuFlushInterval);
this.Field(x => x.NetworkTransportUseSystemProxy);
this.Field(x => x.NetworkTransportAllowedAddressFamily);
this.Field(x => x.UserScript);
}
}
@ -269,6 +274,7 @@ namespace BililiveRecorder.Web.Models.Graphql
this.Field(x => x.OptionalRecordDanmakuFlushInterval, nullable: true, type: typeof(HierarchicalOptionalInputType<uint>));
this.Field(x => x.OptionalNetworkTransportUseSystemProxy, nullable: true, type: typeof(HierarchicalOptionalInputType<bool>));
this.Field(x => x.OptionalNetworkTransportAllowedAddressFamily, nullable: true, type: typeof(HierarchicalOptionalInputType<AllowedAddressFamily>));
this.Field(x => x.OptionalUserScript, nullable: true, type: typeof(HierarchicalOptionalInputType<string>));
}
}

View File

@ -261,6 +261,22 @@
}
}
},
"UserScript": {
"description": "自定义脚本\n默认: ",
"markdownDescription": "自定义脚本 \n默认: ` `\n\n",
"type": "object",
"additionalProperties": false,
"properties": {
"HasValue": {
"type": "boolean",
"default": true
},
"Value": {
"type": "string",
"default": ""
}
}
},
"RecordMode": {
"description": "录制模式\n默认: RecordMode.Standard",
"markdownDescription": "录制模式 \n默认: `RecordMode.Standard `\n\n本设置项是一个 enum键值对应如下\n\n| 键 | 值 |\n|:--:|:--:|\n| RecordMode.Standard | 0 |\n| RecordMode.RawData | 1 |\n\n关于录制模式的说明见 [录制模式](/docs/basic/record_mode/)",

View File

@ -234,4 +234,13 @@ export const data: Array<ConfigEntry> = [
advancedConfig: true,
markdown: ""
},
{
name: "UserScript",
description: "自定义脚本",
type: "string",
defaultValue: "string.Empty",
configType: "globalOnly",
advancedConfig: true,
markdown: ""
},
];

7
nuget.config Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="NuGet" value="https://api.nuget.org/v3/index.json" />
<add key="Jint" value="https://www.myget.org/F/jint/api/v3/index.json" />
</packageSources>
</configuration>