diff --git a/.gitignore b/.gitignore index c059771..eb2eee4 100644 --- a/.gitignore +++ b/.gitignore @@ -263,3 +263,4 @@ __pycache__/ TempBuildInfo/*.cs BililiveRecorder.WPF/Nlog.config +BililiveRecorder.Cli/Properties/launchSettings.json diff --git a/BililiveRecorder.Cli/BililiveRecorder.Cli.csproj b/BililiveRecorder.Cli/BililiveRecorder.Cli.csproj index 039f964..1ed705d 100644 --- a/BililiveRecorder.Cli/BililiveRecorder.Cli.csproj +++ b/BililiveRecorder.Cli/BililiveRecorder.Cli.csproj @@ -28,15 +28,19 @@ - - - + + + + + + + + - diff --git a/BililiveRecorder.Cli/Program.cs b/BililiveRecorder.Cli/Program.cs index dcec629..a7549ea 100644 --- a/BililiveRecorder.Cli/Program.cs +++ b/BililiveRecorder.Cli/Program.cs @@ -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(args) - .MapResult(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("path"), + }; + cmd_run.Handler = CommandHandler.Create(RunConfigMode); - var serviceProvider = BuildServiceProvider(); + var cmd_portable = new Command("portable", "Run BililiveRecorder in config-less mode") + { + new Option(new []{ "--cookie", "-c" }, "Cookie string for api requests"), + new Option("--live-api-host"), + new Option(new[]{ "--filename-format", "-f" }, "File name format"), + new Argument("output path"), + new Argument("room ids") + }; + cmd_portable.Handler = CommandHandler.Create(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(); 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(); 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() + private static IServiceProvider BuildServiceProvider(ConfigV2 config, ILogger logger) => new ServiceCollection() + .AddSingleton(logger) + .AddFlv() + .AddRecorderConfig(config) + .AddRecorder() + .BuildServiceProvider(); + + 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 { - var services = new ServiceCollection(); - services.AddFlvProcessor(); - services.AddCore(); - return services.BuildServiceProvider(); + public string OutputPath { get; set; } = string.Empty; + + public string? Cookie { get; set; } + + public string? LiveApiHost { get; set; } + + public string? FilenameFormat { get; set; } + + public IEnumerable RoomIds { get; set; } = Enumerable.Empty(); } } - - [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; } = "."; - - [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; } - - [Value(0, Min = 1, Required = true, HelpText = "List of room id")] - public IEnumerable RoomIds { get; set; } = Enumerable.Empty(); - } - - [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; - } } diff --git a/BililiveRecorder.Core/Api/Danmaku/DanmakuClient.cs b/BililiveRecorder.Core/Api/Danmaku/DanmakuClient.cs new file mode 100644 index 0000000..83988b1 --- /dev/null +++ b/BililiveRecorder.Core/Api/Danmaku/DanmakuClient.cs @@ -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? StatusChanged; + public event EventHandler? DanmakuReceived; + + public DanmakuClient(IDanmakuServerApiClient apiClient, ILogger logger) + { + this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + this.logger = logger?.ForContext() ?? 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.Shared.Rent(16); + try + { + BinaryPrimitives.WriteUInt32BigEndian(new Span(buffer, 0, 4), (uint)size); + BinaryPrimitives.WriteUInt16BigEndian(new Span(buffer, 4, 2), 16); + BinaryPrimitives.WriteUInt16BigEndian(new Span(buffer, 6, 2), 1); + BinaryPrimitives.WriteUInt32BigEndian(new Span(buffer, 8, 4), (uint)action); + BinaryPrimitives.WriteUInt32BigEndian(new Span(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.Shared.Return(buffer); + } + } + + #endregion + + #region Receive + + private static async Task ProcessDataAsync(Stream stream, Action 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 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 buffer, Action 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 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 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 buffer, Action 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 buffer, int action, Action callback) + { + switch (action) + { + case 5: + { + if (buffer.Length > int.MaxValue) + throw new ArgumentOutOfRangeException("ParseCommandNormalBody buffer length larger than int.MaxValue"); + + var b = ArrayPool.Shared.Rent((int)buffer.Length); + try + { + buffer.CopyTo(b); + var json = Encoding.UTF8.GetString(b, 0, (int)buffer.Length); + callback(json); + } + finally + { + ArrayPool.Shared.Return(b); + } + } + break; + case 3: + + break; + default: + break; + } + } + + private static unsafe void Parse2Protocol(ReadOnlySpan buffer, out DanmakuProtocol protocol) + { + fixed (byte* ptr = buffer) + { + protocol = *(DanmakuProtocol*)ptr; + } + protocol.ChangeEndian(); + } + + private struct DanmakuProtocol + { + /// + /// 消息总长度 (协议头 + 数据长度) + /// + public int PacketLength; + /// + /// 消息头长度 (固定为16[sizeof(DanmakuProtocol)]) + /// + public short HeaderLength; + /// + /// 消息版本号 + /// + public short Version; + /// + /// 消息类型 + /// + public int Action; + /// + /// 参数, 固定为1 + /// + public int Parameter; + /// + /// 转为本机字节序 + /// + 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 + } +} diff --git a/BililiveRecorder.Core/Api/Danmaku/DanmakuModel.cs b/BililiveRecorder.Core/Api/Danmaku/DanmakuModel.cs new file mode 100644 index 0000000..322d600 --- /dev/null +++ b/BililiveRecorder.Core/Api/Danmaku/DanmakuModel.cs @@ -0,0 +1,238 @@ +using Newtonsoft.Json.Linq; + +#nullable enable +namespace BililiveRecorder.Core.Api.Danmaku +{ + public enum DanmakuMsgType + { + /// + /// 彈幕 + /// + Comment, + /// + /// 禮物 + /// + GiftSend, + /// + /// 直播開始 + /// + LiveStart, + /// + /// 直播結束 + /// + LiveEnd, + /// + /// 其他 + /// + Unknown, + /// + /// 购买船票(上船) + /// + GuardBuy, + /// + /// SuperChat + /// + SuperChat, + /// + /// 房间信息更新 + /// + RoomChange + } + + public class DanmakuModel + { + /// + /// 消息類型 + /// + public DanmakuMsgType MsgType { get; set; } + + /// + /// 房间标题 + /// + public string? Title { get; set; } + + /// + /// 大分区 + /// + public string? ParentAreaName { get; set; } + + /// + /// 子分区 + /// + public string? AreaName { get; set; } + + /// + /// 彈幕內容 + /// 此项有值的消息类型: + /// + /// + /// + public string? CommentText { get; set; } + + /// + /// 消息触发者用户名 + /// 此项有值的消息类型: + /// + /// + /// + /// + /// + /// + /// + public string? UserName { get; set; } + + /// + /// SC 价格 + /// + public double Price { get; set; } + + /// + /// SC 保持时间 + /// + public int SCKeepTime { get; set; } + + /// + /// 消息触发者用户ID + /// 此项有值的消息类型: + /// + /// + /// + /// + /// + /// + /// + public int UserID { get; set; } + + /// + /// 用户舰队等级 + /// 0 为非船员 1 为总督 2 为提督 3 为舰长 + /// 此项有值的消息类型: + /// + /// + /// + /// + /// + public int UserGuardLevel { get; set; } + + /// + /// 禮物名稱 + /// + public string? GiftName { get; set; } + + /// + /// 礼物数量 + /// 此项有值的消息类型: + /// + /// + /// + /// 此字段也用于标识上船 的数量(月数) + /// + public int GiftCount { get; set; } + + /// + /// 该用户是否为房管(包括主播) + /// 此项有值的消息类型: + /// + /// + /// + /// + public bool IsAdmin { get; set; } + + /// + /// 是否VIP用戶(老爺) + /// 此项有值的消息类型: + /// + /// + /// + /// + public bool IsVIP { get; set; } + + /// + /// , 事件对应的房间号 + /// + public string? RoomID { get; set; } + + /// + /// 原始数据 + /// + public string? RawString { get; set; } + + /// + /// 原始数据 + /// + 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(); + switch (cmd) + { + case "LIVE": + this.MsgType = DanmakuMsgType.LiveStart; + this.RoomID = obj["roomid"]?.ToObject(); + break; + case "PREPARING": + this.MsgType = DanmakuMsgType.LiveEnd; + this.RoomID = obj["roomid"]?.ToObject(); + break; + case "DANMU_MSG": + this.MsgType = DanmakuMsgType.Comment; + this.CommentText = obj["info"]?[1]?.ToObject(); + this.UserID = obj["info"]?[2]?[0]?.ToObject() ?? 0; + this.UserName = obj["info"]?[2]?[1]?.ToObject(); + this.IsAdmin = obj["info"]?[2]?[2]?.ToObject() == "1"; + this.IsVIP = obj["info"]?[2]?[3]?.ToObject() == "1"; + this.UserGuardLevel = obj["info"]?[7]?.ToObject() ?? 0; + break; + case "SEND_GIFT": + this.MsgType = DanmakuMsgType.GiftSend; + this.GiftName = obj["data"]?["giftName"]?.ToObject(); + this.UserName = obj["data"]?["uname"]?.ToObject(); + this.UserID = obj["data"]?["uid"]?.ToObject() ?? 0; + this.GiftCount = obj["data"]?["num"]?.ToObject() ?? 0; + break; + case "GUARD_BUY": + { + this.MsgType = DanmakuMsgType.GuardBuy; + this.UserID = obj["data"]?["uid"]?.ToObject() ?? 0; + this.UserName = obj["data"]?["username"]?.ToObject(); + this.UserGuardLevel = obj["data"]?["guard_level"]?.ToObject() ?? 0; + this.GiftName = this.UserGuardLevel == 3 ? "舰长" : this.UserGuardLevel == 2 ? "提督" : this.UserGuardLevel == 1 ? "总督" : ""; + this.GiftCount = obj["data"]?["num"]?.ToObject() ?? 0; + break; + } + case "SUPER_CHAT_MESSAGE": + { + this.MsgType = DanmakuMsgType.SuperChat; + this.CommentText = obj["data"]?["message"]?.ToString(); + this.UserID = obj["data"]?["uid"]?.ToObject() ?? 0; + this.UserName = obj["data"]?["user_info"]?["uname"]?.ToString(); + this.Price = obj["data"]?["price"]?.ToObject() ?? 0; + this.SCKeepTime = obj["data"]?["time"]?.ToObject() ?? 0; + break; + } + case "ROOM_CHANGE": + { + this.MsgType = DanmakuMsgType.RoomChange; + this.Title = obj["data"]?["title"]?.ToObject(); + this.AreaName = obj["data"]?["area_name"]?.ToObject(); + this.ParentAreaName = obj["data"]?["parent_area_name"]?.ToObject(); + break; + } + default: + { + this.MsgType = DanmakuMsgType.Unknown; + break; + } + } + } + } +} diff --git a/BililiveRecorder.Core/Api/Danmaku/DanmakuReceivedEventArgs.cs b/BililiveRecorder.Core/Api/Danmaku/DanmakuReceivedEventArgs.cs new file mode 100644 index 0000000..7532659 --- /dev/null +++ b/BililiveRecorder.Core/Api/Danmaku/DanmakuReceivedEventArgs.cs @@ -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)); + } + } +} diff --git a/BililiveRecorder.Core/Api/Danmaku/StatusChangedEventArgs.cs b/BililiveRecorder.Core/Api/Danmaku/StatusChangedEventArgs.cs new file mode 100644 index 0000000..b8b22c4 --- /dev/null +++ b/BililiveRecorder.Core/Api/Danmaku/StatusChangedEventArgs.cs @@ -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; } + } +} diff --git a/BililiveRecorder.Core/Api/Http/HttpApiClient.cs b/BililiveRecorder.Core/Api/Http/HttpApiClient.cs new file mode 100644 index 0000000..90bd017 --- /dev/null +++ b/BililiveRecorder.Core/Api/Http/HttpApiClient.cs @@ -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> FetchAsync(HttpClient client, string url) where T : class + { + var text = await client.GetStringAsync(url).ConfigureAwait(false); + var obj = JsonConvert.DeserializeObject>(text); + if (obj.Code != 0) + throw new BilibiliApiResponseCodeNotZeroException("Bilibili api code: " + (obj.Code?.ToString() ?? "(null)") + "\n" + text); + return obj; + } + + public Task> 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(this.mainClient, url); + } + + public Task> 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(this.mainClient, url); + } + + public Task> 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(this.mainClient, url); + } + + public Task> 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(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); + } + } +} diff --git a/BililiveRecorder.Core/Api/IApiClient.cs b/BililiveRecorder.Core/Api/IApiClient.cs new file mode 100644 index 0000000..7b45249 --- /dev/null +++ b/BililiveRecorder.Core/Api/IApiClient.cs @@ -0,0 +1,13 @@ +using System; +using System.Threading.Tasks; +using BililiveRecorder.Core.Api.Model; + +namespace BililiveRecorder.Core.Api +{ + public interface IApiClient : IDisposable + { + Task> GetRoomInfoAsync(int roomid); + Task> GetUserInfoAsync(int roomid); + Task> GetStreamUrlAsync(int roomid); + } +} diff --git a/BililiveRecorder.Core/Api/IDanmakuClient.cs b/BililiveRecorder.Core/Api/IDanmakuClient.cs new file mode 100644 index 0000000..dc0d215 --- /dev/null +++ b/BililiveRecorder.Core/Api/IDanmakuClient.cs @@ -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? StatusChanged; + event EventHandler? DanmakuReceived; + + Task ConnectAsync(int roomid, CancellationToken cancellationToken); + Task DisconnectAsync(); + } +} diff --git a/BililiveRecorder.Core/Api/IDanmakuServerApiClient.cs b/BililiveRecorder.Core/Api/IDanmakuServerApiClient.cs new file mode 100644 index 0000000..61b9df5 --- /dev/null +++ b/BililiveRecorder.Core/Api/IDanmakuServerApiClient.cs @@ -0,0 +1,11 @@ +using System; +using System.Threading.Tasks; +using BililiveRecorder.Core.Api.Model; + +namespace BililiveRecorder.Core.Api +{ + public interface IDanmakuServerApiClient : IDisposable + { + Task> GetDanmakuServerAsync(int roomid); + } +} diff --git a/BililiveRecorder.Core/Api/Model/BilibiliApiResponse.cs b/BililiveRecorder.Core/Api/Model/BilibiliApiResponse.cs new file mode 100644 index 0000000..10c9fae --- /dev/null +++ b/BililiveRecorder.Core/Api/Model/BilibiliApiResponse.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace BililiveRecorder.Core.Api.Model +{ + public class BilibiliApiResponse where T : class + { + [JsonProperty("code")] + public int? Code { get; set; } + + [JsonProperty("data")] + public T? Data { get; set; } + } +} diff --git a/BililiveRecorder.Core/Api/Model/BilibiliApiResponseCodeNotZeroException.cs b/BililiveRecorder.Core/Api/Model/BilibiliApiResponseCodeNotZeroException.cs new file mode 100644 index 0000000..412862e --- /dev/null +++ b/BililiveRecorder.Core/Api/Model/BilibiliApiResponseCodeNotZeroException.cs @@ -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) { } + } +} diff --git a/BililiveRecorder.Core/Api/Model/DanmuInfo.cs b/BililiveRecorder.Core/Api/Model/DanmuInfo.cs new file mode 100644 index 0000000..61e50c2 --- /dev/null +++ b/BililiveRecorder.Core/Api/Model/DanmuInfo.cs @@ -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(); + + [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; } + } + } +} diff --git a/BililiveRecorder.Core/Api/Model/RoomInfo.cs b/BililiveRecorder.Core/Api/Model/RoomInfo.cs new file mode 100644 index 0000000..28a8c63 --- /dev/null +++ b/BililiveRecorder.Core/Api/Model/RoomInfo.cs @@ -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; + } +} diff --git a/BililiveRecorder.Core/Api/Model/RoomPlayInfo.cs b/BililiveRecorder.Core/Api/Model/RoomPlayInfo.cs new file mode 100644 index 0000000..7442fc3 --- /dev/null +++ b/BililiveRecorder.Core/Api/Model/RoomPlayInfo.cs @@ -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(); + } + + public class StreamItem + { + [JsonProperty("protocol_name")] + public string ProtocolName { get; set; } = string.Empty; + + [JsonProperty("format")] + public FormatItem[]? Formats { get; set; } = Array.Empty(); + } + + public class FormatItem + { + [JsonProperty("format_name")] + public string FormatName { get; set; } = string.Empty; + + [JsonProperty("codec")] + public CodecItem[]? Codecs { get; set; } = Array.Empty(); + } + + 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(); + + [JsonProperty("url_info")] + public UrlInfoItem[]? UrlInfos { get; set; } = Array.Empty(); + } + + public class UrlInfoItem + { + [JsonProperty("host")] + public string Host { get; set; } = string.Empty; + + [JsonProperty("extra")] + public string Extra { get; set; } = string.Empty; + } + } +} diff --git a/BililiveRecorder.Core/Api/Model/UserInfo.cs b/BililiveRecorder.Core/Api/Model/UserInfo.cs new file mode 100644 index 0000000..a1dac23 --- /dev/null +++ b/BililiveRecorder.Core/Api/Model/UserInfo.cs @@ -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; + } + } +} diff --git a/BililiveRecorder.Core/Api/ModelExtensions.cs b/BililiveRecorder.Core/Api/ModelExtensions.cs new file mode 100644 index 0000000..13b813e --- /dev/null +++ b/BililiveRecorder.Core/Api/ModelExtensions.cs @@ -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; + } + } + } +} diff --git a/BililiveRecorder.Core/Api/PolicyWrappedApiClient.cs b/BililiveRecorder.Core/Api/PolicyWrappedApiClient.cs new file mode 100644 index 0000000..b876c80 --- /dev/null +++ b/BililiveRecorder.Core/Api/PolicyWrappedApiClient.cs @@ -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 : IApiClient, IDanmakuServerApiClient, IDisposable where T : class, IApiClient, IDanmakuServerApiClient, IDisposable + { + private readonly T client; + private readonly IReadOnlyPolicyRegistry policies; + + public PolicyWrappedApiClient(T client, IReadOnlyPolicyRegistry policies) + { + this.client = client ?? throw new ArgumentNullException(nameof(client)); + this.policies = policies ?? throw new ArgumentNullException(nameof(policies)); + } + + public async Task> GetDanmakuServerAsync(int roomid) => await this.policies + .Get(PolicyNames.PolicyDanmakuApiRequestAsync) + .ExecuteAsync(_ => this.client.GetDanmakuServerAsync(roomid), new Context(PolicyNames.CacheKeyDanmaku + ":" + roomid)) + .ConfigureAwait(false); + + public async Task> GetRoomInfoAsync(int roomid) => await this.policies + .Get(PolicyNames.PolicyRoomInfoApiRequestAsync) + .ExecuteAsync(_ => this.client.GetRoomInfoAsync(roomid), new Context(PolicyNames.CacheKeyRoomInfo + ":" + roomid)) + .ConfigureAwait(false); + + public async Task> GetStreamUrlAsync(int roomid) => await this.policies + .Get(PolicyNames.PolicyStreamApiRequestAsync) + .ExecuteAsync(_ => this.client.GetStreamUrlAsync(roomid), new Context(PolicyNames.CacheKeyStream + ":" + roomid)) + .ConfigureAwait(false); + + public async Task> GetUserInfoAsync(int roomid) => await this.policies + .Get(PolicyNames.PolicyRoomInfoApiRequestAsync) + .ExecuteAsync(_ => this.client.GetUserInfoAsync(roomid), new Context(PolicyNames.CacheKeyUserInfo + ":" + roomid)) + .ConfigureAwait(false); + + public void Dispose() => this.client.Dispose(); + } +} diff --git a/BililiveRecorder.Core/AssemblyAttribute.cs b/BililiveRecorder.Core/AssemblyAttribute.cs index d73562b..7dcfc68 100644 --- a/BililiveRecorder.Core/AssemblyAttribute.cs +++ b/BililiveRecorder.Core/AssemblyAttribute.cs @@ -1,3 +1,3 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("BililiveRecorder.UnitTest.Core")] +[assembly: InternalsVisibleTo("BililiveRecorder.Core.UnitTests")] diff --git a/BililiveRecorder.Core/BililiveAPI.cs b/BililiveRecorder.Core/BililiveAPI.cs deleted file mode 100644 index 537f5ff..0000000 --- a/BililiveRecorder.Core/BililiveAPI.cs +++ /dev/null @@ -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 时发生错误"); - } - } - - /// - /// 下载json并解析 - /// - /// 下载路径 - /// 数据 - /// - /// - private async Task HttpGetJsonAsync(HttpClient client, string url) - { - try - { - var s = await client.GetStringAsync(url); - var j = JObject.Parse(s); - return j; - } - catch (TaskCanceledException) - { - return null; - } - } - - /// - /// 获取直播间播放地址 - /// - /// 原房间号 - /// FLV播放地址 - /// - /// - public async Task 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()); - var distinct = urls.Distinct().ToArray(); - if (distinct.Length > 0) - { - return distinct[random.Next(distinct.Length)]; - } - } - return null; - } - - /// - /// 获取直播间信息 - /// - /// 房间号(允许短号) - /// 直播间信息 - /// - /// - public async Task 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() != 0) - { - logger.Warn("不能获取 {roomid} 的信息1: {errormsg}", roomid, room?["message"]?.ToObject() ?? "请求超时"); - 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() != 0) - { - logger.Warn("不能获取 {roomid} 的信息2: {errormsg}", roomid, user?["message"]?.ToObject() ?? "请求超时"); - return null; - } - - var i = new RoomInfo() - { - ShortRoomId = room?["data"]?["short_id"]?.ToObject() ?? throw new Exception("未获取到直播间信息"), - RoomId = room?["data"]?["room_id"]?.ToObject() ?? throw new Exception("未获取到直播间信息"), - IsStreaming = 1 == (room?["data"]?["live_status"]?.ToObject() ?? throw new Exception("未获取到直播间信息")), - UserName = user?["data"]?["info"]?["uname"]?.ToObject() ?? throw new Exception("未获取到直播间信息"), - Title = room?["data"]?["title"]?.ToObject() ?? throw new Exception("未获取到直播间信息"), - ParentAreaName = room?["data"]?["parent_area_name"]?.ToObject() ?? throw new Exception("未获取到直播间信息"), - AreaName = room?["data"]?["area_name"]?.ToObject() ?? throw new Exception("未获取到直播间信息"), - }; - return i; - } - catch (Exception ex) - { - logger.Warn(ex, "获取直播间 {roomid} 的信息时出错", roomid); - throw; - } - } - - /// - /// 获取弹幕连接信息 - /// - /// - /// - 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() == 0) - { - var token = result?["data"]?["token"]?.ToObject() ?? 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(), host_server["port"]?.ToObject() ?? 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(), server["port"]?.ToObject() ?? 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()); - 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); - } - } - } -} diff --git a/BililiveRecorder.Core/BililiveRecorder.Core.csproj b/BililiveRecorder.Core/BililiveRecorder.Core.csproj index 9a65e69..f1b4bb5 100644 --- a/BililiveRecorder.Core/BililiveRecorder.Core.csproj +++ b/BililiveRecorder.Core/BililiveRecorder.Core.csproj @@ -10,6 +10,7 @@ 0.0.0.0 2.0 true + enable portable @@ -22,14 +23,20 @@ + + - + + + - + - cd $(SolutionDir) -powershell -ExecutionPolicy Bypass -File .\CI\patch_buildinfo.ps1 Core + + cd $(SolutionDir) + powershell -ExecutionPolicy Bypass -File .\CI\patch_buildinfo.ps1 Core + \ No newline at end of file diff --git a/BililiveRecorder.Core/Callback/RecordEndData.cs b/BililiveRecorder.Core/Callback/RecordEndData.cs deleted file mode 100644 index 80c25b6..0000000 --- a/BililiveRecorder.Core/Callback/RecordEndData.cs +++ /dev/null @@ -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; } - } -} diff --git a/BililiveRecorder.Core/Config/ConfigMapper.cs b/BililiveRecorder.Core/Config/ConfigMapper.cs index d2b5a49..c5c89c0 100644 --- a/BililiveRecorder.Core/Config/ConfigMapper.cs +++ b/BililiveRecorder.Core/Config/ConfigMapper.cs @@ -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(); - AddMap(map, x => x.EnabledFeature, x => x.EnabledFeature); - AddMap(map, x => x.ClipLengthPast, x => x.ClipLengthPast); - AddMap(map, x => x.ClipLengthFuture, x => x.ClipLengthFuture); AddMap(map, x => x.TimingStreamRetry, x => x.TimingStreamRetry); AddMap(map, x => x.TimingStreamConnect, x => x.TimingStreamConnect); AddMap(map, x => x.TimingDanmakuRetry, x => x.TimingDanmakuRetry); @@ -28,9 +24,8 @@ namespace BililiveRecorder.Core.Config AddMap(map, x => x.WebHookUrls, x => x.WebHookUrls); AddMap(map, x => x.LiveApiHost, x => x.LiveApiHost); AddMap(map, x => x.RecordFilenameFormat, x => x.RecordFilenameFormat); - AddMap(map, x => x.ClipFilenameFormat, x => x.ClipFilenameFormat); - AddMap(map, x => x.CuttingMode, x => x.CuttingMode); + AddMap(map, x => x.CuttingMode, x => x.CuttingMode); AddMap(map, x => x.CuttingNumber, x => x.CuttingNumber); AddMap(map, x => x.RecordDanmaku, x => x.RecordDanmaku); AddMap(map, x => x.RecordDanmakuRaw, x => x.RecordDanmakuRaw); diff --git a/BililiveRecorder.Core/Config/ConfigParser.cs b/BililiveRecorder.Core/Config/ConfigParser.cs index 2677c9a..41ff76c 100644 --- a/BililiveRecorder.Core/Config/ConfigParser.cs +++ b/BililiveRecorder.Core/Config/ConfigParser.cs @@ -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(); + 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(json); switch (configBase) @@ -53,7 +58,7 @@ namespace BililiveRecorder.Core.Config { logger.Debug("读取到 config v1"); #pragma warning disable CS0612 - var v1Data = JsonConvert.DeserializeObject(v1.Data); + var v1Data = JsonConvert.DeserializeObject(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()); - } } diff --git a/BililiveRecorder.Core/Config/V1/ConfigV1.cs b/BililiveRecorder.Core/Config/V1/ConfigV1.cs index 4b93ddf..52251ca 100644 --- a/BililiveRecorder.Core/Config/V1/ConfigV1.cs +++ b/BililiveRecorder.Core/Config/V1/ConfigV1.cs @@ -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(); - - /// - /// 当前工作目录 - /// - [JsonIgnore] - [Utils.DoNotCopyProperty] - public string WorkDirectory { get => this._workDirectory; set => this.SetField(ref this._workDirectory, value); } - + //private static readonly Logger logger = LogManager.GetCurrentClassLogger(); /// /// 房间号列表 @@ -31,8 +22,8 @@ namespace BililiveRecorder.Core.Config.V1 /// /// 启用的功能 /// - [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); } /// /// 剪辑-过去的时长(秒) @@ -50,7 +41,7 @@ namespace BililiveRecorder.Core.Config.V1 /// 自动切割模式 /// [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); } /// /// 自动切割数值(分钟/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(ref T field, T value, [CallerMemberName] string propertyName = "") { - if (EqualityComparer.Default.Equals(field, value)) return false; logger.Trace("设置 [{0}] 的值已从 [{1}] 修改到 [{2}]", propertyName, field, value); + if (EqualityComparer.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; diff --git a/BililiveRecorder.Core/Config/V1/ConfigV1Wrapper.cs b/BililiveRecorder.Core/Config/V1/ConfigV1Wrapper.cs index 832e067..f057abf 100644 --- a/BililiveRecorder.Core/Config/V1/ConfigV1Wrapper.cs +++ b/BililiveRecorder.Core/Config/V1/ConfigV1Wrapper.cs @@ -8,6 +8,6 @@ namespace BililiveRecorder.Core.Config.V1 /// Config Data String /// [JsonProperty("data")] - public string Data { get; set; } + public string? Data { get; set; } } } diff --git a/BililiveRecorder.Core/Config/V2/Config.gen.cs b/BililiveRecorder.Core/Config/V2/Config.gen.cs index ceeab8f..fbe19ce 100644 --- a/BililiveRecorder.Core/Config/V2/Config.gen.cs +++ b/BililiveRecorder.Core/Config/V2/Config.gen.cs @@ -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 /// /// 录制文件自动切割模式 /// - public AutoCuttingMode CuttingMode { get => this.GetPropertyValue(); set => this.SetPropertyValue(value); } - public bool HasCuttingMode { get => this.GetPropertyHasValue(nameof(this.CuttingMode)); set => this.SetPropertyHasValue(value, nameof(this.CuttingMode)); } + public CuttingMode CuttingMode { get => this.GetPropertyValue(); set => this.SetPropertyValue(value); } + public bool HasCuttingMode { get => this.GetPropertyHasValue(nameof(this.CuttingMode)); set => this.SetPropertyHasValue(value, nameof(this.CuttingMode)); } [JsonProperty(nameof(CuttingMode)), EditorBrowsable(EditorBrowsableState.Never)] - public Optional OptionalCuttingMode { get => this.GetPropertyValueOptional(nameof(this.CuttingMode)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingMode)); } + public Optional OptionalCuttingMode { get => this.GetPropertyValueOptional(nameof(this.CuttingMode)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingMode)); } /// /// 录制文件自动切割数值(分钟/MiB) @@ -85,21 +84,6 @@ namespace BililiveRecorder.Core.Config.V2 [JsonProperty(nameof(RecordDanmakuGuard)), EditorBrowsable(EditorBrowsableState.Never)] public Optional OptionalRecordDanmakuGuard { get => this.GetPropertyValueOptional(nameof(this.RecordDanmakuGuard)); set => this.SetPropertyValueOptional(value, nameof(this.RecordDanmakuGuard)); } - /// - /// 启用的功能 - /// - public EnabledFeature EnabledFeature => this.GetPropertyValue(); - - /// - /// 剪辑-过去的时长(秒) - /// - public uint ClipLengthPast => this.GetPropertyValue(); - - /// - /// 剪辑-将来的时长(秒) - /// - public uint ClipLengthFuture => this.GetPropertyValue(); - /// /// 录制断开重连时间间隔 毫秒 /// @@ -140,6 +124,11 @@ namespace BililiveRecorder.Core.Config.V2 /// public string? WebHookUrls => this.GetPropertyValue(); + /// + /// Webhook v2 地址 每行一个 + /// + public string? WebHookUrlsV2 => this.GetPropertyValue(); + /// /// 替换 api.live.bilibili.com 服务器为其他反代,可以支持在云服务器上录制 /// @@ -150,11 +139,6 @@ namespace BililiveRecorder.Core.Config.V2 /// public string? RecordFilenameFormat => this.GetPropertyValue(); - /// - /// 剪辑文件名模板 - /// - public string? ClipFilenameFormat => this.GetPropertyValue(); - /// /// 是否显示直播间标题和分区 /// @@ -165,30 +149,6 @@ namespace BililiveRecorder.Core.Config.V2 [JsonObject(MemberSerialization.OptIn)] public sealed partial class GlobalConfig : HierarchicalObject { - /// - /// 启用的功能 - /// - public EnabledFeature EnabledFeature { get => this.GetPropertyValue(); set => this.SetPropertyValue(value); } - public bool HasEnabledFeature { get => this.GetPropertyHasValue(nameof(this.EnabledFeature)); set => this.SetPropertyHasValue(value, nameof(this.EnabledFeature)); } - [JsonProperty(nameof(EnabledFeature)), EditorBrowsable(EditorBrowsableState.Never)] - public Optional OptionalEnabledFeature { get => this.GetPropertyValueOptional(nameof(this.EnabledFeature)); set => this.SetPropertyValueOptional(value, nameof(this.EnabledFeature)); } - - /// - /// 剪辑-过去的时长(秒) - /// - public uint ClipLengthPast { get => this.GetPropertyValue(); set => this.SetPropertyValue(value); } - public bool HasClipLengthPast { get => this.GetPropertyHasValue(nameof(this.ClipLengthPast)); set => this.SetPropertyHasValue(value, nameof(this.ClipLengthPast)); } - [JsonProperty(nameof(ClipLengthPast)), EditorBrowsable(EditorBrowsableState.Never)] - public Optional OptionalClipLengthPast { get => this.GetPropertyValueOptional(nameof(this.ClipLengthPast)); set => this.SetPropertyValueOptional(value, nameof(this.ClipLengthPast)); } - - /// - /// 剪辑-将来的时长(秒) - /// - public uint ClipLengthFuture { get => this.GetPropertyValue(); set => this.SetPropertyValue(value); } - public bool HasClipLengthFuture { get => this.GetPropertyHasValue(nameof(this.ClipLengthFuture)); set => this.SetPropertyHasValue(value, nameof(this.ClipLengthFuture)); } - [JsonProperty(nameof(ClipLengthFuture)), EditorBrowsable(EditorBrowsableState.Never)] - public Optional OptionalClipLengthFuture { get => this.GetPropertyValueOptional(nameof(this.ClipLengthFuture)); set => this.SetPropertyValueOptional(value, nameof(this.ClipLengthFuture)); } - /// /// 录制断开重连时间间隔 毫秒 /// @@ -253,6 +213,14 @@ namespace BililiveRecorder.Core.Config.V2 [JsonProperty(nameof(WebHookUrls)), EditorBrowsable(EditorBrowsableState.Never)] public Optional OptionalWebHookUrls { get => this.GetPropertyValueOptional(nameof(this.WebHookUrls)); set => this.SetPropertyValueOptional(value, nameof(this.WebHookUrls)); } + /// + /// Webhook v2 地址 每行一个 + /// + public string? WebHookUrlsV2 { get => this.GetPropertyValue(); set => this.SetPropertyValue(value); } + public bool HasWebHookUrlsV2 { get => this.GetPropertyHasValue(nameof(this.WebHookUrlsV2)); set => this.SetPropertyHasValue(value, nameof(this.WebHookUrlsV2)); } + [JsonProperty(nameof(WebHookUrlsV2)), EditorBrowsable(EditorBrowsableState.Never)] + public Optional OptionalWebHookUrlsV2 { get => this.GetPropertyValueOptional(nameof(this.WebHookUrlsV2)); set => this.SetPropertyValueOptional(value, nameof(this.WebHookUrlsV2)); } + /// /// 替换 api.live.bilibili.com 服务器为其他反代,可以支持在云服务器上录制 /// @@ -269,14 +237,6 @@ namespace BililiveRecorder.Core.Config.V2 [JsonProperty(nameof(RecordFilenameFormat)), EditorBrowsable(EditorBrowsableState.Never)] public Optional OptionalRecordFilenameFormat { get => this.GetPropertyValueOptional(nameof(this.RecordFilenameFormat)); set => this.SetPropertyValueOptional(value, nameof(this.RecordFilenameFormat)); } - /// - /// 剪辑文件名模板 - /// - public string? ClipFilenameFormat { get => this.GetPropertyValue(); set => this.SetPropertyValue(value); } - public bool HasClipFilenameFormat { get => this.GetPropertyHasValue(nameof(this.ClipFilenameFormat)); set => this.SetPropertyHasValue(value, nameof(this.ClipFilenameFormat)); } - [JsonProperty(nameof(ClipFilenameFormat)), EditorBrowsable(EditorBrowsableState.Never)] - public Optional OptionalClipFilenameFormat { get => this.GetPropertyValueOptional(nameof(this.ClipFilenameFormat)); set => this.SetPropertyValueOptional(value, nameof(this.ClipFilenameFormat)); } - /// /// 是否显示直播间标题和分区 /// @@ -288,10 +248,10 @@ namespace BililiveRecorder.Core.Config.V2 /// /// 录制文件自动切割模式 /// - public AutoCuttingMode CuttingMode { get => this.GetPropertyValue(); set => this.SetPropertyValue(value); } - public bool HasCuttingMode { get => this.GetPropertyHasValue(nameof(this.CuttingMode)); set => this.SetPropertyHasValue(value, nameof(this.CuttingMode)); } + public CuttingMode CuttingMode { get => this.GetPropertyValue(); set => this.SetPropertyValue(value); } + public bool HasCuttingMode { get => this.GetPropertyHasValue(nameof(this.CuttingMode)); set => this.SetPropertyHasValue(value, nameof(this.CuttingMode)); } [JsonProperty(nameof(CuttingMode)), EditorBrowsable(EditorBrowsableState.Never)] - public Optional OptionalCuttingMode { get => this.GetPropertyValueOptional(nameof(this.CuttingMode)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingMode)); } + public Optional OptionalCuttingMode { get => this.GetPropertyValueOptional(nameof(this.CuttingMode)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingMode)); } /// /// 录制文件自动切割数值(分钟/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; diff --git a/BililiveRecorder.Core/Config/V2/CuttingMode.cs b/BililiveRecorder.Core/Config/V2/CuttingMode.cs new file mode 100644 index 0000000..a6ef215 --- /dev/null +++ b/BililiveRecorder.Core/Config/V2/CuttingMode.cs @@ -0,0 +1,18 @@ +namespace BililiveRecorder.Core.Config.V2 +{ + public enum CuttingMode : int + { + /// + /// 禁用 + /// + Disabled, + /// + /// 根据时间切割 + /// + ByTime, + /// + /// 根据文件大小切割 + /// + BySize, + } +} diff --git a/BililiveRecorder.Core/Config/V2/build_config.data.js b/BililiveRecorder.Core/Config/V2/build_config.data.js index 30d77cf..c55921c 100644 --- a/BililiveRecorder.Core/Config/V2/build_config.data.js +++ b/BililiveRecorder.Core/Config/V2/build_config.data.js @@ -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", diff --git a/BililiveRecorder.Core/Config/V2/build_config.js b/BililiveRecorder.Core/Config/V2/build_config.js index 904f263..ca82ae9 100644 --- a/BililiveRecorder.Core/Config/V2/build_config.js +++ b/BililiveRecorder.Core/Config/V2/build_config.js @@ -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") diff --git a/BililiveRecorder.Core/BasicDanmakuWriter.cs b/BililiveRecorder.Core/Danmaku/BasicDanmakuWriter.cs similarity index 88% rename from BililiveRecorder.Core/BasicDanmakuWriter.cs rename to BililiveRecorder.Core/Danmaku/BasicDanmakuWriter.cs index dca2c0d..9c7516e 100644 --- a/BililiveRecorder.Core/BasicDanmakuWriter.cs +++ b/BililiveRecorder.Core/Danmaku/BasicDanmakuWriter.cs @@ -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() ?? 1; - var size = danmakuModel.RawObj?["info"]?[0]?[2]?.ToObject() ?? 25; - var color = danmakuModel.RawObj?["info"]?[0]?[3]?.ToObject() ?? 0XFFFFFF; - var st = danmakuModel.RawObj?["info"]?[0]?[4]?.ToObject() ?? 0L; + var type = danmakuModel.RawObject?["info"]?[0]?[1]?.ToObject() ?? 1; + var size = danmakuModel.RawObject?["info"]?[0]?[2]?.ToObject() ?? 25; + var color = danmakuModel.RawObject?["info"]?[0]?[3]?.ToObject() ?? 0XFFFFFF; + var st = danmakuModel.RawObject?["info"]?[0]?[4]?.ToObject() ?? 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 = @"B站录播姬弹幕文件 - B站录播姬弹幕XML文件本文件的弹幕信息兼容B站主站视频弹幕XML格式,可以使用现有的转换工具把文件中的弹幕转为ass字幕文件录播姬版本房间号主播名录制开始时间弹幕共 条记录上船共 条记录SC共 条记录礼物共 条记录弹幕用户名弹幕参数舰长购买用户名舰长等级购买数量出现时间SuperChat 醒目留言用户名内容显示时长价格出现时间礼物用户名礼物名礼物数量出现时间"; writer.WriteStartElement("BililiveRecorderXmlStyle"); diff --git a/BililiveRecorder.Core/IBasicDanmakuWriter.cs b/BililiveRecorder.Core/Danmaku/IBasicDanmakuWriter.cs similarity index 53% rename from BililiveRecorder.Core/IBasicDanmakuWriter.cs rename to BililiveRecorder.Core/Danmaku/IBasicDanmakuWriter.cs index d74178f..3107fa5 100644 --- a/BililiveRecorder.Core/IBasicDanmakuWriter.cs +++ b/BililiveRecorder.Core/Danmaku/IBasicDanmakuWriter.cs @@ -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); } } diff --git a/BililiveRecorder.Core/DanmakuEvents.cs b/BililiveRecorder.Core/DanmakuEvents.cs deleted file mode 100644 index 98f61b0..0000000 --- a/BililiveRecorder.Core/DanmakuEvents.cs +++ /dev/null @@ -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; - } -} diff --git a/BililiveRecorder.Core/DanmakuModel.cs b/BililiveRecorder.Core/DanmakuModel.cs deleted file mode 100644 index 5d70674..0000000 --- a/BililiveRecorder.Core/DanmakuModel.cs +++ /dev/null @@ -1,270 +0,0 @@ -using Newtonsoft.Json.Linq; - -#nullable enable -namespace BililiveRecorder.Core -{ - public enum MsgTypeEnum - { - /// - /// 彈幕 - /// - Comment, - /// - /// 禮物 - /// - GiftSend, - /// - /// 歡迎老爷 - /// - Welcome, - /// - /// 直播開始 - /// - LiveStart, - /// - /// 直播結束 - /// - LiveEnd, - /// - /// 其他 - /// - Unknown, - /// - /// 欢迎船员 - /// - WelcomeGuard, - /// - /// 购买船票(上船) - /// - GuardBuy, - /// - /// SuperChat - /// - SuperChat, - /// - /// 房间信息更新 - /// - RoomChange - } - - public class DanmakuModel - { - /// - /// 消息類型 - /// - public MsgTypeEnum MsgType { get; set; } - - /// - /// 房间标题 - /// - public string? Title { get; set; } - - /// - /// 大分区 - /// - public string? ParentAreaName { get; set; } - - /// - /// 子分区 - /// - public string? AreaName { get; set; } - - /// - /// 彈幕內容 - /// 此项有值的消息类型: - /// - /// - /// - public string? CommentText { get; set; } - - /// - /// 消息触发者用户名 - /// 此项有值的消息类型: - /// - /// - /// - /// - /// - /// - /// - public string? UserName { get; set; } - - /// - /// SC 价格 - /// - public double Price { get; set; } - - /// - /// SC 保持时间 - /// - public int SCKeepTime { get; set; } - - /// - /// 消息触发者用户ID - /// 此项有值的消息类型: - /// - /// - /// - /// - /// - /// - /// - public int UserID { get; set; } - - /// - /// 用户舰队等级 - /// 0 为非船员 1 为总督 2 为提督 3 为舰长 - /// 此项有值的消息类型: - /// - /// - /// - /// - /// - public int UserGuardLevel { get; set; } - - /// - /// 禮物名稱 - /// - public string? GiftName { get; set; } - - /// - /// 礼物数量 - /// 此项有值的消息类型: - /// - /// - /// - /// 此字段也用于标识上船 的数量(月数) - /// - public int GiftCount { get; set; } - - /// - /// 该用户是否为房管(包括主播) - /// 此项有值的消息类型: - /// - /// - /// - /// - public bool IsAdmin { get; set; } - - /// - /// 是否VIP用戶(老爺) - /// 此项有值的消息类型: - /// - /// - /// - /// - public bool IsVIP { get; set; } - - /// - /// , 事件对应的房间号 - /// - public string? RoomID { get; set; } - - /// - /// 原始数据, 高级开发用 - /// - public string? RawData { get; set; } - - /// - /// 原始数据, 高级开发用 - /// - public JObject? RawObj { get; set; } - - /// - /// 内部用, JSON数据版本号 通常应该是2 - /// - 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(); - switch (cmd) - { - case "LIVE": - this.MsgType = MsgTypeEnum.LiveStart; - this.RoomID = obj["roomid"].ToObject(); - break; - case "PREPARING": - this.MsgType = MsgTypeEnum.LiveEnd; - this.RoomID = obj["roomid"].ToObject(); - break; - case "DANMU_MSG": - this.MsgType = MsgTypeEnum.Comment; - this.CommentText = obj["info"][1].ToObject(); - this.UserID = obj["info"][2][0].ToObject(); - this.UserName = obj["info"][2][1].ToObject(); - this.IsAdmin = obj["info"][2][2].ToObject() == "1"; - this.IsVIP = obj["info"][2][3].ToObject() == "1"; - this.UserGuardLevel = obj["info"][7].ToObject(); - break; - case "SEND_GIFT": - this.MsgType = MsgTypeEnum.GiftSend; - this.GiftName = obj["data"]["giftName"].ToObject(); - this.UserName = obj["data"]["uname"].ToObject(); - this.UserID = obj["data"]["uid"].ToObject(); - this.GiftCount = obj["data"]["num"].ToObject(); - break; - case "GUARD_BUY": - { - this.MsgType = MsgTypeEnum.GuardBuy; - this.UserID = obj["data"]["uid"].ToObject(); - this.UserName = obj["data"]["username"].ToObject(); - this.UserGuardLevel = obj["data"]["guard_level"].ToObject(); - this.GiftName = this.UserGuardLevel == 3 ? "舰长" : this.UserGuardLevel == 2 ? "提督" : this.UserGuardLevel == 1 ? "总督" : ""; - this.GiftCount = obj["data"]["num"].ToObject(); - break; - } - case "SUPER_CHAT_MESSAGE": - { - this.MsgType = MsgTypeEnum.SuperChat; - this.CommentText = obj["data"]["message"]?.ToString(); - this.UserID = obj["data"]["uid"].ToObject(); - this.UserName = obj["data"]["user_info"]["uname"].ToString(); - this.Price = obj["data"]["price"].ToObject(); - this.SCKeepTime = obj["data"]["time"].ToObject(); - break; - } - case "ROOM_CHANGE": - { - this.MsgType = MsgTypeEnum.RoomChange; - this.Title = obj["data"]?["title"]?.ToObject(); - this.AreaName = obj["data"]?["area_name"]?.ToObject(); - this.ParentAreaName = obj["data"]?["parent_area_name"]?.ToObject(); - break; - } - /* - case "WELCOME": - { - MsgType = MsgTypeEnum.Welcome; - UserName = obj["data"]["uname"].ToObject(); - UserID = obj["data"]["uid"].ToObject(); - IsVIP = true; - IsAdmin = obj["data"]?["is_admin"]?.ToObject() ?? obj["data"]?["isadmin"]?.ToObject() == "1"; - break; - } - case "WELCOME_GUARD": - { - MsgType = MsgTypeEnum.WelcomeGuard; - UserName = obj["data"]["username"].ToObject(); - UserID = obj["data"]["uid"].ToObject(); - UserGuardLevel = obj["data"]["guard_level"].ToObject(); - break; - } - */ - default: - { - this.MsgType = MsgTypeEnum.Unknown; - break; - } - } - } - } -} diff --git a/BililiveRecorder.Core/DependencyInjectionExtensions.cs b/BililiveRecorder.Core/DependencyInjectionExtensions.cs index e65cb1f..3b1c7b3 100644 --- a/BililiveRecorder.Core/DependencyInjectionExtensions.cs +++ b/BililiveRecorder.Core/DependencyInjectionExtensions.cs @@ -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().Global) + ; + + public static IServiceCollection AddRecorder(this IServiceCollection services) => services + .AddSingleton() + .AddRecorderPollyPolicy() + .AddRecorderApiClients() + .AddRecorderRecording() + .AddSingleton() + .AddSingleton() + .AddScoped() + .AddScoped() + ; + + private static IServiceCollection AddRecorderPollyPolicy(this IServiceCollection services) { - services.AddSingleton(); -#pragma warning disable IDE0001 - services.AddSingleton(x => x.GetRequiredService().Config); - services.AddSingleton(x => x.GetRequiredService().Global); -#pragma warning restore IDE0001 - services.AddSingleton(); - services.AddSingleton(); + var registry = new PolicyRegistry // TODO + { + [PolicyNames.PolicyRoomInfoApiRequestAsync] = Policy.NoOpAsync(), + [PolicyNames.PolicyDanmakuApiRequestAsync] = Policy.NoOpAsync(), + [PolicyNames.PolicyStreamApiRequestAsync] = Policy.NoOpAsync(), + }; + + return services.AddSingleton>(registry); } + + public static IServiceCollection AddRecorderApiClients(this IServiceCollection services) => services + .AddSingleton() + .AddSingleton>() + .AddSingleton(sp => sp.GetRequiredService>()) + .AddSingleton(sp => sp.GetRequiredService>()) + .AddScoped() + ; + + public static IServiceCollection AddRecorderRecording(this IServiceCollection services) => services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + ; } } diff --git a/BililiveRecorder.Core/Event/AggregatedRoomEventArgs.cs b/BililiveRecorder.Core/Event/AggregatedRoomEventArgs.cs new file mode 100644 index 0000000..4f69277 --- /dev/null +++ b/BililiveRecorder.Core/Event/AggregatedRoomEventArgs.cs @@ -0,0 +1,17 @@ +using System; + +namespace BililiveRecorder.Core.Event +{ + public class AggregatedRoomEventArgs + { + 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; } + } +} diff --git a/BililiveRecorder.Core/Event/NetworkingStatsEventArgs.cs b/BililiveRecorder.Core/Event/NetworkingStatsEventArgs.cs new file mode 100644 index 0000000..3b93471 --- /dev/null +++ b/BililiveRecorder.Core/Event/NetworkingStatsEventArgs.cs @@ -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; } + } +} diff --git a/BililiveRecorder.Core/Event/RecordEventArgsBase.cs b/BililiveRecorder.Core/Event/RecordEventArgsBase.cs new file mode 100644 index 0000000..ddc8c71 --- /dev/null +++ b/BililiveRecorder.Core/Event/RecordEventArgsBase.cs @@ -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; + } +} diff --git a/BililiveRecorder.Core/Event/RecordFileClosedEventArgs.cs b/BililiveRecorder.Core/Event/RecordFileClosedEventArgs.cs new file mode 100644 index 0000000..550fab2 --- /dev/null +++ b/BililiveRecorder.Core/Event/RecordFileClosedEventArgs.cs @@ -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; } + } +} diff --git a/BililiveRecorder.Core/Event/RecordFileOpeningEventArgs.cs b/BililiveRecorder.Core/Event/RecordFileOpeningEventArgs.cs new file mode 100644 index 0000000..9af9076 --- /dev/null +++ b/BililiveRecorder.Core/Event/RecordFileOpeningEventArgs.cs @@ -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; } + } +} diff --git a/BililiveRecorder.Core/Event/RecordSessionEndedEventArgs.cs b/BililiveRecorder.Core/Event/RecordSessionEndedEventArgs.cs new file mode 100644 index 0000000..fcc1ade --- /dev/null +++ b/BililiveRecorder.Core/Event/RecordSessionEndedEventArgs.cs @@ -0,0 +1,9 @@ +namespace BililiveRecorder.Core.Event +{ + public class RecordSessionEndedEventArgs : RecordEventArgsBase + { + public RecordSessionEndedEventArgs() { } + + public RecordSessionEndedEventArgs(IRoom room) : base(room) { } + } +} diff --git a/BililiveRecorder.Core/Event/RecordSessionStartedEventArgs.cs b/BililiveRecorder.Core/Event/RecordSessionStartedEventArgs.cs new file mode 100644 index 0000000..16cb663 --- /dev/null +++ b/BililiveRecorder.Core/Event/RecordSessionStartedEventArgs.cs @@ -0,0 +1,9 @@ +namespace BililiveRecorder.Core.Event +{ + public class RecordSessionStartedEventArgs : RecordEventArgsBase + { + public RecordSessionStartedEventArgs() { } + + public RecordSessionStartedEventArgs(IRoom room) : base(room) { } + } +} diff --git a/BililiveRecorder.Core/Event/RecordingStatsEventArgs.cs b/BililiveRecorder.Core/Event/RecordingStatsEventArgs.cs new file mode 100644 index 0000000..5ed691d --- /dev/null +++ b/BililiveRecorder.Core/Event/RecordingStatsEventArgs.cs @@ -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; } + } +} diff --git a/BililiveRecorder.Core/Events.cs b/BililiveRecorder.Core/Events.cs deleted file mode 100644 index 07db3d5..0000000 --- a/BililiveRecorder.Core/Events.cs +++ /dev/null @@ -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; - } -} diff --git a/BililiveRecorder.Core/IRecordedRoom.cs b/BililiveRecorder.Core/IRecordedRoom.cs deleted file mode 100644 index 4343c77..0000000 --- a/BililiveRecorder.Core/IRecordedRoom.cs +++ /dev/null @@ -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? 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(); - } -} diff --git a/BililiveRecorder.Core/IRecordedRoomFactory.cs b/BililiveRecorder.Core/IRecordedRoomFactory.cs deleted file mode 100644 index ebb677d..0000000 --- a/BililiveRecorder.Core/IRecordedRoomFactory.cs +++ /dev/null @@ -1,9 +0,0 @@ -using BililiveRecorder.Core.Config.V2; - -namespace BililiveRecorder.Core -{ - public interface IRecordedRoomFactory - { - IRecordedRoom CreateRecordedRoom(RoomConfig roomConfig); - } -} diff --git a/BililiveRecorder.Core/IRecorder.cs b/BililiveRecorder.Core/IRecorder.cs index 74f8b7e..b451424 100644 --- a/BililiveRecorder.Core/IRecorder.cs +++ b/BililiveRecorder.Core/IRecorder.cs @@ -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, ICollection, IDisposable + public interface IRecorder : INotifyPropertyChanged, IDisposable { - ConfigV2? Config { get; } + ConfigV2 Config { get; } + ReadOnlyObservableCollection Rooms { get; } - bool Initialize(string workdir); + event EventHandler>? RecordSessionStarted; + event EventHandler>? RecordSessionEnded; + event EventHandler>? RecordFileOpening; + event EventHandler>? RecordFileClosed; + event EventHandler>? NetworkingStats; + event EventHandler>? 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(); } } diff --git a/BililiveRecorder.Core/IRoom.cs b/BililiveRecorder.Core/IRoom.cs new file mode 100644 index 0000000..6da6cbc --- /dev/null +++ b/BililiveRecorder.Core/IRoom.cs @@ -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? RecordSessionStarted; + event EventHandler? RecordSessionEnded; + event EventHandler? RecordFileOpening; + event EventHandler
本文件的弹幕信息兼容B站主站视频弹幕XML格式,可以使用现有的转换工具把文件中的弹幕转为ass字幕文件