Core: Add scripting support

This commit is contained in:
genteure 2022-05-11 19:07:21 +08:00
parent cc27045fc4
commit bb06539993
10 changed files with 275 additions and 123 deletions

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;
@ -218,6 +221,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, 0);
}
const int DefaultQn = 10000;
var selected_qn = DefaultQn;
var codecItem = await this.apiClient.GetCodecItemInStreamUrlAsync(roomid: roomid, qn: DefaultQn).ConfigureAwait(false);
@ -225,12 +241,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 +290,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 +356,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

@ -1,7 +1,9 @@
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;
@ -9,6 +11,7 @@ using Jint.Native.Object;
using Jint.Runtime;
using Jint.Runtime.Interop;
using Serilog;
using Serilog.Events;
namespace BililiveRecorder.Core.Scripting.Runtime
{
@ -16,9 +19,26 @@ namespace BililiveRecorder.Core.Scripting.Runtime
{
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));
@ -30,15 +50,15 @@ namespace BililiveRecorder.Core.Scripting.Runtime
Add("clear", this.Clear);
Add("count", this.Count);
Add("countReset", this.CountReset);
Add("debug", this.Debug);
Add("error", this.Error);
Add("info", this.Info);
Add("log", this.Log);
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.Trace);
Add("warn", this.Warn);
Add("trace", this.BuildLogFunc(LogEventLevel.Information));
Add("warn", this.BuildLogFunc(LogEventLevel.Warning));
[MethodImpl(MethodImplOptions.AggressiveInlining)]
void Add(string name, Func<JsValue, JsValue[], JsValue> func)
@ -71,6 +91,31 @@ namespace BililiveRecorder.Core.Scripting.Runtime
// 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(0))
@ -96,6 +141,7 @@ namespace BililiveRecorder.Core.Scripting.Runtime
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++;
@ -107,7 +153,6 @@ namespace BililiveRecorder.Core.Scripting.Runtime
this.counters[name] = count;
this.logger.Information("[Script] {CounterName}: {Count}", name, count);
return Undefined;
}
@ -119,34 +164,6 @@ namespace BililiveRecorder.Core.Scripting.Runtime
return Undefined;
}
private JsValue Debug(JsValue thisObject, JsValue[] arguments)
{
var messages = this.FormatToString(arguments);
this.logger.Debug("[Script] {Messages}", messages);
return Undefined;
}
private JsValue Error(JsValue thisObject, JsValue[] arguments)
{
var messages = this.FormatToString(arguments);
this.logger.Error("[Script] {Messages}", messages);
return Undefined;
}
private JsValue Info(JsValue thisObject, JsValue[] arguments)
{
var messages = this.FormatToString(arguments);
this.logger.Information("[Script] {Messages}", messages);
return Undefined;
}
private JsValue Log(JsValue thisObject, JsValue[] arguments)
{
var messages = this.FormatToString(arguments);
this.logger.Information("[Script] {Messages}", messages);
return Undefined;
}
private JsValue Time(JsValue thisObject, JsValue[] arguments)
{
var name = arguments.Length > 0 ? arguments[0].ToString() : "default";
@ -190,19 +207,5 @@ namespace BililiveRecorder.Core.Scripting.Runtime
}
return Undefined;
}
private JsValue Trace(JsValue thisObject, JsValue[] arguments)
{
var messages = this.FormatToString(arguments);
this.logger.Information("[Script] {Messages}", messages);
return Undefined;
}
private JsValue Warn(JsValue thisObject, JsValue[] arguments)
{
var messages = this.FormatToString(arguments);
this.logger.Warning("[Script] {Messages}", messages);
return Undefined;
}
}
}

View File

@ -1,3 +1,5 @@
using System.Net.Http;
using System.Threading.Tasks;
using Jint;
using Jint.Native;
using Jint.Native.Function;
@ -14,7 +16,15 @@ namespace BililiveRecorder.Core.Scripting.Runtime
protected override JsValue Call(JsValue thisObject, JsValue[] arguments)
{
return Undefined;
var (promise, resolve, reject) = this._engine.RegisterPromise();
var task = Task.Run(() =>
{
});
var req = new HttpRequestMessage(HttpMethod.Get, "https://example.com");
return promise;
}
}
}

View File

@ -5,14 +5,18 @@ 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;
@ -27,7 +31,7 @@ namespace BililiveRecorder.Core.Scripting
{
setupScript = new JavaScriptParser(@"
globalThis.recorderEvents = {};
").ParseScript();
", new ParserOptions(@"internalSetup.js")).ParseScript();
}
public UserScriptRunner(GlobalConfig config)
@ -69,7 +73,7 @@ globalThis.recorderEvents = {};
return null;
}
var parser = new JavaScriptParser(source);
var parser = new JavaScriptParser(source, new ParserOptions("userscript.js"));
var script = parser.ParseScript();
this.cachedScript = script;
@ -89,55 +93,134 @@ globalThis.recorderEvents = {};
return engine;
}
public string CallOnStreamUrl(ILogger logger, string originalUrl)
{
var log = BuildLogger(logger);
var script = this.GetParsedScript();
if (script is null)
{
return originalUrl;
}
var engine = this.CreateJintEngine(log);
engine.Execute(script);
if (engine.Realm.GlobalObject.Get("recorderEvents") is not ObjectInstance events)
{
log.Warning("脚本: recorderEvents 被修改为非 object");
return originalUrl;
}
if (events.Get("onStreamUrl") is not FunctionInstance func)
{
return originalUrl;
}
var result = engine.Call(func, originalUrl);
if (result.Type == Jint.Runtime.Types.String)
{
}
else if (result.Type == Jint.Runtime.Types.Object)
{
}
else
{
}
throw new NotImplementedException();
}
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;