Merge code into dev-1.3

This commit is contained in:
Genteure 2021-02-23 18:03:37 +08:00
parent 48c8612f95
commit 58970c217b
161 changed files with 4756 additions and 3081 deletions

1
.gitignore vendored
View File

@ -263,3 +263,4 @@ __pycache__/
TempBuildInfo/*.cs
BililiveRecorder.WPF/Nlog.config
BililiveRecorder.Cli/Properties/launchSettings.json

View File

@ -28,15 +28,19 @@
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.4.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
<PackageReference Include="NLog" Version="4.7.6" />
<PackageReference Include="NLog.Config" Version="4.7.6" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Enrichers.Process" Version="2.0.1" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
<PackageReference Include="Serilog.Exceptions" Version="6.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="4.1.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta1.20574.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BililiveRecorder.Core\BililiveRecorder.Core.csproj" />
<ProjectReference Include="..\BililiveRecorder.FlvProcessor\BililiveRecorder.FlvProcessor.csproj" />
</ItemGroup>
<Target Name="PreBuild" BeforeTargets="PreBuildEvent">
<Exec Command="cd $(SolutionDir)&#xD;&#xA;powershell -ExecutionPolicy Bypass -File .\CI\patch_buildinfo.ps1 Cli" />

View File

@ -1,30 +1,70 @@
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.CommandLine.Invocation;
using System.IO;
using System.Linq;
using System.Threading;
using BililiveRecorder.Core;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config.V2;
using BililiveRecorder.DependencyInjection;
using CommandLine;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using Serilog.Events;
using Serilog.Exceptions;
namespace BililiveRecorder.Cli
{
internal class Program
{
private static int Main(string[] args)
=> Parser.Default
.ParseArguments<CmdVerbConfigMode, CmdVerbPortableMode>(args)
.MapResult<CmdVerbConfigMode, CmdVerbPortableMode, int>(RunConfigMode, RunPortableMode, err => 1);
private static int RunConfigMode(CmdVerbConfigMode opts)
{
var semaphore = new SemaphoreSlim(0, 1);
var cmd_run = new Command("run", "Run BililiveRecorder in standard mode")
{
new Argument<string>("path"),
};
cmd_run.Handler = CommandHandler.Create<string>(RunConfigMode);
var serviceProvider = BuildServiceProvider();
var cmd_portable = new Command("portable", "Run BililiveRecorder in config-less mode")
{
new Option<string>(new []{ "--cookie", "-c" }, "Cookie string for api requests"),
new Option<string>("--live-api-host"),
new Option<string>(new[]{ "--filename-format", "-f" }, "File name format"),
new Argument<string>("output path"),
new Argument<int[]>("room ids")
};
cmd_portable.Handler = CommandHandler.Create<PortableModeArguments>(RunPortableMode);
var root = new RootCommand("A Stream Recorder For Bilibili Live")
{
cmd_run,
cmd_portable
};
return root.Invoke(args);
}
private static int RunConfigMode(string path)
{
var logger = BuildLogger();
Log.Logger = logger;
path = Path.GetFullPath(path);
var config = ConfigParser.LoadFrom(path);
if (config is null)
{
logger.Error("Initialize Error");
return -1;
}
config.Global.WorkDirectory = path;
var serviceProvider = BuildServiceProvider(config, logger);
var recorder = serviceProvider.GetRequiredService<IRecorder>();
ConsoleCancelEventHandler p = null!;
var semaphore = new SemaphoreSlim(0, 1);
p = (sender, e) =>
{
Console.CancelKeyPress -= p;
@ -34,21 +74,21 @@ namespace BililiveRecorder.Cli
};
Console.CancelKeyPress += p;
if (!recorder.Initialize(opts.WorkDirectory))
{
Console.WriteLine("Initialize Error");
return -1;
}
semaphore.Wait();
Thread.Sleep(1000 * 2);
Console.ReadLine();
return 0;
}
private static int RunPortableMode(CmdVerbPortableMode opts)
private static int RunPortableMode(PortableModeArguments opts)
{
var semaphore = new SemaphoreSlim(0, 1);
throw new NotImplementedException();
var serviceProvider = BuildServiceProvider();
#pragma warning disable CS0162 // Unreachable code detected
var semaphore = new SemaphoreSlim(0, 1);
#pragma warning restore CS0162 // Unreachable code detected
var serviceProvider = BuildServiceProvider(null, null);
var recorder = serviceProvider.GetRequiredService<IRecorder>();
var config = new ConfigV2()
@ -60,10 +100,10 @@ namespace BililiveRecorder.Cli
config.Global.Cookie = opts.Cookie;
if (!string.IsNullOrWhiteSpace(opts.LiveApiHost))
config.Global.LiveApiHost = opts.LiveApiHost;
if (!string.IsNullOrWhiteSpace(opts.RecordFilenameFormat))
config.Global.RecordFilenameFormat = opts.RecordFilenameFormat;
if (!string.IsNullOrWhiteSpace(opts.FilenameFormat))
config.Global.RecordFilenameFormat = opts.FilenameFormat;
config.Global.WorkDirectory = opts.OutputDirectory;
config.Global.WorkDirectory = opts.OutputPath;
config.Rooms = opts.RoomIds.Select(x => new RoomConfig { RoomId = x, AutoRecord = true }).ToList();
ConsoleCancelEventHandler p = null!;
@ -76,48 +116,44 @@ namespace BililiveRecorder.Cli
};
Console.CancelKeyPress += p;
if (!((Recorder)recorder).InitializeWithConfig(config))
{
Console.WriteLine("Initialize Error");
return -1;
}
//if (!((DeadCodeRecorder)recorder).InitializeWithConfig(config))
//{
// Console.WriteLine("Initialize Error");
// return -1;
//}
semaphore.Wait();
return 0;
}
private static IServiceProvider BuildServiceProvider()
{
var services = new ServiceCollection();
services.AddFlvProcessor();
services.AddCore();
return services.BuildServiceProvider();
}
}
private static IServiceProvider BuildServiceProvider(ConfigV2 config, ILogger logger) => new ServiceCollection()
.AddSingleton(logger)
.AddFlv()
.AddRecorderConfig(config)
.AddRecorder()
.BuildServiceProvider();
[Verb("portable", HelpText = "Run recorder. Ignore config file in output directory")]
public class CmdVerbPortableMode
{
[Option('o', "dir", Default = ".", HelpText = "Output directory", Required = false)]
public string OutputDirectory { get; set; } = ".";
private static ILogger BuildLogger() => new LoggerConfiguration()
.MinimumLevel.Verbose()
.Enrich.WithProcessId()
.Enrich.WithThreadId()
.Enrich.WithThreadName()
.Enrich.FromLogContext()
.Enrich.WithExceptionDetails()
.WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Verbose, outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] [{RoomId}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
public class PortableModeArguments
{
public string OutputPath { get; set; } = string.Empty;
[Option("cookie", HelpText = "Provide custom cookies", Required = false)]
public string? Cookie { get; set; }
[Option("live_api_host", HelpText = "Use custom api host", Required = false)]
public string? LiveApiHost { get; set; }
[Option("record_filename_format", HelpText = "Recording name format", Required = false)]
public string? RecordFilenameFormat { get; set; }
public string? FilenameFormat { get; set; }
[Value(0, Min = 1, Required = true, HelpText = "List of room id")]
public IEnumerable<int> RoomIds { get; set; } = Enumerable.Empty<int>();
}
[Verb("run", HelpText = "Run recorder with config file")]
public class CmdVerbConfigMode
{
[Value(0, HelpText = "Target directory", Required = true)]
public string WorkDirectory { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,448 @@
using System;
using System.Buffers;
using System.Buffers.Binary;
using System.IO;
using System.IO.Compression;
using System.IO.Pipelines;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using Nerdbank.Streams;
using Newtonsoft.Json;
using Serilog;
using Timer = System.Timers.Timer;
namespace BililiveRecorder.Core.Api.Danmaku
{
public class DanmakuClient : IDanmakuClient, IDisposable
{
private readonly ILogger logger;
private readonly IDanmakuServerApiClient apiClient;
private readonly Timer timer;
private readonly SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1, 1);
private Stream? danmakuStream;
private bool disposedValue;
public bool Connected => this.danmakuStream != null;
public event EventHandler<StatusChangedEventArgs>? StatusChanged;
public event EventHandler<DanmakuReceivedEventArgs>? DanmakuReceived;
public DanmakuClient(IDanmakuServerApiClient apiClient, ILogger logger)
{
this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
this.logger = logger?.ForContext<DanmakuClient>() ?? throw new ArgumentNullException(nameof(logger));
this.timer = new Timer(interval: 1000 * 30)
{
AutoReset = true,
Enabled = false
};
this.timer.Elapsed += this.SendPingMessageTimerCallback;
}
public async Task DisconnectAsync()
{
await this.semaphoreSlim.WaitAsync().ConfigureAwait(false);
try
{
this.danmakuStream?.Dispose();
this.danmakuStream = null;
this.timer.Stop();
}
finally
{
this.semaphoreSlim.Release();
}
StatusChanged?.Invoke(this, StatusChangedEventArgs.False);
}
public async Task ConnectAsync(int roomid, CancellationToken cancellationToken)
{
if (this.disposedValue)
throw new ObjectDisposedException(nameof(DanmakuClient));
await this.semaphoreSlim.WaitAsync().ConfigureAwait(false);
try
{
if (this.danmakuStream != null)
return;
var serverInfo = await this.apiClient.GetDanmakuServerAsync(roomid).ConfigureAwait(false);
if (serverInfo.Data is null)
return;
serverInfo.Data.ChooseOne(out var host, out var port, out var token);
if (cancellationToken.IsCancellationRequested)
return;
var tcp = new TcpClient();
await tcp.ConnectAsync(host, port).ConfigureAwait(false);
this.danmakuStream = tcp.GetStream();
await SendHelloAsync(this.danmakuStream, roomid, token).ConfigureAwait(false);
await SendPingAsync(this.danmakuStream);
if (cancellationToken.IsCancellationRequested)
{
tcp.Dispose();
this.danmakuStream.Dispose();
this.danmakuStream = null;
return;
}
this.timer.Start();
_ = Task.Run(async () =>
{
try
{
await ProcessDataAsync(this.danmakuStream, this.ProcessCommand).ConfigureAwait(false);
}
catch (Exception ex)
{
this.logger.Warning(ex, "Error running ProcessDataAsync");
}
try
{
await this.DisconnectAsync().ConfigureAwait(false);
}
catch (Exception) { }
});
}
finally
{
this.semaphoreSlim.Release();
}
StatusChanged?.Invoke(this, StatusChangedEventArgs.True);
}
private void ProcessCommand(string json)
{
try
{
var d = new DanmakuModel(json);
DanmakuReceived?.Invoke(this, new DanmakuReceivedEventArgs(d));
}
catch (Exception ex)
{
this.logger.Warning(ex, "Error running ProcessCommand");
}
}
#pragma warning disable VSTHRD100 // Avoid async void methods
private async void SendPingMessageTimerCallback(object sender, ElapsedEventArgs e)
#pragma warning restore VSTHRD100 // Avoid async void methods
{
try
{
await this.semaphoreSlim.WaitAsync().ConfigureAwait(false);
try
{
if (this.danmakuStream is null)
return;
await SendPingAsync(this.danmakuStream).ConfigureAwait(false);
}
finally
{
this.semaphoreSlim.Release();
}
}
catch (Exception ex)
{
this.logger.Warning(ex, "Error running SendPingMessageTimerCallback");
}
}
#region Dispose
protected virtual void Dispose(bool disposing)
{
if (!this.disposedValue)
{
if (disposing)
{
// dispose managed state (managed objects)
this.timer.Dispose();
this.danmakuStream?.Dispose();
this.semaphoreSlim.Dispose();
}
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
this.disposedValue = true;
}
}
// override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
// ~DanmakuClient()
// {
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
// Dispose(disposing: false);
// }
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
this.Dispose(disposing: true);
GC.SuppressFinalize(this);
}
#endregion
#region Send
private static Task SendHelloAsync(Stream stream, int roomid, string token) =>
SendMessageAsync(stream, 7, JsonConvert.SerializeObject(new
{
uid = 0,
roomid = roomid,
protover = 0,
platform = "web",
clientver = "2.6.25",
type = 2,
key = token,
}, Formatting.None));
private static Task SendPingAsync(Stream stream) =>
SendMessageAsync(stream, 2);
private static async Task SendMessageAsync(Stream stream, int action, string body = "")
{
if (stream is null)
throw new ArgumentNullException(nameof(stream));
var playload = Encoding.UTF8.GetBytes(body);
var size = playload.Length + 16;
var buffer = ArrayPool<byte>.Shared.Rent(16);
try
{
BinaryPrimitives.WriteUInt32BigEndian(new Span<byte>(buffer, 0, 4), (uint)size);
BinaryPrimitives.WriteUInt16BigEndian(new Span<byte>(buffer, 4, 2), 16);
BinaryPrimitives.WriteUInt16BigEndian(new Span<byte>(buffer, 6, 2), 1);
BinaryPrimitives.WriteUInt32BigEndian(new Span<byte>(buffer, 8, 4), (uint)action);
BinaryPrimitives.WriteUInt32BigEndian(new Span<byte>(buffer, 12, 4), 1);
await stream.WriteAsync(buffer, 0, buffer.Length).ConfigureAwait(false);
if (playload.Length > 0)
await stream.WriteAsync(playload, 0, playload.Length).ConfigureAwait(false);
await stream.FlushAsync().ConfigureAwait(false);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
#endregion
#region Receive
private static async Task ProcessDataAsync(Stream stream, Action<string> callback)
{
var reader = PipeReader.Create(stream);
await ReadPipeAsync(reader, callback).ConfigureAwait(false);
}
private static async Task FillPipeAsync(Stream stream, PipeWriter writer)
{
const int minimumBufferSize = 512;
while (true)
{
var memory = writer.GetMemory(minimumBufferSize);
try
{
var bytesRead = await stream.ReadAsync(memory).ConfigureAwait(false);
if (bytesRead == 0)
break;
writer.Advance(bytesRead);
}
catch (Exception)
{
// TODO logger.Log("Debug", ex);
break;
}
var result = await writer.FlushAsync();
if (result.IsCompleted)
break;
}
await writer.CompleteAsync();
}
private static async Task ReadPipeAsync(PipeReader reader, Action<string> callback)
{
while (true)
{
var result = await reader.ReadAsync();
var buffer = result.Buffer;
while (TryParseCommand(ref buffer, callback)) { }
reader.AdvanceTo(buffer.Start, buffer.End);
if (result.IsCompleted)
break;
}
await reader.CompleteAsync();
}
private static bool TryParseCommand(ref ReadOnlySequence<byte> buffer, Action<string> callback)
{
if (buffer.Length < 4)
return false;
int length;
{
var lengthSlice = buffer.Slice(buffer.Start, 4);
if (lengthSlice.IsSingleSegment)
{
length = BinaryPrimitives.ReadInt32BigEndian(lengthSlice.First.Span);
}
else
{
Span<byte> stackBuffer = stackalloc byte[4];
lengthSlice.CopyTo(stackBuffer);
length = BinaryPrimitives.ReadInt32BigEndian(stackBuffer);
}
}
if (buffer.Length < length)
return false;
var headerSlice = buffer.Slice(buffer.Start, 16);
buffer = buffer.Slice(headerSlice.End);
var bodySlice = buffer.Slice(buffer.Start, length - 16);
buffer = buffer.Slice(bodySlice.End);
DanmakuProtocol header;
if (headerSlice.IsSingleSegment)
{
Parse2Protocol(headerSlice.First.Span, out header);
}
else
{
Span<byte> stackBuffer = stackalloc byte[16];
headerSlice.CopyTo(stackBuffer);
Parse2Protocol(stackBuffer, out header);
}
if (header.Version == 2 && header.Action == 5)
ParseCommandDeflateBody(ref bodySlice, callback);
else
ParseCommandNormalBody(ref bodySlice, header.Action, callback);
return true;
}
private static void ParseCommandDeflateBody(ref ReadOnlySequence<byte> buffer, Action<string> callback)
{
using var deflate = new DeflateStream(buffer.Slice(2, buffer.End).AsStream(), CompressionMode.Decompress, leaveOpen: false);
var reader = PipeReader.Create(deflate);
while (true)
{
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
// 全内存内运行同步返回,所以不会有问题
var result = reader.ReadAsync().Result;
#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits
var inner_buffer = result.Buffer;
while (TryParseCommand(ref inner_buffer, callback)) { }
reader.AdvanceTo(inner_buffer.Start, inner_buffer.End);
if (result.IsCompleted)
break;
}
reader.Complete();
}
private static void ParseCommandNormalBody(ref ReadOnlySequence<byte> buffer, int action, Action<string> callback)
{
switch (action)
{
case 5:
{
if (buffer.Length > int.MaxValue)
throw new ArgumentOutOfRangeException("ParseCommandNormalBody buffer length larger than int.MaxValue");
var b = ArrayPool<byte>.Shared.Rent((int)buffer.Length);
try
{
buffer.CopyTo(b);
var json = Encoding.UTF8.GetString(b, 0, (int)buffer.Length);
callback(json);
}
finally
{
ArrayPool<byte>.Shared.Return(b);
}
}
break;
case 3:
break;
default:
break;
}
}
private static unsafe void Parse2Protocol(ReadOnlySpan<byte> buffer, out DanmakuProtocol protocol)
{
fixed (byte* ptr = buffer)
{
protocol = *(DanmakuProtocol*)ptr;
}
protocol.ChangeEndian();
}
private struct DanmakuProtocol
{
/// <summary>
/// 消息总长度 (协议头 + 数据长度)
/// </summary>
public int PacketLength;
/// <summary>
/// 消息头长度 (固定为16[sizeof(DanmakuProtocol)])
/// </summary>
public short HeaderLength;
/// <summary>
/// 消息版本号
/// </summary>
public short Version;
/// <summary>
/// 消息类型
/// </summary>
public int Action;
/// <summary>
/// 参数, 固定为1
/// </summary>
public int Parameter;
/// <summary>
/// 转为本机字节序
/// </summary>
public void ChangeEndian()
{
this.PacketLength = IPAddress.HostToNetworkOrder(this.PacketLength);
this.HeaderLength = IPAddress.HostToNetworkOrder(this.HeaderLength);
this.Version = IPAddress.HostToNetworkOrder(this.Version);
this.Action = IPAddress.HostToNetworkOrder(this.Action);
this.Parameter = IPAddress.HostToNetworkOrder(this.Parameter);
}
}
#endregion
}
}

View File

@ -0,0 +1,238 @@
using Newtonsoft.Json.Linq;
#nullable enable
namespace BililiveRecorder.Core.Api.Danmaku
{
public enum DanmakuMsgType
{
/// <summary>
/// 彈幕
/// </summary>
Comment,
/// <summary>
/// 禮物
/// </summary>
GiftSend,
/// <summary>
/// 直播開始
/// </summary>
LiveStart,
/// <summary>
/// 直播結束
/// </summary>
LiveEnd,
/// <summary>
/// 其他
/// </summary>
Unknown,
/// <summary>
/// 购买船票(上船)
/// </summary>
GuardBuy,
/// <summary>
/// SuperChat
/// </summary>
SuperChat,
/// <summary>
/// 房间信息更新
/// </summary>
RoomChange
}
public class DanmakuModel
{
/// <summary>
/// 消息類型
/// </summary>
public DanmakuMsgType MsgType { get; set; }
/// <summary>
/// 房间标题
/// </summary>
public string? Title { get; set; }
/// <summary>
/// 大分区
/// </summary>
public string? ParentAreaName { get; set; }
/// <summary>
/// 子分区
/// </summary>
public string? AreaName { get; set; }
/// <summary>
/// 彈幕內容
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="DanmakuMsgType.Comment"/></item>
/// </list></para>
/// </summary>
public string? CommentText { get; set; }
/// <summary>
/// 消息触发者用户名
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="DanmakuMsgType.Comment"/></item>
/// <item><see cref="DanmakuMsgType.GiftSend"/></item>
/// <item><see cref="DanmakuMsgType.Welcome"/></item>
/// <item><see cref="DanmakuMsgType.WelcomeGuard"/></item>
/// <item><see cref="DanmakuMsgType.GuardBuy"/></item>
/// </list></para>
/// </summary>
public string? UserName { get; set; }
/// <summary>
/// SC 价格
/// </summary>
public double Price { get; set; }
/// <summary>
/// SC 保持时间
/// </summary>
public int SCKeepTime { get; set; }
/// <summary>
/// 消息触发者用户ID
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="DanmakuMsgType.Comment"/></item>
/// <item><see cref="DanmakuMsgType.GiftSend"/></item>
/// <item><see cref="DanmakuMsgType.Welcome"/></item>
/// <item><see cref="DanmakuMsgType.WelcomeGuard"/></item>
/// <item><see cref="DanmakuMsgType.GuardBuy"/></item>
/// </list></para>
/// </summary>
public int UserID { get; set; }
/// <summary>
/// 用户舰队等级
/// <para>0 为非船员 1 为总督 2 为提督 3 为舰长</para>
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="DanmakuMsgType.Comment"/></item>
/// <item><see cref="DanmakuMsgType.WelcomeGuard"/></item>
/// <item><see cref="DanmakuMsgType.GuardBuy"/></item>
/// </list></para>
/// </summary>
public int UserGuardLevel { get; set; }
/// <summary>
/// 禮物名稱
/// </summary>
public string? GiftName { get; set; }
/// <summary>
/// 礼物数量
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="DanmakuMsgType.GiftSend"/></item>
/// <item><see cref="DanmakuMsgType.GuardBuy"/></item>
/// </list></para>
/// <para>此字段也用于标识上船 <see cref="DanmakuMsgType.GuardBuy"/> 的数量(月数)</para>
/// </summary>
public int GiftCount { get; set; }
/// <summary>
/// 该用户是否为房管(包括主播)
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="DanmakuMsgType.Comment"/></item>
/// <item><see cref="DanmakuMsgType.GiftSend"/></item>
/// </list></para>
/// </summary>
public bool IsAdmin { get; set; }
/// <summary>
/// 是否VIP用戶(老爺)
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="DanmakuMsgType.Comment"/></item>
/// <item><see cref="DanmakuMsgType.Welcome"/></item>
/// </list></para>
/// </summary>
public bool IsVIP { get; set; }
/// <summary>
/// <see cref="DanmakuMsgType.LiveStart"/>,<see cref="DanmakuMsgType.LiveEnd"/> 事件对应的房间号
/// </summary>
public string? RoomID { get; set; }
/// <summary>
/// 原始数据
/// </summary>
public string? RawString { get; set; }
/// <summary>
/// 原始数据
/// </summary>
public JObject? RawObject { get; set; }
public DanmakuModel()
{ }
public DanmakuModel(string json)
{
this.RawString = json;
var obj = JObject.Parse(json);
this.RawObject = obj;
var cmd = obj["cmd"]?.ToObject<string>();
switch (cmd)
{
case "LIVE":
this.MsgType = DanmakuMsgType.LiveStart;
this.RoomID = obj["roomid"]?.ToObject<string>();
break;
case "PREPARING":
this.MsgType = DanmakuMsgType.LiveEnd;
this.RoomID = obj["roomid"]?.ToObject<string>();
break;
case "DANMU_MSG":
this.MsgType = DanmakuMsgType.Comment;
this.CommentText = obj["info"]?[1]?.ToObject<string>();
this.UserID = obj["info"]?[2]?[0]?.ToObject<int>() ?? 0;
this.UserName = obj["info"]?[2]?[1]?.ToObject<string>();
this.IsAdmin = obj["info"]?[2]?[2]?.ToObject<string>() == "1";
this.IsVIP = obj["info"]?[2]?[3]?.ToObject<string>() == "1";
this.UserGuardLevel = obj["info"]?[7]?.ToObject<int>() ?? 0;
break;
case "SEND_GIFT":
this.MsgType = DanmakuMsgType.GiftSend;
this.GiftName = obj["data"]?["giftName"]?.ToObject<string>();
this.UserName = obj["data"]?["uname"]?.ToObject<string>();
this.UserID = obj["data"]?["uid"]?.ToObject<int>() ?? 0;
this.GiftCount = obj["data"]?["num"]?.ToObject<int>() ?? 0;
break;
case "GUARD_BUY":
{
this.MsgType = DanmakuMsgType.GuardBuy;
this.UserID = obj["data"]?["uid"]?.ToObject<int>() ?? 0;
this.UserName = obj["data"]?["username"]?.ToObject<string>();
this.UserGuardLevel = obj["data"]?["guard_level"]?.ToObject<int>() ?? 0;
this.GiftName = this.UserGuardLevel == 3 ? "舰长" : this.UserGuardLevel == 2 ? "提督" : this.UserGuardLevel == 1 ? "总督" : "";
this.GiftCount = obj["data"]?["num"]?.ToObject<int>() ?? 0;
break;
}
case "SUPER_CHAT_MESSAGE":
{
this.MsgType = DanmakuMsgType.SuperChat;
this.CommentText = obj["data"]?["message"]?.ToString();
this.UserID = obj["data"]?["uid"]?.ToObject<int>() ?? 0;
this.UserName = obj["data"]?["user_info"]?["uname"]?.ToString();
this.Price = obj["data"]?["price"]?.ToObject<double>() ?? 0;
this.SCKeepTime = obj["data"]?["time"]?.ToObject<int>() ?? 0;
break;
}
case "ROOM_CHANGE":
{
this.MsgType = DanmakuMsgType.RoomChange;
this.Title = obj["data"]?["title"]?.ToObject<string>();
this.AreaName = obj["data"]?["area_name"]?.ToObject<string>();
this.ParentAreaName = obj["data"]?["parent_area_name"]?.ToObject<string>();
break;
}
default:
{
this.MsgType = DanmakuMsgType.Unknown;
break;
}
}
}
}
}

View File

@ -0,0 +1,14 @@
using System;
namespace BililiveRecorder.Core.Api.Danmaku
{
public class DanmakuReceivedEventArgs : EventArgs
{
public readonly DanmakuModel Danmaku;
public DanmakuReceivedEventArgs(DanmakuModel danmaku)
{
this.Danmaku = danmaku ?? throw new ArgumentNullException(nameof(danmaku));
}
}
}

View File

@ -0,0 +1,18 @@
using System;
namespace BililiveRecorder.Core.Api.Danmaku
{
public class StatusChangedEventArgs : EventArgs
{
public static readonly StatusChangedEventArgs True = new StatusChangedEventArgs
{
Connected = true
};
public static readonly StatusChangedEventArgs False = new StatusChangedEventArgs
{
Connected = false
};
public bool Connected { get; set; }
}
}

View File

@ -0,0 +1,152 @@
using System;
using System.ComponentModel;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BililiveRecorder.Core.Api.Model;
using BililiveRecorder.Core.Config.V2;
using Newtonsoft.Json;
namespace BililiveRecorder.Core.Api.Http
{
public class HttpApiClient : IApiClient, IDanmakuServerApiClient
{
private const string HttpHeaderAccept = "application/json, text/javascript, */*; q=0.01";
private const string HttpHeaderOrigin = "https://live.bilibili.com";
private const string HttpHeaderReferer = "https://live.bilibili.com/";
private const string HttpHeaderUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36";
private static readonly TimeSpan TimeOutTimeSpan = TimeSpan.FromSeconds(10);
private readonly GlobalConfig config;
private readonly HttpClient anonClient;
private HttpClient mainClient;
private bool disposedValue;
public HttpApiClient(GlobalConfig config)
{
this.config = config ?? throw new ArgumentNullException(nameof(config));
config.PropertyChanged += this.Config_PropertyChanged;
this.mainClient = null!;
this.SetCookie();
this.anonClient = new HttpClient
{
Timeout = TimeOutTimeSpan
};
var headers = this.anonClient.DefaultRequestHeaders;
headers.Add("Accept", HttpHeaderAccept);
headers.Add("Origin", HttpHeaderOrigin);
headers.Add("Referer", HttpHeaderReferer);
headers.Add("User-Agent", HttpHeaderUserAgent);
}
private void SetCookie()
{
var client = new HttpClient(new HttpClientHandler
{
UseCookies = false,
UseDefaultCredentials = false,
})
{
Timeout = TimeOutTimeSpan
};
var headers = client.DefaultRequestHeaders;
headers.Add("Accept", HttpHeaderAccept);
headers.Add("Origin", HttpHeaderOrigin);
headers.Add("Referer", HttpHeaderReferer);
headers.Add("User-Agent", HttpHeaderUserAgent);
var cookie_string = this.config.Cookie;
if (!string.IsNullOrWhiteSpace(cookie_string))
headers.Add("Cookie", cookie_string);
var old = Interlocked.Exchange(ref this.mainClient, client);
old?.Dispose();
}
private void Config_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(this.config.Cookie))
this.SetCookie();
}
private static async Task<BilibiliApiResponse<T>> FetchAsync<T>(HttpClient client, string url) where T : class
{
var text = await client.GetStringAsync(url).ConfigureAwait(false);
var obj = JsonConvert.DeserializeObject<BilibiliApiResponse<T>>(text);
if (obj.Code != 0)
throw new BilibiliApiResponseCodeNotZeroException("Bilibili api code: " + (obj.Code?.ToString() ?? "(null)") + "\n" + text);
return obj;
}
public Task<BilibiliApiResponse<RoomInfo>> GetRoomInfoAsync(int roomid)
{
if (this.disposedValue)
throw new ObjectDisposedException(nameof(HttpApiClient));
var url = $@"https://api.live.bilibili.com/room/v1/Room/get_info?id={roomid}";
return FetchAsync<RoomInfo>(this.mainClient, url);
}
public Task<BilibiliApiResponse<UserInfo>> GetUserInfoAsync(int roomid)
{
if (this.disposedValue)
throw new ObjectDisposedException(nameof(HttpApiClient));
var url = $@"https://api.live.bilibili.com/live_user/v1/UserInfo/get_anchor_in_room?roomid={roomid}";
return FetchAsync<UserInfo>(this.mainClient, url);
}
public Task<BilibiliApiResponse<RoomPlayInfo>> GetStreamUrlAsync(int roomid)
{
if (this.disposedValue)
throw new ObjectDisposedException(nameof(HttpApiClient));
var url = $@"https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?room_id={roomid}&protocol=0%2C1&format=0%2C2&codec=0%2C1&qn=10000&platform=web&ptype=16";
return FetchAsync<RoomPlayInfo>(this.mainClient, url);
}
public Task<BilibiliApiResponse<DanmuInfo>> GetDanmakuServerAsync(int roomid)
{
if (this.disposedValue)
throw new ObjectDisposedException(nameof(HttpApiClient));
var url = $@"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id={roomid}&type=0";
return FetchAsync<DanmuInfo>(this.anonClient, url);
}
protected virtual void Dispose(bool disposing)
{
if (!this.disposedValue)
{
if (disposing)
{
// dispose managed state (managed objects)
this.config.PropertyChanged -= this.Config_PropertyChanged;
this.mainClient.Dispose();
this.anonClient.Dispose();
}
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
this.disposedValue = true;
}
}
// override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
// ~HttpApiClient()
// {
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
// Dispose(disposing: false);
// }
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
this.Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,13 @@
using System;
using System.Threading.Tasks;
using BililiveRecorder.Core.Api.Model;
namespace BililiveRecorder.Core.Api
{
public interface IApiClient : IDisposable
{
Task<BilibiliApiResponse<RoomInfo>> GetRoomInfoAsync(int roomid);
Task<BilibiliApiResponse<UserInfo>> GetUserInfoAsync(int roomid);
Task<BilibiliApiResponse<RoomPlayInfo>> GetStreamUrlAsync(int roomid);
}
}

View File

@ -0,0 +1,18 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using BililiveRecorder.Core.Api.Danmaku;
namespace BililiveRecorder.Core.Api
{
public interface IDanmakuClient : IDisposable
{
bool Connected { get; }
event EventHandler<StatusChangedEventArgs>? StatusChanged;
event EventHandler<DanmakuReceivedEventArgs>? DanmakuReceived;
Task ConnectAsync(int roomid, CancellationToken cancellationToken);
Task DisconnectAsync();
}
}

View File

@ -0,0 +1,11 @@
using System;
using System.Threading.Tasks;
using BililiveRecorder.Core.Api.Model;
namespace BililiveRecorder.Core.Api
{
public interface IDanmakuServerApiClient : IDisposable
{
Task<BilibiliApiResponse<DanmuInfo>> GetDanmakuServerAsync(int roomid);
}
}

View File

@ -0,0 +1,13 @@
using Newtonsoft.Json;
namespace BililiveRecorder.Core.Api.Model
{
public class BilibiliApiResponse<T> where T : class
{
[JsonProperty("code")]
public int? Code { get; set; }
[JsonProperty("data")]
public T? Data { get; set; }
}
}

View File

@ -0,0 +1,13 @@
using System;
using System.Runtime.Serialization;
namespace BililiveRecorder.Core.Api.Model
{
public class BilibiliApiResponseCodeNotZeroException : Exception
{
public BilibiliApiResponseCodeNotZeroException() { }
public BilibiliApiResponseCodeNotZeroException(string message) : base(message) { }
public BilibiliApiResponseCodeNotZeroException(string message, Exception innerException) : base(message, innerException) { }
protected BilibiliApiResponseCodeNotZeroException(SerializationInfo info, StreamingContext context) : base(info, context) { }
}
}

View File

@ -0,0 +1,23 @@
using System;
using Newtonsoft.Json;
namespace BililiveRecorder.Core.Api.Model
{
public class DanmuInfo
{
[JsonProperty("host_list")]
public HostListItem[] HostList { get; set; } = Array.Empty<HostListItem>();
[JsonProperty("token")]
public string Token { get; set; } = string.Empty;
public class HostListItem
{
[JsonProperty("host")]
public string Host { get; set; } = string.Empty;
[JsonProperty("port")]
public int Port { get; set; }
}
}
}

View File

@ -0,0 +1,31 @@
using Newtonsoft.Json;
namespace BililiveRecorder.Core.Api.Model
{
public class RoomInfo
{
[JsonProperty("room_id")]
public int RoomId { get; set; }
[JsonProperty("short_id")]
public int ShortId { get; set; }
[JsonProperty("live_status")]
public int LiveStatus { get; set; }
[JsonProperty("area_id")]
public int AreaId { get; set; }
[JsonProperty("parent_area_id")]
public int ParentAreaId { get; set; }
[JsonProperty("area_name")]
public string AreaName { get; set; } = string.Empty;
[JsonProperty("parent_area_name")]
public string ParentAreaName { get; set; } = string.Empty;
[JsonProperty("title")]
public string Title { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,74 @@
using System;
using Newtonsoft.Json;
namespace BililiveRecorder.Core.Api.Model
{
public class RoomPlayInfo
{
[JsonProperty("live_status")]
public int LiveStatus { get; set; }
[JsonProperty("encrypted")]
public bool Encrypted { get; set; }
[JsonProperty("playurl_info")]
public PlayurlInfoClass? PlayurlInfo { get; set; }
public class PlayurlInfoClass
{
[JsonProperty("playurl")]
public PlayurlClass? Playurl { get; set; }
}
public class PlayurlClass
{
[JsonProperty("stream")]
public StreamItem[]? Streams { get; set; } = Array.Empty<StreamItem>();
}
public class StreamItem
{
[JsonProperty("protocol_name")]
public string ProtocolName { get; set; } = string.Empty;
[JsonProperty("format")]
public FormatItem[]? Formats { get; set; } = Array.Empty<FormatItem>();
}
public class FormatItem
{
[JsonProperty("format_name")]
public string FormatName { get; set; } = string.Empty;
[JsonProperty("codec")]
public CodecItem[]? Codecs { get; set; } = Array.Empty<CodecItem>();
}
public class CodecItem
{
[JsonProperty("codec_name")]
public string CodecName { get; set; } = string.Empty;
[JsonProperty("base_url")]
public string BaseUrl { get; set; } = string.Empty;
[JsonProperty("current_qn")]
public int CurrentQn { get; set; }
[JsonProperty("accept_qn")]
public int[] AcceptQn { get; set; } = Array.Empty<int>();
[JsonProperty("url_info")]
public UrlInfoItem[]? UrlInfos { get; set; } = Array.Empty<UrlInfoItem>();
}
public class UrlInfoItem
{
[JsonProperty("host")]
public string Host { get; set; } = string.Empty;
[JsonProperty("extra")]
public string Extra { get; set; } = string.Empty;
}
}
}

View File

@ -0,0 +1,16 @@
using Newtonsoft.Json;
namespace BililiveRecorder.Core.Api.Model
{
public class UserInfo
{
[JsonProperty("info")]
public InfoClass? Info { get; set; }
public class InfoClass
{
[JsonProperty("uname")]
public string Name { get; set; } = string.Empty;
}
}
}

View File

@ -0,0 +1,32 @@
using System;
using System.Linq;
using BililiveRecorder.Core.Api.Model;
namespace BililiveRecorder.Core.Api
{
public static class ModelExtensions
{
private static readonly Random random = new Random();
public static void ChooseOne(this DanmuInfo danmuInfo, out string host, out int port, out string token)
{
const string DefaultServerHost = "broadcastlv.chat.bilibili.com";
const int DefaultServerPort = 2243;
token = danmuInfo.Token;
var list = danmuInfo.HostList.Where(x => !string.IsNullOrWhiteSpace(x.Host) && x.Host != DefaultServerHost && x.Port > 0).ToArray();
if (list.Length > 0)
{
var result = list[random.Next(list.Length)];
host = result.Host;
port = result.Port;
}
else
{
host = DefaultServerHost;
port = DefaultServerPort;
}
}
}
}

View File

@ -0,0 +1,42 @@
using System;
using System.Threading.Tasks;
using BililiveRecorder.Core.Api.Model;
using Polly;
using Polly.Registry;
namespace BililiveRecorder.Core.Api
{
public class PolicyWrappedApiClient<T> : IApiClient, IDanmakuServerApiClient, IDisposable where T : class, IApiClient, IDanmakuServerApiClient, IDisposable
{
private readonly T client;
private readonly IReadOnlyPolicyRegistry<string> policies;
public PolicyWrappedApiClient(T client, IReadOnlyPolicyRegistry<string> policies)
{
this.client = client ?? throw new ArgumentNullException(nameof(client));
this.policies = policies ?? throw new ArgumentNullException(nameof(policies));
}
public async Task<BilibiliApiResponse<DanmuInfo>> GetDanmakuServerAsync(int roomid) => await this.policies
.Get<IAsyncPolicy>(PolicyNames.PolicyDanmakuApiRequestAsync)
.ExecuteAsync(_ => this.client.GetDanmakuServerAsync(roomid), new Context(PolicyNames.CacheKeyDanmaku + ":" + roomid))
.ConfigureAwait(false);
public async Task<BilibiliApiResponse<RoomInfo>> GetRoomInfoAsync(int roomid) => await this.policies
.Get<IAsyncPolicy>(PolicyNames.PolicyRoomInfoApiRequestAsync)
.ExecuteAsync(_ => this.client.GetRoomInfoAsync(roomid), new Context(PolicyNames.CacheKeyRoomInfo + ":" + roomid))
.ConfigureAwait(false);
public async Task<BilibiliApiResponse<RoomPlayInfo>> GetStreamUrlAsync(int roomid) => await this.policies
.Get<IAsyncPolicy>(PolicyNames.PolicyStreamApiRequestAsync)
.ExecuteAsync(_ => this.client.GetStreamUrlAsync(roomid), new Context(PolicyNames.CacheKeyStream + ":" + roomid))
.ConfigureAwait(false);
public async Task<BilibiliApiResponse<UserInfo>> GetUserInfoAsync(int roomid) => await this.policies
.Get<IAsyncPolicy>(PolicyNames.PolicyRoomInfoApiRequestAsync)
.ExecuteAsync(_ => this.client.GetUserInfoAsync(roomid), new Context(PolicyNames.CacheKeyUserInfo + ":" + roomid))
.ConfigureAwait(false);
public void Dispose() => this.client.Dispose();
}
}

View File

@ -1,3 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("BililiveRecorder.UnitTest.Core")]
[assembly: InternalsVisibleTo("BililiveRecorder.Core.UnitTests")]

View File

@ -1,225 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using BililiveRecorder.Core.Config.V2;
using Newtonsoft.Json.Linq;
using NLog;
#nullable enable
namespace BililiveRecorder.Core
{
public class BililiveAPI
{
private const string HTTP_HEADER_ACCEPT = "application/json, text/javascript, */*; q=0.01";
private const string HTTP_HEADER_REFERER = "https://live.bilibili.com/";
private const string DEFAULT_SERVER_HOST = "broadcastlv.chat.bilibili.com";
private const int DEFAULT_SERVER_PORT = 2243;
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private static readonly Random random = new Random();
private readonly GlobalConfig globalConfig;
private readonly HttpClient danmakuhttpclient;
private HttpClient httpclient = null!;
public BililiveAPI(GlobalConfig globalConfig)
{
this.globalConfig = globalConfig;
this.globalConfig.PropertyChanged += (sender, e) =>
{
if (e.PropertyName == nameof(this.globalConfig.Cookie))
this.ApplyCookieSettings(this.globalConfig.Cookie);
};
this.ApplyCookieSettings(this.globalConfig.Cookie);
this.danmakuhttpclient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
this.danmakuhttpclient.DefaultRequestHeaders.Add("Accept", HTTP_HEADER_ACCEPT);
this.danmakuhttpclient.DefaultRequestHeaders.Add("Referer", HTTP_HEADER_REFERER);
this.danmakuhttpclient.DefaultRequestHeaders.Add("User-Agent", Utils.UserAgent);
}
public void ApplyCookieSettings(string? cookie_string)
{
try
{
logger.Trace("设置 Cookie 信息...");
if (!string.IsNullOrWhiteSpace(cookie_string))
{
var pclient = new HttpClient(handler: new HttpClientHandler
{
UseCookies = false,
UseDefaultCredentials = false,
}, disposeHandler: true)
{
Timeout = TimeSpan.FromSeconds(10)
};
pclient.DefaultRequestHeaders.Add("Accept", HTTP_HEADER_ACCEPT);
pclient.DefaultRequestHeaders.Add("Referer", HTTP_HEADER_REFERER);
pclient.DefaultRequestHeaders.Add("User-Agent", Utils.UserAgent);
pclient.DefaultRequestHeaders.Add("Cookie", cookie_string);
this.httpclient = pclient;
}
else
{
var cleanclient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
cleanclient.DefaultRequestHeaders.Add("Accept", HTTP_HEADER_ACCEPT);
cleanclient.DefaultRequestHeaders.Add("Referer", HTTP_HEADER_REFERER);
cleanclient.DefaultRequestHeaders.Add("User-Agent", Utils.UserAgent);
this.httpclient = cleanclient;
}
logger.Debug("设置 Cookie 成功");
}
catch (Exception ex)
{
logger.Error(ex, "设置 Cookie 时发生错误");
}
}
/// <summary>
/// 下载json并解析
/// </summary>
/// <param name="url">下载路径</param>
/// <returns>数据</returns>
/// <exception cref="ArgumentNullException"/>
/// <exception cref="WebException"/>
private async Task<JObject?> HttpGetJsonAsync(HttpClient client, string url)
{
try
{
var s = await client.GetStringAsync(url);
var j = JObject.Parse(s);
return j;
}
catch (TaskCanceledException)
{
return null;
}
}
/// <summary>
/// 获取直播间播放地址
/// </summary>
/// <param name="roomid">原房间号</param>
/// <returns>FLV播放地址</returns>
/// <exception cref="WebException"/>
/// <exception cref="Exception"/>
public async Task<string?> GetPlayUrlAsync(int roomid)
{
var url = $@"{this.globalConfig.LiveApiHost}/room/v1/Room/playUrl?cid={roomid}&quality=4&platform=web";
// 随机选择一个 url
if ((await this.HttpGetJsonAsync(this.httpclient, url))?["data"]?["durl"] is JArray array)
{
var urls = array.Select(t => t?["url"]?.ToObject<string>());
var distinct = urls.Distinct().ToArray();
if (distinct.Length > 0)
{
return distinct[random.Next(distinct.Length)];
}
}
return null;
}
/// <summary>
/// 获取直播间信息
/// </summary>
/// <param name="roomid">房间号(允许短号)</param>
/// <returns>直播间信息</returns>
/// <exception cref="WebException"/>
/// <exception cref="Exception"/>
public async Task<RoomInfo?> GetRoomInfoAsync(int roomid)
{
try
{
var room = await this.HttpGetJsonAsync(this.httpclient, $@"https://api.live.bilibili.com/room/v1/Room/get_info?id={roomid}");
if (room?["code"]?.ToObject<int>() != 0)
{
logger.Warn("不能获取 {roomid} 的信息1: {errormsg}", roomid, room?["message"]?.ToObject<string>() ?? "请求超时");
return null;
}
var user = await this.HttpGetJsonAsync(this.httpclient, $@"https://api.live.bilibili.com/live_user/v1/UserInfo/get_anchor_in_room?roomid={roomid}");
if (user?["code"]?.ToObject<int>() != 0)
{
logger.Warn("不能获取 {roomid} 的信息2: {errormsg}", roomid, user?["message"]?.ToObject<string>() ?? "请求超时");
return null;
}
var i = new RoomInfo()
{
ShortRoomId = room?["data"]?["short_id"]?.ToObject<int>() ?? throw new Exception("未获取到直播间信息"),
RoomId = room?["data"]?["room_id"]?.ToObject<int>() ?? throw new Exception("未获取到直播间信息"),
IsStreaming = 1 == (room?["data"]?["live_status"]?.ToObject<int>() ?? throw new Exception("未获取到直播间信息")),
UserName = user?["data"]?["info"]?["uname"]?.ToObject<string>() ?? throw new Exception("未获取到直播间信息"),
Title = room?["data"]?["title"]?.ToObject<string>() ?? throw new Exception("未获取到直播间信息"),
ParentAreaName = room?["data"]?["parent_area_name"]?.ToObject<string>() ?? throw new Exception("未获取到直播间信息"),
AreaName = room?["data"]?["area_name"]?.ToObject<string>() ?? throw new Exception("未获取到直播间信息"),
};
return i;
}
catch (Exception ex)
{
logger.Warn(ex, "获取直播间 {roomid} 的信息时出错", roomid);
throw;
}
}
/// <summary>
/// 获取弹幕连接信息
/// </summary>
/// <param name="roomid"></param>
/// <returns></returns>
public async Task<(string token, string host, int port)> GetDanmuConf(int roomid)
{
try
{
var result = await this.HttpGetJsonAsync(this.danmakuhttpclient, $@"https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id={roomid}&platform=pc&player=web");
if (result?["code"]?.ToObject<int>() == 0)
{
var token = result?["data"]?["token"]?.ToObject<string>() ?? string.Empty;
var servers = new List<(string? host, int port)>();
if (result?["data"]?["host_server_list"] is JArray host_server_list)
{
foreach (var host_server_jtoken in host_server_list)
if (host_server_jtoken is JObject host_server)
servers.Add((host_server["host"]?.ToObject<string>(), host_server["port"]?.ToObject<int>() ?? 0));
}
if (result?["data"]?["server_list"] is JArray server_list)
{
foreach (var server_jtoken in server_list)
if (server_jtoken is JObject server)
servers.Add((server["host"]?.ToObject<string>(), server["port"]?.ToObject<int>() ?? 0));
}
servers.RemoveAll(x => string.IsNullOrWhiteSpace(x.host) || x.port <= 0 || x.host == DEFAULT_SERVER_HOST);
if (servers.Count > 0)
{
var (host, port) = servers[random.Next(servers.Count)];
return (token, host!, port);
}
else
{
return (token, DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT);
}
}
else
{
logger.Warn("获取直播间 {roomid} 的弹幕连接信息时返回了 {code}", roomid, result?["code"]?.ToObject<int>());
return (string.Empty, DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT);
}
}
catch (Exception ex)
{
logger.Warn(ex, "获取直播间 {roomid} 的弹幕连接信息时出错", roomid);
return (string.Empty, DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT);
}
}
}
}

View File

@ -10,6 +10,7 @@
<FileVersion>0.0.0.0</FileVersion>
<OldToolsVersion>2.0</OldToolsVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<DebugType>portable</DebugType>
@ -22,14 +23,20 @@
<PackageReference Include="JsonSubTypes" Version="1.8.0" />
<PackageReference Include="HierarchicalPropertyDefault" Version="0.1.1-beta-g721d36b97c" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="1.4.0" />
<PackageReference Include="Nerdbank.Streams" Version="2.6.81" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="NLog" Version="4.7.6" />
<PackageReference Include="Polly" Version="7.2.1" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="System.IO.Pipelines" Version="5.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BililiveRecorder.FlvProcessor\BililiveRecorder.FlvProcessor.csproj" />
<ProjectReference Include="..\BililiveRecorder.Flv\BililiveRecorder.Flv.csproj" />
</ItemGroup>
<PropertyGroup>
<PreBuildEvent>cd $(SolutionDir)
powershell -ExecutionPolicy Bypass -File .\CI\patch_buildinfo.ps1 Core</PreBuildEvent>
<PreBuildEvent>
cd $(SolutionDir)
powershell -ExecutionPolicy Bypass -File .\CI\patch_buildinfo.ps1 Core
</PreBuildEvent>
</PropertyGroup>
</Project>

View File

@ -1,18 +0,0 @@
using System;
#nullable enable
namespace BililiveRecorder.Core.Callback
{
public class RecordEndData
{
public Guid EventRandomId { get; set; } = Guid.NewGuid();
public int RoomId { get; set; } = 0;
public string Name { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public long FileSize { get; set; }
public DateTimeOffset StartRecordTime { get; set; }
public DateTimeOffset EndRecordTime { get; set; }
}
}

View File

@ -3,9 +3,8 @@ using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using BililiveRecorder.FlvProcessor;
using BililiveRecorder.Core.Config.V2;
#nullable enable
#pragma warning disable CS0612 // obsolete
namespace BililiveRecorder.Core.Config
{
@ -15,9 +14,6 @@ namespace BililiveRecorder.Core.Config
{
var map = new Dictionary<PropertyInfo, PropertyInfo>();
AddMap<V1.ConfigV1, V2.GlobalConfig, EnabledFeature>(map, x => x.EnabledFeature, x => x.EnabledFeature);
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.ClipLengthPast, x => x.ClipLengthPast);
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.ClipLengthFuture, x => x.ClipLengthFuture);
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.TimingStreamRetry, x => x.TimingStreamRetry);
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.TimingStreamConnect, x => x.TimingStreamConnect);
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.TimingDanmakuRetry, x => x.TimingDanmakuRetry);
@ -28,9 +24,8 @@ namespace BililiveRecorder.Core.Config
AddMap<V1.ConfigV1, V2.GlobalConfig, string?>(map, x => x.WebHookUrls, x => x.WebHookUrls);
AddMap<V1.ConfigV1, V2.GlobalConfig, string?>(map, x => x.LiveApiHost, x => x.LiveApiHost);
AddMap<V1.ConfigV1, V2.GlobalConfig, string?>(map, x => x.RecordFilenameFormat, x => x.RecordFilenameFormat);
AddMap<V1.ConfigV1, V2.GlobalConfig, string?>(map, x => x.ClipFilenameFormat, x => x.ClipFilenameFormat);
AddMap<V1.ConfigV1, V2.GlobalConfig, AutoCuttingMode>(map, x => x.CuttingMode, x => x.CuttingMode);
AddMap<V1.ConfigV1, V2.GlobalConfig, CuttingMode>(map, x => x.CuttingMode, x => x.CuttingMode);
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.CuttingNumber, x => x.CuttingNumber);
AddMap<V1.ConfigV1, V2.GlobalConfig, bool>(map, x => x.RecordDanmaku, x => x.RecordDanmaku);
AddMap<V1.ConfigV1, V2.GlobalConfig, bool>(map, x => x.RecordDanmakuRaw, x => x.RecordDanmakuRaw);

View File

@ -3,15 +3,20 @@ using System.IO;
using System.Linq;
using System.Text;
using Newtonsoft.Json;
using NLog;
using Serilog;
#nullable enable
namespace BililiveRecorder.Core.Config
{
public static class ConfigParser
public class ConfigParser
{
private const string CONFIG_FILE_NAME = "config.json";
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private static readonly ILogger logger = Log.ForContext<ConfigParser>();
private static readonly JsonSerializerSettings settings = new JsonSerializerSettings()
{
DefaultValueHandling = DefaultValueHandling.Ignore,
NullValueHandling = NullValueHandling.Ignore
};
public static V2.ConfigV2? LoadFrom(string directory)
{
@ -24,11 +29,11 @@ namespace BililiveRecorder.Core.Config
if (!File.Exists(filepath))
{
logger.Debug("Config file does not exist. \"{path}\"", filepath);
logger.Debug("Config file does not exist {Path}", filepath);
return new V2.ConfigV2();
}
logger.Debug("Loading config from path \"{path}\".", filepath);
logger.Debug("Loading config from {Path}", filepath);
var json = File.ReadAllText(filepath, Encoding.UTF8);
return LoadJson(json);
@ -44,7 +49,7 @@ namespace BililiveRecorder.Core.Config
{
try
{
logger.Debug("Config json: {config}", json);
logger.Debug("Config json: {Json}", json);
var configBase = JsonConvert.DeserializeObject<ConfigBase>(json);
switch (configBase)
@ -53,7 +58,7 @@ namespace BililiveRecorder.Core.Config
{
logger.Debug("读取到 config v1");
#pragma warning disable CS0612
var v1Data = JsonConvert.DeserializeObject<V1.ConfigV1>(v1.Data);
var v1Data = JsonConvert.DeserializeObject<V1.ConfigV1>(v1.Data ?? string.Empty);
#pragma warning restore CS0612
var newConfig = ConfigMapper.Map1To2(v1Data);
@ -106,7 +111,7 @@ namespace BililiveRecorder.Core.Config
{
try
{
var json = JsonConvert.SerializeObject(config);
var json = JsonConvert.SerializeObject(config, Formatting.None, settings);
return json;
}
catch (Exception ex)
@ -146,6 +151,5 @@ namespace BililiveRecorder.Core.Config
}
private static readonly Random random = new Random();
private static string RandomString(int length) => new string(Enumerable.Repeat("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", length).Select(s => s[random.Next(s.Length)]).ToArray());
}
}

View File

@ -2,25 +2,16 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using BililiveRecorder.FlvProcessor;
using Newtonsoft.Json;
using NLog;
#nullable disable
namespace BililiveRecorder.Core.Config.V1
{
[Obsolete]
[JsonObject(memberSerialization: MemberSerialization.OptIn)]
public class ConfigV1 : INotifyPropertyChanged
{
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
/// <summary>
/// 当前工作目录
/// </summary>
[JsonIgnore]
[Utils.DoNotCopyProperty]
public string WorkDirectory { get => this._workDirectory; set => this.SetField(ref this._workDirectory, value); }
//private static readonly Logger logger = LogManager.GetCurrentClassLogger();
/// <summary>
/// 房间号列表
@ -31,8 +22,8 @@ namespace BililiveRecorder.Core.Config.V1
/// <summary>
/// 启用的功能
/// </summary>
[JsonProperty("feature")]
public EnabledFeature EnabledFeature { get => this._enabledFeature; set => this.SetField(ref this._enabledFeature, value); }
//[JsonProperty("feature")]
//public EnabledFeature EnabledFeature { get => this._enabledFeature; set => this.SetField(ref this._enabledFeature, value); }
/// <summary>
/// 剪辑-过去的时长(秒)
@ -50,7 +41,7 @@ namespace BililiveRecorder.Core.Config.V1
/// 自动切割模式
/// </summary>
[JsonProperty("cutting_mode")]
public AutoCuttingMode CuttingMode { get => this._cuttingMode; set => this.SetField(ref this._cuttingMode, value); }
public V2.CuttingMode CuttingMode { get => this._cuttingMode; set => this.SetField(ref this._cuttingMode, value); }
/// <summary>
/// 自动切割数值(分钟/MiB
@ -165,18 +156,17 @@ namespace BililiveRecorder.Core.Config.V1
protected virtual void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false; logger.Trace("设置 [{0}] 的值已从 [{1}] 修改到 [{2}]", propertyName, field, value);
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
// logger.Trace("设置 [{0}] 的值已从 [{1}] 修改到 [{2}]", propertyName, field, value);
field = value; this.OnPropertyChanged(propertyName); return true;
}
#endregion
private string _workDirectory;
private uint _clipLengthPast = 20;
private uint _clipLengthFuture = 10;
private uint _cuttingNumber = 10;
private EnabledFeature _enabledFeature = EnabledFeature.RecordOnly;
private AutoCuttingMode _cuttingMode = AutoCuttingMode.Disabled;
//private EnabledFeature _enabledFeature = EnabledFeature.RecordOnly;
private V2.CuttingMode _cuttingMode = V2.CuttingMode.Disabled;
private uint _timingWatchdogTimeout = 10 * 1000;
private uint _timingStreamRetry = 6 * 1000;

View File

@ -8,6 +8,6 @@ namespace BililiveRecorder.Core.Config.V1
/// Config Data String
/// </summary>
[JsonProperty("data")]
public string Data { get; set; }
public string? Data { get; set; }
}
}

View File

@ -3,7 +3,6 @@
// RUN FORMATTER AFTER GENERATE
// ******************************
using System.ComponentModel;
using BililiveRecorder.FlvProcessor;
using HierarchicalPropertyDefault;
using Newtonsoft.Json;
@ -32,10 +31,10 @@ namespace BililiveRecorder.Core.Config.V2
/// <summary>
/// 录制文件自动切割模式
/// </summary>
public AutoCuttingMode CuttingMode { get => this.GetPropertyValue<AutoCuttingMode>(); set => this.SetPropertyValue(value); }
public bool HasCuttingMode { get => this.GetPropertyHasValue(nameof(this.CuttingMode)); set => this.SetPropertyHasValue<AutoCuttingMode>(value, nameof(this.CuttingMode)); }
public CuttingMode CuttingMode { get => this.GetPropertyValue<CuttingMode>(); set => this.SetPropertyValue(value); }
public bool HasCuttingMode { get => this.GetPropertyHasValue(nameof(this.CuttingMode)); set => this.SetPropertyHasValue<CuttingMode>(value, nameof(this.CuttingMode)); }
[JsonProperty(nameof(CuttingMode)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<AutoCuttingMode> OptionalCuttingMode { get => this.GetPropertyValueOptional<AutoCuttingMode>(nameof(this.CuttingMode)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingMode)); }
public Optional<CuttingMode> OptionalCuttingMode { get => this.GetPropertyValueOptional<CuttingMode>(nameof(this.CuttingMode)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingMode)); }
/// <summary>
/// 录制文件自动切割数值(分钟/MiB
@ -85,21 +84,6 @@ namespace BililiveRecorder.Core.Config.V2
[JsonProperty(nameof(RecordDanmakuGuard)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<bool> OptionalRecordDanmakuGuard { get => this.GetPropertyValueOptional<bool>(nameof(this.RecordDanmakuGuard)); set => this.SetPropertyValueOptional(value, nameof(this.RecordDanmakuGuard)); }
/// <summary>
/// 启用的功能
/// </summary>
public EnabledFeature EnabledFeature => this.GetPropertyValue<EnabledFeature>();
/// <summary>
/// 剪辑-过去的时长(秒)
/// </summary>
public uint ClipLengthPast => this.GetPropertyValue<uint>();
/// <summary>
/// 剪辑-将来的时长(秒)
/// </summary>
public uint ClipLengthFuture => this.GetPropertyValue<uint>();
/// <summary>
/// 录制断开重连时间间隔 毫秒
/// </summary>
@ -140,6 +124,11 @@ namespace BililiveRecorder.Core.Config.V2
/// </summary>
public string? WebHookUrls => this.GetPropertyValue<string>();
/// <summary>
/// Webhook v2 地址 每行一个
/// </summary>
public string? WebHookUrlsV2 => this.GetPropertyValue<string>();
/// <summary>
/// 替换 api.live.bilibili.com 服务器为其他反代,可以支持在云服务器上录制
/// </summary>
@ -150,11 +139,6 @@ namespace BililiveRecorder.Core.Config.V2
/// </summary>
public string? RecordFilenameFormat => this.GetPropertyValue<string>();
/// <summary>
/// 剪辑文件名模板
/// </summary>
public string? ClipFilenameFormat => this.GetPropertyValue<string>();
/// <summary>
/// 是否显示直播间标题和分区
/// </summary>
@ -165,30 +149,6 @@ namespace BililiveRecorder.Core.Config.V2
[JsonObject(MemberSerialization.OptIn)]
public sealed partial class GlobalConfig : HierarchicalObject<DefaultConfig, GlobalConfig>
{
/// <summary>
/// 启用的功能
/// </summary>
public EnabledFeature EnabledFeature { get => this.GetPropertyValue<EnabledFeature>(); set => this.SetPropertyValue(value); }
public bool HasEnabledFeature { get => this.GetPropertyHasValue(nameof(this.EnabledFeature)); set => this.SetPropertyHasValue<EnabledFeature>(value, nameof(this.EnabledFeature)); }
[JsonProperty(nameof(EnabledFeature)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<EnabledFeature> OptionalEnabledFeature { get => this.GetPropertyValueOptional<EnabledFeature>(nameof(this.EnabledFeature)); set => this.SetPropertyValueOptional(value, nameof(this.EnabledFeature)); }
/// <summary>
/// 剪辑-过去的时长(秒)
/// </summary>
public uint ClipLengthPast { get => this.GetPropertyValue<uint>(); set => this.SetPropertyValue(value); }
public bool HasClipLengthPast { get => this.GetPropertyHasValue(nameof(this.ClipLengthPast)); set => this.SetPropertyHasValue<uint>(value, nameof(this.ClipLengthPast)); }
[JsonProperty(nameof(ClipLengthPast)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<uint> OptionalClipLengthPast { get => this.GetPropertyValueOptional<uint>(nameof(this.ClipLengthPast)); set => this.SetPropertyValueOptional(value, nameof(this.ClipLengthPast)); }
/// <summary>
/// 剪辑-将来的时长(秒)
/// </summary>
public uint ClipLengthFuture { get => this.GetPropertyValue<uint>(); set => this.SetPropertyValue(value); }
public bool HasClipLengthFuture { get => this.GetPropertyHasValue(nameof(this.ClipLengthFuture)); set => this.SetPropertyHasValue<uint>(value, nameof(this.ClipLengthFuture)); }
[JsonProperty(nameof(ClipLengthFuture)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<uint> OptionalClipLengthFuture { get => this.GetPropertyValueOptional<uint>(nameof(this.ClipLengthFuture)); set => this.SetPropertyValueOptional(value, nameof(this.ClipLengthFuture)); }
/// <summary>
/// 录制断开重连时间间隔 毫秒
/// </summary>
@ -253,6 +213,14 @@ namespace BililiveRecorder.Core.Config.V2
[JsonProperty(nameof(WebHookUrls)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<string?> OptionalWebHookUrls { get => this.GetPropertyValueOptional<string>(nameof(this.WebHookUrls)); set => this.SetPropertyValueOptional(value, nameof(this.WebHookUrls)); }
/// <summary>
/// Webhook v2 地址 每行一个
/// </summary>
public string? WebHookUrlsV2 { get => this.GetPropertyValue<string>(); set => this.SetPropertyValue(value); }
public bool HasWebHookUrlsV2 { get => this.GetPropertyHasValue(nameof(this.WebHookUrlsV2)); set => this.SetPropertyHasValue<string>(value, nameof(this.WebHookUrlsV2)); }
[JsonProperty(nameof(WebHookUrlsV2)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<string?> OptionalWebHookUrlsV2 { get => this.GetPropertyValueOptional<string>(nameof(this.WebHookUrlsV2)); set => this.SetPropertyValueOptional(value, nameof(this.WebHookUrlsV2)); }
/// <summary>
/// 替换 api.live.bilibili.com 服务器为其他反代,可以支持在云服务器上录制
/// </summary>
@ -269,14 +237,6 @@ namespace BililiveRecorder.Core.Config.V2
[JsonProperty(nameof(RecordFilenameFormat)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<string?> OptionalRecordFilenameFormat { get => this.GetPropertyValueOptional<string>(nameof(this.RecordFilenameFormat)); set => this.SetPropertyValueOptional(value, nameof(this.RecordFilenameFormat)); }
/// <summary>
/// 剪辑文件名模板
/// </summary>
public string? ClipFilenameFormat { get => this.GetPropertyValue<string>(); set => this.SetPropertyValue(value); }
public bool HasClipFilenameFormat { get => this.GetPropertyHasValue(nameof(this.ClipFilenameFormat)); set => this.SetPropertyHasValue<string>(value, nameof(this.ClipFilenameFormat)); }
[JsonProperty(nameof(ClipFilenameFormat)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<string?> OptionalClipFilenameFormat { get => this.GetPropertyValueOptional<string>(nameof(this.ClipFilenameFormat)); set => this.SetPropertyValueOptional(value, nameof(this.ClipFilenameFormat)); }
/// <summary>
/// 是否显示直播间标题和分区
/// </summary>
@ -288,10 +248,10 @@ namespace BililiveRecorder.Core.Config.V2
/// <summary>
/// 录制文件自动切割模式
/// </summary>
public AutoCuttingMode CuttingMode { get => this.GetPropertyValue<AutoCuttingMode>(); set => this.SetPropertyValue(value); }
public bool HasCuttingMode { get => this.GetPropertyHasValue(nameof(this.CuttingMode)); set => this.SetPropertyHasValue<AutoCuttingMode>(value, nameof(this.CuttingMode)); }
public CuttingMode CuttingMode { get => this.GetPropertyValue<CuttingMode>(); set => this.SetPropertyValue(value); }
public bool HasCuttingMode { get => this.GetPropertyHasValue(nameof(this.CuttingMode)); set => this.SetPropertyHasValue<CuttingMode>(value, nameof(this.CuttingMode)); }
[JsonProperty(nameof(CuttingMode)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<AutoCuttingMode> OptionalCuttingMode { get => this.GetPropertyValueOptional<AutoCuttingMode>(nameof(this.CuttingMode)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingMode)); }
public Optional<CuttingMode> OptionalCuttingMode { get => this.GetPropertyValueOptional<CuttingMode>(nameof(this.CuttingMode)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingMode)); }
/// <summary>
/// 录制文件自动切割数值(分钟/MiB
@ -348,12 +308,6 @@ namespace BililiveRecorder.Core.Config.V2
internal static readonly DefaultConfig Instance = new DefaultConfig();
private DefaultConfig() { }
public EnabledFeature EnabledFeature => EnabledFeature.RecordOnly;
public uint ClipLengthPast => 20;
public uint ClipLengthFuture => 10;
public uint TimingStreamRetry => 6 * 1000;
public uint TimingStreamConnect => 5 * 1000;
@ -370,15 +324,15 @@ namespace BililiveRecorder.Core.Config.V2
public string WebHookUrls => string.Empty;
public string WebHookUrlsV2 => string.Empty;
public string LiveApiHost => "https://api.live.bilibili.com";
public string RecordFilenameFormat => @"{roomid}-{name}/录制-{roomid}-{date}-{time}-{title}.flv";
public string ClipFilenameFormat => @"{roomid}-{name}/剪辑片段-{roomid}-{date}-{time}-{title}.flv";
public bool WpfShowTitleAndArea => true;
public bool WpfShowTitleAndArea => false;
public AutoCuttingMode CuttingMode => AutoCuttingMode.Disabled;
public CuttingMode CuttingMode => CuttingMode.Disabled;
public uint CuttingNumber => 100;

View File

@ -0,0 +1,18 @@
namespace BililiveRecorder.Core.Config.V2
{
public enum CuttingMode : int
{
/// <summary>
/// 禁用
/// </summary>
Disabled,
/// <summary>
/// 根据时间切割
/// </summary>
ByTime,
/// <summary>
/// 根据文件大小切割
/// </summary>
BySize,
}
}

View File

@ -1,20 +1,5 @@
module.exports = {
"global": [{
"name": "EnabledFeature",
"type": "EnabledFeature",
"desc": "启用的功能",
"default": "EnabledFeature.RecordOnly"
}, {
"name": "ClipLengthPast",
"type": "uint",
"desc": "剪辑-过去的时长(秒)",
"default": "20"
}, {
"name": "ClipLengthFuture",
"type": "uint",
"desc": "剪辑-将来的时长(秒)",
"default": "10"
}, {
"name": "TimingStreamRetry",
"type": "uint",
"desc": "录制断开重连时间间隔 毫秒",
@ -56,6 +41,12 @@ module.exports = {
"desc": "录制文件写入结束 Webhook 地址 每行一个",
"default": "string.Empty",
"nullable": true
}, {
"name": "WebHookUrlsV2",
"type": "string",
"desc": "Webhook v2 地址 每行一个",
"default": "string.Empty",
"nullable": true
}, {
"name": "LiveApiHost",
"type": "string",
@ -68,17 +59,11 @@ module.exports = {
"desc": "录制文件名模板",
"default": "@\"{roomid}-{name}/录制-{roomid}-{date}-{time}-{title}.flv\"",
"nullable": true
}, {
"name": "ClipFilenameFormat",
"type": "string",
"desc": "剪辑文件名模板",
"default": "@\"{roomid}-{name}/剪辑片段-{roomid}-{date}-{time}-{title}.flv\"",
"nullable": true
}, {
"name": "WpfShowTitleAndArea",
"type": "bool",
"desc": "是否显示直播间标题和分区",
"default": "false",
"default": "true",
},],
"room": [{
"name": "RoomId",
@ -94,9 +79,9 @@ module.exports = {
"without_global": true
}, {
"name": "CuttingMode",
"type": "AutoCuttingMode",
"type": "CuttingMode",
"desc": "录制文件自动切割模式",
"default": "AutoCuttingMode.Disabled"
"default": "CuttingMode.Disabled"
}, {
"name": "CuttingNumber",
"type": "uint",

View File

@ -8,7 +8,6 @@ const CODE_HEADER =
// RUN FORMATTER AFTER GENERATE
// ******************************
using System.ComponentModel;
using BililiveRecorder.FlvProcessor;
using HierarchicalPropertyDefault;
using Newtonsoft.Json;
@ -77,3 +76,5 @@ result += CODE_FOOTER;
fs.writeFileSync("./Config.gen.cs", result, {
encoding: "utf8"
});
console.log("记得 format Config.gen.cs")

View File

@ -4,10 +4,11 @@ using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Xml;
using BililiveRecorder.Core.Api.Danmaku;
using BililiveRecorder.Core.Config.V2;
#nullable enable
namespace BililiveRecorder.Core
namespace BililiveRecorder.Core.Danmaku
{
public class BasicDanmakuWriter : IBasicDanmakuWriter
{
@ -26,16 +27,14 @@ namespace BililiveRecorder.Core
private XmlWriter? xmlWriter = null;
private DateTimeOffset offset = DateTimeOffset.UtcNow;
private uint writeCount = 0;
private readonly RoomConfig config;
private RoomConfig? config;
public BasicDanmakuWriter(RoomConfig config)
{
this.config = config ?? throw new ArgumentNullException(nameof(config));
}
public BasicDanmakuWriter()
{ }
private readonly SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1, 1);
public void EnableWithPath(string path, IRecordedRoom recordedRoom)
public void EnableWithPath(string path, IRoom room)
{
if (this.disposedValue) return;
@ -52,8 +51,10 @@ namespace BililiveRecorder.Core
try { Directory.CreateDirectory(Path.GetDirectoryName(path)); } catch (Exception) { }
var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.Read);
this.config = room.RoomConfig;
this.xmlWriter = XmlWriter.Create(stream, xmlWriterSettings);
this.WriteStartDocument(this.xmlWriter, recordedRoom);
this.WriteStartDocument(this.xmlWriter, room);
this.offset = DateTimeOffset.UtcNow;
this.writeCount = 0;
}
@ -86,6 +87,7 @@ namespace BililiveRecorder.Core
public void Write(DanmakuModel danmakuModel)
{
if (this.disposedValue) return;
if (this.config is null) return;
this.semaphoreSlim.Wait();
try
@ -96,24 +98,24 @@ namespace BililiveRecorder.Core
var recordDanmakuRaw = this.config.RecordDanmakuRaw;
switch (danmakuModel.MsgType)
{
case MsgTypeEnum.Comment:
case DanmakuMsgType.Comment:
{
var type = danmakuModel.RawObj?["info"]?[0]?[1]?.ToObject<int>() ?? 1;
var size = danmakuModel.RawObj?["info"]?[0]?[2]?.ToObject<int>() ?? 25;
var color = danmakuModel.RawObj?["info"]?[0]?[3]?.ToObject<int>() ?? 0XFFFFFF;
var st = danmakuModel.RawObj?["info"]?[0]?[4]?.ToObject<long>() ?? 0L;
var type = danmakuModel.RawObject?["info"]?[0]?[1]?.ToObject<int>() ?? 1;
var size = danmakuModel.RawObject?["info"]?[0]?[2]?.ToObject<int>() ?? 25;
var color = danmakuModel.RawObject?["info"]?[0]?[3]?.ToObject<int>() ?? 0XFFFFFF;
var st = danmakuModel.RawObject?["info"]?[0]?[4]?.ToObject<long>() ?? 0L;
var ts = Math.Max((DateTimeOffset.FromUnixTimeMilliseconds(st) - this.offset).TotalSeconds, 0d);
this.xmlWriter.WriteStartElement("d");
this.xmlWriter.WriteAttributeString("p", $"{ts},{type},{size},{color},{st},0,{danmakuModel.UserID},0");
this.xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
if (recordDanmakuRaw)
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["info"]?.ToString(Newtonsoft.Json.Formatting.None));
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObject?["info"]?.ToString(Newtonsoft.Json.Formatting.None));
this.xmlWriter.WriteValue(RemoveInvalidXMLChars(danmakuModel.CommentText));
this.xmlWriter.WriteEndElement();
}
break;
case MsgTypeEnum.SuperChat:
case DanmakuMsgType.SuperChat:
if (this.config.RecordDanmakuSuperChat)
{
this.xmlWriter.WriteStartElement("sc");
@ -123,12 +125,12 @@ namespace BililiveRecorder.Core
this.xmlWriter.WriteAttributeString("price", danmakuModel.Price.ToString());
this.xmlWriter.WriteAttributeString("time", danmakuModel.SCKeepTime.ToString());
if (recordDanmakuRaw)
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObject?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
this.xmlWriter.WriteValue(RemoveInvalidXMLChars(danmakuModel.CommentText));
this.xmlWriter.WriteEndElement();
}
break;
case MsgTypeEnum.GiftSend:
case DanmakuMsgType.GiftSend:
if (this.config.RecordDanmakuGift)
{
this.xmlWriter.WriteStartElement("gift");
@ -138,11 +140,11 @@ namespace BililiveRecorder.Core
this.xmlWriter.WriteAttributeString("giftname", danmakuModel.GiftName);
this.xmlWriter.WriteAttributeString("giftcount", danmakuModel.GiftCount.ToString());
if (recordDanmakuRaw)
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObject?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
this.xmlWriter.WriteEndElement();
}
break;
case MsgTypeEnum.GuardBuy:
case DanmakuMsgType.GuardBuy:
if (this.config.RecordDanmakuGuard)
{
this.xmlWriter.WriteStartElement("guard");
@ -152,7 +154,7 @@ namespace BililiveRecorder.Core
this.xmlWriter.WriteAttributeString("level", danmakuModel.UserGuardLevel.ToString()); ;
this.xmlWriter.WriteAttributeString("count", danmakuModel.GiftCount.ToString());
if (recordDanmakuRaw)
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObject?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
this.xmlWriter.WriteEndElement();
}
break;
@ -174,7 +176,7 @@ namespace BililiveRecorder.Core
}
}
private void WriteStartDocument(XmlWriter writer, IRecordedRoom recordedRoom)
private void WriteStartDocument(XmlWriter writer, IRoom room)
{
writer.WriteStartDocument();
writer.WriteProcessingInstruction("xml-stylesheet", "type=\"text/xsl\" href=\"#s\"");
@ -191,9 +193,10 @@ namespace BililiveRecorder.Core
writer.WriteAttributeString("version", BuildInfo.Version + "-" + BuildInfo.HeadShaShort);
writer.WriteEndElement();
writer.WriteStartElement("BililiveRecorderRecordInfo");
writer.WriteAttributeString("roomid", recordedRoom.RoomId.ToString());
writer.WriteAttributeString("name", recordedRoom.StreamerName);
writer.WriteAttributeString("roomid", room.RoomConfig.RoomId.ToString());
writer.WriteAttributeString("name", room.Name);
writer.WriteAttributeString("start_time", DateTimeOffset.Now.ToString("O"));
// TODO 添加更多信息
writer.WriteEndElement();
const string style = @"<z:stylesheet version=""1.0"" id=""s"" xml:id=""s"" xmlns:z=""http://www.w3.org/1999/XSL/Transform""><z:output method=""html""/><z:template match=""/""><html><meta name=""viewport"" content=""width=device-width""/><title>B站录播姬弹幕文件 - <z:value-of select=""/i/BililiveRecorderRecordInfo/@name""/></title><style>body{margin:0}h1,h2,p,table{margin-left:5px}table{border-spacing:0}td,th{border:1px solid grey;padding:1px}th{position:sticky;top:0;background:#4098de}tr:hover{background:#d9f4ff}div{overflow:auto;max-height:80vh;max-width:100vw;width:fit-content}</style><h1>B站录播姬弹幕XML文件</h1><p>本文件的弹幕信息兼容B站主站视频弹幕XML格式可以使用现有的转换工具把文件中的弹幕转为ass字幕文件</p><table><tr><td>录播姬版本</td><td><z:value-of select=""/i/BililiveRecorder/@version""/></td></tr><tr><td>房间号</td><td><z:value-of select=""/i/BililiveRecorderRecordInfo/@roomid""/></td></tr><tr><td>主播名</td><td><z:value-of select=""/i/BililiveRecorderRecordInfo/@name""/></td></tr><tr><td>录制开始时间</td><td><z:value-of select=""/i/BililiveRecorderRecordInfo/@start_time""/></td></tr><tr><td><a href=""#d"">弹幕</a></td><td>共 <z:value-of select=""count(/i/d)""/> 条记录</td></tr><tr><td><a href=""#guard"">上船</a></td><td>共 <z:value-of select=""count(/i/guard)""/> 条记录</td></tr><tr><td><a href=""#sc"">SC</a></td><td>共 <z:value-of select=""count(/i/sc)""/> 条记录</td></tr><tr><td><a href=""#gift"">礼物</a></td><td>共 <z:value-of select=""count(/i/gift)""/> 条记录</td></tr></table><h2 id=""d"">弹幕</h2><div><table><tr><th>用户名</th><th>弹幕</th><th>参数</th></tr><z:for-each select=""/i/d""><tr><td><z:value-of select=""@user""/></td><td><z:value-of select="".""/></td><td><z:value-of select=""@p""/></td></tr></z:for-each></table></div><h2 id=""guard"">舰长购买</h2><div><table><tr><th>用户名</th><th>舰长等级</th><th>购买数量</th><th>出现时间</th></tr><z:for-each select=""/i/guard""><tr><td><z:value-of select=""@user""/></td><td><z:value-of select=""@level""/></td><td><z:value-of select=""@count""/></td><td><z:value-of select=""@ts""/></td></tr></z:for-each></table></div><h2 id=""sc"">SuperChat 醒目留言</h2><div><table><tr><th>用户名</th><th>内容</th><th>显示时长</th><th>价格</th><th>出现时间</th></tr><z:for-each select=""/i/sc""><tr><td><z:value-of select=""@user""/></td><td><z:value-of select="".""/></td><td><z:value-of select=""@time""/></td><td><z:value-of select=""@price""/></td><td><z:value-of select=""@ts""/></td></tr></z:for-each></table></div><h2 id=""gift"">礼物</h2><div><table><tr><th>用户名</th><th>礼物名</th><th>礼物数量</th><th>出现时间</th></tr><z:for-each select=""/i/gift""><tr><td><z:value-of select=""@user""/></td><td><z:value-of select=""@giftname""/></td><td><z:value-of select=""@giftcount""/></td><td><z:value-of select=""@ts""/></td></tr></z:for-each></table></div></html></z:template></z:stylesheet>";
writer.WriteStartElement("BililiveRecorderXmlStyle");

View File

@ -1,11 +1,12 @@
using System;
using BililiveRecorder.Core.Api.Danmaku;
namespace BililiveRecorder.Core
namespace BililiveRecorder.Core.Danmaku
{
public interface IBasicDanmakuWriter : IDisposable
{
void Disable();
void EnableWithPath(string path, IRecordedRoom recordedRoom);
void EnableWithPath(string path, IRoom room);
void Write(DanmakuModel danmakuModel);
}
}

View File

@ -1,22 +0,0 @@
using System;
namespace BililiveRecorder.Core
{
public delegate void DisconnectEvt(object sender, DisconnectEvtArgs e);
public class DisconnectEvtArgs
{
public Exception Error;
}
public delegate void ReceivedRoomCountEvt(object sender, ReceivedRoomCountArgs e);
public class ReceivedRoomCountArgs
{
public uint UserCount;
}
public delegate void ReceivedDanmakuEvt(object sender, ReceivedDanmakuArgs e);
public class ReceivedDanmakuArgs
{
public DanmakuModel Danmaku;
}
}

View File

@ -1,270 +0,0 @@
using Newtonsoft.Json.Linq;
#nullable enable
namespace BililiveRecorder.Core
{
public enum MsgTypeEnum
{
/// <summary>
/// 彈幕
/// </summary>
Comment,
/// <summary>
/// 禮物
/// </summary>
GiftSend,
/// <summary>
/// 歡迎老爷
/// </summary>
Welcome,
/// <summary>
/// 直播開始
/// </summary>
LiveStart,
/// <summary>
/// 直播結束
/// </summary>
LiveEnd,
/// <summary>
/// 其他
/// </summary>
Unknown,
/// <summary>
/// 欢迎船员
/// </summary>
WelcomeGuard,
/// <summary>
/// 购买船票(上船)
/// </summary>
GuardBuy,
/// <summary>
/// SuperChat
/// </summary>
SuperChat,
/// <summary>
/// 房间信息更新
/// </summary>
RoomChange
}
public class DanmakuModel
{
/// <summary>
/// 消息類型
/// </summary>
public MsgTypeEnum MsgType { get; set; }
/// <summary>
/// 房间标题
/// </summary>
public string? Title { get; set; }
/// <summary>
/// 大分区
/// </summary>
public string? ParentAreaName { get; set; }
/// <summary>
/// 子分区
/// </summary>
public string? AreaName { get; set; }
/// <summary>
/// 彈幕內容
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="MsgTypeEnum.Comment"/></item>
/// </list></para>
/// </summary>
public string? CommentText { get; set; }
/// <summary>
/// 消息触发者用户名
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="MsgTypeEnum.Comment"/></item>
/// <item><see cref="MsgTypeEnum.GiftSend"/></item>
/// <item><see cref="MsgTypeEnum.Welcome"/></item>
/// <item><see cref="MsgTypeEnum.WelcomeGuard"/></item>
/// <item><see cref="MsgTypeEnum.GuardBuy"/></item>
/// </list></para>
/// </summary>
public string? UserName { get; set; }
/// <summary>
/// SC 价格
/// </summary>
public double Price { get; set; }
/// <summary>
/// SC 保持时间
/// </summary>
public int SCKeepTime { get; set; }
/// <summary>
/// 消息触发者用户ID
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="MsgTypeEnum.Comment"/></item>
/// <item><see cref="MsgTypeEnum.GiftSend"/></item>
/// <item><see cref="MsgTypeEnum.Welcome"/></item>
/// <item><see cref="MsgTypeEnum.WelcomeGuard"/></item>
/// <item><see cref="MsgTypeEnum.GuardBuy"/></item>
/// </list></para>
/// </summary>
public int UserID { get; set; }
/// <summary>
/// 用户舰队等级
/// <para>0 为非船员 1 为总督 2 为提督 3 为舰长</para>
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="MsgTypeEnum.Comment"/></item>
/// <item><see cref="MsgTypeEnum.WelcomeGuard"/></item>
/// <item><see cref="MsgTypeEnum.GuardBuy"/></item>
/// </list></para>
/// </summary>
public int UserGuardLevel { get; set; }
/// <summary>
/// 禮物名稱
/// </summary>
public string? GiftName { get; set; }
/// <summary>
/// 礼物数量
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="MsgTypeEnum.GiftSend"/></item>
/// <item><see cref="MsgTypeEnum.GuardBuy"/></item>
/// </list></para>
/// <para>此字段也用于标识上船 <see cref="MsgTypeEnum.GuardBuy"/> 的数量(月数)</para>
/// </summary>
public int GiftCount { get; set; }
/// <summary>
/// 该用户是否为房管(包括主播)
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="MsgTypeEnum.Comment"/></item>
/// <item><see cref="MsgTypeEnum.GiftSend"/></item>
/// </list></para>
/// </summary>
public bool IsAdmin { get; set; }
/// <summary>
/// 是否VIP用戶(老爺)
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="MsgTypeEnum.Comment"/></item>
/// <item><see cref="MsgTypeEnum.Welcome"/></item>
/// </list></para>
/// </summary>
public bool IsVIP { get; set; }
/// <summary>
/// <see cref="MsgTypeEnum.LiveStart"/>,<see cref="MsgTypeEnum.LiveEnd"/> 事件对应的房间号
/// </summary>
public string? RoomID { get; set; }
/// <summary>
/// 原始数据, 高级开发用
/// </summary>
public string? RawData { get; set; }
/// <summary>
/// 原始数据, 高级开发用
/// </summary>
public JObject? RawObj { get; set; }
/// <summary>
/// 内部用, JSON数据版本号 通常应该是2
/// </summary>
public int JSON_Version { get; set; }
public DanmakuModel()
{ }
public DanmakuModel(string JSON)
{
this.RawData = JSON;
this.JSON_Version = 2;
var obj = JObject.Parse(JSON);
this.RawObj = obj;
var cmd = obj["cmd"]?.ToObject<string>();
switch (cmd)
{
case "LIVE":
this.MsgType = MsgTypeEnum.LiveStart;
this.RoomID = obj["roomid"].ToObject<string>();
break;
case "PREPARING":
this.MsgType = MsgTypeEnum.LiveEnd;
this.RoomID = obj["roomid"].ToObject<string>();
break;
case "DANMU_MSG":
this.MsgType = MsgTypeEnum.Comment;
this.CommentText = obj["info"][1].ToObject<string>();
this.UserID = obj["info"][2][0].ToObject<int>();
this.UserName = obj["info"][2][1].ToObject<string>();
this.IsAdmin = obj["info"][2][2].ToObject<string>() == "1";
this.IsVIP = obj["info"][2][3].ToObject<string>() == "1";
this.UserGuardLevel = obj["info"][7].ToObject<int>();
break;
case "SEND_GIFT":
this.MsgType = MsgTypeEnum.GiftSend;
this.GiftName = obj["data"]["giftName"].ToObject<string>();
this.UserName = obj["data"]["uname"].ToObject<string>();
this.UserID = obj["data"]["uid"].ToObject<int>();
this.GiftCount = obj["data"]["num"].ToObject<int>();
break;
case "GUARD_BUY":
{
this.MsgType = MsgTypeEnum.GuardBuy;
this.UserID = obj["data"]["uid"].ToObject<int>();
this.UserName = obj["data"]["username"].ToObject<string>();
this.UserGuardLevel = obj["data"]["guard_level"].ToObject<int>();
this.GiftName = this.UserGuardLevel == 3 ? "舰长" : this.UserGuardLevel == 2 ? "提督" : this.UserGuardLevel == 1 ? "总督" : "";
this.GiftCount = obj["data"]["num"].ToObject<int>();
break;
}
case "SUPER_CHAT_MESSAGE":
{
this.MsgType = MsgTypeEnum.SuperChat;
this.CommentText = obj["data"]["message"]?.ToString();
this.UserID = obj["data"]["uid"].ToObject<int>();
this.UserName = obj["data"]["user_info"]["uname"].ToString();
this.Price = obj["data"]["price"].ToObject<double>();
this.SCKeepTime = obj["data"]["time"].ToObject<int>();
break;
}
case "ROOM_CHANGE":
{
this.MsgType = MsgTypeEnum.RoomChange;
this.Title = obj["data"]?["title"]?.ToObject<string>();
this.AreaName = obj["data"]?["area_name"]?.ToObject<string>();
this.ParentAreaName = obj["data"]?["parent_area_name"]?.ToObject<string>();
break;
}
/*
case "WELCOME":
{
MsgType = MsgTypeEnum.Welcome;
UserName = obj["data"]["uname"].ToObject<string>();
UserID = obj["data"]["uid"].ToObject<int>();
IsVIP = true;
IsAdmin = obj["data"]?["is_admin"]?.ToObject<bool>() ?? obj["data"]?["isadmin"]?.ToObject<string>() == "1";
break;
}
case "WELCOME_GUARD":
{
MsgType = MsgTypeEnum.WelcomeGuard;
UserName = obj["data"]["username"].ToObject<string>();
UserID = obj["data"]["uid"].ToObject<int>();
UserGuardLevel = obj["data"]["guard_level"].ToObject<int>();
break;
}
*/
default:
{
this.MsgType = MsgTypeEnum.Unknown;
break;
}
}
}
}
}

View File

@ -1,20 +1,60 @@
using BililiveRecorder.Core;
using BililiveRecorder.Core.Api;
using BililiveRecorder.Core.Api.Danmaku;
using BililiveRecorder.Core.Api.Http;
using BililiveRecorder.Core.Config.V2;
using BililiveRecorder.Core.Danmaku;
using BililiveRecorder.Core.Recording;
using BililiveRecorder.Flv;
using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.Registry;
namespace BililiveRecorder.DependencyInjection
{
public static class DependencyInjectionExtensions
{
public static void AddCore(this IServiceCollection services)
public static IServiceCollection AddRecorderConfig(this IServiceCollection services, ConfigV2 config) => services
.AddSingleton(config)
.AddSingleton(sp => sp.GetRequiredService<ConfigV2>().Global)
;
public static IServiceCollection AddRecorder(this IServiceCollection services) => services
.AddSingleton<IMemoryStreamProvider, RecyclableMemoryStreamProvider>()
.AddRecorderPollyPolicy()
.AddRecorderApiClients()
.AddRecorderRecording()
.AddSingleton<IRecorder, Recorder>()
.AddSingleton<IRoomFactory, RoomFactory>()
.AddScoped<IRoom, Room>()
.AddScoped<IBasicDanmakuWriter, BasicDanmakuWriter>()
;
private static IServiceCollection AddRecorderPollyPolicy(this IServiceCollection services)
{
services.AddSingleton<IRecorder, Recorder>();
#pragma warning disable IDE0001
services.AddSingleton<ConfigV2>(x => x.GetRequiredService<IRecorder>().Config);
services.AddSingleton<GlobalConfig>(x => x.GetRequiredService<ConfigV2>().Global);
#pragma warning restore IDE0001
services.AddSingleton<BililiveAPI>();
services.AddSingleton<IRecordedRoomFactory, RecordedRoomFactory>();
var registry = new PolicyRegistry // TODO
{
[PolicyNames.PolicyRoomInfoApiRequestAsync] = Policy.NoOpAsync(),
[PolicyNames.PolicyDanmakuApiRequestAsync] = Policy.NoOpAsync(),
[PolicyNames.PolicyStreamApiRequestAsync] = Policy.NoOpAsync(),
};
return services.AddSingleton<IReadOnlyPolicyRegistry<string>>(registry);
}
public static IServiceCollection AddRecorderApiClients(this IServiceCollection services) => services
.AddSingleton<HttpApiClient>()
.AddSingleton<PolicyWrappedApiClient<HttpApiClient>>()
.AddSingleton<IApiClient>(sp => sp.GetRequiredService<PolicyWrappedApiClient<HttpApiClient>>())
.AddSingleton<IDanmakuServerApiClient>(sp => sp.GetRequiredService<PolicyWrappedApiClient<HttpApiClient>>())
.AddScoped<IDanmakuClient, DanmakuClient>()
;
public static IServiceCollection AddRecorderRecording(this IServiceCollection services) => services
.AddScoped<IRecordTaskFactory, RecordTaskFactory>()
.AddScoped<IFlvProcessingContextWriterFactory, FlvProcessingContextWriterFactory>()
.AddScoped<IFlvTagReaderFactory, FlvTagReaderFactory>()
.AddScoped<ITagGroupReaderFactory, TagGroupReaderFactory>()
;
}
}

View File

@ -0,0 +1,17 @@
using System;
namespace BililiveRecorder.Core.Event
{
public class AggregatedRoomEventArgs<T>
{
public AggregatedRoomEventArgs(IRoom room, T @event)
{
this.Room = room ?? throw new ArgumentNullException(nameof(room));
this.Event = @event;
}
public IRoom Room { get; }
public T Event { get; }
}
}

View File

@ -0,0 +1,17 @@
using System;
namespace BililiveRecorder.Core.Event
{
public class NetworkingStatsEventArgs : EventArgs
{
public DateTimeOffset StartTime { get; set; }
public DateTimeOffset EndTime { get; set; }
public TimeSpan Duration { get; set; }
public int BytesDownloaded { get; set; }
public double Mbps { get; set; }
}
}

View File

@ -0,0 +1,33 @@
using System;
namespace BililiveRecorder.Core.Event
{
public abstract class RecordEventArgsBase : EventArgs
{
public RecordEventArgsBase() { }
public RecordEventArgsBase(IRoom room)
{
this.RoomId = room.RoomConfig.RoomId;
this.ShortId = room.ShortId;
this.Name = room.Name;
this.Title = room.Title;
this.AreaNameParent = room.AreaNameParent;
this.AreaNameChild = room.AreaNameChild;
}
public Guid SessionId { get; set; }
public int RoomId { get; set; }
public int ShortId { get; set; }
public string Name { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string AreaNameParent { get; set; } = string.Empty;
public string AreaNameChild { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,25 @@
using System;
using Newtonsoft.Json;
namespace BililiveRecorder.Core.Event
{
public class RecordFileClosedEventArgs : RecordEventArgsBase
{
public RecordFileClosedEventArgs() { }
public RecordFileClosedEventArgs(IRoom room) : base(room) { }
[JsonIgnore]
public string FullPath { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public long FileSize { get; set; }
public double Duration { get; set; }
public DateTimeOffset FileOpenTime { get; set; }
public DateTimeOffset FileCloseTime { get; set; }
}
}

View File

@ -0,0 +1,19 @@
using System;
using Newtonsoft.Json;
namespace BililiveRecorder.Core.Event
{
public class RecordFileOpeningEventArgs : RecordEventArgsBase
{
public RecordFileOpeningEventArgs() { }
public RecordFileOpeningEventArgs(IRoom room) : base(room) { }
[JsonIgnore]
public string FullPath { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public DateTimeOffset FileOpenTime { get; set; }
}
}

View File

@ -0,0 +1,9 @@
namespace BililiveRecorder.Core.Event
{
public class RecordSessionEndedEventArgs : RecordEventArgsBase
{
public RecordSessionEndedEventArgs() { }
public RecordSessionEndedEventArgs(IRoom room) : base(room) { }
}
}

View File

@ -0,0 +1,9 @@
namespace BililiveRecorder.Core.Event
{
public class RecordSessionStartedEventArgs : RecordEventArgsBase
{
public RecordSessionStartedEventArgs() { }
public RecordSessionStartedEventArgs(IRoom room) : base(room) { }
}
}

View File

@ -0,0 +1,29 @@
using System;
namespace BililiveRecorder.Core.Event
{
public class RecordingStatsEventArgs : EventArgs
{
public long InputVideoByteCount { get; set; }
public long InputAudioByteCount { get; set; }
public int OutputVideoFrameCount { get; set; }
public int OutputAudioFrameCount { get; set; }
public long OutputVideoByteCount { get; set; }
public long OutputAudioByteCount { get; set; }
public long TotalInputVideoByteCount { get; set; }
public long TotalInputAudioByteCount { get; set; }
public int TotalOutputVideoFrameCount { get; set; }
public int TotalOutputAudioFrameCount { get; set; }
public long TotalOutputVideoByteCount { get; set; }
public long TotalOutputAudioByteCount { get; set; }
public double AddedDuration { get; set; }
public double PassedTime { get; set; }
public double DuraionRatio { get; set; }
public int SessionMaxTimestamp { get; set; }
public int FileMaxTimestamp { get; set; }
}
}

View File

@ -1,14 +0,0 @@
namespace BililiveRecorder.Core
{
public delegate void RoomInfoUpdatedEvent(object sender, RoomInfoUpdatedArgs e);
public class RoomInfoUpdatedArgs
{
public RoomInfo RoomInfo;
}
public delegate void StreamStartedEvent(object sender, StreamStartedArgs e);
public class StreamStartedArgs
{
public TriggerType type;
}
}

View File

@ -1,47 +0,0 @@
using System;
using System.ComponentModel;
using BililiveRecorder.Core.Callback;
using BililiveRecorder.Core.Config.V2;
using BililiveRecorder.FlvProcessor;
#nullable enable
namespace BililiveRecorder.Core
{
public interface IRecordedRoom : INotifyPropertyChanged, IDisposable
{
Guid Guid { get; }
RoomConfig RoomConfig { get; }
int ShortRoomId { get; }
int RoomId { get; }
string StreamerName { get; }
string Title { get; }
event EventHandler<RecordEndData>? RecordEnded;
IStreamMonitor StreamMonitor { get; }
IFlvStreamProcessor? Processor { get; }
bool IsMonitoring { get; }
bool IsRecording { get; }
bool IsDanmakuConnected { get; }
bool IsStreaming { get; }
double DownloadSpeedPersentage { get; }
double DownloadSpeedMegaBitps { get; }
DateTime LastUpdateDateTime { get; }
void Clip();
bool Start();
void Stop();
void StartRecord();
void StopRecord();
void RefreshRoomInfo();
void Shutdown();
}
}

View File

@ -1,9 +0,0 @@
using BililiveRecorder.Core.Config.V2;
namespace BililiveRecorder.Core
{
public interface IRecordedRoomFactory
{
IRecordedRoom CreateRecordedRoom(RoomConfig roomConfig);
}
}

View File

@ -1,26 +1,27 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Collections.ObjectModel;
using System.ComponentModel;
using BililiveRecorder.Core.Config.V2;
using BililiveRecorder.Core.Event;
#nullable enable
namespace BililiveRecorder.Core
{
public interface IRecorder : INotifyPropertyChanged, INotifyCollectionChanged, IEnumerable<IRecordedRoom>, ICollection<IRecordedRoom>, IDisposable
public interface IRecorder : INotifyPropertyChanged, IDisposable
{
ConfigV2? Config { get; }
ConfigV2 Config { get; }
ReadOnlyObservableCollection<IRoom> Rooms { get; }
bool Initialize(string workdir);
event EventHandler<AggregatedRoomEventArgs<RecordSessionStartedEventArgs>>? RecordSessionStarted;
event EventHandler<AggregatedRoomEventArgs<RecordSessionEndedEventArgs>>? RecordSessionEnded;
event EventHandler<AggregatedRoomEventArgs<RecordFileOpeningEventArgs>>? RecordFileOpening;
event EventHandler<AggregatedRoomEventArgs<RecordFileClosedEventArgs>>? RecordFileClosed;
event EventHandler<AggregatedRoomEventArgs<NetworkingStatsEventArgs>>? NetworkingStats;
event EventHandler<AggregatedRoomEventArgs<RecordingStatsEventArgs>>? RecordingStats;
void AddRoom(int roomid);
void AddRoom(int roomid, bool enabled);
void RemoveRoom(IRoom room);
void RemoveRoom(IRecordedRoom rr);
void SaveConfigToFile();
// void Shutdown();
void SaveConfig();
}
}

View File

@ -0,0 +1,38 @@
using System;
using System.ComponentModel;
using System.Threading.Tasks;
using BililiveRecorder.Core.Config.V2;
using BililiveRecorder.Core.Event;
namespace BililiveRecorder.Core
{
public interface IRoom : INotifyPropertyChanged, IDisposable
{
Guid ObjectId { get; }
RoomConfig RoomConfig { get; }
int ShortId { get; }
string Name { get; }
string Title { get; }
string AreaNameParent { get; }
string AreaNameChild { get; }
bool Recording { get; }
bool Streaming { get; }
bool DanmakuConnected { get; }
RecordingStats Stats { get; }
event EventHandler<RecordSessionStartedEventArgs>? RecordSessionStarted;
event EventHandler<RecordSessionEndedEventArgs>? RecordSessionEnded;
event EventHandler<RecordFileOpeningEventArgs>? RecordFileOpening;
event EventHandler<RecordFileClosedEventArgs>? RecordFileClosed;
event EventHandler<RecordingStatsEventArgs>? RecordingStats;
event EventHandler<NetworkingStatsEventArgs>? NetworkingStats;
void StartRecord();
void StopRecord();
void SplitOutput();
Task RefreshRoomInfoAsync();
}
}

View File

@ -0,0 +1,9 @@
using BililiveRecorder.Core.Config.V2;
namespace BililiveRecorder.Core
{
public interface IRoomFactory
{
IRoom CreateRoom(RoomConfig roomConfig);
}
}

View File

@ -1,20 +0,0 @@
using System;
using System.ComponentModel;
using System.Threading.Tasks;
namespace BililiveRecorder.Core
{
public interface IStreamMonitor : IDisposable, INotifyPropertyChanged
{
bool IsMonitoring { get; }
bool IsDanmakuConnected { get; }
event RoomInfoUpdatedEvent RoomInfoUpdated;
event StreamStartedEvent StreamStarted;
event ReceivedDanmakuEvt ReceivedDanmaku;
bool Start();
void Stop();
void Check(TriggerType type, int millisecondsDelay = 0);
Task<RoomInfo> FetchRoomInfoAsync();
}
}

View File

@ -0,0 +1,7 @@
namespace BililiveRecorder.Core
{
public class LoggingContext
{
public const string RoomId = nameof(RoomId);
}
}

View File

@ -0,0 +1,14 @@
namespace BililiveRecorder.Core
{
internal static class PolicyNames
{
internal const string PolicyRoomInfoApiRequestAsync = nameof(PolicyRoomInfoApiRequestAsync);
internal const string PolicyDanmakuApiRequestAsync = nameof(PolicyDanmakuApiRequestAsync);
internal const string PolicyStreamApiRequestAsync = nameof(PolicyStreamApiRequestAsync);
internal const string CacheKeyUserInfo = nameof(CacheKeyUserInfo);
internal const string CacheKeyRoomInfo = nameof(CacheKeyRoomInfo);
internal const string CacheKeyDanmaku = nameof(CacheKeyDanmaku);
internal const string CacheKeyStream = nameof(CacheKeyStream);
}
}

View File

@ -0,0 +1,28 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BililiveRecorder.Flv.Pipeline;
namespace BililiveRecorder.Core.ProcessingRules
{
public class SplitRule : IFullProcessingRule
{
private static readonly FlvProcessingContext NewFileContext = new FlvProcessingContext(PipelineNewFileAction.Instance, new Dictionary<object, object?>());
// 0 = false, 1 = true
private int splitFlag = 0;
public async Task RunAsync(FlvProcessingContext context, ProcessingDelegate next)
{
await next(context).ConfigureAwait(false);
if (1 == Interlocked.Exchange(ref this.splitFlag, 0))
{
await next(NewFileContext).ConfigureAwait(false);
context.AddNewFileAtStart();
}
}
public void SetSplitFlag() => Interlocked.Exchange(ref this.splitFlag, 1);
}
}

View File

@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BililiveRecorder.Core.Event;
using BililiveRecorder.Flv;
using BililiveRecorder.Flv.Pipeline;
namespace BililiveRecorder.Core.ProcessingRules
{
public class StatsRule : ISimpleProcessingRule
{
public event EventHandler<RecordingStatsEventArgs>? StatsUpdated;
public long TotalInputVideoByteCount { get; private set; }
public long TotalInputAudioByteCount { get; private set; }
// 两个值相加可得出总数据量
public int TotalOutputVideoFrameCount { get; private set; }
public int TotalOutputAudioFrameCount { get; private set; }
public long TotalOutputVideoByteCount { get; private set; }
public long TotalOutputAudioByteCount { get; private set; }
public int SumOfMaxTimestampOfClosedFiles { get; private set; }
public int CurrentFileMaxTimestamp { get; private set; }
public DateTimeOffset LastWriteTime { get; private set; }
public async Task RunAsync(FlvProcessingContext context, Func<Task> next)
{
var e = new RecordingStatsEventArgs();
if (context.OriginalInput is PipelineDataAction data)
{
e.TotalInputVideoByteCount = this.TotalInputVideoByteCount += e.InputVideoByteCount = data.Tags.Where(x => x.Type == TagType.Video).Sum(x => x.Size + (11 + 4));
e.TotalInputAudioByteCount = this.TotalInputAudioByteCount += e.InputAudioByteCount = data.Tags.Where(x => x.Type == TagType.Audio).Sum(x => x.Size + (11 + 4));
}
await next().ConfigureAwait(false);
var groups = new List<List<PipelineDataAction>?>();
{
List<PipelineDataAction>? curr = null;
foreach (var action in context.Output)
{
if (action is PipelineDataAction dataAction)
{
if (curr is null)
{
curr = new List<PipelineDataAction>();
groups.Add(curr);
}
curr.Add(dataAction);
}
else if (action is PipelineNewFileAction)
{
curr = null;
groups.Add(null);
}
}
}
var maxTimestampBeforeCalc = this.CurrentFileMaxTimestamp;
foreach (var item in groups)
{
if (item is null)
NewFile();
else
CalcStats(e, item);
}
e.AddedDuration = (this.CurrentFileMaxTimestamp - maxTimestampBeforeCalc) / 1000d;
var now = DateTimeOffset.UtcNow;
e.PassedTime = (now - this.LastWriteTime).TotalSeconds;
this.LastWriteTime = now;
e.DuraionRatio = e.AddedDuration / e.PassedTime;
StatsUpdated?.Invoke(this, e);
return;
void CalcStats(RecordingStatsEventArgs e, IReadOnlyList<PipelineDataAction> dataActions)
{
if (dataActions.Count > 0)
{
e.TotalOutputVideoFrameCount = this.TotalOutputVideoFrameCount += e.OutputVideoFrameCount = dataActions.Sum(x => x.Tags.Count(x => x.Type == TagType.Video));
e.TotalOutputAudioFrameCount = this.TotalOutputAudioFrameCount += e.OutputAudioFrameCount = dataActions.Sum(x => x.Tags.Count(x => x.Type == TagType.Audio));
e.TotalOutputVideoByteCount = this.TotalOutputVideoByteCount += e.OutputVideoByteCount = dataActions.Sum(x => x.Tags.Where(x => x.Type == TagType.Video).Sum(x => (x.Nalus == null ? x.Size : (5 + x.Nalus.Sum(n => n.FullSize + 4))) + (11 + 4)));
e.TotalOutputAudioByteCount = this.TotalOutputAudioByteCount += e.OutputAudioByteCount = dataActions.Sum(x => x.Tags.Where(x => x.Type == TagType.Audio).Sum(x => x.Size + (11 + 4)));
var lastTags = dataActions[dataActions.Count - 1].Tags;
if (lastTags.Count > 0)
this.CurrentFileMaxTimestamp = e.FileMaxTimestamp = lastTags[lastTags.Count - 1].Timestamp;
}
e.SessionMaxTimestamp = this.SumOfMaxTimestampOfClosedFiles + this.CurrentFileMaxTimestamp;
}
void NewFile()
{
this.SumOfMaxTimestampOfClosedFiles += this.CurrentFileMaxTimestamp;
this.CurrentFileMaxTimestamp = 0;
}
}
}
}

View File

@ -1,3 +0,0 @@
# BililiveRecorder.Core
TODO

View File

@ -1,666 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using BililiveRecorder.Core.Callback;
using BililiveRecorder.Core.Config.V2;
using BililiveRecorder.FlvProcessor;
using NLog;
namespace BililiveRecorder.Core
{
public class RecordedRoom : IRecordedRoom
{
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private static readonly Random random = new Random();
private static readonly Version VERSION_1_0 = new Version(1, 0);
#nullable enable
private int _shortRoomid;
private string _streamerName;
private string _title;
private string _parentAreaName = string.Empty;
private string _areaName = string.Empty;
private bool _isStreaming;
public int ShortRoomId
{
get => this._shortRoomid;
private set
{
if (value == this._shortRoomid) { return; }
this._shortRoomid = value;
this.TriggerPropertyChanged(nameof(this.ShortRoomId));
}
}
public int RoomId
{
get => this.RoomConfig.RoomId;
private set
{
if (value == this.RoomConfig.RoomId) { return; }
this.RoomConfig.RoomId = value;
this.TriggerPropertyChanged(nameof(this.RoomId));
}
}
public string StreamerName
{
get => this._streamerName;
private set
{
if (value == this._streamerName) { return; }
this._streamerName = value;
this.TriggerPropertyChanged(nameof(this.StreamerName));
}
}
public string Title
{
get => this._title;
private set
{
if (value == this._title) { return; }
this._title = value;
this.TriggerPropertyChanged(nameof(this.Title));
}
}
public string ParentAreaName
{
get => this._parentAreaName;
private set
{
if (value == this._parentAreaName) { return; }
this._parentAreaName = value;
this.TriggerPropertyChanged(nameof(this.ParentAreaName));
}
}
public string AreaName
{
get => this._areaName;
private set
{
if (value == this._areaName) { return; }
this._areaName = value;
this.TriggerPropertyChanged(nameof(this.AreaName));
}
}
public bool IsMonitoring => this.StreamMonitor.IsMonitoring;
public bool IsRecording => !(this.StreamDownloadTask?.IsCompleted ?? true);
public bool IsDanmakuConnected => this.StreamMonitor.IsDanmakuConnected;
public bool IsStreaming
{
get => this._isStreaming;
private set
{
if (value == this._isStreaming) { return; }
this._isStreaming = value;
this.TriggerPropertyChanged(nameof(this.IsStreaming));
}
}
public RoomConfig RoomConfig { get; }
#nullable restore
private RecordEndData recordEndData;
public event EventHandler<RecordEndData> RecordEnded;
private readonly IBasicDanmakuWriter basicDanmakuWriter;
private readonly IProcessorFactory processorFactory;
private IFlvStreamProcessor _processor;
public IFlvStreamProcessor Processor
{
get => this._processor;
private set
{
if (value == this._processor) { return; }
this._processor = value;
this.TriggerPropertyChanged(nameof(this.Processor));
}
}
private BililiveAPI BililiveAPI { get; }
public IStreamMonitor StreamMonitor { get; }
private bool _retry = true;
private HttpResponseMessage _response;
private Stream _stream;
private Task StartupTask = null;
private readonly object StartupTaskLock = new object();
public Task StreamDownloadTask = null;
public CancellationTokenSource cancellationTokenSource = null;
private double _DownloadSpeedPersentage = 0;
private double _DownloadSpeedMegaBitps = 0;
private long _lastUpdateSize = 0;
private int _lastUpdateTimestamp = 0;
public DateTime LastUpdateDateTime { get; private set; } = DateTime.Now;
public double DownloadSpeedPersentage
{
get { return this._DownloadSpeedPersentage; }
private set { if (value != this._DownloadSpeedPersentage) { this._DownloadSpeedPersentage = value; this.TriggerPropertyChanged(nameof(this.DownloadSpeedPersentage)); } }
}
public double DownloadSpeedMegaBitps
{
get { return this._DownloadSpeedMegaBitps; }
private set { if (value != this._DownloadSpeedMegaBitps) { this._DownloadSpeedMegaBitps = value; this.TriggerPropertyChanged(nameof(this.DownloadSpeedMegaBitps)); } }
}
public Guid Guid { get; } = Guid.NewGuid();
// TODO: 重构 DI
public RecordedRoom(IBasicDanmakuWriter basicDanmakuWriter,
IStreamMonitor streamMonitor,
IProcessorFactory processorFactory,
BililiveAPI bililiveAPI,
RoomConfig roomConfig)
{
this.RoomConfig = roomConfig;
this.StreamerName = "获取中...";
this.BililiveAPI = bililiveAPI;
this.processorFactory = processorFactory;
this.basicDanmakuWriter = basicDanmakuWriter;
this.StreamMonitor = streamMonitor;
this.StreamMonitor.RoomInfoUpdated += this.StreamMonitor_RoomInfoUpdated;
this.StreamMonitor.StreamStarted += this.StreamMonitor_StreamStarted;
this.StreamMonitor.ReceivedDanmaku += this.StreamMonitor_ReceivedDanmaku;
this.StreamMonitor.PropertyChanged += this.StreamMonitor_PropertyChanged;
this.PropertyChanged += this.RecordedRoom_PropertyChanged;
this.StreamMonitor.FetchRoomInfoAsync();
if (this.RoomConfig.AutoRecord)
this.Start();
}
private void RecordedRoom_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(this.IsMonitoring):
this.RoomConfig.AutoRecord = this.IsMonitoring;
break;
default:
break;
}
}
private void StreamMonitor_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(IStreamMonitor.IsDanmakuConnected):
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected)));
break;
default:
break;
}
}
private void StreamMonitor_ReceivedDanmaku(object sender, ReceivedDanmakuArgs e)
{
switch (e.Danmaku.MsgType)
{
case MsgTypeEnum.LiveStart:
this.IsStreaming = true;
break;
case MsgTypeEnum.LiveEnd:
this.IsStreaming = false;
break;
case MsgTypeEnum.RoomChange:
this.Title = e.Danmaku.Title ?? string.Empty;
this.ParentAreaName = e.Danmaku.ParentAreaName ?? string.Empty;
this.AreaName = e.Danmaku.AreaName ?? string.Empty;
break;
default:
break;
}
this.basicDanmakuWriter.Write(e.Danmaku);
}
private void StreamMonitor_RoomInfoUpdated(object sender, RoomInfoUpdatedArgs e)
{
// TODO: StreamMonitor 里的 RoomInfoUpdated Handler 也会设置一次 RoomId
// 暂时保持不变,此处的 RoomId 需要触发 PropertyChanged 事件
this.RoomId = e.RoomInfo.RoomId;
this.ShortRoomId = e.RoomInfo.ShortRoomId;
this.IsStreaming = e.RoomInfo.IsStreaming;
this.StreamerName = e.RoomInfo.UserName;
this.Title = e.RoomInfo.Title;
this.ParentAreaName = e.RoomInfo.ParentAreaName;
this.AreaName = e.RoomInfo.AreaName;
}
public bool Start()
{
// TODO: 重构: 删除 Start() Stop() 通过 RoomConfig.AutoRecord 控制监控状态和逻辑
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
var r = this.StreamMonitor.Start();
this.TriggerPropertyChanged(nameof(this.IsMonitoring));
return r;
}
public void Stop()
{
// TODO: 见 Start()
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
this.StreamMonitor.Stop();
this.TriggerPropertyChanged(nameof(this.IsMonitoring));
}
public void RefreshRoomInfo()
{
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
this.StreamMonitor.FetchRoomInfoAsync();
}
private void StreamMonitor_StreamStarted(object sender, StreamStartedArgs e)
{
lock (this.StartupTaskLock)
if (!this.IsRecording && (this.StartupTask?.IsCompleted ?? true))
this.StartupTask = this._StartRecordAsync();
}
public void StartRecord()
{
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
this.StreamMonitor.Check(TriggerType.Manual);
}
public void StopRecord()
{
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
this._retry = false;
try
{
if (this.cancellationTokenSource != null)
{
this.cancellationTokenSource.Cancel();
if (!(this.StreamDownloadTask?.Wait(TimeSpan.FromSeconds(2)) ?? true))
{
logger.Log(this.RoomId, LogLevel.Warn, "停止录制超时,尝试强制关闭连接,请检查网络连接是否稳定");
this._stream?.Close();
this._stream?.Dispose();
this._response?.Dispose();
this.StreamDownloadTask?.Wait();
}
}
}
catch (Exception ex)
{
logger.Log(this.RoomId, LogLevel.Warn, "在尝试停止录制时发生错误,请检查网络连接是否稳定", ex);
}
finally
{
this._retry = true;
}
}
private async Task _StartRecordAsync()
{
if (this.IsRecording)
{
// TODO: 这里逻辑可能有问题StartupTask 会变成当前这个已经结束的
logger.Log(this.RoomId, LogLevel.Warn, "已经在录制中了");
return;
}
this.cancellationTokenSource = new CancellationTokenSource();
var token = this.cancellationTokenSource.Token;
try
{
var flv_path = await this.BililiveAPI.GetPlayUrlAsync(this.RoomId);
if (string.IsNullOrWhiteSpace(flv_path))
{
if (this._retry)
{
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
}
return;
}
unwrap_redir:
using (var client = new HttpClient(new HttpClientHandler
{
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
}))
{
client.Timeout = TimeSpan.FromMilliseconds(this.RoomConfig.TimingStreamConnect);
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*"));
client.DefaultRequestHeaders.UserAgent.Clear();
client.DefaultRequestHeaders.UserAgent.ParseAdd(Utils.UserAgent);
client.DefaultRequestHeaders.Referrer = new Uri("https://live.bilibili.com");
client.DefaultRequestHeaders.Add("Origin", "https://live.bilibili.com");
logger.Log(this.RoomId, LogLevel.Info, "连接直播服务器 " + new Uri(flv_path).Host);
logger.Log(this.RoomId, LogLevel.Debug, "直播流地址: " + flv_path);
this._response = await client.GetAsync(flv_path, HttpCompletionOption.ResponseHeadersRead);
}
if (this._response.StatusCode == HttpStatusCode.Redirect || this._response.StatusCode == HttpStatusCode.Moved)
{
// workaround for missing Referrer
flv_path = this._response.Headers.Location.OriginalString;
this._response.Dispose();
goto unwrap_redir;
}
else if (this._response.StatusCode != HttpStatusCode.OK)
{
logger.Log(this.RoomId, LogLevel.Info, string.Format("尝试下载直播流时服务器返回了 ({0}){1}", this._response.StatusCode, this._response.ReasonPhrase));
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
_CleanupFlvRequest();
return;
}
else
{
this.Processor = this.processorFactory.CreateStreamProcessor().Initialize(this.GetStreamFilePath, this.GetClipFilePath, this.RoomConfig.EnabledFeature, this.RoomConfig.CuttingMode);
this.Processor.ClipLengthFuture = this.RoomConfig.ClipLengthFuture;
this.Processor.ClipLengthPast = this.RoomConfig.ClipLengthPast;
this.Processor.CuttingNumber = this.RoomConfig.CuttingNumber;
this.Processor.StreamFinalized += (sender, e) => { this.basicDanmakuWriter.Disable(); };
this.Processor.FileFinalized += (sender, size) =>
{
if (this.recordEndData is null) return;
var data = this.recordEndData;
this.recordEndData = null;
data.EndRecordTime = DateTimeOffset.Now;
data.FileSize = size;
RecordEnded?.Invoke(this, data);
};
this.Processor.OnMetaData += (sender, e) =>
{
e.Metadata["BililiveRecorder"] = new Dictionary<string, object>()
{
{
"starttime",
DateTime.UtcNow
},
{
"version",
BuildInfo.Version + " " + BuildInfo.HeadShaShort
},
{
"roomid",
this.RoomId.ToString()
},
{
"streamername",
this.StreamerName
},
};
};
this._stream = await this._response.Content.ReadAsStreamAsync();
try
{
if (this._response.Headers.ConnectionClose == false || (this._response.Headers.ConnectionClose is null && this._response.Version != VERSION_1_0))
this._stream.ReadTimeout = 3 * 1000;
}
catch (InvalidOperationException) { }
this.StreamDownloadTask = Task.Run(_ReadStreamLoop);
this.TriggerPropertyChanged(nameof(this.IsRecording));
}
}
catch (TaskCanceledException)
{
// client.GetAsync timed out
// useless exception message :/
_CleanupFlvRequest();
logger.Log(this.RoomId, LogLevel.Warn, "连接直播服务器超时。");
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
}
catch (Exception ex)
{
_CleanupFlvRequest();
logger.Log(this.RoomId, LogLevel.Error, "启动直播流下载出错。" + (this._retry ? "将重试启动。" : ""), ex);
if (this._retry)
{
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
}
}
return;
async Task _ReadStreamLoop()
{
try
{
const int BUF_SIZE = 1024 * 8;
byte[] buffer = new byte[BUF_SIZE];
while (!token.IsCancellationRequested)
{
int bytesRead = await this._stream.ReadAsync(buffer, 0, BUF_SIZE, token);
_UpdateDownloadSpeed(bytesRead);
if (bytesRead != 0)
{
if (bytesRead != BUF_SIZE)
{
this.Processor.AddBytes(buffer.Take(bytesRead).ToArray());
}
else
{
this.Processor.AddBytes(buffer);
}
}
else
{
break;
}
}
logger.Log(this.RoomId, LogLevel.Info,
(token.IsCancellationRequested ? "本地操作结束当前录制。" : "服务器关闭直播流,可能是直播已结束。")
+ (this._retry ? "将重试启动。" : ""));
if (this._retry)
{
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
}
}
catch (Exception e)
{
if (e is ObjectDisposedException && token.IsCancellationRequested) { return; }
logger.Log(this.RoomId, LogLevel.Warn, "录播发生错误", e);
}
finally
{
_CleanupFlvRequest();
}
}
void _CleanupFlvRequest()
{
if (this.Processor != null)
{
this.Processor.FinallizeFile();
this.Processor.Dispose();
this.Processor = null;
}
this._stream?.Dispose();
this._stream = null;
this._response?.Dispose();
this._response = null;
this._lastUpdateTimestamp = 0;
this.DownloadSpeedMegaBitps = 0d;
this.DownloadSpeedPersentage = 0d;
this.TriggerPropertyChanged(nameof(this.IsRecording));
}
void _UpdateDownloadSpeed(int bytesRead)
{
DateTime now = DateTime.Now;
double passedSeconds = (now - this.LastUpdateDateTime).TotalSeconds;
this._lastUpdateSize += bytesRead;
if (passedSeconds > 1.5)
{
this.DownloadSpeedMegaBitps = this._lastUpdateSize / passedSeconds * 8d / 1_000_000d; // mega bit per second
this.DownloadSpeedPersentage = (this.DownloadSpeedPersentage / 2) + ((this.Processor.TotalMaxTimestamp - this._lastUpdateTimestamp) / passedSeconds / 1000 / 2); // ((RecordedTime/1000) / RealTime)%
this._lastUpdateTimestamp = this.Processor.TotalMaxTimestamp;
this._lastUpdateSize = 0;
this.LastUpdateDateTime = now;
}
}
}
// Called by API or GUI
public void Clip() => this.Processor?.Clip();
public void Shutdown() => this.Dispose(true);
private (string fullPath, string relativePath) GetStreamFilePath()
{
var path = this.FormatFilename(this.RoomConfig.RecordFilenameFormat);
// 有点脏的写法,不过凑合吧
if (this.RoomConfig.RecordDanmaku)
{
var xmlpath = Path.ChangeExtension(path.fullPath, "xml");
this.basicDanmakuWriter.EnableWithPath(xmlpath, this);
}
this.recordEndData = new RecordEndData
{
RoomId = RoomId,
Title = Title,
Name = StreamerName,
StartRecordTime = DateTimeOffset.Now,
RelativePath = path.relativePath,
};
return path;
}
private string GetClipFilePath() => this.FormatFilename(this.RoomConfig.ClipFilenameFormat).fullPath;
private (string fullPath, string relativePath) FormatFilename(string formatString)
{
var now = DateTime.Now;
var date = now.ToString("yyyyMMdd");
var time = now.ToString("HHmmss");
var randomStr = random.Next(100, 999).ToString();
var relativePath = formatString
.Replace(@"{date}", date)
.Replace(@"{time}", time)
.Replace(@"{random}", randomStr)
.Replace(@"{roomid}", this.RoomId.ToString())
.Replace(@"{title}", this.Title.RemoveInvalidFileName())
.Replace(@"{name}", this.StreamerName.RemoveInvalidFileName())
.Replace(@"{parea}", this.ParentAreaName.RemoveInvalidFileName())
.Replace(@"{area}", this.AreaName.RemoveInvalidFileName())
;
if (!relativePath.EndsWith(".flv", StringComparison.OrdinalIgnoreCase))
relativePath += ".flv";
relativePath = relativePath.RemoveInvalidFileName(ignore_slash: true);
var workDirectory = this.RoomConfig.WorkDirectory;
var fullPath = Path.Combine(workDirectory, relativePath);
fullPath = Path.GetFullPath(fullPath);
if (!CheckPath(workDirectory, Path.GetDirectoryName(fullPath)))
{
logger.Log(this.RoomId, LogLevel.Warn, "录制文件位置超出允许范围,请检查设置。将写入到默认路径。");
relativePath = Path.Combine(this.RoomId.ToString(), $"{this.RoomId}-{date}-{time}-{randomStr}.flv");
fullPath = Path.Combine(workDirectory, relativePath);
}
if (new FileInfo(relativePath).Exists)
{
logger.Log(this.RoomId, LogLevel.Warn, "录制文件名冲突,请检查设置。将写入到默认路径。");
relativePath = Path.Combine(this.RoomId.ToString(), $"{this.RoomId}-{date}-{time}-{randomStr}.flv");
fullPath = Path.Combine(workDirectory, relativePath);
}
return (fullPath, relativePath);
}
private static bool CheckPath(string parent, string child)
{
DirectoryInfo di_p = new DirectoryInfo(parent);
DirectoryInfo di_c = new DirectoryInfo(child);
if (di_c.FullName == di_p.FullName)
return true;
bool isParent = false;
while (di_c.Parent != null)
{
if (di_c.Parent.FullName == di_p.FullName)
{
isParent = true;
break;
}
else
di_c = di_c.Parent;
}
return isParent;
}
public event PropertyChangedEventHandler PropertyChanged;
protected void TriggerPropertyChanged(string propertyName)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
#region IDisposable Support
private bool disposedValue = false; // 要检测冗余调用
protected virtual void Dispose(bool disposing)
{
if (!this.disposedValue)
{
if (disposing)
{
this.Stop();
this.StopRecord();
this.Processor?.FinallizeFile();
this.Processor?.Dispose();
this.StreamMonitor?.Dispose();
this._response?.Dispose();
this._stream?.Dispose();
this.cancellationTokenSource?.Dispose();
this.basicDanmakuWriter?.Dispose();
}
this.Processor = null;
this._response = null;
this._stream = null;
this.cancellationTokenSource = null;
this.disposedValue = true;
}
}
public void Dispose()
{
// 请勿更改此代码。将清理代码放入以上 Dispose(bool disposing) 中。
this.Dispose(true);
}
#endregion
}
}

View File

@ -1,25 +0,0 @@
using System;
using BililiveRecorder.Core.Config.V2;
using BililiveRecorder.FlvProcessor;
namespace BililiveRecorder.Core
{
public class RecordedRoomFactory : IRecordedRoomFactory
{
private readonly IProcessorFactory processorFactory;
private readonly BililiveAPI bililiveAPI;
public RecordedRoomFactory(IProcessorFactory processorFactory, BililiveAPI bililiveAPI)
{
this.processorFactory = processorFactory ?? throw new ArgumentNullException(nameof(processorFactory));
this.bililiveAPI = bililiveAPI ?? throw new ArgumentNullException(nameof(bililiveAPI));
}
public IRecordedRoom CreateRecordedRoom(RoomConfig roomConfig)
{
var basicDanmakuWriter = new BasicDanmakuWriter(roomConfig);
var streamMonitor = new StreamMonitor(roomConfig, this.bililiveAPI);
return new RecordedRoom(basicDanmakuWriter, streamMonitor, this.processorFactory, this.bililiveAPI, roomConfig);
}
}
}

View File

@ -1,240 +1,167 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Threading;
using BililiveRecorder.Core.Callback;
using System.Threading.Tasks;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config.V2;
using Microsoft.Extensions.DependencyInjection;
using NLog;
using BililiveRecorder.Core.Event;
using BililiveRecorder.Core.SimpleWebhook;
#nullable enable
namespace BililiveRecorder.Core
{
public class Recorder : IRecorder
{
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private readonly object lockObject = new object();
private readonly ObservableCollection<IRoom> roomCollection;
private readonly IRoomFactory roomFactory;
private readonly BasicWebhookV1 basicWebhookV1;
private readonly BasicWebhookV2 basicWebhookV2;
private readonly CancellationTokenSource tokenSource;
private readonly IServiceProvider serviceProvider;
private IRecordedRoomFactory? recordedRoomFactory;
private bool _valid = false;
private bool disposedValue;
private ObservableCollection<IRecordedRoom> Rooms { get; } = new ObservableCollection<IRecordedRoom>();
public ConfigV2? Config { get; private set; }
private BasicWebhook? Webhook { get; set; }
public int Count => this.Rooms.Count;
public bool IsReadOnly => true;
public IRecordedRoom this[int index] => this.Rooms[index];
public Recorder(IServiceProvider serviceProvider)
public Recorder(IRoomFactory roomFactory, ConfigV2 config)
{
this.serviceProvider = serviceProvider;
this.tokenSource = new CancellationTokenSource();
Repeat.Interval(TimeSpan.FromSeconds(3), this.DownloadWatchdog, this.tokenSource.Token);
this.roomFactory = roomFactory ?? throw new ArgumentNullException(nameof(roomFactory));
this.Config = config ?? throw new ArgumentNullException(nameof(config));
this.roomCollection = new ObservableCollection<IRoom>();
this.Rooms = new ReadOnlyObservableCollection<IRoom>(this.roomCollection);
this.Rooms.CollectionChanged += (sender, e) =>
{
logger.Trace($"Rooms.CollectionChanged;{e.Action};" +
$"O:{e.OldItems?.Cast<IRecordedRoom>()?.Select(rr => rr.RoomId.ToString())?.Aggregate((current, next) => current + "," + next)};" +
$"N:{e.NewItems?.Cast<IRecordedRoom>()?.Select(rr => rr.RoomId.ToString())?.Aggregate((current, next) => current + "," + next)}");
};
}
this.basicWebhookV1 = new BasicWebhookV1(config);
this.basicWebhookV2 = new BasicWebhookV2(config.Global);
public bool Initialize(string workdir)
{
if (this._valid)
throw new InvalidOperationException("Recorder is in valid state");
logger.Debug("Initialize: " + workdir);
var config = ConfigParser.LoadFrom(directory: workdir);
if (config is not null)
{
this.Config = config;
this.Config.Global.WorkDirectory = workdir;
this.Webhook = new BasicWebhook(this.Config);
this.recordedRoomFactory = this.serviceProvider.GetRequiredService<IRecordedRoomFactory>();
this._valid = true;
this.Config.Rooms.ForEach(r => this.AddRoom(r));
ConfigParser.SaveTo(this.Config.Global.WorkDirectory, this.Config);
return true;
}
else
{
return false;
foreach (var item in config.Rooms)
this.AddRoom(item);
this.SaveConfig();
}
}
public bool InitializeWithConfig(ConfigV2 config)
{
// 脏写法 but it works
if (this._valid)
throw new InvalidOperationException("Recorder is in valid state");
public event EventHandler<AggregatedRoomEventArgs<RecordSessionStartedEventArgs>>? RecordSessionStarted;
public event EventHandler<AggregatedRoomEventArgs<RecordSessionEndedEventArgs>>? RecordSessionEnded;
public event EventHandler<AggregatedRoomEventArgs<RecordFileOpeningEventArgs>>? RecordFileOpening;
public event EventHandler<AggregatedRoomEventArgs<RecordFileClosedEventArgs>>? RecordFileClosed;
public event EventHandler<AggregatedRoomEventArgs<NetworkingStatsEventArgs>>? NetworkingStats;
public event EventHandler<AggregatedRoomEventArgs<RecordingStatsEventArgs>>? RecordingStats;
public event PropertyChangedEventHandler? PropertyChanged;
if (config is null)
throw new ArgumentNullException(nameof(config));
public ConfigV2 Config { get; }
logger.Debug("Initialize With Config.");
this.Config = config;
this.Webhook = new BasicWebhook(this.Config);
this.recordedRoomFactory = this.serviceProvider.GetRequiredService<IRecordedRoomFactory>();
this._valid = true;
this.Config.Rooms.ForEach(r => this.AddRoom(r));
return true;
}
public ReadOnlyObservableCollection<IRoom> Rooms { get; }
/// <summary>
/// 添加直播间到录播姬
/// </summary>
/// <param name="roomid">房间号(支持短号)</param>
/// <exception cref="ArgumentOutOfRangeException"/>
public void AddRoom(int roomid) => this.AddRoom(roomid, true);
/// <summary>
/// 添加直播间到录播姬
/// </summary>
/// <param name="roomid">房间号(支持短号)</param>
/// <param name="enabled">是否默认启用</param>
/// <exception cref="ArgumentOutOfRangeException"/>
public void AddRoom(int roomid, bool enabled)
{
try
lock (this.lockObject)
{
if (!this._valid) { throw new InvalidOperationException("Not Initialized"); }
if (roomid <= 0)
{
throw new ArgumentOutOfRangeException(nameof(roomid), "房间号需要大于0");
}
var config = new RoomConfig
{
RoomId = roomid,
AutoRecord = enabled,
};
this.AddRoom(config);
}
catch (Exception ex)
{
logger.Debug(ex, "AddRoom 添加 {roomid} 直播间错误 ", roomid);
var roomConfig = new RoomConfig { RoomId = roomid, AutoRecord = enabled };
this.AddRoom(roomConfig);
this.SaveConfig();
}
}
/// <summary>
/// 添加直播间到录播姬
/// </summary>
/// <param name="roomConfig">房间设置</param>
public void AddRoom(RoomConfig roomConfig)
private void AddRoom(RoomConfig roomConfig)
{
try
{
if (!this._valid) { throw new InvalidOperationException("Not Initialized"); }
roomConfig.SetParent(this.Config?.Global);
var rr = this.recordedRoomFactory!.CreateRecordedRoom(roomConfig);
logger.Debug("AddRoom 添加了 {roomid} 直播间 ", rr.RoomId);
rr.RecordEnded += this.RecordedRoom_RecordEnded;
this.Rooms.Add(rr);
roomConfig.SetParent(this.Config.Global);
var room = this.roomFactory.CreateRoom(roomConfig);
this.roomCollection.Add(room);
this.AddEventSubscription(room);
}
catch (Exception ex)
public void RemoveRoom(IRoom room)
{
logger.Debug(ex, "AddRoom 添加 {roomid} 直播间错误 ", roomConfig.RoomId);
lock (this.lockObject)
{
if (this.roomCollection.Remove(room))
{
this.RemoveEventSubscription(room);
room.Dispose();
}
this.SaveConfig();
}
}
/// <summary>
/// 从录播姬移除直播间
/// </summary>
/// <param name="rr">直播间</param>
public void RemoveRoom(IRecordedRoom rr)
public void SaveConfig()
{
if (rr is null) return;
if (!this._valid) { throw new InvalidOperationException("Not Initialized"); }
rr.Shutdown();
rr.RecordEnded -= this.RecordedRoom_RecordEnded;
logger.Debug("RemoveRoom 移除了直播间 {roomid}", rr.RoomId);
this.Rooms.Remove(rr);
}
private void Shutdown()
{
if (!this._valid) { return; }
logger.Debug("Shutdown called.");
this.tokenSource.Cancel();
this.SaveConfigToFile();
this.Rooms.ToList().ForEach(rr =>
{
rr.Shutdown();
});
this.Rooms.Clear();
}
private void RecordedRoom_RecordEnded(object sender, RecordEndData e) => this.Webhook?.Send(e);
public void SaveConfigToFile()
{
if (this.Config is null) return;
this.Config.Rooms = this.Rooms.Select(x => x.RoomConfig).ToList();
ConfigParser.SaveTo(this.Config.Global.WorkDirectory!, this.Config);
}
private void DownloadWatchdog()
#region Events
private void AddEventSubscription(IRoom room)
{
if (!this._valid) { return; }
try
{
this.Rooms.ToList().ForEach(room =>
{
if (room.IsRecording)
{
if (DateTime.Now - room.LastUpdateDateTime > TimeSpan.FromMilliseconds(this.Config!.Global.TimingWatchdogTimeout))
{
logger.Warn("服务器未断开连接但停止提供 [{roomid}] 直播间的直播数据,通常是录制侧网络不稳定导致,将会断开重连", room.RoomId);
room.StopRecord();
room.StartRecord();
}
}
});
}
catch (Exception ex)
{
logger.Error(ex, "直播流下载监控出错");
}
room.RecordSessionStarted += this.Room_RecordSessionStarted;
room.RecordSessionEnded += this.Room_RecordSessionEnded;
room.RecordFileOpening += this.Room_RecordFileOpening;
room.RecordFileClosed += this.Room_RecordFileClosed;
room.NetworkingStats += this.Room_NetworkingStats;
room.RecordingStats += this.Room_RecordingStats;
room.PropertyChanged += this.Room_PropertyChanged;
}
void ICollection<IRecordedRoom>.Add(IRecordedRoom item) => throw new NotSupportedException("Collection is readonly");
void ICollection<IRecordedRoom>.Clear() => throw new NotSupportedException("Collection is readonly");
bool ICollection<IRecordedRoom>.Remove(IRecordedRoom item) => throw new NotSupportedException("Collection is readonly");
bool ICollection<IRecordedRoom>.Contains(IRecordedRoom item) => this.Rooms.Contains(item);
void ICollection<IRecordedRoom>.CopyTo(IRecordedRoom[] array, int arrayIndex) => this.Rooms.CopyTo(array, arrayIndex);
public IEnumerator<IRecordedRoom> GetEnumerator() => this.Rooms.GetEnumerator();
IEnumerator<IRecordedRoom> IEnumerable<IRecordedRoom>.GetEnumerator() => this.Rooms.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => this.Rooms.GetEnumerator();
public event PropertyChangedEventHandler PropertyChanged
private void Room_NetworkingStats(object sender, NetworkingStatsEventArgs e)
{
add => (this.Rooms as INotifyPropertyChanged).PropertyChanged += value;
remove => (this.Rooms as INotifyPropertyChanged).PropertyChanged -= value;
var room = (IRoom)sender;
NetworkingStats?.Invoke(this, new AggregatedRoomEventArgs<NetworkingStatsEventArgs>(room, e));
}
public event NotifyCollectionChangedEventHandler CollectionChanged
private void Room_RecordingStats(object sender, RecordingStatsEventArgs e)
{
add => (this.Rooms as INotifyCollectionChanged).CollectionChanged += value;
remove => (this.Rooms as INotifyCollectionChanged).CollectionChanged -= value;
var room = (IRoom)sender;
RecordingStats?.Invoke(this, new AggregatedRoomEventArgs<RecordingStatsEventArgs>(room, e));
}
private void Room_RecordFileClosed(object sender, RecordFileClosedEventArgs e)
{
var room = (IRoom)sender;
_ = Task.Run(async () => await this.basicWebhookV2.SendFileClosedAsync(e).ConfigureAwait(false));
_ = Task.Run(async () => await this.basicWebhookV1.SendAsync(new RecordEndData(e)).ConfigureAwait(false));
RecordFileClosed?.Invoke(this, new AggregatedRoomEventArgs<RecordFileClosedEventArgs>(room, e));
}
private void Room_RecordFileOpening(object sender, RecordFileOpeningEventArgs e)
{
var room = (IRoom)sender;
_ = Task.Run(async () => await this.basicWebhookV2.SendFileOpeningAsync(e).ConfigureAwait(false));
RecordFileOpening?.Invoke(this, new AggregatedRoomEventArgs<RecordFileOpeningEventArgs>(room, e));
}
private void Room_RecordSessionStarted(object sender, RecordSessionStartedEventArgs e)
{
var room = (IRoom)sender;
_ = Task.Run(async () => await this.basicWebhookV2.SendSessionStartedAsync(e).ConfigureAwait(false));
RecordSessionStarted?.Invoke(this, new AggregatedRoomEventArgs<RecordSessionStartedEventArgs>(room, e));
}
private void Room_RecordSessionEnded(object sender, RecordSessionEndedEventArgs e)
{
var room = (IRoom)sender;
_ = Task.Run(async () => await this.basicWebhookV2.SendSessionEndedAsync(e).ConfigureAwait(false));
RecordSessionEnded?.Invoke(this, new AggregatedRoomEventArgs<RecordSessionEndedEventArgs>(room, e));
}
private void Room_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
// TODO
// throw new NotImplementedException();
}
private void RemoveEventSubscription(IRoom room)
{
room.RecordSessionStarted -= this.Room_RecordSessionStarted;
room.RecordSessionEnded -= this.Room_RecordSessionEnded;
room.RecordFileOpening -= this.Room_RecordFileOpening;
room.RecordFileClosed -= this.Room_RecordFileClosed;
room.RecordingStats -= this.Room_RecordingStats;
room.PropertyChanged -= this.Room_PropertyChanged;
}
#endregion
#region Dispose
protected virtual void Dispose(bool disposing)
{
if (!this.disposedValue)
@ -242,7 +169,9 @@ namespace BililiveRecorder.Core
if (disposing)
{
// dispose managed state (managed objects)
this.Shutdown();
this.SaveConfig();
foreach (var room in this.roomCollection)
room.Dispose();
}
// free unmanaged resources (unmanaged objects) and override finalizer
@ -264,5 +193,7 @@ namespace BililiveRecorder.Core
this.Dispose(disposing: true);
GC.SuppressFinalize(this);
}
#endregion
}
}

View File

@ -0,0 +1,20 @@
using System;
using BililiveRecorder.Flv;
using BililiveRecorder.Flv.Writer;
using Microsoft.Extensions.DependencyInjection;
namespace BililiveRecorder.Core.Recording
{
public class FlvProcessingContextWriterFactory : IFlvProcessingContextWriterFactory
{
private readonly IServiceProvider serviceProvider;
public FlvProcessingContextWriterFactory(IServiceProvider serviceProvider)
{
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
}
public IFlvProcessingContextWriter CreateWriter(IFlvWriterTargetProvider targetProvider) =>
new FlvProcessingContextWriter(targetProvider, this.serviceProvider.GetRequiredService<IMemoryStreamProvider>(), null);
}
}

View File

@ -0,0 +1,22 @@
using System;
using System.IO.Pipelines;
using BililiveRecorder.Flv;
using BililiveRecorder.Flv.Parser;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
namespace BililiveRecorder.Core.Recording
{
public class FlvTagReaderFactory : IFlvTagReaderFactory
{
private readonly IServiceProvider serviceProvider;
public FlvTagReaderFactory(IServiceProvider serviceProvider)
{
this.serviceProvider = serviceProvider;
}
public IFlvTagReader CreateFlvTagReader(PipeReader pipeReader) =>
new FlvTagPipeReader(pipeReader, this.serviceProvider.GetRequiredService<IMemoryStreamProvider>(), this.serviceProvider.GetService<ILogger>());
}
}

View File

@ -0,0 +1,9 @@
using BililiveRecorder.Flv;
namespace BililiveRecorder.Core.Recording
{
public interface IFlvProcessingContextWriterFactory
{
IFlvProcessingContextWriter CreateWriter(IFlvWriterTargetProvider targetProvider);
}
}

View File

@ -0,0 +1,10 @@
using System.IO.Pipelines;
using BililiveRecorder.Flv;
namespace BililiveRecorder.Core.Recording
{
public interface IFlvTagReaderFactory
{
IFlvTagReader CreateFlvTagReader(PipeReader pipeReader);
}
}

View File

@ -0,0 +1,21 @@
using System;
using System.Threading.Tasks;
using BililiveRecorder.Core.Event;
namespace BililiveRecorder.Core.Recording
{
public interface IRecordTask
{
Guid SessionId { get; }
event EventHandler<NetworkingStatsEventArgs>? NetworkingStats;
event EventHandler<RecordingStatsEventArgs>? RecordingStats;
event EventHandler<RecordFileOpeningEventArgs>? RecordFileOpening;
event EventHandler<RecordFileClosedEventArgs>? RecordFileClosed;
event EventHandler? RecordSessionEnded;
void SplitOutput();
Task StartAsync();
void RequestStop();
}
}

View File

@ -0,0 +1,7 @@
namespace BililiveRecorder.Core.Recording
{
public interface IRecordTaskFactory
{
IRecordTask CreateRecordTask(IRoom room);
}
}

View File

@ -0,0 +1,9 @@
using BililiveRecorder.Flv;
namespace BililiveRecorder.Core.Recording
{
public interface ITagGroupReaderFactory
{
ITagGroupReader CreateTagGroupReader(IFlvTagReader flvTagReader);
}
}

View File

@ -0,0 +1,523 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Pipelines;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using BililiveRecorder.Core.Api;
using BililiveRecorder.Core.Event;
using BililiveRecorder.Core.ProcessingRules;
using BililiveRecorder.Flv;
using BililiveRecorder.Flv.Amf;
using BililiveRecorder.Flv.Pipeline;
using Serilog;
using Timer = System.Timers.Timer;
namespace BililiveRecorder.Core.Recording
{
public class RecordTask : IRecordTask
{
private const string HttpHeaderAccept = "*/*";
private const string HttpHeaderOrigin = "https://live.bilibili.com";
private const string HttpHeaderReferer = "https://live.bilibili.com/";
private const string HttpHeaderUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36";
private readonly Random random = new Random();
private readonly Timer timer = new Timer(1000 * 2);
private readonly CancellationTokenSource cts = new CancellationTokenSource();
private readonly CancellationToken ct;
private readonly IRoom room;
private readonly ILogger logger;
private readonly IApiClient apiClient;
private readonly IFlvTagReaderFactory flvTagReaderFactory;
private readonly ITagGroupReaderFactory tagGroupReaderFactory;
private readonly IFlvProcessingContextWriterFactory writerFactory;
private readonly ProcessingDelegate pipeline;
private readonly IFlvWriterTargetProvider targetProvider;
private readonly StatsRule statsRule;
private readonly SplitRule splitFileRule;
private readonly FlvProcessingContext context = new FlvProcessingContext();
private readonly IDictionary<object, object?> session = new Dictionary<object, object?>();
private bool started = false;
private Task? filler;
private ITagGroupReader? reader;
private IFlvProcessingContextWriter? writer;
private readonly object fillerStatsLock = new object();
private int fillerDownloadedBytes;
private DateTimeOffset fillerLastStatsTrigger;
public RecordTask(IRoom room,
ILogger logger,
IProcessingPipelineBuilder builder,
IApiClient apiClient,
IFlvTagReaderFactory flvTagReaderFactory,
ITagGroupReaderFactory tagGroupReaderFactory,
IFlvProcessingContextWriterFactory writerFactory)
{
this.room = room ?? throw new ArgumentNullException(nameof(room));
this.logger = logger?.ForContext<RecordTask>().ForContext(LoggingContext.RoomId, this.room.RoomConfig.RoomId) ?? throw new ArgumentNullException(nameof(logger));
this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
this.flvTagReaderFactory = flvTagReaderFactory ?? throw new ArgumentNullException(nameof(flvTagReaderFactory));
this.tagGroupReaderFactory = tagGroupReaderFactory ?? throw new ArgumentNullException(nameof(tagGroupReaderFactory));
this.writerFactory = writerFactory ?? throw new ArgumentNullException(nameof(writerFactory));
if (builder is null)
throw new ArgumentNullException(nameof(builder));
this.ct = this.cts.Token;
this.statsRule = new StatsRule();
this.splitFileRule = new SplitRule();
this.statsRule.StatsUpdated += this.StatsRule_StatsUpdated;
this.pipeline = builder
.Add(this.splitFileRule)
.Add(this.statsRule)
.AddDefault()
.AddRemoveFillerData()
.Build();
this.targetProvider = new WriterTargetProvider(this.room, this.logger.ForContext(LoggingContext.RoomId, this.room.RoomConfig.RoomId), paths =>
{
this.logger.ForContext(LoggingContext.RoomId, this.room.RoomConfig.RoomId).Debug("输出路径 {Path}", paths.fullPath);
var e = new RecordFileOpeningEventArgs(this.room)
{
SessionId = this.SessionId,
FullPath = paths.fullPath,
RelativePath = paths.relativePath,
FileOpenTime = DateTimeOffset.Now,
};
RecordFileOpening?.Invoke(this, e);
return e;
});
this.timer.Elapsed += this.Timer_Elapsed_TriggerStats;
}
public Guid SessionId { get; } = Guid.NewGuid();
public event EventHandler<NetworkingStatsEventArgs>? NetworkingStats;
public event EventHandler<RecordingStatsEventArgs>? RecordingStats;
public event EventHandler<RecordFileOpeningEventArgs>? RecordFileOpening;
public event EventHandler<RecordFileClosedEventArgs>? RecordFileClosed;
public event EventHandler? RecordSessionEnded;
public void SplitOutput() => this.splitFileRule.SetSplitFlag();
public void RequestStop() => this.cts.Cancel();
public async Task StartAsync()
{
if (this.started)
throw new InvalidOperationException("Only one StartAsync call allowed per instance.");
this.started = true;
var fullUrl = await this.FetchStreamUrlAsync().ConfigureAwait(false);
this.logger.Information("连接直播服务器 {Host}", new Uri(fullUrl).Host);
this.logger.Debug("直播流地址 {Url}", fullUrl);
var stream = await this.GetStreamAsync(fullUrl).ConfigureAwait(false);
var pipe = new Pipe(new PipeOptions(useSynchronizationContext: false));
this.reader = this.tagGroupReaderFactory.CreateTagGroupReader(this.flvTagReaderFactory.CreateFlvTagReader(pipe.Reader));
this.writer = this.writerFactory.CreateWriter(this.targetProvider);
this.writer.BeforeScriptTagWrite = this.Writer_BeforeScriptTagWrite;
this.writer.FileClosed += (sender, e) =>
{
var openingEventArgs = (RecordFileOpeningEventArgs)e.State!;
RecordFileClosed?.Invoke(this, new RecordFileClosedEventArgs(this.room)
{
SessionId = this.SessionId,
FullPath = openingEventArgs.FullPath,
RelativePath = openingEventArgs.RelativePath,
FileOpenTime = openingEventArgs.FileOpenTime,
FileCloseTime = DateTimeOffset.Now,
Duration = e.Duration,
FileSize = e.FileSize,
});
};
this.fillerLastStatsTrigger = DateTimeOffset.UtcNow;
this.filler = Task.Run(async () => await this.FillPipeAsync(stream, pipe.Writer).ConfigureAwait(false));
_ = Task.Run(this.RecordingLoopAsync);
}
private async Task FillPipeAsync(Stream stream, PipeWriter writer)
{
const int minimumBufferSize = 1024;
this.timer.Start();
Exception? exception = null;
try
{
while (!this.ct.IsCancellationRequested)
{
var memory = writer.GetMemory(minimumBufferSize);
try
{
var bytesRead = await stream.ReadAsync(memory, this.ct).ConfigureAwait(false);
if (bytesRead == 0)
break;
writer.Advance(bytesRead);
Interlocked.Add(ref this.fillerDownloadedBytes, bytesRead);
}
catch (Exception ex)
{
exception = ex;
break;
}
var result = await writer.FlushAsync(this.ct).ConfigureAwait(false);
if (result.IsCompleted)
break;
}
}
finally
{
this.timer.Stop();
stream.Dispose();
await writer.CompleteAsync(exception).ConfigureAwait(false);
}
}
private void Timer_Elapsed_TriggerStats(object sender, ElapsedEventArgs e)
{
int bytes;
TimeSpan diff;
DateTimeOffset start, end;
lock (this.fillerStatsLock)
{
bytes = Interlocked.Exchange(ref this.fillerDownloadedBytes, 0);
end = DateTimeOffset.UtcNow;
start = this.fillerLastStatsTrigger;
this.fillerLastStatsTrigger = end;
diff = end - start;
}
var mbps = bytes * 8d / 1024d / 1024d / diff.TotalSeconds;
NetworkingStats?.Invoke(this, new NetworkingStatsEventArgs
{
BytesDownloaded = bytes,
Duration = diff,
StartTime = start,
EndTime = end,
Mbps = mbps
});
}
private void Writer_BeforeScriptTagWrite(ScriptTagBody scriptTagBody)
{
if (scriptTagBody.Values.Count == 2 && scriptTagBody.Values[1] is ScriptDataEcmaArray value)
{
var now = DateTimeOffset.Now;
const string version = "TODO-dev-1.3.x";
value["Title"] = (ScriptDataString)this.room.Title;
value["Artist"] = (ScriptDataString)$"{this.room.Name} ({this.room.RoomConfig.RoomId})";
value["Comment"] = (ScriptDataString)
($"B站直播间 {this.room.RoomConfig.RoomId} 的直播录像\n" +
$"主播名: {this.room.Name}\n" +
$"直播标题: {this.room.Title}\n" +
$"直播分区: {this.room.AreaNameParent}·{this.room.AreaNameChild}\n" +
$"录制时间: {now:O}\n" +
$"\n使用 B站录播姬 录制 https://rec.danmuji.org\n" +
$"录播姬版本: {version}");
value["BililiveRecorder"] = new ScriptDataEcmaArray
{
["RecordedBy"] = (ScriptDataString)"BililiveRecorder B站录播姬",
["RecorderVersion"] = (ScriptDataString)version, // TODO fix version
["StartTime"] = (ScriptDataDate)now,
["RoomId"] = (ScriptDataString)this.room.RoomConfig.RoomId.ToString(),
["ShortId"] = (ScriptDataString)this.room.ShortId.ToString(),
["Name"] = (ScriptDataString)this.room.Name,
["StreamTitle"] = (ScriptDataString)this.room.Title,
["AreaNameParent"] = (ScriptDataString)this.room.AreaNameParent,
["AreaNameChild"] = (ScriptDataString)this.room.AreaNameChild,
};
}
}
private async Task RecordingLoopAsync()
{
if (this.reader is null) return;
if (this.writer is null) return;
try
{
while (!this.ct.IsCancellationRequested)
{
var group = await this.reader.ReadGroupAsync(this.ct).ConfigureAwait(false);
if (group is null)
break;
this.context.Reset(group, this.session);
await this.pipeline(this.context).ConfigureAwait(false);
if (this.context.Comments.Count > 0)
this.logger.Debug("修复逻辑输出 {Comments}", string.Join("\n", this.context.Comments));
await this.writer.WriteAsync(this.context).ConfigureAwait(false);
if (this.context.Output.Any(x => x is PipelineDisconnectAction))
{
this.logger.Information("根据修复逻辑的要求结束录制");
break;
}
}
}
catch (OperationCanceledException ex)
{
this.logger.Debug(ex, "录制被取消");
}
catch (IOException ex)
{
this.logger.Warning(ex, "录制时发生IO错误");
}
catch (Exception ex)
{
this.logger.Warning(ex, "录制时发生未知错误");
}
finally
{
this.logger.Debug("录制退出");
this.reader?.Dispose();
this.reader = null;
this.writer?.Dispose();
this.writer = null;
this.cts.Cancel();
RecordSessionEnded?.Invoke(this, EventArgs.Empty);
}
}
private async Task<Stream> GetStreamAsync(string fullUrl)
{
var client = CreateHttpClient();
while (true)
{
var resp = await client.GetAsync(fullUrl,
HttpCompletionOption.ResponseHeadersRead,
new CancellationTokenSource((int)this.room.RoomConfig.TimingStreamConnect).Token)
.ConfigureAwait(false);
switch (resp.StatusCode)
{
case System.Net.HttpStatusCode.OK:
{
this.logger.Debug("开始接收直播流");
var stream = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false);
return stream;
}
case System.Net.HttpStatusCode.Moved:
case System.Net.HttpStatusCode.Redirect:
{
fullUrl = resp.Headers.Location.OriginalString;
this.logger.Debug("跳转到 {Url}", fullUrl);
resp.Dispose();
break;
}
default:
throw new Exception(string.Format("尝试下载直播流时服务器返回了 ({0}){1}", resp.StatusCode, resp.ReasonPhrase));
}
}
}
private async Task<string> FetchStreamUrlAsync()
{
var apiResp = await this.apiClient.GetStreamUrlAsync(this.room.RoomConfig.RoomId).ConfigureAwait(false);
var url_data = apiResp?.Data?.PlayurlInfo?.Playurl?.Streams;
if (url_data is null)
throw new Exception("playurl is null");
var url_http_stream_flv_avc =
url_data.FirstOrDefault(x => x.ProtocolName == "http_stream")
?.Formats?.FirstOrDefault(x => x.FormatName == "flv")
?.Codecs?.FirstOrDefault(x => x.CodecName == "avc");
if (url_http_stream_flv_avc is null)
throw new Exception("no supported stream url");
if (url_http_stream_flv_avc.CurrentQn != 10000)
this.logger.Warning("当前录制的画质是 {CurrentQn}", url_http_stream_flv_avc.CurrentQn);
var url_infos = url_http_stream_flv_avc.UrlInfos;
if (url_infos is null || url_infos.Length == 0)
throw new Exception("no url_info");
var url_info = url_infos[this.random.Next(url_infos.Length)];
var fullUrl = url_info.Host + url_http_stream_flv_avc.BaseUrl + url_info.Extra;
return fullUrl;
}
private static HttpClient CreateHttpClient()
{
var httpClient = new HttpClient(new HttpClientHandler
{
AllowAutoRedirect = false
});
var headers = httpClient.DefaultRequestHeaders;
headers.Add("Accept", HttpHeaderAccept);
headers.Add("Origin", HttpHeaderOrigin);
headers.Add("Referer", HttpHeaderReferer);
headers.Add("User-Agent", HttpHeaderUserAgent);
return httpClient;
}
private void StatsRule_StatsUpdated(object sender, RecordingStatsEventArgs e)
{
if (this.room.RoomConfig.CuttingMode == Config.V2.CuttingMode.ByTime)
{
if (e.FileMaxTimestamp > (this.room.RoomConfig.CuttingNumber * (60 * 1000)))
{
this.splitFileRule.SetSplitFlag();
}
}
RecordingStats?.Invoke(this, e);
}
internal class WriterTargetProvider : IFlvWriterTargetProvider
{
private static readonly Random random = new Random();
private readonly IRoom room;
private readonly ILogger logger;
private readonly Func<(string fullPath, string relativePath), object> OnNewFile;
private string last_path = string.Empty;
public WriterTargetProvider(IRoom room, ILogger logger, Func<(string fullPath, string relativePath), object> onNewFile)
{
this.room = room ?? throw new ArgumentNullException(nameof(room));
this.logger = logger?.ForContext<WriterTargetProvider>() ?? throw new ArgumentNullException(nameof(logger));
this.OnNewFile = onNewFile ?? throw new ArgumentNullException(nameof(onNewFile));
}
public bool ShouldCreateNewFile(Stream outputStream, IList<Tag> tags)
{
if (this.room.RoomConfig.CuttingMode == Config.V2.CuttingMode.BySize)
{
var pendingSize = tags.Sum(x => (x.Nalus == null ? x.Size : (5 + x.Nalus.Sum(n => n.FullSize + 4))) + (11 + 4));
return (outputStream.Length + pendingSize) > (this.room.RoomConfig.CuttingNumber * (1024 * 1024));
}
return false;
}
public (Stream stream, object state) CreateOutputStream()
{
var paths = this.FormatFilename(this.room.RoomConfig.RecordFilenameFormat!);
try
{ Directory.CreateDirectory(Path.GetDirectoryName(paths.fullPath)); }
catch (Exception) { }
this.last_path = paths.fullPath;
var state = this.OnNewFile(paths);
var stream = new FileStream(paths.fullPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read | FileShare.Delete);
return (stream, state);
}
public Stream CreateAlternativeHeaderStream()
{
var path = string.IsNullOrWhiteSpace(this.last_path)
? Path.ChangeExtension(this.FormatFilename(this.room.RoomConfig.RecordFilenameFormat!).fullPath, "headers.txt")
: Path.ChangeExtension(this.last_path, "headers.txt");
try
{ Directory.CreateDirectory(Path.GetDirectoryName(path)); }
catch (Exception) { }
var stream = new FileStream(path, FileMode.Append, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
return stream;
}
private (string fullPath, string relativePath) FormatFilename(string formatString)
{
var now = DateTime.Now;
var date = now.ToString("yyyyMMdd");
var time = now.ToString("HHmmss");
var randomStr = random.Next(100, 999).ToString();
var relativePath = formatString
.Replace(@"{date}", date)
.Replace(@"{time}", time)
.Replace(@"{random}", randomStr)
.Replace(@"{roomid}", this.room.RoomConfig.RoomId.ToString())
.Replace(@"{title}", RemoveInvalidFileName(this.room.Title))
.Replace(@"{name}", RemoveInvalidFileName(this.room.Name))
.Replace(@"{parea}", RemoveInvalidFileName(this.room.AreaNameParent))
.Replace(@"{area}", RemoveInvalidFileName(this.room.AreaNameChild))
;
if (!relativePath.EndsWith(".flv", StringComparison.OrdinalIgnoreCase))
relativePath += ".flv";
relativePath = RemoveInvalidFileName(relativePath, ignore_slash: true);
var workDirectory = this.room.RoomConfig.WorkDirectory;
var fullPath = Path.Combine(workDirectory, relativePath);
fullPath = Path.GetFullPath(fullPath);
if (!CheckIsWithinPath(workDirectory!, Path.GetDirectoryName(fullPath)))
{
this.logger.Warning("录制文件位置超出允许范围,请检查设置。将写入到默认路径。");
relativePath = Path.Combine(this.room.RoomConfig.RoomId.ToString(), $"{this.room.RoomConfig.RoomId}-{date}-{time}-{randomStr}.flv");
fullPath = Path.Combine(workDirectory, relativePath);
}
if (File.Exists(fullPath))
{
this.logger.Warning("录制文件名冲突,请检查设置。将写入到默认路径。");
relativePath = Path.Combine(this.room.RoomConfig.RoomId.ToString(), $"{this.room.RoomConfig.RoomId}-{date}-{time}-{randomStr}.flv");
fullPath = Path.Combine(workDirectory, relativePath);
}
return (fullPath, relativePath);
}
internal static string RemoveInvalidFileName(string input, bool ignore_slash = false)
{
foreach (var c in Path.GetInvalidFileNameChars())
if (!ignore_slash || c != '\\' && c != '/')
input = input.Replace(c, '_');
return input;
}
internal static bool CheckIsWithinPath(string parent, string child)
{
if (parent is null || child is null)
return false;
parent = parent.Replace('/', '\\');
if (!parent.EndsWith("\\"))
parent += "\\";
parent = Path.GetFullPath(parent);
child = child.Replace('/', '\\');
if (!child.EndsWith("\\"))
child += "\\";
child = Path.GetFullPath(child);
return child.StartsWith(parent, StringComparison.Ordinal);
}
}
}
}

View File

@ -0,0 +1,18 @@
using System;
using Microsoft.Extensions.DependencyInjection;
namespace BililiveRecorder.Core.Recording
{
public class RecordTaskFactory : IRecordTaskFactory
{
private readonly IServiceProvider serviceProvider;
public RecordTaskFactory(IServiceProvider serviceProvider)
{
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
}
public IRecordTask CreateRecordTask(IRoom room) =>
ActivatorUtilities.CreateInstance<RecordTask>(this.serviceProvider, room);
}
}

View File

@ -0,0 +1,11 @@
using BililiveRecorder.Flv;
using BililiveRecorder.Flv.Grouping;
namespace BililiveRecorder.Core.Recording
{
public class TagGroupReaderFactory : ITagGroupReaderFactory
{
public ITagGroupReader CreateTagGroupReader(IFlvTagReader flvTagReader) =>
new TagGroupReader(flvTagReader);
}
}

View File

@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
#nullable enable
namespace BililiveRecorder.Core
{
public class RecordingStats : INotifyPropertyChanged
{
private TimeSpan sessionMaxTimestamp;
private TimeSpan fileMaxTimestamp;
private TimeSpan sessionDuration;
private double networkMbps;
private long totalInputBytes;
private long totalOutputBytes;
private double duraionRatio;
public TimeSpan SessionDuration { get => this.sessionDuration; set => this.SetField(ref this.sessionDuration, value); }
public TimeSpan SessionMaxTimestamp { get => this.sessionMaxTimestamp; set => this.SetField(ref this.sessionMaxTimestamp, value); }
public TimeSpan FileMaxTimestamp { get => this.fileMaxTimestamp; set => this.SetField(ref this.fileMaxTimestamp, value); }
public double DuraionRatio { get => this.duraionRatio; set => this.SetField(ref this.duraionRatio, value); }
public long TotalInputBytes { get => this.totalInputBytes; set => this.SetField(ref this.totalInputBytes, value); }
public long TotalOutputBytes { get => this.totalOutputBytes; set => this.SetField(ref this.totalOutputBytes, value); }
public double NetworkMbps { get => this.networkMbps; set => this.SetField(ref this.networkMbps, value); }
public void Reset()
{
this.SessionDuration = TimeSpan.Zero;
this.SessionMaxTimestamp = TimeSpan.Zero;
this.FileMaxTimestamp = TimeSpan.Zero;
this.DuraionRatio = 0;
this.TotalInputBytes = 0;
this.TotalOutputBytes = 0;
this.NetworkMbps = 0;
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
protected bool SetField<T>(ref T location, T value, [CallerMemberName] string propertyName = "")
{
if (EqualityComparer<T>.Default.Equals(location, value))
return false;
location = value;
this.OnPropertyChanged(propertyName);
return true;
}
}
}

View File

@ -0,0 +1,29 @@
using System.IO;
using BililiveRecorder.Flv;
using Microsoft.IO;
namespace BililiveRecorder.Core
{
public class RecyclableMemoryStreamProvider : IMemoryStreamProvider
{
private readonly RecyclableMemoryStreamManager manager = new RecyclableMemoryStreamManager(32 * 1024, 64 * 1024, 64 * 1024 * 32)
{
MaximumFreeSmallPoolBytes = 64 * 1024 * 1024,
MaximumFreeLargePoolBytes = 64 * 1024 * 32,
};
public RecyclableMemoryStreamProvider()
{
//manager.StreamFinalized += () =>
//{
// Debug.WriteLine("TestRecyclableMemoryStreamProvider: Stream Finalized");
//};
//manager.StreamDisposed += () =>
//{
// // Debug.WriteLine("TestRecyclableMemoryStreamProvider: Stream Disposed");
//};
}
public Stream CreateMemoryStream(string tag) => this.manager.GetStream(tag);
}
}

View File

@ -1,42 +0,0 @@
/**
* Author: Roger Lipscombe
* Source: https://stackoverflow.com/a/7472334
* */
using System;
using System.Threading;
using System.Threading.Tasks;
namespace BililiveRecorder.Core
{
public static class Repeat
{
public static Task Interval(
TimeSpan pollInterval,
Action action,
CancellationToken token)
{
// We don't use Observable.Interval:
// If we block, the values start bunching up behind each other.
return Task.Factory.StartNew(
() =>
{
for (; ; )
{
action();
if (token.WaitCancellationRequested(pollInterval))
break;
}
}, token, TaskCreationOptions.LongRunning, TaskScheduler.Default);
}
}
internal static class CancellationTokenExtensions
{
public static bool WaitCancellationRequested(
this CancellationToken token,
TimeSpan timeout)
{
return token.WaitHandle.WaitOne(timeout);
}
}
}

View File

@ -0,0 +1,488 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using BililiveRecorder.Core.Api;
using BililiveRecorder.Core.Config.V2;
using BililiveRecorder.Core.Danmaku;
using BililiveRecorder.Core.Event;
using BililiveRecorder.Core.Recording;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using Timer = System.Timers.Timer;
namespace BililiveRecorder.Core
{
public class Room : IRoom
{
private readonly object recordStartLock = new object();
private readonly SemaphoreSlim recordRetryDelaySemaphoreSlim = new SemaphoreSlim(1, 1);
private readonly Timer timer;
private readonly IServiceScope scope;
private readonly ILogger loggerWithoutContext;
private readonly IDanmakuClient danmakuClient;
private readonly IApiClient apiClient;
private readonly IBasicDanmakuWriter basicDanmakuWriter;
private readonly IRecordTaskFactory recordTaskFactory;
private readonly CancellationTokenSource cts;
private readonly CancellationToken ct;
private ILogger logger;
private bool disposedValue;
private int shortId;
private string name = string.Empty;
private string title = string.Empty;
private string areaNameParent = string.Empty;
private string areaNameChild = string.Empty;
private bool streaming;
private bool danmakuConnected;
private IRecordTask? recordTask;
private DateTimeOffset recordTaskStartTime;
public Room(IServiceScope scope, RoomConfig roomConfig, ILogger logger, IDanmakuClient danmakuClient, IApiClient apiClient, IBasicDanmakuWriter basicDanmakuWriter, IRecordTaskFactory recordTaskFactory)
{
this.scope = scope ?? throw new ArgumentNullException(nameof(scope));
this.RoomConfig = roomConfig ?? throw new ArgumentNullException(nameof(roomConfig));
this.loggerWithoutContext = logger?.ForContext<Room>() ?? throw new ArgumentNullException(nameof(logger));
this.logger = this.loggerWithoutContext.ForContext(LoggingContext.RoomId, this.RoomConfig.RoomId);
this.danmakuClient = danmakuClient ?? throw new ArgumentNullException(nameof(danmakuClient));
this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
this.basicDanmakuWriter = basicDanmakuWriter ?? throw new ArgumentNullException(nameof(basicDanmakuWriter));
this.recordTaskFactory = recordTaskFactory ?? throw new ArgumentNullException(nameof(recordTaskFactory));
this.timer = new Timer(this.RoomConfig.TimingCheckInterval * 1000);
this.cts = new CancellationTokenSource();
this.ct = this.cts.Token;
this.RoomConfig.PropertyChanged += this.RoomConfig_PropertyChanged;
this.timer.Elapsed += this.Timer_Elapsed;
this.danmakuClient.StatusChanged += this.DanmakuClient_StatusChanged;
this.danmakuClient.DanmakuReceived += this.DanmakuClient_DanmakuReceived;
_ = Task.Run(async () =>
{
await Task.Delay(1000);
await this.RefreshRoomInfoAsync();
});
}
public int ShortId { get => this.shortId; private set => this.SetField(ref this.shortId, value); }
public string Name { get => this.name; private set => this.SetField(ref this.name, value); }
public string Title { get => this.title; private set => this.SetField(ref this.title, value); }
public string AreaNameParent { get => this.areaNameParent; private set => this.SetField(ref this.areaNameParent, value); }
public string AreaNameChild { get => this.areaNameChild; private set => this.SetField(ref this.areaNameChild, value); }
public bool Streaming
{
get => this.streaming;
private set
{
if (value == this.streaming) return;
// 从未开播状态切换为开播状态时重置允许录制状态
var triggerRecord = value && !this.streaming;
if (triggerRecord)
this.AutoRecordAllowedForThisSession = true;
this.streaming = value;
this.OnPropertyChanged(nameof(this.Streaming));
if (triggerRecord && this.RoomConfig.AutoRecord)
_ = Task.Run(() => this.CreateAndStartNewRecordTask());
}
}
public bool DanmakuConnected { get => this.danmakuConnected; private set => this.SetField(ref this.danmakuConnected, value); }
public bool Recording => this.recordTask != null;
public bool AutoRecordAllowedForThisSession { get; private set; }
public RoomConfig RoomConfig { get; }
public RecordingStats Stats { get; } = new RecordingStats();
public Guid ObjectId { get; } = Guid.NewGuid();
public event EventHandler<RecordSessionStartedEventArgs>? RecordSessionStarted;
public event EventHandler<RecordSessionEndedEventArgs>? RecordSessionEnded;
public event EventHandler<RecordFileOpeningEventArgs>? RecordFileOpening;
public event EventHandler<RecordFileClosedEventArgs>? RecordFileClosed;
public event EventHandler<NetworkingStatsEventArgs>? NetworkingStats;
public event EventHandler<RecordingStatsEventArgs>? RecordingStats;
public event PropertyChangedEventHandler? PropertyChanged;
public void SplitOutput()
{
lock (this.recordStartLock)
{
this.recordTask?.SplitOutput();
}
}
public void StartRecord()
{
lock (this.recordStartLock)
{
this.AutoRecordAllowedForThisSession = true;
_ = Task.Run(async () =>
{
try
{
await this.FetchRoomInfoThenCreateRecordTaskAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
this.logger.Warning(ex, "尝试开始录制时出错");
}
});
}
}
public void StopRecord()
{
lock (this.recordStartLock)
{
this.AutoRecordAllowedForThisSession = false;
if (this.recordTask == null)
return;
this.recordTask.RequestStop();
}
}
public async Task RefreshRoomInfoAsync()
{
try
{
await this.FetchUserInfoAsync().ConfigureAwait(false);
await this.FetchRoomInfoAsync().ConfigureAwait(false);
this.StartDamakuConnection(delay: false);
}
catch (Exception ex)
{
this.logger.Warning(ex, "刷新房间信息时出错");
}
}
#region Recording
/// <exception cref="Exception"/>
private async Task FetchRoomInfoAsync()
{
var room = (await this.apiClient.GetRoomInfoAsync(this.RoomConfig.RoomId).ConfigureAwait(false)).Data;
if (room != null)
{
this.RoomConfig.RoomId = room.RoomId;
this.ShortId = room.ShortId;
this.Title = room.Title;
this.AreaNameParent = room.ParentAreaName;
this.AreaNameChild = room.AreaName;
this.Streaming = room.LiveStatus == 1;
}
}
/// <exception cref="Exception"/>
private async Task FetchUserInfoAsync()
{
var user = await this.apiClient.GetUserInfoAsync(this.RoomConfig.RoomId).ConfigureAwait(false);
this.Name = user.Data?.Info?.Name ?? this.Name;
}
/// <exception cref="Exception"/>
private async Task FetchRoomInfoThenCreateRecordTaskAsync()
{
await this.FetchRoomInfoAsync().ConfigureAwait(false);
this.CreateAndStartNewRecordTask();
}
///
private void CreateAndStartNewRecordTask()
{
lock (this.recordStartLock)
{
if (this.disposedValue)
return;
if (!this.Streaming)
return;
if (this.recordTask != null)
return;
var task = this.recordTaskFactory.CreateRecordTask(this);
task.NetworkingStats += this.RecordTask_NetworkingStats;
task.RecordingStats += this.RecordTask_RecordingStats;
task.RecordFileOpening += this.RecordTask_RecordFileOpening;
task.RecordFileClosed += this.RecordTask_RecordFileClosed;
task.RecordSessionEnded += this.RecordTask_RecordSessionEnded;
this.recordTask = task;
this.recordTaskStartTime = DateTimeOffset.UtcNow;
this.OnPropertyChanged(nameof(this.Recording));
_ = Task.Run(async () =>
{
try
{
await this.recordTask.StartAsync();
}
catch (Exception ex)
{
Console.WriteLine("启动录制出错 " + ex.ToString());
this.recordTask = null;
_ = Task.Run(async () => await this.TryRestartRecordingAsync().ConfigureAwait(false));
this.OnPropertyChanged(nameof(this.Recording));
return;
}
RecordSessionStarted?.Invoke(this, new RecordSessionStartedEventArgs(this)
{
SessionId = this.recordTask.SessionId
});
});
}
}
///
private async Task TryRestartRecordingAsync(bool delay = true)
{
if (this.AutoRecordAllowedForThisSession)
{
try
{
if (delay)
{
if (!await this.recordRetryDelaySemaphoreSlim.WaitAsync(0).ConfigureAwait(false))
return;
try
{
await Task.Delay((int)this.RoomConfig.TimingStreamRetry, this.ct).ConfigureAwait(false);
}
finally
{
this.recordRetryDelaySemaphoreSlim.Release();
}
}
if (!this.AutoRecordAllowedForThisSession)
return;
await this.FetchRoomInfoThenCreateRecordTaskAsync().ConfigureAwait(false);
}
catch (TaskCanceledException)
{
}
catch (Exception ex)
{
this.logger.Warning(ex, "重试开始录制时出错");
}
}
}
///
private void StartDamakuConnection(bool delay = true) =>
Task.Run(async () =>
{
try
{
if (delay)
await Task.Delay((int)this.RoomConfig.TimingDanmakuRetry, this.ct).ConfigureAwait(false);
await this.danmakuClient.ConnectAsync(this.RoomConfig.RoomId, this.ct).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
}
catch (Exception ex)
{
this.logger.Warning(ex, "连接弹幕服务器时出错");
if (!this.ct.IsCancellationRequested)
this.StartDamakuConnection();
}
});
#endregion
#region Event Handlers
///
private void RecordTask_NetworkingStats(object sender, NetworkingStatsEventArgs e)
{
this.logger.Verbose("Networking stats: {@stats}", e);
this.Stats.NetworkMbps = e.Mbps;
NetworkingStats?.Invoke(this, e);
}
///
private void RecordTask_RecordingStats(object sender, RecordingStatsEventArgs e)
{
this.logger.Verbose("Recording stats: {@stats}", e);
var diff = DateTimeOffset.UtcNow - this.recordTaskStartTime;
this.Stats.SessionDuration = diff.Subtract(TimeSpan.FromMilliseconds(diff.Milliseconds));
this.Stats.FileMaxTimestamp = TimeSpan.FromMilliseconds(e.FileMaxTimestamp);
this.Stats.SessionMaxTimestamp = TimeSpan.FromMilliseconds(e.SessionMaxTimestamp);
this.Stats.DuraionRatio = e.DuraionRatio;
this.Stats.TotalInputBytes = e.TotalInputVideoByteCount + e.TotalInputAudioByteCount;
this.Stats.TotalOutputBytes = e.TotalOutputVideoByteCount + e.TotalOutputAudioByteCount;
RecordingStats?.Invoke(this, e);
}
///
private void RecordTask_RecordFileClosed(object sender, RecordFileClosedEventArgs e)
{
this.basicDanmakuWriter.Disable();
RecordFileClosed?.Invoke(this, e);
}
///
private void RecordTask_RecordFileOpening(object sender, RecordFileOpeningEventArgs e)
{
if (this.RoomConfig.RecordDanmaku)
this.basicDanmakuWriter.EnableWithPath(Path.ChangeExtension(e.FullPath, "xml"), this);
RecordFileOpening?.Invoke(this, e);
}
///
private void RecordTask_RecordSessionEnded(object sender, EventArgs e)
{
Guid id;
lock (this.recordStartLock)
{
id = this.recordTask?.SessionId ?? default;
this.recordTask = null;
_ = Task.Run(async () => await this.TryRestartRecordingAsync().ConfigureAwait(false));
}
this.OnPropertyChanged(nameof(this.Recording));
RecordSessionEnded?.Invoke(this, new RecordSessionEndedEventArgs(this)
{
SessionId = id
});
}
private void DanmakuClient_DanmakuReceived(object sender, Api.Danmaku.DanmakuReceivedEventArgs e)
{
var d = e.Danmaku;
switch (d.MsgType)
{
case Api.Danmaku.DanmakuMsgType.LiveStart:
this.Streaming = true;
break;
case Api.Danmaku.DanmakuMsgType.LiveEnd:
this.Streaming = false;
break;
case Api.Danmaku.DanmakuMsgType.RoomChange:
this.Title = d.Title ?? this.Title;
this.AreaNameParent = d.ParentAreaName ?? this.AreaNameParent;
this.AreaNameChild = d.AreaName ?? this.AreaNameChild;
break;
default:
break;
}
this.basicDanmakuWriter.Write(d);
}
private void DanmakuClient_StatusChanged(object sender, Api.Danmaku.StatusChangedEventArgs e)
{
if (e.Connected)
{
this.DanmakuConnected = true;
}
else
{
this.DanmakuConnected = false;
this.StartDamakuConnection();
}
}
private void Timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
{
if (this.RoomConfig.AutoRecord)
_ = Task.Run(async () => await this.TryRestartRecordingAsync(delay: false).ConfigureAwait(false));
}
private void RoomConfig_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(this.RoomConfig.RoomId):
this.logger = this.loggerWithoutContext.ForContext(LoggingContext.RoomId, this.RoomConfig.RoomId);
break;
case nameof(this.RoomConfig.TimingCheckInterval):
this.timer.Interval = this.RoomConfig.TimingCheckInterval * 1000;
break;
default:
break;
}
}
#endregion
#region PropertyChanged
protected void SetField<T>(ref T location, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(location, value))
return;
location = value;
if (propertyName != null)
this.OnPropertyChanged(propertyName);
}
protected void OnPropertyChanged(string propertyName) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName: propertyName));
#endregion
#region Dispose
protected virtual void Dispose(bool disposing)
{
if (!this.disposedValue)
{
this.disposedValue = true;
if (disposing)
{
// dispose managed state (managed objects)
this.cts.Cancel();
this.cts.Dispose();
this.recordTask?.RequestStop();
this.basicDanmakuWriter.Disable();
this.scope.Dispose();
}
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
}
}
// override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
// ~Room()
// {
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
// Dispose(disposing: false);
// }
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
this.Dispose(disposing: true);
GC.SuppressFinalize(this);
}
#endregion
}
}

View File

@ -0,0 +1,24 @@
using System;
using BililiveRecorder.Core.Config.V2;
using Microsoft.Extensions.DependencyInjection;
namespace BililiveRecorder.Core
{
public class RoomFactory : IRoomFactory
{
private readonly IServiceProvider serviceProvider;
public RoomFactory(IServiceProvider serviceProvider)
{
this.serviceProvider = serviceProvider;
}
public IRoom CreateRoom(RoomConfig roomConfig)
{
var scope = this.serviceProvider.CreateScope();
var sp = scope.ServiceProvider;
return ActivatorUtilities.CreateInstance<Room>(sp, scope, roomConfig);
}
}
}

View File

@ -1,14 +0,0 @@
#nullable enable
namespace BililiveRecorder.Core
{
public class RoomInfo
{
public int ShortRoomId;
public int RoomId;
public bool IsStreaming;
public string UserName = string.Empty;
public string Title = string.Empty;
public string ParentAreaName = string.Empty;
public string AreaName = string.Empty;
}
}

View File

@ -5,30 +5,29 @@ using System.Text;
using System.Threading.Tasks;
using BililiveRecorder.Core.Config.V2;
using Newtonsoft.Json;
using NLog;
using Serilog;
#nullable enable
namespace BililiveRecorder.Core.Callback
namespace BililiveRecorder.Core.SimpleWebhook
{
public class BasicWebhook
public class BasicWebhookV1
{
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private static readonly ILogger logger = Log.ForContext<BasicWebhookV1>();
private static readonly HttpClient client;
private readonly ConfigV2 Config;
static BasicWebhook()
static BasicWebhookV1()
{
client = new HttpClient();
client.DefaultRequestHeaders.Add("User-Agent", $"BililiveRecorder/{typeof(BasicWebhook).Assembly.GetName().Version}-{BuildInfo.HeadShaShort}");
client.DefaultRequestHeaders.Add("User-Agent", $"BililiveRecorder/{typeof(BasicWebhookV1).Assembly.GetName().Version}-{BuildInfo.HeadShaShort}");
}
public BasicWebhook(ConfigV2 config)
public BasicWebhookV1(ConfigV2 config)
{
this.Config = config ?? throw new ArgumentNullException(nameof(config));
}
public async void Send(RecordEndData data)
public async Task SendAsync(RecordEndData data)
{
var urls = this.Config.Global.WebHookUrls;
if (string.IsNullOrWhiteSpace(urls)) return;
@ -57,7 +56,7 @@ namespace BililiveRecorder.Core.Callback
}
catch (Exception ex)
{
logger.Warn(ex, "发送 Webhook 到 {url} 失败", url);
logger.Warning(ex, "发送 Webhook 到 {Url} 失败", url);
}
}
}

View File

@ -0,0 +1,73 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using BililiveRecorder.Core.Config.V2;
using BililiveRecorder.Core.Event;
using Newtonsoft.Json;
using Serilog;
namespace BililiveRecorder.Core.SimpleWebhook
{
public class BasicWebhookV2
{
private static readonly ILogger logger = Log.ForContext<BasicWebhookV2>();
private readonly HttpClient client;
private readonly GlobalConfig config;
public BasicWebhookV2(GlobalConfig config)
{
this.config = config ?? throw new ArgumentNullException(nameof(config));
this.client = new HttpClient();
this.client.DefaultRequestHeaders.Add("User-Agent", $"BililiveRecorder/{typeof(BasicWebhookV1).Assembly.GetName().Version}-{BuildInfo.HeadShaShort}");
}
public Task SendSessionStartedAsync(RecordSessionStartedEventArgs args) =>
this.SendAsync(new EventWrapper<RecordSessionStartedEventArgs>(args) { EventType = EventType.SessionStarted });
public Task SendSessionEndedAsync(RecordSessionEndedEventArgs args) =>
this.SendAsync(new EventWrapper<RecordSessionEndedEventArgs>(args) { EventType = EventType.SessionEnded });
public Task SendFileOpeningAsync(RecordFileOpeningEventArgs args) =>
this.SendAsync(new EventWrapper<RecordFileOpeningEventArgs>(args) { EventType = EventType.FileOpening });
public Task SendFileClosedAsync(RecordFileClosedEventArgs args) =>
this.SendAsync(new EventWrapper<RecordFileClosedEventArgs>(args) { EventType = EventType.FileClosed });
private async Task SendAsync(object data)
{
var urls = this.config.WebHookUrlsV2;
if (string.IsNullOrWhiteSpace(urls)) return;
var dataStr = JsonConvert.SerializeObject(data, Formatting.None);
using var body = new ByteArrayContent(Encoding.UTF8.GetBytes(dataStr));
body.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
var tasks = urls!
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => this.SendImplAsync(x, body));
await Task.WhenAll(tasks).ConfigureAwait(false);
}
private async Task SendImplAsync(string url, HttpContent data)
{
for (var i = 0; i < 3; i++)
try
{
var result = await this.client.PostAsync(url, data).ConfigureAwait(false);
result.EnsureSuccessStatusCode();
return;
}
catch (Exception ex)
{
logger.Warning(ex, "发送 Webhook 到 {Url} 失败", url);
}
}
}
}

View File

@ -0,0 +1,15 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BililiveRecorder.Core.SimpleWebhook
{
[JsonConverter(typeof(StringEnumConverter))]
public enum EventType
{
Unknown,
SessionStarted,
SessionEnded,
FileOpening,
FileClosed,
}
}

View File

@ -0,0 +1,24 @@
using System;
namespace BililiveRecorder.Core.SimpleWebhook
{
public class EventWrapper<T> where T : class
{
public EventWrapper()
{
}
public EventWrapper(T data)
{
this.EventData = data;
}
public EventType EventType { get; set; }
public DateTimeOffset EventTimestamp { get; set; } = DateTimeOffset.Now;
public Guid EventId { get; set; } = Guid.NewGuid();
public T? EventData { get; set; }
}
}

View File

@ -0,0 +1,33 @@
using System;
using BililiveRecorder.Core.Event;
#nullable enable
namespace BililiveRecorder.Core.SimpleWebhook
{
public class RecordEndData
{
public RecordEndData(RecordFileClosedEventArgs args)
{
if (args is null)
throw new ArgumentNullException(nameof(args));
this.RoomId = args.RoomId;
this.Name = args.Name;
this.Title = args.Title;
this.RelativePath = args.RelativePath;
this.FileSize = args.FileSize;
this.StartRecordTime = args.FileOpenTime;
this.EndRecordTime = args.FileCloseTime;
}
public Guid EventRandomId { get; set; } = Guid.NewGuid();
public int RoomId { get; set; } = 0;
public string Name { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public long FileSize { get; set; }
public DateTimeOffset StartRecordTime { get; set; }
public DateTimeOffset EndRecordTime { get; set; }
}
}

View File

@ -0,0 +1,40 @@
using System;
using System.Buffers;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
namespace BililiveRecorder.Core
{
internal static class StreamExtensions
{
// modified from dotnet/runtime 8a52f1e948b6f22f418817ec1068f07b8dae2aa5
// file: src/libraries/System.Private.CoreLib/src/System/IO/Stream.cs
// licensed under the MIT license
public static ValueTask<int> ReadAsync(this Stream stream, Memory<byte> buffer, CancellationToken cancellationToken = default)
{
if (MemoryMarshal.TryGetArray(buffer, out ArraySegment<byte> array))
{
return new ValueTask<int>(stream.ReadAsync(array.Array!, array.Offset, array.Count, cancellationToken));
}
var sharedBuffer = ArrayPool<byte>.Shared.Rent(buffer.Length);
return FinishReadAsync(stream.ReadAsync(sharedBuffer, 0, buffer.Length, cancellationToken), sharedBuffer, buffer);
static async ValueTask<int> FinishReadAsync(Task<int> readTask, byte[] localBuffer, Memory<byte> localDestination)
{
try
{
var result = await readTask.ConfigureAwait(false);
new ReadOnlySpan<byte>(localBuffer, 0, result).CopyTo(localDestination.Span);
return result;
}
finally
{
ArrayPool<byte>.Shared.Return(localBuffer);
}
}
}
}
}

View File

@ -1,463 +0,0 @@
using System;
using System.ComponentModel;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BililiveRecorder.Core.Config.V2;
using Newtonsoft.Json;
using NLog;
using Timer = System.Timers.Timer;
namespace BililiveRecorder.Core
{
/**
*
* HTTP轮询两部分
*
*
*
*
* HTTP轮询
*
*
*
* */
public class StreamMonitor : IStreamMonitor
{
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private readonly RoomConfig roomConfig;
private readonly BililiveAPI bililiveAPI;
private Exception dmError = null;
private TcpClient dmClient;
private NetworkStream dmNetStream;
private Thread dmReceiveMessageLoopThread;
private readonly CancellationTokenSource dmTokenSource = null;
private bool dmConnectionTriggered = false;
private readonly Timer httpTimer;
private int RoomId { get => this.roomConfig.RoomId; set => this.roomConfig.RoomId = value; }
public bool IsMonitoring { get; private set; } = false;
public bool IsDanmakuConnected => this.dmClient?.Connected ?? false;
public event RoomInfoUpdatedEvent RoomInfoUpdated;
public event StreamStartedEvent StreamStarted;
public event ReceivedDanmakuEvt ReceivedDanmaku;
public event PropertyChangedEventHandler PropertyChanged;
public StreamMonitor(RoomConfig roomConfig, BililiveAPI bililiveAPI)
{
this.roomConfig = roomConfig;
this.bililiveAPI = bililiveAPI;
ReceivedDanmaku += this.Receiver_ReceivedDanmaku;
RoomInfoUpdated += this.StreamMonitor_RoomInfoUpdated;
this.dmTokenSource = new CancellationTokenSource();
Repeat.Interval(TimeSpan.FromSeconds(30), () =>
{
if (this.dmNetStream != null && this.dmNetStream.CanWrite)
{
try
{
this.SendSocketData(2);
}
catch (Exception) { }
}
}, this.dmTokenSource.Token);
this.httpTimer = new Timer(roomConfig.TimingCheckInterval * 1000)
{
Enabled = false,
AutoReset = true,
SynchronizingObject = null,
Site = null
};
this.httpTimer.Elapsed += (sender, e) =>
{
try
{
this.Check(TriggerType.HttpApi);
}
catch (Exception ex)
{
logger.Log(this.RoomId, LogLevel.Warn, "获取直播间开播状态出错", ex);
}
};
roomConfig.PropertyChanged += (sender, e) =>
{
if (e.PropertyName.Equals(nameof(roomConfig.TimingCheckInterval)))
{
this.httpTimer.Interval = roomConfig.TimingCheckInterval * 1000;
}
};
}
private void StreamMonitor_RoomInfoUpdated(object sender, RoomInfoUpdatedArgs e)
{
this.RoomId = e.RoomInfo.RoomId;
// TODO: RecordedRoom 里的 RoomInfoUpdated Handler 也会设置一次 RoomId
// 暂时保持不变,此处需要使用请求返回的房间号连接弹幕服务器
if (!this.dmConnectionTriggered)
{
this.dmConnectionTriggered = true;
Task.Run(() => this.ConnectWithRetryAsync());
}
}
private void Receiver_ReceivedDanmaku(object sender, ReceivedDanmakuArgs e)
{
switch (e.Danmaku.MsgType)
{
case MsgTypeEnum.LiveStart:
if (this.IsMonitoring)
{
Task.Run(() => StreamStarted?.Invoke(this, new StreamStartedArgs() { type = TriggerType.Danmaku }));
}
break;
case MsgTypeEnum.LiveEnd:
break;
default:
break;
}
}
#region API
public bool Start()
{
if (this.disposedValue)
{
throw new ObjectDisposedException(nameof(StreamMonitor));
}
this.IsMonitoring = true;
this.httpTimer.Start();
this.Check(TriggerType.HttpApi);
return true;
}
public void Stop()
{
if (this.disposedValue)
{
throw new ObjectDisposedException(nameof(StreamMonitor));
}
this.IsMonitoring = false;
this.httpTimer.Stop();
}
public void Check(TriggerType type, int millisecondsDelay = 0)
{
if (this.disposedValue)
{
throw new ObjectDisposedException(nameof(StreamMonitor));
}
if (millisecondsDelay < 0)
{
throw new ArgumentOutOfRangeException(nameof(millisecondsDelay), "不能小于0");
}
Task.Run(async () =>
{
await Task.Delay(millisecondsDelay).ConfigureAwait(false);
if ((await this.FetchRoomInfoAsync().ConfigureAwait(false))?.IsStreaming ?? false)
{
StreamStarted?.Invoke(this, new StreamStartedArgs() { type = type });
}
});
}
public async Task<RoomInfo> FetchRoomInfoAsync()
{
RoomInfo roomInfo = await this.bililiveAPI.GetRoomInfoAsync(this.RoomId).ConfigureAwait(false);
if (roomInfo != null)
RoomInfoUpdated?.Invoke(this, new RoomInfoUpdatedArgs { RoomInfo = roomInfo });
return roomInfo;
}
#endregion
#region
private async Task ConnectWithRetryAsync()
{
bool connect_result = false;
while (!this.IsDanmakuConnected && !this.dmTokenSource.Token.IsCancellationRequested)
{
logger.Log(this.RoomId, LogLevel.Info, "连接弹幕服务器...");
connect_result = await this.ConnectAsync().ConfigureAwait(false);
if (!connect_result)
await Task.Delay((int)Math.Max(this.roomConfig.TimingDanmakuRetry, 0));
}
if (connect_result)
{
logger.Log(this.RoomId, LogLevel.Info, "弹幕服务器连接成功");
}
}
private async Task<bool> ConnectAsync()
{
if (this.IsDanmakuConnected) { return true; }
try
{
var (token, host, port) = await this.bililiveAPI.GetDanmuConf(this.RoomId);
logger.Log(this.RoomId, LogLevel.Debug, $"连接弹幕服务器 {host}:{port} {(string.IsNullOrWhiteSpace(token) ? "" : "")} token");
this.dmClient = new TcpClient();
await this.dmClient.ConnectAsync(host, port).ConfigureAwait(false);
this.dmNetStream = this.dmClient.GetStream();
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected)));
this.dmReceiveMessageLoopThread = new Thread(this.ReceiveMessageLoop)
{
Name = "ReceiveMessageLoop " + this.RoomId,
IsBackground = true
};
this.dmReceiveMessageLoopThread.Start();
var hello = JsonConvert.SerializeObject(new
{
uid = 0,
roomid = this.RoomId,
protover = 2,
platform = "web",
clientver = "1.11.0",
type = 2,
key = token,
}, Formatting.None);
this.SendSocketData(7, hello);
this.SendSocketData(2);
return true;
}
catch (Exception ex)
{
this.dmError = ex;
logger.Log(this.RoomId, LogLevel.Warn, "连接弹幕服务器错误", ex);
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected)));
return false;
}
}
private void ReceiveMessageLoop()
{
logger.Log(this.RoomId, LogLevel.Trace, "ReceiveMessageLoop Started");
try
{
var stableBuffer = new byte[16];
var buffer = new byte[4096];
while (this.IsDanmakuConnected)
{
this.dmNetStream.ReadB(stableBuffer, 0, 16);
Parse2Protocol(stableBuffer, out DanmakuProtocol protocol);
if (protocol.PacketLength < 16)
{
throw new NotSupportedException("协议失败: (L:" + protocol.PacketLength + ")");
}
var payloadlength = protocol.PacketLength - 16;
if (payloadlength == 0)
{
continue;//没有内容了
}
if (buffer.Length < payloadlength) // 不够长再申请
{
buffer = new byte[payloadlength];
}
this.dmNetStream.ReadB(buffer, 0, payloadlength);
if (protocol.Version == 2 && protocol.Action == 5) // 处理deflate消息
{
// Skip 0x78 0xDA
using (DeflateStream deflate = new DeflateStream(new MemoryStream(buffer, 2, payloadlength - 2), CompressionMode.Decompress))
{
while (deflate.Read(stableBuffer, 0, 16) > 0)
{
Parse2Protocol(stableBuffer, out protocol);
payloadlength = protocol.PacketLength - 16;
if (payloadlength == 0)
{
continue; // 没有内容了
}
if (buffer.Length < payloadlength) // 不够长再申请
{
buffer = new byte[payloadlength];
}
deflate.Read(buffer, 0, payloadlength);
ProcessDanmaku(protocol.Action, buffer, payloadlength);
}
}
}
else
{
ProcessDanmaku(protocol.Action, buffer, payloadlength);
}
void ProcessDanmaku(int action, byte[] local_buffer, int length)
{
switch (action)
{
case 3:
// var viewer = BitConverter.ToUInt32(local_buffer.Take(4).Reverse().ToArray(), 0); //观众人数
break;
case 5://playerCommand
var json = Encoding.UTF8.GetString(local_buffer, 0, length);
try
{
ReceivedDanmaku?.Invoke(this, new ReceivedDanmakuArgs() { Danmaku = new DanmakuModel(json) });
}
catch (Exception ex)
{
logger.Log(this.RoomId, LogLevel.Warn, "", ex);
}
break;
default:
break;
}
}
}
}
catch (Exception ex)
{
this.dmError = ex;
// logger.Error(ex);
logger.Log(this.RoomId, LogLevel.Debug, "Disconnected");
this.dmClient?.Close();
this.dmNetStream = null;
if (!(this.dmTokenSource?.IsCancellationRequested ?? true))
{
logger.Log(this.RoomId, LogLevel.Warn, "弹幕连接被断开,将尝试重连", ex);
Task.Run(async () =>
{
await Task.Delay((int)Math.Max(this.roomConfig.TimingDanmakuRetry, 0));
await this.ConnectWithRetryAsync();
});
}
}
finally
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected)));
}
}
private void SendSocketData(int action, string body = "")
{
const int param = 1;
const short magic = 16;
const short ver = 1;
var playload = Encoding.UTF8.GetBytes(body);
var buffer = new byte[(playload.Length + 16)];
using (var ms = new MemoryStream(buffer))
{
var b = BitConverter.GetBytes(buffer.Length).ToBE();
ms.Write(b, 0, 4);
b = BitConverter.GetBytes(magic).ToBE();
ms.Write(b, 0, 2);
b = BitConverter.GetBytes(ver).ToBE();
ms.Write(b, 0, 2);
b = BitConverter.GetBytes(action).ToBE();
ms.Write(b, 0, 4);
b = BitConverter.GetBytes(param).ToBE();
ms.Write(b, 0, 4);
if (playload.Length > 0)
{
ms.Write(playload, 0, playload.Length);
}
this.dmNetStream.Write(buffer, 0, buffer.Length);
this.dmNetStream.Flush();
}
}
private static unsafe void Parse2Protocol(byte[] buffer, out DanmakuProtocol protocol)
{
fixed (byte* ptr = buffer)
{
protocol = *(DanmakuProtocol*)ptr;
}
protocol.ChangeEndian();
}
private struct DanmakuProtocol
{
/// <summary>
/// 消息总长度 (协议头 + 数据长度)
/// </summary>
public int PacketLength;
/// <summary>
/// 消息头长度 (固定为16[sizeof(DanmakuProtocol)])
/// </summary>
public short HeaderLength;
/// <summary>
/// 消息版本号
/// </summary>
public short Version;
/// <summary>
/// 消息类型
/// </summary>
public int Action;
/// <summary>
/// 参数, 固定为1
/// </summary>
public int Parameter;
/// <summary>
/// 转为本机字节序
/// </summary>
public void ChangeEndian()
{
this.PacketLength = IPAddress.HostToNetworkOrder(this.PacketLength);
this.HeaderLength = IPAddress.HostToNetworkOrder(this.HeaderLength);
this.Version = IPAddress.HostToNetworkOrder(this.Version);
this.Action = IPAddress.HostToNetworkOrder(this.Action);
this.Parameter = IPAddress.HostToNetworkOrder(this.Parameter);
}
}
#endregion
#region IDisposable Support
private bool disposedValue = false; // 要检测冗余调用
protected virtual void Dispose(bool disposing)
{
if (!this.disposedValue)
{
if (disposing)
{
this.dmTokenSource?.Cancel();
this.dmTokenSource?.Dispose();
this.httpTimer?.Dispose();
this.dmClient?.Close();
}
this.dmNetStream = null;
this.disposedValue = true;
}
}
public void Dispose()
{
// 请勿更改此代码。将清理代码放入以上 Dispose(bool disposing) 中。
this.Dispose(true);
}
#endregion
}
}

View File

@ -1,10 +0,0 @@
namespace BililiveRecorder.Core
{
public enum TriggerType
{
Danmaku,
HttpApi,
HttpApiRecheck,
Manual,
}
}

View File

@ -1,103 +0,0 @@
using System;
using System.IO;
using System.Linq;
using System.Net.Sockets;
using NLog;
namespace BililiveRecorder.Core
{
public static class Utils
{
internal static byte[] ToBE(this byte[] b)
{
if (BitConverter.IsLittleEndian)
{
return b.Reverse().ToArray();
}
else
{
return b;
}
}
internal static void ReadB(this NetworkStream stream, byte[] buffer, int offset, int count)
{
if (offset + count > buffer.Length)
{
throw new ArgumentException();
}
int read = 0;
while (read < count)
{
var available = stream.Read(buffer, offset, count - read);
if (available == 0)
{
throw new ObjectDisposedException(null);
}
read += available;
offset += available;
}
}
internal static string RemoveInvalidFileName(this string name, bool ignore_slash = false)
{
foreach (char c in Path.GetInvalidFileNameChars())
{
if (ignore_slash && (c == '\\' || c == '/'))
continue;
name = name.Replace(c, '_');
}
return name;
}
public static bool CopyPropertiesTo<T>(this T source, T target) where T : class
{
if (source == null || target == null || source == target) { return false; }
foreach (var p in source.GetType().GetProperties())
{
if (Attribute.IsDefined(p, typeof(DoNotCopyProperty)))
{
continue;
}
var val = p.GetValue(source);
if (val == null || !val.Equals(p.GetValue(target)))
{
p.SetValue(target, val);
}
}
return true;
}
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public class DoNotCopyProperty : Attribute { }
internal static void Log(this Logger logger, int id, LogLevel level, string message, Exception exception = null)
{
var log = new LogEventInfo()
{
Level = level,
Message = message,
Exception = exception,
};
log.Properties["roomid"] = id;
logger.Log(log);
}
private static string _useragent;
internal static string UserAgent
{
get
{
if (string.IsNullOrWhiteSpace(_useragent))
{
string version = typeof(Utils).Assembly.GetName().Version.ToString();
_useragent = $"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.122 Safari/537.36 BililiveRecorder/{version} (+https://github.com/Bililive/BililiveRecorder;bliverec@danmuji.org)";
}
return _useragent;
}
}
}
}

View File

@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Xml;
@ -17,13 +19,23 @@ namespace BililiveRecorder.Flv.Amf
DateTimeZoneHandling = DateTimeZoneHandling.RoundtripKind,
};
public ScriptDataString Name { get; set; } = string.Empty;
public ScriptTagBody()
{
this.Values = new List<IScriptDataValue>();
}
public ScriptDataObject Value { get; set; } = new ScriptDataObject();
public ScriptTagBody(List<IScriptDataValue> values)
{
this.Values = values ?? throw new ArgumentNullException(nameof(values));
}
public static ScriptTagBody Parse(string json) => JsonConvert.DeserializeObject<ScriptTagBody>(json, jsonSerializerSettings)!;
public List<IScriptDataValue> Values { get; set; }
public string ToJson() => JsonConvert.SerializeObject(this, jsonSerializerSettings);
public static ScriptTagBody Parse(string json) =>
new ScriptTagBody(JsonConvert.DeserializeObject<List<IScriptDataValue>>(json, jsonSerializerSettings)
?? throw new Exception("JsonConvert.DeserializeObject returned null"));
public string ToJson() => JsonConvert.SerializeObject(this.Values, jsonSerializerSettings);
public static ScriptTagBody Parse(byte[] bytes)
{
@ -31,26 +43,16 @@ namespace BililiveRecorder.Flv.Amf
return Parse(ms);
}
public static ScriptTagBody Parse(Stream stream)
{
return Parse(new BigEndianBinaryReader(stream, Encoding.UTF8, true));
}
public static ScriptTagBody Parse(Stream stream) => Parse(new BigEndianBinaryReader(stream, Encoding.UTF8, true));
public static ScriptTagBody Parse(BigEndianBinaryReader binaryReader)
{
if (ParseValue(binaryReader) is ScriptDataString stringName)
return new ScriptTagBody
{
Name = stringName,
Value = ((ParseValue(binaryReader)) switch
{
ScriptDataEcmaArray value => value,
ScriptDataObject value => value,
_ => throw new AmfException("type of ScriptTagBody.Value is not supported"),
})
};
else
throw new AmfException("ScriptTagBody.Name is not String");
var list = new List<IScriptDataValue>();
while (binaryReader.BaseStream.Position < binaryReader.BaseStream.Length)
list.Add(ParseValue(binaryReader));
return new ScriptTagBody(list);
}
public byte[] ToBytes()
@ -62,8 +64,10 @@ namespace BililiveRecorder.Flv.Amf
public void WriteTo(Stream stream)
{
this.Name.WriteTo(stream);
this.Value.WriteTo(stream);
foreach (var value in this.Values)
{
value.WriteTo(stream);
}
}
public static IScriptDataValue ParseValue(BigEndianBinaryReader binaryReader)
@ -144,6 +148,7 @@ namespace BililiveRecorder.Flv.Amf
{
var bytes = binaryReader.ReadBytes((int)length);
var str = Encoding.UTF8.GetString(bytes);
str = str.Replace("\0", "");
return (ScriptDataLongString)str;
}
}
@ -160,7 +165,7 @@ namespace BililiveRecorder.Flv.Amf
throw new AmfException("ObjectEndMarker not matched.");
return null;
}
return Encoding.UTF8.GetString(binaryReader.ReadBytes(length));
return Encoding.UTF8.GetString(binaryReader.ReadBytes(length)).Replace("\0", ""); ;
}
}
@ -169,8 +174,7 @@ namespace BililiveRecorder.Flv.Amf
{
var str = reader.ReadElementContentAsString();
var obj = Parse(str);
this.Name = obj.Name;
this.Value = obj.Value;
this.Values = obj.Values;
}
void IXmlSerializable.WriteXml(XmlWriter writer) => writer.WriteString(this.ToJson());
}

View File

@ -0,0 +1,7 @@
namespace BililiveRecorder.Flv.Amf
{
public static class ScriptTagBodyExtensions
{
public static ScriptDataEcmaArray? GetMetadataValue(this ScriptTagBody body) => body.Values.Count > 1 ? body.Values[1] as ScriptDataEcmaArray : null;
}
}

View File

@ -16,6 +16,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="System.IO.Pipelines" Version="5.0.1" />
<PackageReference Include="System.Memory" Version="4.5.4" />
</ItemGroup>

View File

@ -1,13 +1,12 @@
using BililiveRecorder.Flv.Pipeline;
using Microsoft.Extensions.DependencyInjection;
namespace BililiveRecorder.DependencyInjection
{
public static class DependencyInjectionExtensions
{
public static IServiceCollection AddFlv(this IServiceCollection services)
{
return services;
}
public static IServiceCollection AddFlv(this IServiceCollection services) => services
.AddTransient<IProcessingPipelineBuilder, ProcessingPipelineBuilder>()
;
}
}

View File

@ -0,0 +1,13 @@
using System;
namespace BililiveRecorder.Flv
{
public class FileClosedEventArgs : EventArgs
{
public long FileSize { get; set; }
public double Duration { get; set; }
public object? State { get; set; }
}
}

View File

@ -7,7 +7,8 @@ namespace BililiveRecorder.Flv.Grouping.Rules
{
public bool StartWith(Tag tag) => tag.IsData();
public bool AppendWith(Tag tag) => tag.IsNonKeyframeData();
public bool AppendWith(Tag tag, List<Tag> tags) => tag.IsNonKeyframeData()
|| (tag.Type == TagType.Audio && tag.Flag == TagFlag.Header && tags.TrueForAll(x => x.Type != TagType.Audio));
public PipelineAction CreatePipelineAction(List<Tag> tags) => new PipelineDataAction(tags);
}

View File

@ -7,7 +7,7 @@ namespace BililiveRecorder.Flv.Grouping.Rules
{
public bool StartWith(Tag tag) => tag.IsHeader();
public bool AppendWith(Tag tag) => tag.IsHeader();
public bool AppendWith(Tag tag, List<Tag> tags) => tag.IsHeader();
public PipelineAction CreatePipelineAction(List<Tag> tags) => new PipelineHeaderAction(tags);
}

View File

@ -8,7 +8,7 @@ namespace BililiveRecorder.Flv.Grouping.Rules
{
public bool StartWith(Tag tag) => tag.IsScript();
public bool AppendWith(Tag tag) => false;
public bool AppendWith(Tag tag, List<Tag> tags) => false;
public PipelineAction CreatePipelineAction(List<Tag> tags) => new PipelineScriptAction(tags.First());
}

View File

@ -34,7 +34,7 @@ namespace BililiveRecorder.Flv.Grouping
};
}
public async Task<PipelineAction?> ReadGroupAsync()
public async Task<PipelineAction?> ReadGroupAsync(CancellationToken token)
{
if (!this.semaphoreSlim.Wait(0))
{
@ -44,7 +44,7 @@ namespace BililiveRecorder.Flv.Grouping
{
var tags = new List<Tag>();
var firstTag = await this.TagReader.ReadTagAsync().ConfigureAwait(false);
var firstTag = await this.TagReader.ReadTagAsync(token).ConfigureAwait(false);
// 数据已经全部读完
if (firstTag is null)
@ -57,13 +57,13 @@ namespace BililiveRecorder.Flv.Grouping
tags.Add(firstTag);
while (true)
while (!token.IsCancellationRequested)
{
var tag = await this.TagReader.PeekTagAsync().ConfigureAwait(false);
var tag = await this.TagReader.PeekTagAsync(token).ConfigureAwait(false);
if (tag != null && rule.AppendWith(tag))
if (tag != null && rule.AppendWith(tag, tags))
{
await this.TagReader.ReadTagAsync().ConfigureAwait(false);
await this.TagReader.ReadTagAsync(token).ConfigureAwait(false);
tags.Add(tag);
}
else
@ -93,13 +93,13 @@ namespace BililiveRecorder.Flv.Grouping
this.TagReader.Dispose();
}
// TODO: free unmanaged resources (unmanaged objects) and override finalizer
// TODO: set large fields to null
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
this.disposedValue = true;
}
}
// // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
// override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
// ~TagGroupReader()
// {
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method

View File

@ -1,11 +1,17 @@
using System;
using System.Threading.Tasks;
using BililiveRecorder.Flv.Amf;
using BililiveRecorder.Flv.Pipeline;
namespace BililiveRecorder.Flv
{
public interface IFlvProcessingContextWriter : IDisposable
{
Action<ScriptTagBody>? BeforeScriptTagWrite { get; set; }
Action<ScriptTagBody>? BeforeScriptTagRewrite { get; set; }
event EventHandler<FileClosedEventArgs> FileClosed;
Task WriteAsync(FlvProcessingContext context);
}
}

View File

@ -12,12 +12,12 @@ namespace BililiveRecorder.Flv
/// Returns the next available Flv Tag but does not consume it.
/// </summary>
/// <returns></returns>
Task<Tag?> PeekTagAsync();
Task<Tag?> PeekTagAsync(System.Threading.CancellationToken token);
/// <summary>
/// Reads the next Flv Tag.
/// </summary>
/// <returns></returns>
Task<Tag?> ReadTagAsync();
Task<Tag?> ReadTagAsync(System.Threading.CancellationToken token);
}
}

View File

@ -1,11 +1,14 @@
using System.Collections.Generic;
using System.IO;
namespace BililiveRecorder.Flv
{
public interface IFlvWriterTargetProvider
{
Stream CreateOutputStream();
(Stream stream, object state) CreateOutputStream();
Stream CreateAlternativeHeaderStream();
bool ShouldCreateNewFile(Stream outputStream, IList<Tag> tags);
}
}

View File

@ -6,7 +6,7 @@ namespace BililiveRecorder.Flv
public interface IGroupingRule
{
bool StartWith(Tag tag);
bool AppendWith(Tag tag);
bool AppendWith(Tag tag, List<Tag> tags);
PipelineAction CreatePipelineAction(List<Tag> tags);
}
}

View File

@ -1,4 +1,5 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using BililiveRecorder.Flv.Pipeline;
@ -6,6 +7,6 @@ namespace BililiveRecorder.Flv
{
public interface ITagGroupReader : IDisposable
{
Task<PipelineAction?> ReadGroupAsync();
Task<PipelineAction?> ReadGroupAsync(CancellationToken token);
}
}

View File

@ -7,6 +7,7 @@ using System.IO;
using System.IO.Pipelines;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
namespace BililiveRecorder.Flv.Parser
{
@ -16,7 +17,7 @@ namespace BililiveRecorder.Flv.Parser
public class FlvTagPipeReader : IFlvTagReader, IDisposable
{
private static int memoryCreateCounter = 0;
private readonly ILogger? logger;
private readonly IMemoryStreamProvider memoryStreamProvider;
private readonly bool skipData;
private readonly bool leaveOpen;
@ -29,12 +30,13 @@ namespace BililiveRecorder.Flv.Parser
public PipeReader Reader { get; }
public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider) : this(reader, memoryStreamProvider, false) { }
public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, ILogger? logger = null) : this(reader, memoryStreamProvider, false, logger) { }
public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, bool skipData = false) : this(reader, memoryStreamProvider, skipData, false) { }
public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, bool skipData = false, ILogger? logger = null) : this(reader, memoryStreamProvider, skipData, false, logger) { }
public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, bool skipData = false, bool leaveOpen = false)
public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, bool skipData = false, bool leaveOpen = false, ILogger? logger = null)
{
this.logger = logger?.ForContext<FlvTagPipeReader>();
this.Reader = reader ?? throw new ArgumentNullException(nameof(reader));
this.memoryStreamProvider = memoryStreamProvider ?? throw new ArgumentNullException(nameof(memoryStreamProvider));
@ -269,9 +271,16 @@ namespace BililiveRecorder.Flv.Parser
tagBodyStream.Seek(0, SeekOrigin.Begin);
if (tag.Type == TagType.Script)
{
try
{
tag.ScriptData = Amf.ScriptTagBody.Parse(tagBodyStream);
}
catch (Exception ex)
{
this.logger?.Debug(ex, "Error parsing script tag body");
}
}
else if (tag.Type == TagType.Video && !tag.Flag.HasFlag(TagFlag.Header))
{
if (H264Nalu.TryParseNalu(tagBodyStream, out var nalus))
@ -288,7 +297,7 @@ namespace BililiveRecorder.Flv.Parser
}
/// <inheritdoc/>
public async Task<Tag?> PeekTagAsync()
public async Task<Tag?> PeekTagAsync(CancellationToken token)
{
try
{
@ -300,7 +309,7 @@ namespace BililiveRecorder.Flv.Parser
}
else
{
this.peekTag = await this.ReadNextTagAsync();
this.peekTag = await this.ReadNextTagAsync(token);
this.peek = true;
return this.peekTag;
}
@ -312,7 +321,7 @@ namespace BililiveRecorder.Flv.Parser
}
/// <inheritdoc/>
public async Task<Tag?> ReadTagAsync()
public async Task<Tag?> ReadTagAsync(CancellationToken token)
{
try
{
@ -326,7 +335,7 @@ namespace BililiveRecorder.Flv.Parser
}
else
{
return await this.ReadNextTagAsync();
return await this.ReadNextTagAsync(token);
}
}
finally

View File

@ -24,6 +24,7 @@ namespace BililiveRecorder.Flv.Pipeline
public static IProcessingPipelineBuilder AddDefault(this IProcessingPipelineBuilder builder) =>
builder
.Add<HandleDelayedAudioHeaderRule>()
.Add<CheckMissingKeyframeRule>()
.Add<CheckDiscontinuityRule>()
.Add<UpdateTimestampRule>()

Some files were not shown because too many files have changed in this diff Show More