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站录播姬弹幕文件 - <z:value-of select=""/i/BililiveRecorderRecordInfo/@name""/>

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? RecordFileClosed; + event EventHandler? RecordingStats; + event EventHandler? NetworkingStats; + + void StartRecord(); + void StopRecord(); + void SplitOutput(); + Task RefreshRoomInfoAsync(); + } +} diff --git a/BililiveRecorder.Core/IRoomFactory.cs b/BililiveRecorder.Core/IRoomFactory.cs new file mode 100644 index 0000000..708c2e1 --- /dev/null +++ b/BililiveRecorder.Core/IRoomFactory.cs @@ -0,0 +1,9 @@ +using BililiveRecorder.Core.Config.V2; + +namespace BililiveRecorder.Core +{ + public interface IRoomFactory + { + IRoom CreateRoom(RoomConfig roomConfig); + } +} diff --git a/BililiveRecorder.Core/IStreamMonitor.cs b/BililiveRecorder.Core/IStreamMonitor.cs deleted file mode 100644 index 5e26dfb..0000000 --- a/BililiveRecorder.Core/IStreamMonitor.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.ComponentModel; -using System.Threading.Tasks; - -namespace BililiveRecorder.Core -{ - public interface IStreamMonitor : IDisposable, INotifyPropertyChanged - { - bool IsMonitoring { get; } - bool IsDanmakuConnected { get; } - event RoomInfoUpdatedEvent RoomInfoUpdated; - event StreamStartedEvent StreamStarted; - event ReceivedDanmakuEvt ReceivedDanmaku; - - bool Start(); - void Stop(); - void Check(TriggerType type, int millisecondsDelay = 0); - Task FetchRoomInfoAsync(); - } -} diff --git a/BililiveRecorder.Core/LoggingContext.cs b/BililiveRecorder.Core/LoggingContext.cs new file mode 100644 index 0000000..ae86e8c --- /dev/null +++ b/BililiveRecorder.Core/LoggingContext.cs @@ -0,0 +1,7 @@ +namespace BililiveRecorder.Core +{ + public class LoggingContext + { + public const string RoomId = nameof(RoomId); + } +} diff --git a/BililiveRecorder.Core/PolicyNames.cs b/BililiveRecorder.Core/PolicyNames.cs new file mode 100644 index 0000000..7d32322 --- /dev/null +++ b/BililiveRecorder.Core/PolicyNames.cs @@ -0,0 +1,14 @@ +namespace BililiveRecorder.Core +{ + internal static class PolicyNames + { + internal const string PolicyRoomInfoApiRequestAsync = nameof(PolicyRoomInfoApiRequestAsync); + internal const string PolicyDanmakuApiRequestAsync = nameof(PolicyDanmakuApiRequestAsync); + internal const string PolicyStreamApiRequestAsync = nameof(PolicyStreamApiRequestAsync); + + internal const string CacheKeyUserInfo = nameof(CacheKeyUserInfo); + internal const string CacheKeyRoomInfo = nameof(CacheKeyRoomInfo); + internal const string CacheKeyDanmaku = nameof(CacheKeyDanmaku); + internal const string CacheKeyStream = nameof(CacheKeyStream); + } +} diff --git a/BililiveRecorder.Core/ProcessingRules/SplitRule.cs b/BililiveRecorder.Core/ProcessingRules/SplitRule.cs new file mode 100644 index 0000000..e730e4b --- /dev/null +++ b/BililiveRecorder.Core/ProcessingRules/SplitRule.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using BililiveRecorder.Flv.Pipeline; + +namespace BililiveRecorder.Core.ProcessingRules +{ + public class SplitRule : IFullProcessingRule + { + private static readonly FlvProcessingContext NewFileContext = new FlvProcessingContext(PipelineNewFileAction.Instance, new Dictionary()); + + // 0 = false, 1 = true + private int splitFlag = 0; + + public async Task RunAsync(FlvProcessingContext context, ProcessingDelegate next) + { + await next(context).ConfigureAwait(false); + + if (1 == Interlocked.Exchange(ref this.splitFlag, 0)) + { + await next(NewFileContext).ConfigureAwait(false); + context.AddNewFileAtStart(); + } + } + + public void SetSplitFlag() => Interlocked.Exchange(ref this.splitFlag, 1); + } +} diff --git a/BililiveRecorder.Core/ProcessingRules/StatsRule.cs b/BililiveRecorder.Core/ProcessingRules/StatsRule.cs new file mode 100644 index 0000000..ad5003c --- /dev/null +++ b/BililiveRecorder.Core/ProcessingRules/StatsRule.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BililiveRecorder.Core.Event; +using BililiveRecorder.Flv; +using BililiveRecorder.Flv.Pipeline; + +namespace BililiveRecorder.Core.ProcessingRules +{ + public class StatsRule : ISimpleProcessingRule + { + public event EventHandler? StatsUpdated; + + public long TotalInputVideoByteCount { get; private set; } + public long TotalInputAudioByteCount { get; private set; } + // 两个值相加可得出总数据量 + + public int TotalOutputVideoFrameCount { get; private set; } + public int TotalOutputAudioFrameCount { get; private set; } + public long TotalOutputVideoByteCount { get; private set; } + public long TotalOutputAudioByteCount { get; private set; } + + public int SumOfMaxTimestampOfClosedFiles { get; private set; } + public int CurrentFileMaxTimestamp { get; private set; } + + public DateTimeOffset LastWriteTime { get; private set; } + + public async Task RunAsync(FlvProcessingContext context, Func next) + { + var e = new RecordingStatsEventArgs(); + + if (context.OriginalInput is PipelineDataAction data) + { + e.TotalInputVideoByteCount = this.TotalInputVideoByteCount += e.InputVideoByteCount = data.Tags.Where(x => x.Type == TagType.Video).Sum(x => x.Size + (11 + 4)); + e.TotalInputAudioByteCount = this.TotalInputAudioByteCount += e.InputAudioByteCount = data.Tags.Where(x => x.Type == TagType.Audio).Sum(x => x.Size + (11 + 4)); + } + + await next().ConfigureAwait(false); + + var groups = new List?>(); + { + List? curr = null; + foreach (var action in context.Output) + { + if (action is PipelineDataAction dataAction) + { + if (curr is null) + { + curr = new List(); + groups.Add(curr); + } + curr.Add(dataAction); + } + else if (action is PipelineNewFileAction) + { + curr = null; + groups.Add(null); + } + } + } + + var maxTimestampBeforeCalc = this.CurrentFileMaxTimestamp; + + foreach (var item in groups) + { + if (item is null) + NewFile(); + else + CalcStats(e, item); + } + + e.AddedDuration = (this.CurrentFileMaxTimestamp - maxTimestampBeforeCalc) / 1000d; + var now = DateTimeOffset.UtcNow; + e.PassedTime = (now - this.LastWriteTime).TotalSeconds; + this.LastWriteTime = now; + e.DuraionRatio = e.AddedDuration / e.PassedTime; + + StatsUpdated?.Invoke(this, e); + + return; + void CalcStats(RecordingStatsEventArgs e, IReadOnlyList dataActions) + { + if (dataActions.Count > 0) + { + e.TotalOutputVideoFrameCount = this.TotalOutputVideoFrameCount += e.OutputVideoFrameCount = dataActions.Sum(x => x.Tags.Count(x => x.Type == TagType.Video)); + e.TotalOutputAudioFrameCount = this.TotalOutputAudioFrameCount += e.OutputAudioFrameCount = dataActions.Sum(x => x.Tags.Count(x => x.Type == TagType.Audio)); + e.TotalOutputVideoByteCount = this.TotalOutputVideoByteCount += e.OutputVideoByteCount = dataActions.Sum(x => x.Tags.Where(x => x.Type == TagType.Video).Sum(x => (x.Nalus == null ? x.Size : (5 + x.Nalus.Sum(n => n.FullSize + 4))) + (11 + 4))); + e.TotalOutputAudioByteCount = this.TotalOutputAudioByteCount += e.OutputAudioByteCount = dataActions.Sum(x => x.Tags.Where(x => x.Type == TagType.Audio).Sum(x => x.Size + (11 + 4))); + + var lastTags = dataActions[dataActions.Count - 1].Tags; + if (lastTags.Count > 0) + this.CurrentFileMaxTimestamp = e.FileMaxTimestamp = lastTags[lastTags.Count - 1].Timestamp; + + } + + e.SessionMaxTimestamp = this.SumOfMaxTimestampOfClosedFiles + this.CurrentFileMaxTimestamp; + } + + void NewFile() + { + this.SumOfMaxTimestampOfClosedFiles += this.CurrentFileMaxTimestamp; + this.CurrentFileMaxTimestamp = 0; + } + } + } +} diff --git a/BililiveRecorder.Core/README.md b/BililiveRecorder.Core/README.md deleted file mode 100644 index f0bd304..0000000 --- a/BililiveRecorder.Core/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# BililiveRecorder.Core - -TODO diff --git a/BililiveRecorder.Core/RecordedRoom.cs b/BililiveRecorder.Core/RecordedRoom.cs deleted file mode 100644 index 8f2f603..0000000 --- a/BililiveRecorder.Core/RecordedRoom.cs +++ /dev/null @@ -1,666 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading; -using System.Threading.Tasks; -using BililiveRecorder.Core.Callback; -using BililiveRecorder.Core.Config.V2; -using BililiveRecorder.FlvProcessor; -using NLog; - -namespace BililiveRecorder.Core -{ - public class RecordedRoom : IRecordedRoom - { - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); - private static readonly Random random = new Random(); - private static readonly Version VERSION_1_0 = new Version(1, 0); - -#nullable enable - - private int _shortRoomid; - private string _streamerName; - private string _title; - private string _parentAreaName = string.Empty; - private string _areaName = string.Empty; - private bool _isStreaming; - - public int ShortRoomId - { - get => this._shortRoomid; - private set - { - if (value == this._shortRoomid) { return; } - this._shortRoomid = value; - this.TriggerPropertyChanged(nameof(this.ShortRoomId)); - } - } - public int RoomId - { - get => this.RoomConfig.RoomId; - private set - { - if (value == this.RoomConfig.RoomId) { return; } - this.RoomConfig.RoomId = value; - this.TriggerPropertyChanged(nameof(this.RoomId)); - } - } - public string StreamerName - { - get => this._streamerName; - private set - { - if (value == this._streamerName) { return; } - this._streamerName = value; - this.TriggerPropertyChanged(nameof(this.StreamerName)); - } - } - public string Title - { - get => this._title; - private set - { - if (value == this._title) { return; } - this._title = value; - this.TriggerPropertyChanged(nameof(this.Title)); - } - } - public string ParentAreaName - { - get => this._parentAreaName; - private set - { - if (value == this._parentAreaName) { return; } - this._parentAreaName = value; - this.TriggerPropertyChanged(nameof(this.ParentAreaName)); - } - } - public string AreaName - { - get => this._areaName; - private set - { - if (value == this._areaName) { return; } - this._areaName = value; - this.TriggerPropertyChanged(nameof(this.AreaName)); - } - } - - public bool IsMonitoring => this.StreamMonitor.IsMonitoring; - public bool IsRecording => !(this.StreamDownloadTask?.IsCompleted ?? true); - public bool IsDanmakuConnected => this.StreamMonitor.IsDanmakuConnected; - public bool IsStreaming - { - get => this._isStreaming; - private set - { - if (value == this._isStreaming) { return; } - this._isStreaming = value; - this.TriggerPropertyChanged(nameof(this.IsStreaming)); - } - } - - public RoomConfig RoomConfig { get; } - -#nullable restore - - private RecordEndData recordEndData; - public event EventHandler RecordEnded; - - private readonly IBasicDanmakuWriter basicDanmakuWriter; - private readonly IProcessorFactory processorFactory; - private IFlvStreamProcessor _processor; - public IFlvStreamProcessor Processor - { - get => this._processor; - private set - { - if (value == this._processor) { return; } - this._processor = value; - this.TriggerPropertyChanged(nameof(this.Processor)); - } - } - - private BililiveAPI BililiveAPI { get; } - public IStreamMonitor StreamMonitor { get; } - - private bool _retry = true; - private HttpResponseMessage _response; - private Stream _stream; - private Task StartupTask = null; - private readonly object StartupTaskLock = new object(); - public Task StreamDownloadTask = null; - public CancellationTokenSource cancellationTokenSource = null; - - private double _DownloadSpeedPersentage = 0; - private double _DownloadSpeedMegaBitps = 0; - private long _lastUpdateSize = 0; - private int _lastUpdateTimestamp = 0; - public DateTime LastUpdateDateTime { get; private set; } = DateTime.Now; - public double DownloadSpeedPersentage - { - get { return this._DownloadSpeedPersentage; } - private set { if (value != this._DownloadSpeedPersentage) { this._DownloadSpeedPersentage = value; this.TriggerPropertyChanged(nameof(this.DownloadSpeedPersentage)); } } - } - public double DownloadSpeedMegaBitps - { - get { return this._DownloadSpeedMegaBitps; } - private set { if (value != this._DownloadSpeedMegaBitps) { this._DownloadSpeedMegaBitps = value; this.TriggerPropertyChanged(nameof(this.DownloadSpeedMegaBitps)); } } - } - - public Guid Guid { get; } = Guid.NewGuid(); - - // TODO: 重构 DI - public RecordedRoom(IBasicDanmakuWriter basicDanmakuWriter, - IStreamMonitor streamMonitor, - IProcessorFactory processorFactory, - BililiveAPI bililiveAPI, - RoomConfig roomConfig) - { - this.RoomConfig = roomConfig; - this.StreamerName = "获取中..."; - - this.BililiveAPI = bililiveAPI; - - this.processorFactory = processorFactory; - - this.basicDanmakuWriter = basicDanmakuWriter; - - this.StreamMonitor = streamMonitor; - this.StreamMonitor.RoomInfoUpdated += this.StreamMonitor_RoomInfoUpdated; - this.StreamMonitor.StreamStarted += this.StreamMonitor_StreamStarted; - this.StreamMonitor.ReceivedDanmaku += this.StreamMonitor_ReceivedDanmaku; - this.StreamMonitor.PropertyChanged += this.StreamMonitor_PropertyChanged; - - this.PropertyChanged += this.RecordedRoom_PropertyChanged; - - this.StreamMonitor.FetchRoomInfoAsync(); - - if (this.RoomConfig.AutoRecord) - this.Start(); - } - - private void RecordedRoom_PropertyChanged(object sender, PropertyChangedEventArgs e) - { - switch (e.PropertyName) - { - case nameof(this.IsMonitoring): - this.RoomConfig.AutoRecord = this.IsMonitoring; - break; - default: - break; - } - } - - private void StreamMonitor_PropertyChanged(object sender, PropertyChangedEventArgs e) - { - switch (e.PropertyName) - { - case nameof(IStreamMonitor.IsDanmakuConnected): - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected))); - break; - default: - break; - } - } - - private void StreamMonitor_ReceivedDanmaku(object sender, ReceivedDanmakuArgs e) - { - switch (e.Danmaku.MsgType) - { - case MsgTypeEnum.LiveStart: - this.IsStreaming = true; - break; - case MsgTypeEnum.LiveEnd: - this.IsStreaming = false; - break; - case MsgTypeEnum.RoomChange: - this.Title = e.Danmaku.Title ?? string.Empty; - this.ParentAreaName = e.Danmaku.ParentAreaName ?? string.Empty; - this.AreaName = e.Danmaku.AreaName ?? string.Empty; - break; - default: - break; - } - this.basicDanmakuWriter.Write(e.Danmaku); - } - - private void StreamMonitor_RoomInfoUpdated(object sender, RoomInfoUpdatedArgs e) - { - // TODO: StreamMonitor 里的 RoomInfoUpdated Handler 也会设置一次 RoomId - // 暂时保持不变,此处的 RoomId 需要触发 PropertyChanged 事件 - this.RoomId = e.RoomInfo.RoomId; - this.ShortRoomId = e.RoomInfo.ShortRoomId; - this.IsStreaming = e.RoomInfo.IsStreaming; - this.StreamerName = e.RoomInfo.UserName; - this.Title = e.RoomInfo.Title; - this.ParentAreaName = e.RoomInfo.ParentAreaName; - this.AreaName = e.RoomInfo.AreaName; - } - - public bool Start() - { - // TODO: 重构: 删除 Start() Stop() 通过 RoomConfig.AutoRecord 控制监控状态和逻辑 - if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom)); - - var r = this.StreamMonitor.Start(); - this.TriggerPropertyChanged(nameof(this.IsMonitoring)); - return r; - } - - public void Stop() - { - // TODO: 见 Start() - if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom)); - - this.StreamMonitor.Stop(); - this.TriggerPropertyChanged(nameof(this.IsMonitoring)); - } - - public void RefreshRoomInfo() - { - if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom)); - this.StreamMonitor.FetchRoomInfoAsync(); - } - - private void StreamMonitor_StreamStarted(object sender, StreamStartedArgs e) - { - lock (this.StartupTaskLock) - if (!this.IsRecording && (this.StartupTask?.IsCompleted ?? true)) - this.StartupTask = this._StartRecordAsync(); - } - - public void StartRecord() - { - if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom)); - this.StreamMonitor.Check(TriggerType.Manual); - } - - public void StopRecord() - { - if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom)); - - this._retry = false; - try - { - if (this.cancellationTokenSource != null) - { - this.cancellationTokenSource.Cancel(); - if (!(this.StreamDownloadTask?.Wait(TimeSpan.FromSeconds(2)) ?? true)) - { - logger.Log(this.RoomId, LogLevel.Warn, "停止录制超时,尝试强制关闭连接,请检查网络连接是否稳定"); - - this._stream?.Close(); - this._stream?.Dispose(); - this._response?.Dispose(); - this.StreamDownloadTask?.Wait(); - } - } - } - catch (Exception ex) - { - logger.Log(this.RoomId, LogLevel.Warn, "在尝试停止录制时发生错误,请检查网络连接是否稳定", ex); - } - finally - { - this._retry = true; - } - } - - private async Task _StartRecordAsync() - { - if (this.IsRecording) - { - // TODO: 这里逻辑可能有问题,StartupTask 会变成当前这个已经结束的 - logger.Log(this.RoomId, LogLevel.Warn, "已经在录制中了"); - return; - } - - this.cancellationTokenSource = new CancellationTokenSource(); - var token = this.cancellationTokenSource.Token; - try - { - var flv_path = await this.BililiveAPI.GetPlayUrlAsync(this.RoomId); - if (string.IsNullOrWhiteSpace(flv_path)) - { - if (this._retry) - { - this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry); - } - return; - } - - unwrap_redir: - - using (var client = new HttpClient(new HttpClientHandler - { - AllowAutoRedirect = false, - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, - })) - { - - client.Timeout = TimeSpan.FromMilliseconds(this.RoomConfig.TimingStreamConnect); - client.DefaultRequestHeaders.Accept.Clear(); - client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*")); - client.DefaultRequestHeaders.UserAgent.Clear(); - client.DefaultRequestHeaders.UserAgent.ParseAdd(Utils.UserAgent); - client.DefaultRequestHeaders.Referrer = new Uri("https://live.bilibili.com"); - client.DefaultRequestHeaders.Add("Origin", "https://live.bilibili.com"); - - - logger.Log(this.RoomId, LogLevel.Info, "连接直播服务器 " + new Uri(flv_path).Host); - logger.Log(this.RoomId, LogLevel.Debug, "直播流地址: " + flv_path); - - this._response = await client.GetAsync(flv_path, HttpCompletionOption.ResponseHeadersRead); - } - - if (this._response.StatusCode == HttpStatusCode.Redirect || this._response.StatusCode == HttpStatusCode.Moved) - { - // workaround for missing Referrer - flv_path = this._response.Headers.Location.OriginalString; - this._response.Dispose(); - goto unwrap_redir; - } - else if (this._response.StatusCode != HttpStatusCode.OK) - { - logger.Log(this.RoomId, LogLevel.Info, string.Format("尝试下载直播流时服务器返回了 ({0}){1}", this._response.StatusCode, this._response.ReasonPhrase)); - - this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry); - - _CleanupFlvRequest(); - return; - } - else - { - this.Processor = this.processorFactory.CreateStreamProcessor().Initialize(this.GetStreamFilePath, this.GetClipFilePath, this.RoomConfig.EnabledFeature, this.RoomConfig.CuttingMode); - this.Processor.ClipLengthFuture = this.RoomConfig.ClipLengthFuture; - this.Processor.ClipLengthPast = this.RoomConfig.ClipLengthPast; - this.Processor.CuttingNumber = this.RoomConfig.CuttingNumber; - this.Processor.StreamFinalized += (sender, e) => { this.basicDanmakuWriter.Disable(); }; - this.Processor.FileFinalized += (sender, size) => - { - if (this.recordEndData is null) return; - var data = this.recordEndData; - this.recordEndData = null; - - data.EndRecordTime = DateTimeOffset.Now; - data.FileSize = size; - RecordEnded?.Invoke(this, data); - }; - this.Processor.OnMetaData += (sender, e) => - { - e.Metadata["BililiveRecorder"] = new Dictionary() - { - { - "starttime", - DateTime.UtcNow - }, - { - "version", - BuildInfo.Version + " " + BuildInfo.HeadShaShort - }, - { - "roomid", - this.RoomId.ToString() - }, - { - "streamername", - this.StreamerName - }, - }; - }; - - this._stream = await this._response.Content.ReadAsStreamAsync(); - - try - { - if (this._response.Headers.ConnectionClose == false || (this._response.Headers.ConnectionClose is null && this._response.Version != VERSION_1_0)) - this._stream.ReadTimeout = 3 * 1000; - } - catch (InvalidOperationException) { } - - this.StreamDownloadTask = Task.Run(_ReadStreamLoop); - this.TriggerPropertyChanged(nameof(this.IsRecording)); - } - } - catch (TaskCanceledException) - { - // client.GetAsync timed out - // useless exception message :/ - - _CleanupFlvRequest(); - logger.Log(this.RoomId, LogLevel.Warn, "连接直播服务器超时。"); - this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry); - } - catch (Exception ex) - { - _CleanupFlvRequest(); - logger.Log(this.RoomId, LogLevel.Error, "启动直播流下载出错。" + (this._retry ? "将重试启动。" : ""), ex); - if (this._retry) - { - this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry); - } - } - return; - - async Task _ReadStreamLoop() - { - try - { - const int BUF_SIZE = 1024 * 8; - byte[] buffer = new byte[BUF_SIZE]; - while (!token.IsCancellationRequested) - { - int bytesRead = await this._stream.ReadAsync(buffer, 0, BUF_SIZE, token); - _UpdateDownloadSpeed(bytesRead); - if (bytesRead != 0) - { - if (bytesRead != BUF_SIZE) - { - this.Processor.AddBytes(buffer.Take(bytesRead).ToArray()); - } - else - { - this.Processor.AddBytes(buffer); - } - } - else - { - break; - } - } - - logger.Log(this.RoomId, LogLevel.Info, - (token.IsCancellationRequested ? "本地操作结束当前录制。" : "服务器关闭直播流,可能是直播已结束。") - + (this._retry ? "将重试启动。" : "")); - if (this._retry) - { - this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry); - } - } - catch (Exception e) - { - if (e is ObjectDisposedException && token.IsCancellationRequested) { return; } - - logger.Log(this.RoomId, LogLevel.Warn, "录播发生错误", e); - } - finally - { - _CleanupFlvRequest(); - } - } - void _CleanupFlvRequest() - { - if (this.Processor != null) - { - this.Processor.FinallizeFile(); - this.Processor.Dispose(); - this.Processor = null; - } - this._stream?.Dispose(); - this._stream = null; - this._response?.Dispose(); - this._response = null; - - this._lastUpdateTimestamp = 0; - this.DownloadSpeedMegaBitps = 0d; - this.DownloadSpeedPersentage = 0d; - this.TriggerPropertyChanged(nameof(this.IsRecording)); - } - void _UpdateDownloadSpeed(int bytesRead) - { - DateTime now = DateTime.Now; - double passedSeconds = (now - this.LastUpdateDateTime).TotalSeconds; - this._lastUpdateSize += bytesRead; - if (passedSeconds > 1.5) - { - this.DownloadSpeedMegaBitps = this._lastUpdateSize / passedSeconds * 8d / 1_000_000d; // mega bit per second - this.DownloadSpeedPersentage = (this.DownloadSpeedPersentage / 2) + ((this.Processor.TotalMaxTimestamp - this._lastUpdateTimestamp) / passedSeconds / 1000 / 2); // ((RecordedTime/1000) / RealTime)% - this._lastUpdateTimestamp = this.Processor.TotalMaxTimestamp; - this._lastUpdateSize = 0; - this.LastUpdateDateTime = now; - } - } - } - - // Called by API or GUI - public void Clip() => this.Processor?.Clip(); - - public void Shutdown() => this.Dispose(true); - - private (string fullPath, string relativePath) GetStreamFilePath() - { - var path = this.FormatFilename(this.RoomConfig.RecordFilenameFormat); - - // 有点脏的写法,不过凑合吧 - if (this.RoomConfig.RecordDanmaku) - { - var xmlpath = Path.ChangeExtension(path.fullPath, "xml"); - this.basicDanmakuWriter.EnableWithPath(xmlpath, this); - } - - this.recordEndData = new RecordEndData - { - RoomId = RoomId, - Title = Title, - Name = StreamerName, - StartRecordTime = DateTimeOffset.Now, - RelativePath = path.relativePath, - }; - - return path; - } - - private string GetClipFilePath() => this.FormatFilename(this.RoomConfig.ClipFilenameFormat).fullPath; - - private (string fullPath, string relativePath) FormatFilename(string formatString) - { - var now = DateTime.Now; - var date = now.ToString("yyyyMMdd"); - var time = now.ToString("HHmmss"); - var randomStr = random.Next(100, 999).ToString(); - - var relativePath = formatString - .Replace(@"{date}", date) - .Replace(@"{time}", time) - .Replace(@"{random}", randomStr) - .Replace(@"{roomid}", this.RoomId.ToString()) - .Replace(@"{title}", this.Title.RemoveInvalidFileName()) - .Replace(@"{name}", this.StreamerName.RemoveInvalidFileName()) - .Replace(@"{parea}", this.ParentAreaName.RemoveInvalidFileName()) - .Replace(@"{area}", this.AreaName.RemoveInvalidFileName()) - ; - - if (!relativePath.EndsWith(".flv", StringComparison.OrdinalIgnoreCase)) - relativePath += ".flv"; - - relativePath = relativePath.RemoveInvalidFileName(ignore_slash: true); - var workDirectory = this.RoomConfig.WorkDirectory; - var fullPath = Path.Combine(workDirectory, relativePath); - fullPath = Path.GetFullPath(fullPath); - - if (!CheckPath(workDirectory, Path.GetDirectoryName(fullPath))) - { - logger.Log(this.RoomId, LogLevel.Warn, "录制文件位置超出允许范围,请检查设置。将写入到默认路径。"); - relativePath = Path.Combine(this.RoomId.ToString(), $"{this.RoomId}-{date}-{time}-{randomStr}.flv"); - fullPath = Path.Combine(workDirectory, relativePath); - } - - if (new FileInfo(relativePath).Exists) - { - logger.Log(this.RoomId, LogLevel.Warn, "录制文件名冲突,请检查设置。将写入到默认路径。"); - relativePath = Path.Combine(this.RoomId.ToString(), $"{this.RoomId}-{date}-{time}-{randomStr}.flv"); - fullPath = Path.Combine(workDirectory, relativePath); - } - - return (fullPath, relativePath); - } - - private static bool CheckPath(string parent, string child) - { - DirectoryInfo di_p = new DirectoryInfo(parent); - DirectoryInfo di_c = new DirectoryInfo(child); - - if (di_c.FullName == di_p.FullName) - return true; - - bool isParent = false; - while (di_c.Parent != null) - { - if (di_c.Parent.FullName == di_p.FullName) - { - isParent = true; - break; - } - else - di_c = di_c.Parent; - } - return isParent; - } - - public event PropertyChangedEventHandler PropertyChanged; - protected void TriggerPropertyChanged(string propertyName) - => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - - #region IDisposable Support - private bool disposedValue = false; // 要检测冗余调用 - - protected virtual void Dispose(bool disposing) - { - if (!this.disposedValue) - { - if (disposing) - { - this.Stop(); - this.StopRecord(); - this.Processor?.FinallizeFile(); - this.Processor?.Dispose(); - this.StreamMonitor?.Dispose(); - this._response?.Dispose(); - this._stream?.Dispose(); - this.cancellationTokenSource?.Dispose(); - this.basicDanmakuWriter?.Dispose(); - } - - this.Processor = null; - this._response = null; - this._stream = null; - this.cancellationTokenSource = null; - - this.disposedValue = true; - } - } - - public void Dispose() - { - // 请勿更改此代码。将清理代码放入以上 Dispose(bool disposing) 中。 - this.Dispose(true); - } - #endregion - } -} diff --git a/BililiveRecorder.Core/RecordedRoomFactory.cs b/BililiveRecorder.Core/RecordedRoomFactory.cs deleted file mode 100644 index 547f72a..0000000 --- a/BililiveRecorder.Core/RecordedRoomFactory.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using BililiveRecorder.Core.Config.V2; -using BililiveRecorder.FlvProcessor; - -namespace BililiveRecorder.Core -{ - public class RecordedRoomFactory : IRecordedRoomFactory - { - private readonly IProcessorFactory processorFactory; - private readonly BililiveAPI bililiveAPI; - - public RecordedRoomFactory(IProcessorFactory processorFactory, BililiveAPI bililiveAPI) - { - this.processorFactory = processorFactory ?? throw new ArgumentNullException(nameof(processorFactory)); - this.bililiveAPI = bililiveAPI ?? throw new ArgumentNullException(nameof(bililiveAPI)); - } - - public IRecordedRoom CreateRecordedRoom(RoomConfig roomConfig) - { - var basicDanmakuWriter = new BasicDanmakuWriter(roomConfig); - var streamMonitor = new StreamMonitor(roomConfig, this.bililiveAPI); - return new RecordedRoom(basicDanmakuWriter, streamMonitor, this.processorFactory, this.bililiveAPI, roomConfig); - } - } -} diff --git a/BililiveRecorder.Core/Recorder.cs b/BililiveRecorder.Core/Recorder.cs index 8bc1a5a..4b590c0 100644 --- a/BililiveRecorder.Core/Recorder.cs +++ b/BililiveRecorder.Core/Recorder.cs @@ -1,240 +1,167 @@ using System; -using System.Collections; -using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Collections.Specialized; using System.ComponentModel; using System.Linq; -using System.Threading; -using BililiveRecorder.Core.Callback; +using System.Threading.Tasks; using BililiveRecorder.Core.Config; using BililiveRecorder.Core.Config.V2; -using Microsoft.Extensions.DependencyInjection; -using NLog; +using BililiveRecorder.Core.Event; +using BililiveRecorder.Core.SimpleWebhook; -#nullable enable namespace BililiveRecorder.Core { public class Recorder : IRecorder { - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); + private readonly object lockObject = new object(); + private readonly ObservableCollection roomCollection; + private readonly IRoomFactory roomFactory; + private readonly BasicWebhookV1 basicWebhookV1; + private readonly BasicWebhookV2 basicWebhookV2; - private readonly CancellationTokenSource tokenSource; - private readonly IServiceProvider serviceProvider; - private IRecordedRoomFactory? recordedRoomFactory; - private bool _valid = false; private bool disposedValue; - private ObservableCollection Rooms { get; } = new ObservableCollection(); - - public ConfigV2? Config { get; private set; } - - private BasicWebhook? Webhook { get; set; } - - public int Count => this.Rooms.Count; - public bool IsReadOnly => true; - public IRecordedRoom this[int index] => this.Rooms[index]; - - public Recorder(IServiceProvider serviceProvider) + public Recorder(IRoomFactory roomFactory, ConfigV2 config) { - this.serviceProvider = serviceProvider; - this.tokenSource = new CancellationTokenSource(); - Repeat.Interval(TimeSpan.FromSeconds(3), this.DownloadWatchdog, this.tokenSource.Token); + this.roomFactory = roomFactory ?? throw new ArgumentNullException(nameof(roomFactory)); + this.Config = config ?? throw new ArgumentNullException(nameof(config)); + this.roomCollection = new ObservableCollection(); + this.Rooms = new ReadOnlyObservableCollection(this.roomCollection); - this.Rooms.CollectionChanged += (sender, e) => - { - logger.Trace($"Rooms.CollectionChanged;{e.Action};" + - $"O:{e.OldItems?.Cast()?.Select(rr => rr.RoomId.ToString())?.Aggregate((current, next) => current + "," + next)};" + - $"N:{e.NewItems?.Cast()?.Select(rr => rr.RoomId.ToString())?.Aggregate((current, next) => current + "," + next)}"); - }; - } + this.basicWebhookV1 = new BasicWebhookV1(config); + this.basicWebhookV2 = new BasicWebhookV2(config.Global); - public bool Initialize(string workdir) - { - if (this._valid) - throw new InvalidOperationException("Recorder is in valid state"); - logger.Debug("Initialize: " + workdir); - var config = ConfigParser.LoadFrom(directory: workdir); - if (config is not null) { - this.Config = config; - this.Config.Global.WorkDirectory = workdir; - this.Webhook = new BasicWebhook(this.Config); - this.recordedRoomFactory = this.serviceProvider.GetRequiredService(); - this._valid = true; - this.Config.Rooms.ForEach(r => this.AddRoom(r)); - ConfigParser.SaveTo(this.Config.Global.WorkDirectory, this.Config); - return true; - } - else - { - return false; + foreach (var item in config.Rooms) + this.AddRoom(item); + this.SaveConfig(); } } - public bool InitializeWithConfig(ConfigV2 config) - { - // 脏写法 but it works - if (this._valid) - throw new InvalidOperationException("Recorder is in valid state"); + public event EventHandler>? RecordSessionStarted; + public event EventHandler>? RecordSessionEnded; + public event EventHandler>? RecordFileOpening; + public event EventHandler>? RecordFileClosed; + public event EventHandler>? NetworkingStats; + public event EventHandler>? RecordingStats; + public event PropertyChangedEventHandler? PropertyChanged; - if (config is null) - throw new ArgumentNullException(nameof(config)); + public ConfigV2 Config { get; } - logger.Debug("Initialize With Config."); - this.Config = config; - this.Webhook = new BasicWebhook(this.Config); - this.recordedRoomFactory = this.serviceProvider.GetRequiredService(); - this._valid = true; - this.Config.Rooms.ForEach(r => this.AddRoom(r)); - return true; - } + public ReadOnlyObservableCollection Rooms { get; } - /// - /// 添加直播间到录播姬 - /// - /// 房间号(支持短号) - /// public void AddRoom(int roomid) => this.AddRoom(roomid, true); - /// - /// 添加直播间到录播姬 - /// - /// 房间号(支持短号) - /// 是否默认启用 - /// public void AddRoom(int roomid, bool enabled) { - try + lock (this.lockObject) { - if (!this._valid) { throw new InvalidOperationException("Not Initialized"); } - if (roomid <= 0) + var roomConfig = new RoomConfig { RoomId = roomid, AutoRecord = enabled }; + this.AddRoom(roomConfig); + this.SaveConfig(); + } + } + + private void AddRoom(RoomConfig roomConfig) + { + roomConfig.SetParent(this.Config.Global); + var room = this.roomFactory.CreateRoom(roomConfig); + this.roomCollection.Add(room); + this.AddEventSubscription(room); + } + + public void RemoveRoom(IRoom room) + { + lock (this.lockObject) + { + if (this.roomCollection.Remove(room)) { - throw new ArgumentOutOfRangeException(nameof(roomid), "房间号需要大于0"); + this.RemoveEventSubscription(room); + room.Dispose(); } - - var config = new RoomConfig - { - RoomId = roomid, - AutoRecord = enabled, - }; - - this.AddRoom(config); - } - catch (Exception ex) - { - logger.Debug(ex, "AddRoom 添加 {roomid} 直播间错误 ", roomid); + this.SaveConfig(); } } - /// - /// 添加直播间到录播姬 - /// - /// 房间设置 - public void AddRoom(RoomConfig roomConfig) + public void SaveConfig() { - try - { - if (!this._valid) { throw new InvalidOperationException("Not Initialized"); } - - roomConfig.SetParent(this.Config?.Global); - var rr = this.recordedRoomFactory!.CreateRecordedRoom(roomConfig); - - logger.Debug("AddRoom 添加了 {roomid} 直播间 ", rr.RoomId); - rr.RecordEnded += this.RecordedRoom_RecordEnded; - this.Rooms.Add(rr); - } - catch (Exception ex) - { - logger.Debug(ex, "AddRoom 添加 {roomid} 直播间错误 ", roomConfig.RoomId); - } - } - - /// - /// 从录播姬移除直播间 - /// - /// 直播间 - public void RemoveRoom(IRecordedRoom rr) - { - if (rr is null) return; - if (!this._valid) { throw new InvalidOperationException("Not Initialized"); } - rr.Shutdown(); - rr.RecordEnded -= this.RecordedRoom_RecordEnded; - logger.Debug("RemoveRoom 移除了直播间 {roomid}", rr.RoomId); - this.Rooms.Remove(rr); - } - - private void Shutdown() - { - if (!this._valid) { return; } - logger.Debug("Shutdown called."); - this.tokenSource.Cancel(); - - this.SaveConfigToFile(); - - this.Rooms.ToList().ForEach(rr => - { - rr.Shutdown(); - }); - - this.Rooms.Clear(); - } - - private void RecordedRoom_RecordEnded(object sender, RecordEndData e) => this.Webhook?.Send(e); - - public void SaveConfigToFile() - { - if (this.Config is null) return; - this.Config.Rooms = this.Rooms.Select(x => x.RoomConfig).ToList(); ConfigParser.SaveTo(this.Config.Global.WorkDirectory!, this.Config); } - private void DownloadWatchdog() + #region Events + + private void AddEventSubscription(IRoom room) { - if (!this._valid) { return; } - try - { - this.Rooms.ToList().ForEach(room => - { - if (room.IsRecording) - { - if (DateTime.Now - room.LastUpdateDateTime > TimeSpan.FromMilliseconds(this.Config!.Global.TimingWatchdogTimeout)) - { - logger.Warn("服务器未断开连接但停止提供 [{roomid}] 直播间的直播数据,通常是录制侧网络不稳定导致,将会断开重连", room.RoomId); - room.StopRecord(); - room.StartRecord(); - } - } - }); - } - catch (Exception ex) - { - logger.Error(ex, "直播流下载监控出错"); - } + room.RecordSessionStarted += this.Room_RecordSessionStarted; + room.RecordSessionEnded += this.Room_RecordSessionEnded; + room.RecordFileOpening += this.Room_RecordFileOpening; + room.RecordFileClosed += this.Room_RecordFileClosed; + room.NetworkingStats += this.Room_NetworkingStats; + room.RecordingStats += this.Room_RecordingStats; + room.PropertyChanged += this.Room_PropertyChanged; } - void ICollection.Add(IRecordedRoom item) => throw new NotSupportedException("Collection is readonly"); - void ICollection.Clear() => throw new NotSupportedException("Collection is readonly"); - bool ICollection.Remove(IRecordedRoom item) => throw new NotSupportedException("Collection is readonly"); - bool ICollection.Contains(IRecordedRoom item) => this.Rooms.Contains(item); - void ICollection.CopyTo(IRecordedRoom[] array, int arrayIndex) => this.Rooms.CopyTo(array, arrayIndex); - public IEnumerator GetEnumerator() => this.Rooms.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => this.Rooms.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => this.Rooms.GetEnumerator(); - - public event PropertyChangedEventHandler PropertyChanged + private void Room_NetworkingStats(object sender, NetworkingStatsEventArgs e) { - add => (this.Rooms as INotifyPropertyChanged).PropertyChanged += value; - remove => (this.Rooms as INotifyPropertyChanged).PropertyChanged -= value; + var room = (IRoom)sender; + NetworkingStats?.Invoke(this, new AggregatedRoomEventArgs(room, e)); } - public event NotifyCollectionChangedEventHandler CollectionChanged + private void Room_RecordingStats(object sender, RecordingStatsEventArgs e) { - add => (this.Rooms as INotifyCollectionChanged).CollectionChanged += value; - remove => (this.Rooms as INotifyCollectionChanged).CollectionChanged -= value; + var room = (IRoom)sender; + RecordingStats?.Invoke(this, new AggregatedRoomEventArgs(room, e)); } + private void Room_RecordFileClosed(object sender, RecordFileClosedEventArgs e) + { + var room = (IRoom)sender; + _ = Task.Run(async () => await this.basicWebhookV2.SendFileClosedAsync(e).ConfigureAwait(false)); + _ = Task.Run(async () => await this.basicWebhookV1.SendAsync(new RecordEndData(e)).ConfigureAwait(false)); + RecordFileClosed?.Invoke(this, new AggregatedRoomEventArgs(room, e)); + } + + private void Room_RecordFileOpening(object sender, RecordFileOpeningEventArgs e) + { + var room = (IRoom)sender; + _ = Task.Run(async () => await this.basicWebhookV2.SendFileOpeningAsync(e).ConfigureAwait(false)); + RecordFileOpening?.Invoke(this, new AggregatedRoomEventArgs(room, e)); + } + + private void Room_RecordSessionStarted(object sender, RecordSessionStartedEventArgs e) + { + var room = (IRoom)sender; + _ = Task.Run(async () => await this.basicWebhookV2.SendSessionStartedAsync(e).ConfigureAwait(false)); + RecordSessionStarted?.Invoke(this, new AggregatedRoomEventArgs(room, e)); + } + + private void Room_RecordSessionEnded(object sender, RecordSessionEndedEventArgs e) + { + var room = (IRoom)sender; + _ = Task.Run(async () => await this.basicWebhookV2.SendSessionEndedAsync(e).ConfigureAwait(false)); + RecordSessionEnded?.Invoke(this, new AggregatedRoomEventArgs(room, e)); + } + + private void Room_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + // TODO + // throw new NotImplementedException(); + } + + private void RemoveEventSubscription(IRoom room) + { + room.RecordSessionStarted -= this.Room_RecordSessionStarted; + room.RecordSessionEnded -= this.Room_RecordSessionEnded; + room.RecordFileOpening -= this.Room_RecordFileOpening; + room.RecordFileClosed -= this.Room_RecordFileClosed; + room.RecordingStats -= this.Room_RecordingStats; + room.PropertyChanged -= this.Room_PropertyChanged; + } + + #endregion + + #region Dispose + protected virtual void Dispose(bool disposing) { if (!this.disposedValue) @@ -242,7 +169,9 @@ namespace BililiveRecorder.Core if (disposing) { // dispose managed state (managed objects) - this.Shutdown(); + this.SaveConfig(); + foreach (var room in this.roomCollection) + room.Dispose(); } // free unmanaged resources (unmanaged objects) and override finalizer @@ -264,5 +193,7 @@ namespace BililiveRecorder.Core this.Dispose(disposing: true); GC.SuppressFinalize(this); } + + #endregion } } diff --git a/BililiveRecorder.Core/Recording/FlvProcessingContextWriterFactory.cs b/BililiveRecorder.Core/Recording/FlvProcessingContextWriterFactory.cs new file mode 100644 index 0000000..0c70c2b --- /dev/null +++ b/BililiveRecorder.Core/Recording/FlvProcessingContextWriterFactory.cs @@ -0,0 +1,20 @@ +using System; +using BililiveRecorder.Flv; +using BililiveRecorder.Flv.Writer; +using Microsoft.Extensions.DependencyInjection; + +namespace BililiveRecorder.Core.Recording +{ + public class FlvProcessingContextWriterFactory : IFlvProcessingContextWriterFactory + { + private readonly IServiceProvider serviceProvider; + + public FlvProcessingContextWriterFactory(IServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + public IFlvProcessingContextWriter CreateWriter(IFlvWriterTargetProvider targetProvider) => + new FlvProcessingContextWriter(targetProvider, this.serviceProvider.GetRequiredService(), null); + } +} diff --git a/BililiveRecorder.Core/Recording/FlvTagReaderFactory.cs b/BililiveRecorder.Core/Recording/FlvTagReaderFactory.cs new file mode 100644 index 0000000..524dde4 --- /dev/null +++ b/BililiveRecorder.Core/Recording/FlvTagReaderFactory.cs @@ -0,0 +1,22 @@ +using System; +using System.IO.Pipelines; +using BililiveRecorder.Flv; +using BililiveRecorder.Flv.Parser; +using Microsoft.Extensions.DependencyInjection; +using Serilog; + +namespace BililiveRecorder.Core.Recording +{ + public class FlvTagReaderFactory : IFlvTagReaderFactory + { + private readonly IServiceProvider serviceProvider; + + public FlvTagReaderFactory(IServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + public IFlvTagReader CreateFlvTagReader(PipeReader pipeReader) => + new FlvTagPipeReader(pipeReader, this.serviceProvider.GetRequiredService(), this.serviceProvider.GetService()); + } +} diff --git a/BililiveRecorder.Core/Recording/IFlvProcessingContextWriterFactory.cs b/BililiveRecorder.Core/Recording/IFlvProcessingContextWriterFactory.cs new file mode 100644 index 0000000..c42deb5 --- /dev/null +++ b/BililiveRecorder.Core/Recording/IFlvProcessingContextWriterFactory.cs @@ -0,0 +1,9 @@ +using BililiveRecorder.Flv; + +namespace BililiveRecorder.Core.Recording +{ + public interface IFlvProcessingContextWriterFactory + { + IFlvProcessingContextWriter CreateWriter(IFlvWriterTargetProvider targetProvider); + } +} diff --git a/BililiveRecorder.Core/Recording/IFlvTagReaderFactory.cs b/BililiveRecorder.Core/Recording/IFlvTagReaderFactory.cs new file mode 100644 index 0000000..e3e818f --- /dev/null +++ b/BililiveRecorder.Core/Recording/IFlvTagReaderFactory.cs @@ -0,0 +1,10 @@ +using System.IO.Pipelines; +using BililiveRecorder.Flv; + +namespace BililiveRecorder.Core.Recording +{ + public interface IFlvTagReaderFactory + { + IFlvTagReader CreateFlvTagReader(PipeReader pipeReader); + } +} diff --git a/BililiveRecorder.Core/Recording/IRecordTask.cs b/BililiveRecorder.Core/Recording/IRecordTask.cs new file mode 100644 index 0000000..5fce8c1 --- /dev/null +++ b/BililiveRecorder.Core/Recording/IRecordTask.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading.Tasks; +using BililiveRecorder.Core.Event; + +namespace BililiveRecorder.Core.Recording +{ + public interface IRecordTask + { + Guid SessionId { get; } + + event EventHandler? NetworkingStats; + event EventHandler? RecordingStats; + event EventHandler? RecordFileOpening; + event EventHandler? RecordFileClosed; + event EventHandler? RecordSessionEnded; + + void SplitOutput(); + Task StartAsync(); + void RequestStop(); + } +} diff --git a/BililiveRecorder.Core/Recording/IRecordTaskFactory.cs b/BililiveRecorder.Core/Recording/IRecordTaskFactory.cs new file mode 100644 index 0000000..a2f4d69 --- /dev/null +++ b/BililiveRecorder.Core/Recording/IRecordTaskFactory.cs @@ -0,0 +1,7 @@ +namespace BililiveRecorder.Core.Recording +{ + public interface IRecordTaskFactory + { + IRecordTask CreateRecordTask(IRoom room); + } +} diff --git a/BililiveRecorder.Core/Recording/ITagGroupReaderFactory.cs b/BililiveRecorder.Core/Recording/ITagGroupReaderFactory.cs new file mode 100644 index 0000000..efddab4 --- /dev/null +++ b/BililiveRecorder.Core/Recording/ITagGroupReaderFactory.cs @@ -0,0 +1,9 @@ +using BililiveRecorder.Flv; + +namespace BililiveRecorder.Core.Recording +{ + public interface ITagGroupReaderFactory + { + ITagGroupReader CreateTagGroupReader(IFlvTagReader flvTagReader); + } +} diff --git a/BililiveRecorder.Core/Recording/RecordTask.cs b/BililiveRecorder.Core/Recording/RecordTask.cs new file mode 100644 index 0000000..4528701 --- /dev/null +++ b/BililiveRecorder.Core/Recording/RecordTask.cs @@ -0,0 +1,523 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Pipelines; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using System.Timers; +using BililiveRecorder.Core.Api; +using BililiveRecorder.Core.Event; +using BililiveRecorder.Core.ProcessingRules; +using BililiveRecorder.Flv; +using BililiveRecorder.Flv.Amf; +using BililiveRecorder.Flv.Pipeline; +using Serilog; +using Timer = System.Timers.Timer; + +namespace BililiveRecorder.Core.Recording +{ + public class RecordTask : IRecordTask + { + private const string HttpHeaderAccept = "*/*"; + private const string HttpHeaderOrigin = "https://live.bilibili.com"; + private const string HttpHeaderReferer = "https://live.bilibili.com/"; + private const string HttpHeaderUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36"; + + private readonly Random random = new Random(); + private readonly Timer timer = new Timer(1000 * 2); + private readonly CancellationTokenSource cts = new CancellationTokenSource(); + private readonly CancellationToken ct; + + private readonly IRoom room; + private readonly ILogger logger; + private readonly IApiClient apiClient; + private readonly IFlvTagReaderFactory flvTagReaderFactory; + private readonly ITagGroupReaderFactory tagGroupReaderFactory; + private readonly IFlvProcessingContextWriterFactory writerFactory; + private readonly ProcessingDelegate pipeline; + + private readonly IFlvWriterTargetProvider targetProvider; + private readonly StatsRule statsRule; + private readonly SplitRule splitFileRule; + + private readonly FlvProcessingContext context = new FlvProcessingContext(); + private readonly IDictionary session = new Dictionary(); + + private bool started = false; + private Task? filler; + private ITagGroupReader? reader; + private IFlvProcessingContextWriter? writer; + + private readonly object fillerStatsLock = new object(); + private int fillerDownloadedBytes; + private DateTimeOffset fillerLastStatsTrigger; + + public RecordTask(IRoom room, + ILogger logger, + IProcessingPipelineBuilder builder, + IApiClient apiClient, + IFlvTagReaderFactory flvTagReaderFactory, + ITagGroupReaderFactory tagGroupReaderFactory, + IFlvProcessingContextWriterFactory writerFactory) + { + this.room = room ?? throw new ArgumentNullException(nameof(room)); + this.logger = logger?.ForContext().ForContext(LoggingContext.RoomId, this.room.RoomConfig.RoomId) ?? throw new ArgumentNullException(nameof(logger)); + this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + this.flvTagReaderFactory = flvTagReaderFactory ?? throw new ArgumentNullException(nameof(flvTagReaderFactory)); + this.tagGroupReaderFactory = tagGroupReaderFactory ?? throw new ArgumentNullException(nameof(tagGroupReaderFactory)); + this.writerFactory = writerFactory ?? throw new ArgumentNullException(nameof(writerFactory)); + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + this.ct = this.cts.Token; + + this.statsRule = new StatsRule(); + this.splitFileRule = new SplitRule(); + + this.statsRule.StatsUpdated += this.StatsRule_StatsUpdated; + + this.pipeline = builder + .Add(this.splitFileRule) + .Add(this.statsRule) + .AddDefault() + .AddRemoveFillerData() + .Build(); + + this.targetProvider = new WriterTargetProvider(this.room, this.logger.ForContext(LoggingContext.RoomId, this.room.RoomConfig.RoomId), paths => + { + this.logger.ForContext(LoggingContext.RoomId, this.room.RoomConfig.RoomId).Debug("输出路径 {Path}", paths.fullPath); + + var e = new RecordFileOpeningEventArgs(this.room) + { + SessionId = this.SessionId, + FullPath = paths.fullPath, + RelativePath = paths.relativePath, + FileOpenTime = DateTimeOffset.Now, + }; + RecordFileOpening?.Invoke(this, e); + return e; + }); + + this.timer.Elapsed += this.Timer_Elapsed_TriggerStats; + } + + public Guid SessionId { get; } = Guid.NewGuid(); + + public event EventHandler? NetworkingStats; + public event EventHandler? RecordingStats; + public event EventHandler? RecordFileOpening; + public event EventHandler? RecordFileClosed; + public event EventHandler? RecordSessionEnded; + + public void SplitOutput() => this.splitFileRule.SetSplitFlag(); + + public void RequestStop() => this.cts.Cancel(); + + public async Task StartAsync() + { + if (this.started) + throw new InvalidOperationException("Only one StartAsync call allowed per instance."); + this.started = true; + + var fullUrl = await this.FetchStreamUrlAsync().ConfigureAwait(false); + + this.logger.Information("连接直播服务器 {Host}", new Uri(fullUrl).Host); + this.logger.Debug("直播流地址 {Url}", fullUrl); + + var stream = await this.GetStreamAsync(fullUrl).ConfigureAwait(false); + + var pipe = new Pipe(new PipeOptions(useSynchronizationContext: false)); + + this.reader = this.tagGroupReaderFactory.CreateTagGroupReader(this.flvTagReaderFactory.CreateFlvTagReader(pipe.Reader)); + + this.writer = this.writerFactory.CreateWriter(this.targetProvider); + this.writer.BeforeScriptTagWrite = this.Writer_BeforeScriptTagWrite; + this.writer.FileClosed += (sender, e) => + { + var openingEventArgs = (RecordFileOpeningEventArgs)e.State!; + RecordFileClosed?.Invoke(this, new RecordFileClosedEventArgs(this.room) + { + SessionId = this.SessionId, + FullPath = openingEventArgs.FullPath, + RelativePath = openingEventArgs.RelativePath, + FileOpenTime = openingEventArgs.FileOpenTime, + FileCloseTime = DateTimeOffset.Now, + Duration = e.Duration, + FileSize = e.FileSize, + }); + }; + + this.fillerLastStatsTrigger = DateTimeOffset.UtcNow; + this.filler = Task.Run(async () => await this.FillPipeAsync(stream, pipe.Writer).ConfigureAwait(false)); + + _ = Task.Run(this.RecordingLoopAsync); + } + + private async Task FillPipeAsync(Stream stream, PipeWriter writer) + { + const int minimumBufferSize = 1024; + this.timer.Start(); + Exception? exception = null; + try + { + while (!this.ct.IsCancellationRequested) + { + var memory = writer.GetMemory(minimumBufferSize); + try + { + var bytesRead = await stream.ReadAsync(memory, this.ct).ConfigureAwait(false); + if (bytesRead == 0) + break; + writer.Advance(bytesRead); + Interlocked.Add(ref this.fillerDownloadedBytes, bytesRead); + } + catch (Exception ex) + { + exception = ex; + break; + } + + var result = await writer.FlushAsync(this.ct).ConfigureAwait(false); + if (result.IsCompleted) + break; + } + } + finally + { + this.timer.Stop(); + stream.Dispose(); + await writer.CompleteAsync(exception).ConfigureAwait(false); + } + } + + private void Timer_Elapsed_TriggerStats(object sender, ElapsedEventArgs e) + { + int bytes; + TimeSpan diff; + DateTimeOffset start, end; + + lock (this.fillerStatsLock) + { + bytes = Interlocked.Exchange(ref this.fillerDownloadedBytes, 0); + end = DateTimeOffset.UtcNow; + start = this.fillerLastStatsTrigger; + this.fillerLastStatsTrigger = end; + diff = end - start; + } + + var mbps = bytes * 8d / 1024d / 1024d / diff.TotalSeconds; + + NetworkingStats?.Invoke(this, new NetworkingStatsEventArgs + { + BytesDownloaded = bytes, + Duration = diff, + StartTime = start, + EndTime = end, + Mbps = mbps + }); + } + + private void Writer_BeforeScriptTagWrite(ScriptTagBody scriptTagBody) + { + if (scriptTagBody.Values.Count == 2 && scriptTagBody.Values[1] is ScriptDataEcmaArray value) + { + var now = DateTimeOffset.Now; + const string version = "TODO-dev-1.3.x"; + value["Title"] = (ScriptDataString)this.room.Title; + value["Artist"] = (ScriptDataString)$"{this.room.Name} ({this.room.RoomConfig.RoomId})"; + value["Comment"] = (ScriptDataString) + ($"B站直播间 {this.room.RoomConfig.RoomId} 的直播录像\n" + + $"主播名: {this.room.Name}\n" + + $"直播标题: {this.room.Title}\n" + + $"直播分区: {this.room.AreaNameParent}·{this.room.AreaNameChild}\n" + + $"录制时间: {now:O}\n" + + $"\n使用 B站录播姬 录制 https://rec.danmuji.org\n" + + $"录播姬版本: {version}"); + value["BililiveRecorder"] = new ScriptDataEcmaArray + { + ["RecordedBy"] = (ScriptDataString)"BililiveRecorder B站录播姬", + ["RecorderVersion"] = (ScriptDataString)version, // TODO fix version + ["StartTime"] = (ScriptDataDate)now, + ["RoomId"] = (ScriptDataString)this.room.RoomConfig.RoomId.ToString(), + ["ShortId"] = (ScriptDataString)this.room.ShortId.ToString(), + ["Name"] = (ScriptDataString)this.room.Name, + ["StreamTitle"] = (ScriptDataString)this.room.Title, + ["AreaNameParent"] = (ScriptDataString)this.room.AreaNameParent, + ["AreaNameChild"] = (ScriptDataString)this.room.AreaNameChild, + }; + } + } + + private async Task RecordingLoopAsync() + { + if (this.reader is null) return; + if (this.writer is null) return; + try + { + while (!this.ct.IsCancellationRequested) + { + var group = await this.reader.ReadGroupAsync(this.ct).ConfigureAwait(false); + + if (group is null) + break; + + this.context.Reset(group, this.session); + + await this.pipeline(this.context).ConfigureAwait(false); + + if (this.context.Comments.Count > 0) + this.logger.Debug("修复逻辑输出 {Comments}", string.Join("\n", this.context.Comments)); + + await this.writer.WriteAsync(this.context).ConfigureAwait(false); + + if (this.context.Output.Any(x => x is PipelineDisconnectAction)) + { + this.logger.Information("根据修复逻辑的要求结束录制"); + break; + } + } + } + catch (OperationCanceledException ex) + { + this.logger.Debug(ex, "录制被取消"); + } + catch (IOException ex) + { + this.logger.Warning(ex, "录制时发生IO错误"); + } + catch (Exception ex) + { + this.logger.Warning(ex, "录制时发生未知错误"); + } + finally + { + this.logger.Debug("录制退出"); + + this.reader?.Dispose(); + this.reader = null; + this.writer?.Dispose(); + this.writer = null; + this.cts.Cancel(); + + RecordSessionEnded?.Invoke(this, EventArgs.Empty); + } + } + + private async Task GetStreamAsync(string fullUrl) + { + var client = CreateHttpClient(); + + while (true) + { + var resp = await client.GetAsync(fullUrl, + HttpCompletionOption.ResponseHeadersRead, + new CancellationTokenSource((int)this.room.RoomConfig.TimingStreamConnect).Token) + .ConfigureAwait(false); + + switch (resp.StatusCode) + { + case System.Net.HttpStatusCode.OK: + { + this.logger.Debug("开始接收直播流"); + var stream = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false); + return stream; + } + case System.Net.HttpStatusCode.Moved: + case System.Net.HttpStatusCode.Redirect: + { + fullUrl = resp.Headers.Location.OriginalString; + this.logger.Debug("跳转到 {Url}", fullUrl); + resp.Dispose(); + break; + } + default: + throw new Exception(string.Format("尝试下载直播流时服务器返回了 ({0}){1}", resp.StatusCode, resp.ReasonPhrase)); + } + } + } + + private async Task FetchStreamUrlAsync() + { + var apiResp = await this.apiClient.GetStreamUrlAsync(this.room.RoomConfig.RoomId).ConfigureAwait(false); + var url_data = apiResp?.Data?.PlayurlInfo?.Playurl?.Streams; + + if (url_data is null) + throw new Exception("playurl is null"); + + var url_http_stream_flv_avc = + url_data.FirstOrDefault(x => x.ProtocolName == "http_stream") + ?.Formats?.FirstOrDefault(x => x.FormatName == "flv") + ?.Codecs?.FirstOrDefault(x => x.CodecName == "avc"); + + if (url_http_stream_flv_avc is null) + throw new Exception("no supported stream url"); + + if (url_http_stream_flv_avc.CurrentQn != 10000) + this.logger.Warning("当前录制的画质是 {CurrentQn}", url_http_stream_flv_avc.CurrentQn); + + var url_infos = url_http_stream_flv_avc.UrlInfos; + if (url_infos is null || url_infos.Length == 0) + throw new Exception("no url_info"); + + var url_info = url_infos[this.random.Next(url_infos.Length)]; + + var fullUrl = url_info.Host + url_http_stream_flv_avc.BaseUrl + url_info.Extra; + return fullUrl; + } + + private static HttpClient CreateHttpClient() + { + var httpClient = new HttpClient(new HttpClientHandler + { + AllowAutoRedirect = false + }); + var headers = httpClient.DefaultRequestHeaders; + headers.Add("Accept", HttpHeaderAccept); + headers.Add("Origin", HttpHeaderOrigin); + headers.Add("Referer", HttpHeaderReferer); + headers.Add("User-Agent", HttpHeaderUserAgent); + return httpClient; + } + + private void StatsRule_StatsUpdated(object sender, RecordingStatsEventArgs e) + { + if (this.room.RoomConfig.CuttingMode == Config.V2.CuttingMode.ByTime) + { + if (e.FileMaxTimestamp > (this.room.RoomConfig.CuttingNumber * (60 * 1000))) + { + this.splitFileRule.SetSplitFlag(); + } + } + + RecordingStats?.Invoke(this, e); + } + + internal class WriterTargetProvider : IFlvWriterTargetProvider + { + private static readonly Random random = new Random(); + + private readonly IRoom room; + private readonly ILogger logger; + private readonly Func<(string fullPath, string relativePath), object> OnNewFile; + + private string last_path = string.Empty; + + public WriterTargetProvider(IRoom room, ILogger logger, Func<(string fullPath, string relativePath), object> onNewFile) + { + this.room = room ?? throw new ArgumentNullException(nameof(room)); + this.logger = logger?.ForContext() ?? throw new ArgumentNullException(nameof(logger)); + this.OnNewFile = onNewFile ?? throw new ArgumentNullException(nameof(onNewFile)); + } + + public bool ShouldCreateNewFile(Stream outputStream, IList tags) + { + if (this.room.RoomConfig.CuttingMode == Config.V2.CuttingMode.BySize) + { + var pendingSize = tags.Sum(x => (x.Nalus == null ? x.Size : (5 + x.Nalus.Sum(n => n.FullSize + 4))) + (11 + 4)); + return (outputStream.Length + pendingSize) > (this.room.RoomConfig.CuttingNumber * (1024 * 1024)); + } + return false; + } + + public (Stream stream, object state) CreateOutputStream() + { + var paths = this.FormatFilename(this.room.RoomConfig.RecordFilenameFormat!); + + try + { Directory.CreateDirectory(Path.GetDirectoryName(paths.fullPath)); } + catch (Exception) { } + + this.last_path = paths.fullPath; + var state = this.OnNewFile(paths); + + var stream = new FileStream(paths.fullPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read | FileShare.Delete); + return (stream, state); + } + + public Stream CreateAlternativeHeaderStream() + { + var path = string.IsNullOrWhiteSpace(this.last_path) + ? Path.ChangeExtension(this.FormatFilename(this.room.RoomConfig.RecordFilenameFormat!).fullPath, "headers.txt") + : Path.ChangeExtension(this.last_path, "headers.txt"); + + try + { Directory.CreateDirectory(Path.GetDirectoryName(path)); } + catch (Exception) { } + + var stream = new FileStream(path, FileMode.Append, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + return stream; + } + + private (string fullPath, string relativePath) FormatFilename(string formatString) + { + var now = DateTime.Now; + var date = now.ToString("yyyyMMdd"); + var time = now.ToString("HHmmss"); + var randomStr = random.Next(100, 999).ToString(); + + var relativePath = formatString + .Replace(@"{date}", date) + .Replace(@"{time}", time) + .Replace(@"{random}", randomStr) + .Replace(@"{roomid}", this.room.RoomConfig.RoomId.ToString()) + .Replace(@"{title}", RemoveInvalidFileName(this.room.Title)) + .Replace(@"{name}", RemoveInvalidFileName(this.room.Name)) + .Replace(@"{parea}", RemoveInvalidFileName(this.room.AreaNameParent)) + .Replace(@"{area}", RemoveInvalidFileName(this.room.AreaNameChild)) + ; + + if (!relativePath.EndsWith(".flv", StringComparison.OrdinalIgnoreCase)) + relativePath += ".flv"; + + relativePath = RemoveInvalidFileName(relativePath, ignore_slash: true); + var workDirectory = this.room.RoomConfig.WorkDirectory; + var fullPath = Path.Combine(workDirectory, relativePath); + fullPath = Path.GetFullPath(fullPath); + + if (!CheckIsWithinPath(workDirectory!, Path.GetDirectoryName(fullPath))) + { + this.logger.Warning("录制文件位置超出允许范围,请检查设置。将写入到默认路径。"); + relativePath = Path.Combine(this.room.RoomConfig.RoomId.ToString(), $"{this.room.RoomConfig.RoomId}-{date}-{time}-{randomStr}.flv"); + fullPath = Path.Combine(workDirectory, relativePath); + } + + if (File.Exists(fullPath)) + { + this.logger.Warning("录制文件名冲突,请检查设置。将写入到默认路径。"); + relativePath = Path.Combine(this.room.RoomConfig.RoomId.ToString(), $"{this.room.RoomConfig.RoomId}-{date}-{time}-{randomStr}.flv"); + fullPath = Path.Combine(workDirectory, relativePath); + } + + return (fullPath, relativePath); + } + + internal static string RemoveInvalidFileName(string input, bool ignore_slash = false) + { + foreach (var c in Path.GetInvalidFileNameChars()) + if (!ignore_slash || c != '\\' && c != '/') + input = input.Replace(c, '_'); + return input; + } + + internal static bool CheckIsWithinPath(string parent, string child) + { + if (parent is null || child is null) + return false; + + parent = parent.Replace('/', '\\'); + if (!parent.EndsWith("\\")) + parent += "\\"; + parent = Path.GetFullPath(parent); + + child = child.Replace('/', '\\'); + if (!child.EndsWith("\\")) + child += "\\"; + child = Path.GetFullPath(child); + + return child.StartsWith(parent, StringComparison.Ordinal); + } + } + } +} diff --git a/BililiveRecorder.Core/Recording/RecordTaskFactory.cs b/BililiveRecorder.Core/Recording/RecordTaskFactory.cs new file mode 100644 index 0000000..a7486ef --- /dev/null +++ b/BililiveRecorder.Core/Recording/RecordTaskFactory.cs @@ -0,0 +1,18 @@ +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace BililiveRecorder.Core.Recording +{ + public class RecordTaskFactory : IRecordTaskFactory + { + private readonly IServiceProvider serviceProvider; + + public RecordTaskFactory(IServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + public IRecordTask CreateRecordTask(IRoom room) => + ActivatorUtilities.CreateInstance(this.serviceProvider, room); + } +} diff --git a/BililiveRecorder.Core/Recording/TagGroupReaderFactory.cs b/BililiveRecorder.Core/Recording/TagGroupReaderFactory.cs new file mode 100644 index 0000000..081c21e --- /dev/null +++ b/BililiveRecorder.Core/Recording/TagGroupReaderFactory.cs @@ -0,0 +1,11 @@ +using BililiveRecorder.Flv; +using BililiveRecorder.Flv.Grouping; + +namespace BililiveRecorder.Core.Recording +{ + public class TagGroupReaderFactory : ITagGroupReaderFactory + { + public ITagGroupReader CreateTagGroupReader(IFlvTagReader flvTagReader) => + new TagGroupReader(flvTagReader); + } +} diff --git a/BililiveRecorder.Core/RecordingStats.cs b/BililiveRecorder.Core/RecordingStats.cs new file mode 100644 index 0000000..3039281 --- /dev/null +++ b/BililiveRecorder.Core/RecordingStats.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +#nullable enable +namespace BililiveRecorder.Core +{ + public class RecordingStats : INotifyPropertyChanged + { + private TimeSpan sessionMaxTimestamp; + private TimeSpan fileMaxTimestamp; + private TimeSpan sessionDuration; + private double networkMbps; + private long totalInputBytes; + private long totalOutputBytes; + private double duraionRatio; + + public TimeSpan SessionDuration { get => this.sessionDuration; set => this.SetField(ref this.sessionDuration, value); } + public TimeSpan SessionMaxTimestamp { get => this.sessionMaxTimestamp; set => this.SetField(ref this.sessionMaxTimestamp, value); } + public TimeSpan FileMaxTimestamp { get => this.fileMaxTimestamp; set => this.SetField(ref this.fileMaxTimestamp, value); } + + public double DuraionRatio { get => this.duraionRatio; set => this.SetField(ref this.duraionRatio, value); } + + public long TotalInputBytes { get => this.totalInputBytes; set => this.SetField(ref this.totalInputBytes, value); } + public long TotalOutputBytes { get => this.totalOutputBytes; set => this.SetField(ref this.totalOutputBytes, value); } + + public double NetworkMbps { get => this.networkMbps; set => this.SetField(ref this.networkMbps, value); } + + public void Reset() + { + this.SessionDuration = TimeSpan.Zero; + this.SessionMaxTimestamp = TimeSpan.Zero; + this.FileMaxTimestamp = TimeSpan.Zero; + this.DuraionRatio = 0; + this.TotalInputBytes = 0; + this.TotalOutputBytes = 0; + this.NetworkMbps = 0; + } + + public event PropertyChangedEventHandler? PropertyChanged; + protected virtual void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + protected bool SetField(ref T location, T value, [CallerMemberName] string propertyName = "") + { + if (EqualityComparer.Default.Equals(location, value)) + return false; + location = value; + this.OnPropertyChanged(propertyName); + return true; + } + } +} diff --git a/BililiveRecorder.Core/RecyclableMemoryStreamProvider.cs b/BililiveRecorder.Core/RecyclableMemoryStreamProvider.cs new file mode 100644 index 0000000..5df12f7 --- /dev/null +++ b/BililiveRecorder.Core/RecyclableMemoryStreamProvider.cs @@ -0,0 +1,29 @@ +using System.IO; +using BililiveRecorder.Flv; +using Microsoft.IO; + +namespace BililiveRecorder.Core +{ + public class RecyclableMemoryStreamProvider : IMemoryStreamProvider + { + private readonly RecyclableMemoryStreamManager manager = new RecyclableMemoryStreamManager(32 * 1024, 64 * 1024, 64 * 1024 * 32) + { + MaximumFreeSmallPoolBytes = 64 * 1024 * 1024, + MaximumFreeLargePoolBytes = 64 * 1024 * 32, + }; + + public RecyclableMemoryStreamProvider() + { + //manager.StreamFinalized += () => + //{ + // Debug.WriteLine("TestRecyclableMemoryStreamProvider: Stream Finalized"); + //}; + //manager.StreamDisposed += () => + //{ + // // Debug.WriteLine("TestRecyclableMemoryStreamProvider: Stream Disposed"); + //}; + } + + public Stream CreateMemoryStream(string tag) => this.manager.GetStream(tag); + } +} diff --git a/BililiveRecorder.Core/Repeat.cs b/BililiveRecorder.Core/Repeat.cs deleted file mode 100644 index 1b9dfa5..0000000 --- a/BililiveRecorder.Core/Repeat.cs +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Author: Roger Lipscombe - * Source: https://stackoverflow.com/a/7472334 - * */ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace BililiveRecorder.Core -{ - public static class Repeat - { - public static Task Interval( - TimeSpan pollInterval, - Action action, - CancellationToken token) - { - // We don't use Observable.Interval: - // If we block, the values start bunching up behind each other. - return Task.Factory.StartNew( - () => - { - for (; ; ) - { - action(); - if (token.WaitCancellationRequested(pollInterval)) - break; - } - }, token, TaskCreationOptions.LongRunning, TaskScheduler.Default); - } - } - - internal static class CancellationTokenExtensions - { - public static bool WaitCancellationRequested( - this CancellationToken token, - TimeSpan timeout) - { - return token.WaitHandle.WaitOne(timeout); - } - } -} diff --git a/BililiveRecorder.Core/Room.cs b/BililiveRecorder.Core/Room.cs new file mode 100644 index 0000000..16e262a --- /dev/null +++ b/BililiveRecorder.Core/Room.cs @@ -0,0 +1,488 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using BililiveRecorder.Core.Api; +using BililiveRecorder.Core.Config.V2; +using BililiveRecorder.Core.Danmaku; +using BililiveRecorder.Core.Event; +using BililiveRecorder.Core.Recording; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using Timer = System.Timers.Timer; + +namespace BililiveRecorder.Core +{ + public class Room : IRoom + { + private readonly object recordStartLock = new object(); + private readonly SemaphoreSlim recordRetryDelaySemaphoreSlim = new SemaphoreSlim(1, 1); + private readonly Timer timer; + + private readonly IServiceScope scope; + private readonly ILogger loggerWithoutContext; + private readonly IDanmakuClient danmakuClient; + private readonly IApiClient apiClient; + private readonly IBasicDanmakuWriter basicDanmakuWriter; + private readonly IRecordTaskFactory recordTaskFactory; + private readonly CancellationTokenSource cts; + private readonly CancellationToken ct; + + private ILogger logger; + private bool disposedValue; + + private int shortId; + private string name = string.Empty; + private string title = string.Empty; + private string areaNameParent = string.Empty; + private string areaNameChild = string.Empty; + private bool streaming; + private bool danmakuConnected; + + private IRecordTask? recordTask; + private DateTimeOffset recordTaskStartTime; + + public Room(IServiceScope scope, RoomConfig roomConfig, ILogger logger, IDanmakuClient danmakuClient, IApiClient apiClient, IBasicDanmakuWriter basicDanmakuWriter, IRecordTaskFactory recordTaskFactory) + { + this.scope = scope ?? throw new ArgumentNullException(nameof(scope)); + this.RoomConfig = roomConfig ?? throw new ArgumentNullException(nameof(roomConfig)); + this.loggerWithoutContext = logger?.ForContext() ?? throw new ArgumentNullException(nameof(logger)); + this.logger = this.loggerWithoutContext.ForContext(LoggingContext.RoomId, this.RoomConfig.RoomId); + this.danmakuClient = danmakuClient ?? throw new ArgumentNullException(nameof(danmakuClient)); + this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + this.basicDanmakuWriter = basicDanmakuWriter ?? throw new ArgumentNullException(nameof(basicDanmakuWriter)); + this.recordTaskFactory = recordTaskFactory ?? throw new ArgumentNullException(nameof(recordTaskFactory)); + + this.timer = new Timer(this.RoomConfig.TimingCheckInterval * 1000); + this.cts = new CancellationTokenSource(); + this.ct = this.cts.Token; + + this.RoomConfig.PropertyChanged += this.RoomConfig_PropertyChanged; + this.timer.Elapsed += this.Timer_Elapsed; + this.danmakuClient.StatusChanged += this.DanmakuClient_StatusChanged; + this.danmakuClient.DanmakuReceived += this.DanmakuClient_DanmakuReceived; + + _ = Task.Run(async () => + { + await Task.Delay(1000); + await this.RefreshRoomInfoAsync(); + }); + } + + public int ShortId { get => this.shortId; private set => this.SetField(ref this.shortId, value); } + public string Name { get => this.name; private set => this.SetField(ref this.name, value); } + public string Title { get => this.title; private set => this.SetField(ref this.title, value); } + public string AreaNameParent { get => this.areaNameParent; private set => this.SetField(ref this.areaNameParent, value); } + public string AreaNameChild { get => this.areaNameChild; private set => this.SetField(ref this.areaNameChild, value); } + public bool Streaming + { + get => this.streaming; + private set + { + if (value == this.streaming) return; + + // 从未开播状态切换为开播状态时重置允许录制状态 + var triggerRecord = value && !this.streaming; + if (triggerRecord) + this.AutoRecordAllowedForThisSession = true; + + this.streaming = value; + this.OnPropertyChanged(nameof(this.Streaming)); + if (triggerRecord && this.RoomConfig.AutoRecord) + _ = Task.Run(() => this.CreateAndStartNewRecordTask()); + } + } + public bool DanmakuConnected { get => this.danmakuConnected; private set => this.SetField(ref this.danmakuConnected, value); } + public bool Recording => this.recordTask != null; + + public bool AutoRecordAllowedForThisSession { get; private set; } + + public RoomConfig RoomConfig { get; } + public RecordingStats Stats { get; } = new RecordingStats(); + + public Guid ObjectId { get; } = Guid.NewGuid(); + + public event EventHandler? RecordSessionStarted; + public event EventHandler? RecordSessionEnded; + public event EventHandler? RecordFileOpening; + public event EventHandler? RecordFileClosed; + public event EventHandler? NetworkingStats; + public event EventHandler? RecordingStats; + public event PropertyChangedEventHandler? PropertyChanged; + + public void SplitOutput() + { + lock (this.recordStartLock) + { + this.recordTask?.SplitOutput(); + } + } + + public void StartRecord() + { + lock (this.recordStartLock) + { + this.AutoRecordAllowedForThisSession = true; + + _ = Task.Run(async () => + { + try + { + await this.FetchRoomInfoThenCreateRecordTaskAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.Warning(ex, "尝试开始录制时出错"); + } + }); + } + } + + public void StopRecord() + { + lock (this.recordStartLock) + { + this.AutoRecordAllowedForThisSession = false; + + if (this.recordTask == null) + return; + + this.recordTask.RequestStop(); + } + } + + public async Task RefreshRoomInfoAsync() + { + try + { + await this.FetchUserInfoAsync().ConfigureAwait(false); + await this.FetchRoomInfoAsync().ConfigureAwait(false); + this.StartDamakuConnection(delay: false); + } + catch (Exception ex) + { + this.logger.Warning(ex, "刷新房间信息时出错"); + } + } + + #region Recording + + /// + private async Task FetchRoomInfoAsync() + { + var room = (await this.apiClient.GetRoomInfoAsync(this.RoomConfig.RoomId).ConfigureAwait(false)).Data; + if (room != null) + { + this.RoomConfig.RoomId = room.RoomId; + this.ShortId = room.ShortId; + this.Title = room.Title; + this.AreaNameParent = room.ParentAreaName; + this.AreaNameChild = room.AreaName; + this.Streaming = room.LiveStatus == 1; + } + } + + /// + private async Task FetchUserInfoAsync() + { + var user = await this.apiClient.GetUserInfoAsync(this.RoomConfig.RoomId).ConfigureAwait(false); + this.Name = user.Data?.Info?.Name ?? this.Name; + } + + /// + private async Task FetchRoomInfoThenCreateRecordTaskAsync() + { + await this.FetchRoomInfoAsync().ConfigureAwait(false); + this.CreateAndStartNewRecordTask(); + } + + /// + private void CreateAndStartNewRecordTask() + { + lock (this.recordStartLock) + { + if (this.disposedValue) + return; + + if (!this.Streaming) + return; + + if (this.recordTask != null) + return; + + var task = this.recordTaskFactory.CreateRecordTask(this); + task.NetworkingStats += this.RecordTask_NetworkingStats; + task.RecordingStats += this.RecordTask_RecordingStats; + task.RecordFileOpening += this.RecordTask_RecordFileOpening; + task.RecordFileClosed += this.RecordTask_RecordFileClosed; + task.RecordSessionEnded += this.RecordTask_RecordSessionEnded; + this.recordTask = task; + this.recordTaskStartTime = DateTimeOffset.UtcNow; + this.OnPropertyChanged(nameof(this.Recording)); + + _ = Task.Run(async () => + { + try + { + await this.recordTask.StartAsync(); + } + catch (Exception ex) + { + Console.WriteLine("启动录制出错 " + ex.ToString()); + this.recordTask = null; + _ = Task.Run(async () => await this.TryRestartRecordingAsync().ConfigureAwait(false)); + this.OnPropertyChanged(nameof(this.Recording)); + return; + } + RecordSessionStarted?.Invoke(this, new RecordSessionStartedEventArgs(this) + { + SessionId = this.recordTask.SessionId + }); + }); + } + } + + /// + private async Task TryRestartRecordingAsync(bool delay = true) + { + if (this.AutoRecordAllowedForThisSession) + { + try + { + if (delay) + { + if (!await this.recordRetryDelaySemaphoreSlim.WaitAsync(0).ConfigureAwait(false)) + return; + + try + { + await Task.Delay((int)this.RoomConfig.TimingStreamRetry, this.ct).ConfigureAwait(false); + } + finally + { + this.recordRetryDelaySemaphoreSlim.Release(); + } + } + + if (!this.AutoRecordAllowedForThisSession) + return; + + await this.FetchRoomInfoThenCreateRecordTaskAsync().ConfigureAwait(false); + } + catch (TaskCanceledException) + { + } + catch (Exception ex) + { + this.logger.Warning(ex, "重试开始录制时出错"); + } + } + } + + /// + private void StartDamakuConnection(bool delay = true) => + Task.Run(async () => + { + try + { + if (delay) + await Task.Delay((int)this.RoomConfig.TimingDanmakuRetry, this.ct).ConfigureAwait(false); + + await this.danmakuClient.ConnectAsync(this.RoomConfig.RoomId, this.ct).ConfigureAwait(false); + } + catch (TaskCanceledException) + { + } + catch (Exception ex) + { + this.logger.Warning(ex, "连接弹幕服务器时出错"); + + if (!this.ct.IsCancellationRequested) + this.StartDamakuConnection(); + } + }); + + #endregion + + #region Event Handlers + + /// + private void RecordTask_NetworkingStats(object sender, NetworkingStatsEventArgs e) + { + this.logger.Verbose("Networking stats: {@stats}", e); + + this.Stats.NetworkMbps = e.Mbps; + + NetworkingStats?.Invoke(this, e); + } + + /// + private void RecordTask_RecordingStats(object sender, RecordingStatsEventArgs e) + { + this.logger.Verbose("Recording stats: {@stats}", e); + + var diff = DateTimeOffset.UtcNow - this.recordTaskStartTime; + this.Stats.SessionDuration = diff.Subtract(TimeSpan.FromMilliseconds(diff.Milliseconds)); + this.Stats.FileMaxTimestamp = TimeSpan.FromMilliseconds(e.FileMaxTimestamp); + this.Stats.SessionMaxTimestamp = TimeSpan.FromMilliseconds(e.SessionMaxTimestamp); + this.Stats.DuraionRatio = e.DuraionRatio; + + this.Stats.TotalInputBytes = e.TotalInputVideoByteCount + e.TotalInputAudioByteCount; + this.Stats.TotalOutputBytes = e.TotalOutputVideoByteCount + e.TotalOutputAudioByteCount; + + RecordingStats?.Invoke(this, e); + } + + /// + private void RecordTask_RecordFileClosed(object sender, RecordFileClosedEventArgs e) + { + this.basicDanmakuWriter.Disable(); + RecordFileClosed?.Invoke(this, e); + } + + /// + private void RecordTask_RecordFileOpening(object sender, RecordFileOpeningEventArgs e) + { + if (this.RoomConfig.RecordDanmaku) + this.basicDanmakuWriter.EnableWithPath(Path.ChangeExtension(e.FullPath, "xml"), this); + RecordFileOpening?.Invoke(this, e); + } + + /// + private void RecordTask_RecordSessionEnded(object sender, EventArgs e) + { + Guid id; + lock (this.recordStartLock) + { + id = this.recordTask?.SessionId ?? default; + this.recordTask = null; + _ = Task.Run(async () => await this.TryRestartRecordingAsync().ConfigureAwait(false)); + } + + this.OnPropertyChanged(nameof(this.Recording)); + RecordSessionEnded?.Invoke(this, new RecordSessionEndedEventArgs(this) + { + SessionId = id + }); + } + + private void DanmakuClient_DanmakuReceived(object sender, Api.Danmaku.DanmakuReceivedEventArgs e) + { + var d = e.Danmaku; + + switch (d.MsgType) + { + case Api.Danmaku.DanmakuMsgType.LiveStart: + this.Streaming = true; + break; + case Api.Danmaku.DanmakuMsgType.LiveEnd: + this.Streaming = false; + break; + case Api.Danmaku.DanmakuMsgType.RoomChange: + this.Title = d.Title ?? this.Title; + this.AreaNameParent = d.ParentAreaName ?? this.AreaNameParent; + this.AreaNameChild = d.AreaName ?? this.AreaNameChild; + break; + default: + break; + } + + this.basicDanmakuWriter.Write(d); + } + + private void DanmakuClient_StatusChanged(object sender, Api.Danmaku.StatusChangedEventArgs e) + { + if (e.Connected) + { + this.DanmakuConnected = true; + } + else + { + this.DanmakuConnected = false; + this.StartDamakuConnection(); + } + } + + private void Timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e) + { + if (this.RoomConfig.AutoRecord) + _ = Task.Run(async () => await this.TryRestartRecordingAsync(delay: false).ConfigureAwait(false)); + } + + private void RoomConfig_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(this.RoomConfig.RoomId): + this.logger = this.loggerWithoutContext.ForContext(LoggingContext.RoomId, this.RoomConfig.RoomId); + break; + case nameof(this.RoomConfig.TimingCheckInterval): + this.timer.Interval = this.RoomConfig.TimingCheckInterval * 1000; + break; + default: + break; + } + } + + #endregion + + #region PropertyChanged + + protected void SetField(ref T location, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(location, value)) + return; + + location = value; + + if (propertyName != null) + this.OnPropertyChanged(propertyName); + } + + protected void OnPropertyChanged(string propertyName) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName: propertyName)); + + #endregion + + #region Dispose + + protected virtual void Dispose(bool disposing) + { + if (!this.disposedValue) + { + this.disposedValue = true; + if (disposing) + { + // dispose managed state (managed objects) + this.cts.Cancel(); + this.cts.Dispose(); + this.recordTask?.RequestStop(); + this.basicDanmakuWriter.Disable(); + this.scope.Dispose(); + } + + // free unmanaged resources (unmanaged objects) and override finalizer + // set large fields to null + } + } + + // override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources + // ~Room() + // { + // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + // Dispose(disposing: false); + // } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + this.Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + #endregion + } +} diff --git a/BililiveRecorder.Core/RoomFactory.cs b/BililiveRecorder.Core/RoomFactory.cs new file mode 100644 index 0000000..d0af7ba --- /dev/null +++ b/BililiveRecorder.Core/RoomFactory.cs @@ -0,0 +1,24 @@ +using System; +using BililiveRecorder.Core.Config.V2; +using Microsoft.Extensions.DependencyInjection; + +namespace BililiveRecorder.Core +{ + public class RoomFactory : IRoomFactory + { + private readonly IServiceProvider serviceProvider; + + public RoomFactory(IServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + public IRoom CreateRoom(RoomConfig roomConfig) + { + var scope = this.serviceProvider.CreateScope(); + var sp = scope.ServiceProvider; + + return ActivatorUtilities.CreateInstance(sp, scope, roomConfig); + } + } +} diff --git a/BililiveRecorder.Core/RoomInfo.cs b/BililiveRecorder.Core/RoomInfo.cs deleted file mode 100644 index 1445e74..0000000 --- a/BililiveRecorder.Core/RoomInfo.cs +++ /dev/null @@ -1,14 +0,0 @@ -#nullable enable -namespace BililiveRecorder.Core -{ - public class RoomInfo - { - public int ShortRoomId; - public int RoomId; - public bool IsStreaming; - public string UserName = string.Empty; - public string Title = string.Empty; - public string ParentAreaName = string.Empty; - public string AreaName = string.Empty; - } -} diff --git a/BililiveRecorder.Core/Callback/BasicWebhook.cs b/BililiveRecorder.Core/SimpleWebhook/BasicWebhookV1.cs similarity index 77% rename from BililiveRecorder.Core/Callback/BasicWebhook.cs rename to BililiveRecorder.Core/SimpleWebhook/BasicWebhookV1.cs index b563f77..4d6943a 100644 --- a/BililiveRecorder.Core/Callback/BasicWebhook.cs +++ b/BililiveRecorder.Core/SimpleWebhook/BasicWebhookV1.cs @@ -5,30 +5,29 @@ using System.Text; using System.Threading.Tasks; using BililiveRecorder.Core.Config.V2; using Newtonsoft.Json; -using NLog; +using Serilog; -#nullable enable -namespace BililiveRecorder.Core.Callback +namespace BililiveRecorder.Core.SimpleWebhook { - public class BasicWebhook + public class BasicWebhookV1 { - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); + private static readonly ILogger logger = Log.ForContext(); private static readonly HttpClient client; private readonly ConfigV2 Config; - static BasicWebhook() + static BasicWebhookV1() { client = new HttpClient(); - client.DefaultRequestHeaders.Add("User-Agent", $"BililiveRecorder/{typeof(BasicWebhook).Assembly.GetName().Version}-{BuildInfo.HeadShaShort}"); + client.DefaultRequestHeaders.Add("User-Agent", $"BililiveRecorder/{typeof(BasicWebhookV1).Assembly.GetName().Version}-{BuildInfo.HeadShaShort}"); } - public BasicWebhook(ConfigV2 config) + public BasicWebhookV1(ConfigV2 config) { this.Config = config ?? throw new ArgumentNullException(nameof(config)); } - public async void Send(RecordEndData data) + public async Task SendAsync(RecordEndData data) { var urls = this.Config.Global.WebHookUrls; if (string.IsNullOrWhiteSpace(urls)) return; @@ -57,7 +56,7 @@ namespace BililiveRecorder.Core.Callback } catch (Exception ex) { - logger.Warn(ex, "发送 Webhook 到 {url} 失败", url); + logger.Warning(ex, "发送 Webhook 到 {Url} 失败", url); } } } diff --git a/BililiveRecorder.Core/SimpleWebhook/BasicWebhookV2.cs b/BililiveRecorder.Core/SimpleWebhook/BasicWebhookV2.cs new file mode 100644 index 0000000..4d3e8dd --- /dev/null +++ b/BililiveRecorder.Core/SimpleWebhook/BasicWebhookV2.cs @@ -0,0 +1,73 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using BililiveRecorder.Core.Config.V2; +using BililiveRecorder.Core.Event; +using Newtonsoft.Json; +using Serilog; + +namespace BililiveRecorder.Core.SimpleWebhook +{ + public class BasicWebhookV2 + { + private static readonly ILogger logger = Log.ForContext(); + + private readonly HttpClient client; + private readonly GlobalConfig config; + + public BasicWebhookV2(GlobalConfig config) + { + this.config = config ?? throw new ArgumentNullException(nameof(config)); + + this.client = new HttpClient(); + this.client.DefaultRequestHeaders.Add("User-Agent", $"BililiveRecorder/{typeof(BasicWebhookV1).Assembly.GetName().Version}-{BuildInfo.HeadShaShort}"); + } + + public Task SendSessionStartedAsync(RecordSessionStartedEventArgs args) => + this.SendAsync(new EventWrapper(args) { EventType = EventType.SessionStarted }); + + public Task SendSessionEndedAsync(RecordSessionEndedEventArgs args) => + this.SendAsync(new EventWrapper(args) { EventType = EventType.SessionEnded }); + + public Task SendFileOpeningAsync(RecordFileOpeningEventArgs args) => + this.SendAsync(new EventWrapper(args) { EventType = EventType.FileOpening }); + + public Task SendFileClosedAsync(RecordFileClosedEventArgs args) => + this.SendAsync(new EventWrapper(args) { EventType = EventType.FileClosed }); + + private async Task SendAsync(object data) + { + var urls = this.config.WebHookUrlsV2; + if (string.IsNullOrWhiteSpace(urls)) return; + + var dataStr = JsonConvert.SerializeObject(data, Formatting.None); + using var body = new ByteArrayContent(Encoding.UTF8.GetBytes(dataStr)); + body.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + + var tasks = urls! + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => this.SendImplAsync(x, body)); + + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + private async Task SendImplAsync(string url, HttpContent data) + { + for (var i = 0; i < 3; i++) + try + { + var result = await this.client.PostAsync(url, data).ConfigureAwait(false); + result.EnsureSuccessStatusCode(); + return; + } + catch (Exception ex) + { + logger.Warning(ex, "发送 Webhook 到 {Url} 失败", url); + } + } + } +} diff --git a/BililiveRecorder.Core/SimpleWebhook/EventType.cs b/BililiveRecorder.Core/SimpleWebhook/EventType.cs new file mode 100644 index 0000000..f6c1efa --- /dev/null +++ b/BililiveRecorder.Core/SimpleWebhook/EventType.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace BililiveRecorder.Core.SimpleWebhook +{ + [JsonConverter(typeof(StringEnumConverter))] + public enum EventType + { + Unknown, + SessionStarted, + SessionEnded, + FileOpening, + FileClosed, + } +} diff --git a/BililiveRecorder.Core/SimpleWebhook/EventWrapper.cs b/BililiveRecorder.Core/SimpleWebhook/EventWrapper.cs new file mode 100644 index 0000000..68d2c96 --- /dev/null +++ b/BililiveRecorder.Core/SimpleWebhook/EventWrapper.cs @@ -0,0 +1,24 @@ +using System; + +namespace BililiveRecorder.Core.SimpleWebhook +{ + public class EventWrapper where T : class + { + public EventWrapper() + { + } + + public EventWrapper(T data) + { + this.EventData = data; + } + + public EventType EventType { get; set; } + + public DateTimeOffset EventTimestamp { get; set; } = DateTimeOffset.Now; + + public Guid EventId { get; set; } = Guid.NewGuid(); + + public T? EventData { get; set; } + } +} diff --git a/BililiveRecorder.Core/SimpleWebhook/RecordEndData.cs b/BililiveRecorder.Core/SimpleWebhook/RecordEndData.cs new file mode 100644 index 0000000..85398cc --- /dev/null +++ b/BililiveRecorder.Core/SimpleWebhook/RecordEndData.cs @@ -0,0 +1,33 @@ +using System; +using BililiveRecorder.Core.Event; + +#nullable enable +namespace BililiveRecorder.Core.SimpleWebhook +{ + public class RecordEndData + { + public RecordEndData(RecordFileClosedEventArgs args) + { + if (args is null) + throw new ArgumentNullException(nameof(args)); + + this.RoomId = args.RoomId; + this.Name = args.Name; + this.Title = args.Title; + this.RelativePath = args.RelativePath; + this.FileSize = args.FileSize; + this.StartRecordTime = args.FileOpenTime; + this.EndRecordTime = args.FileCloseTime; + } + + public Guid EventRandomId { get; set; } = Guid.NewGuid(); + + public int RoomId { get; set; } = 0; + public string Name { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string RelativePath { get; set; } = string.Empty; + public long FileSize { get; set; } + public DateTimeOffset StartRecordTime { get; set; } + public DateTimeOffset EndRecordTime { get; set; } + } +} diff --git a/BililiveRecorder.Core/StreamExtensions.cs b/BililiveRecorder.Core/StreamExtensions.cs new file mode 100644 index 0000000..8c20fc1 --- /dev/null +++ b/BililiveRecorder.Core/StreamExtensions.cs @@ -0,0 +1,40 @@ +using System; +using System.Buffers; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace BililiveRecorder.Core +{ + internal static class StreamExtensions + { + // modified from dotnet/runtime 8a52f1e948b6f22f418817ec1068f07b8dae2aa5 + // file: src/libraries/System.Private.CoreLib/src/System/IO/Stream.cs + // licensed under the MIT license + public static ValueTask ReadAsync(this Stream stream, Memory buffer, CancellationToken cancellationToken = default) + { + if (MemoryMarshal.TryGetArray(buffer, out ArraySegment array)) + { + return new ValueTask(stream.ReadAsync(array.Array!, array.Offset, array.Count, cancellationToken)); + } + + var sharedBuffer = ArrayPool.Shared.Rent(buffer.Length); + return FinishReadAsync(stream.ReadAsync(sharedBuffer, 0, buffer.Length, cancellationToken), sharedBuffer, buffer); + + static async ValueTask FinishReadAsync(Task readTask, byte[] localBuffer, Memory localDestination) + { + try + { + var result = await readTask.ConfigureAwait(false); + new ReadOnlySpan(localBuffer, 0, result).CopyTo(localDestination.Span); + return result; + } + finally + { + ArrayPool.Shared.Return(localBuffer); + } + } + } + } +} diff --git a/BililiveRecorder.Core/StreamMonitor.cs b/BililiveRecorder.Core/StreamMonitor.cs deleted file mode 100644 index 4950128..0000000 --- a/BililiveRecorder.Core/StreamMonitor.cs +++ /dev/null @@ -1,463 +0,0 @@ -using System; -using System.ComponentModel; -using System.IO; -using System.IO.Compression; -using System.Net; -using System.Net.Sockets; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using BililiveRecorder.Core.Config.V2; -using Newtonsoft.Json; -using NLog; -using Timer = System.Timers.Timer; - -namespace BililiveRecorder.Core -{ - /** - * 直播状态监控 - * 分为弹幕连接和HTTP轮询两部分 - * - * 弹幕连接: - * 一直保持连接,并把收到的弹幕保存到数据库 - * - * HTTP轮询: - * 只有在监控启动时运行,根据直播状态触发事件 - * - * - * */ - public class StreamMonitor : IStreamMonitor - { - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); - - private readonly RoomConfig roomConfig; - private readonly BililiveAPI bililiveAPI; - - private Exception dmError = null; - private TcpClient dmClient; - private NetworkStream dmNetStream; - private Thread dmReceiveMessageLoopThread; - private readonly CancellationTokenSource dmTokenSource = null; - private bool dmConnectionTriggered = false; - private readonly Timer httpTimer; - - private int RoomId { get => this.roomConfig.RoomId; set => this.roomConfig.RoomId = value; } - - public bool IsMonitoring { get; private set; } = false; - public bool IsDanmakuConnected => this.dmClient?.Connected ?? false; - public event RoomInfoUpdatedEvent RoomInfoUpdated; - public event StreamStartedEvent StreamStarted; - public event ReceivedDanmakuEvt ReceivedDanmaku; - public event PropertyChangedEventHandler PropertyChanged; - - public StreamMonitor(RoomConfig roomConfig, BililiveAPI bililiveAPI) - { - this.roomConfig = roomConfig; - this.bililiveAPI = bililiveAPI; - - ReceivedDanmaku += this.Receiver_ReceivedDanmaku; - RoomInfoUpdated += this.StreamMonitor_RoomInfoUpdated; - - this.dmTokenSource = new CancellationTokenSource(); - Repeat.Interval(TimeSpan.FromSeconds(30), () => - { - if (this.dmNetStream != null && this.dmNetStream.CanWrite) - { - try - { - this.SendSocketData(2); - } - catch (Exception) { } - } - }, this.dmTokenSource.Token); - - this.httpTimer = new Timer(roomConfig.TimingCheckInterval * 1000) - { - Enabled = false, - AutoReset = true, - SynchronizingObject = null, - Site = null - }; - this.httpTimer.Elapsed += (sender, e) => - { - try - { - this.Check(TriggerType.HttpApi); - } - catch (Exception ex) - { - logger.Log(this.RoomId, LogLevel.Warn, "获取直播间开播状态出错", ex); - } - }; - - roomConfig.PropertyChanged += (sender, e) => - { - if (e.PropertyName.Equals(nameof(roomConfig.TimingCheckInterval))) - { - this.httpTimer.Interval = roomConfig.TimingCheckInterval * 1000; - } - }; - } - - private void StreamMonitor_RoomInfoUpdated(object sender, RoomInfoUpdatedArgs e) - { - this.RoomId = e.RoomInfo.RoomId; - // TODO: RecordedRoom 里的 RoomInfoUpdated Handler 也会设置一次 RoomId - // 暂时保持不变,此处需要使用请求返回的房间号连接弹幕服务器 - if (!this.dmConnectionTriggered) - { - this.dmConnectionTriggered = true; - Task.Run(() => this.ConnectWithRetryAsync()); - } - } - - private void Receiver_ReceivedDanmaku(object sender, ReceivedDanmakuArgs e) - { - switch (e.Danmaku.MsgType) - { - case MsgTypeEnum.LiveStart: - if (this.IsMonitoring) - { - Task.Run(() => StreamStarted?.Invoke(this, new StreamStartedArgs() { type = TriggerType.Danmaku })); - } - break; - case MsgTypeEnum.LiveEnd: - break; - default: - break; - } - } - - #region 对外API - - public bool Start() - { - if (this.disposedValue) - { - throw new ObjectDisposedException(nameof(StreamMonitor)); - } - - this.IsMonitoring = true; - this.httpTimer.Start(); - this.Check(TriggerType.HttpApi); - return true; - } - - public void Stop() - { - if (this.disposedValue) - { - throw new ObjectDisposedException(nameof(StreamMonitor)); - } - - this.IsMonitoring = false; - this.httpTimer.Stop(); - } - - public void Check(TriggerType type, int millisecondsDelay = 0) - { - if (this.disposedValue) - { - throw new ObjectDisposedException(nameof(StreamMonitor)); - } - - if (millisecondsDelay < 0) - { - throw new ArgumentOutOfRangeException(nameof(millisecondsDelay), "不能小于0"); - } - - Task.Run(async () => - { - await Task.Delay(millisecondsDelay).ConfigureAwait(false); - if ((await this.FetchRoomInfoAsync().ConfigureAwait(false))?.IsStreaming ?? false) - { - StreamStarted?.Invoke(this, new StreamStartedArgs() { type = type }); - } - }); - } - - public async Task FetchRoomInfoAsync() - { - RoomInfo roomInfo = await this.bililiveAPI.GetRoomInfoAsync(this.RoomId).ConfigureAwait(false); - if (roomInfo != null) - RoomInfoUpdated?.Invoke(this, new RoomInfoUpdatedArgs { RoomInfo = roomInfo }); - return roomInfo; - } - - #endregion - #region 弹幕连接 - - private async Task ConnectWithRetryAsync() - { - bool connect_result = false; - while (!this.IsDanmakuConnected && !this.dmTokenSource.Token.IsCancellationRequested) - { - logger.Log(this.RoomId, LogLevel.Info, "连接弹幕服务器..."); - connect_result = await this.ConnectAsync().ConfigureAwait(false); - if (!connect_result) - await Task.Delay((int)Math.Max(this.roomConfig.TimingDanmakuRetry, 0)); - } - - if (connect_result) - { - logger.Log(this.RoomId, LogLevel.Info, "弹幕服务器连接成功"); - } - } - - private async Task ConnectAsync() - { - if (this.IsDanmakuConnected) { return true; } - - try - { - var (token, host, port) = await this.bililiveAPI.GetDanmuConf(this.RoomId); - - logger.Log(this.RoomId, LogLevel.Debug, $"连接弹幕服务器 {host}:{port} {(string.IsNullOrWhiteSpace(token) ? "无" : "有")} token"); - - this.dmClient = new TcpClient(); - await this.dmClient.ConnectAsync(host, port).ConfigureAwait(false); - this.dmNetStream = this.dmClient.GetStream(); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected))); - - this.dmReceiveMessageLoopThread = new Thread(this.ReceiveMessageLoop) - { - Name = "ReceiveMessageLoop " + this.RoomId, - IsBackground = true - }; - this.dmReceiveMessageLoopThread.Start(); - - var hello = JsonConvert.SerializeObject(new - { - uid = 0, - roomid = this.RoomId, - protover = 2, - platform = "web", - clientver = "1.11.0", - type = 2, - key = token, - - }, Formatting.None); - this.SendSocketData(7, hello); - this.SendSocketData(2); - - return true; - } - catch (Exception ex) - { - this.dmError = ex; - logger.Log(this.RoomId, LogLevel.Warn, "连接弹幕服务器错误", ex); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected))); - return false; - } - } - - private void ReceiveMessageLoop() - { - logger.Log(this.RoomId, LogLevel.Trace, "ReceiveMessageLoop Started"); - try - { - var stableBuffer = new byte[16]; - var buffer = new byte[4096]; - while (this.IsDanmakuConnected) - { - this.dmNetStream.ReadB(stableBuffer, 0, 16); - Parse2Protocol(stableBuffer, out DanmakuProtocol protocol); - - if (protocol.PacketLength < 16) - { - throw new NotSupportedException("协议失败: (L:" + protocol.PacketLength + ")"); - } - - var payloadlength = protocol.PacketLength - 16; - if (payloadlength == 0) - { - continue;//没有内容了 - } - - if (buffer.Length < payloadlength) // 不够长再申请 - { - buffer = new byte[payloadlength]; - } - - this.dmNetStream.ReadB(buffer, 0, payloadlength); - - if (protocol.Version == 2 && protocol.Action == 5) // 处理deflate消息 - { - // Skip 0x78 0xDA - using (DeflateStream deflate = new DeflateStream(new MemoryStream(buffer, 2, payloadlength - 2), CompressionMode.Decompress)) - { - while (deflate.Read(stableBuffer, 0, 16) > 0) - { - Parse2Protocol(stableBuffer, out protocol); - payloadlength = protocol.PacketLength - 16; - if (payloadlength == 0) - { - continue; // 没有内容了 - } - if (buffer.Length < payloadlength) // 不够长再申请 - { - buffer = new byte[payloadlength]; - } - deflate.Read(buffer, 0, payloadlength); - ProcessDanmaku(protocol.Action, buffer, payloadlength); - } - } - } - else - { - ProcessDanmaku(protocol.Action, buffer, payloadlength); - } - - void ProcessDanmaku(int action, byte[] local_buffer, int length) - { - switch (action) - { - case 3: - // var viewer = BitConverter.ToUInt32(local_buffer.Take(4).Reverse().ToArray(), 0); //观众人数 - break; - case 5://playerCommand - var json = Encoding.UTF8.GetString(local_buffer, 0, length); - try - { - ReceivedDanmaku?.Invoke(this, new ReceivedDanmakuArgs() { Danmaku = new DanmakuModel(json) }); - } - catch (Exception ex) - { - logger.Log(this.RoomId, LogLevel.Warn, "", ex); - } - break; - default: - break; - } - } - } - } - catch (Exception ex) - { - this.dmError = ex; - // logger.Error(ex); - - logger.Log(this.RoomId, LogLevel.Debug, "Disconnected"); - this.dmClient?.Close(); - this.dmNetStream = null; - if (!(this.dmTokenSource?.IsCancellationRequested ?? true)) - { - logger.Log(this.RoomId, LogLevel.Warn, "弹幕连接被断开,将尝试重连", ex); - Task.Run(async () => - { - await Task.Delay((int)Math.Max(this.roomConfig.TimingDanmakuRetry, 0)); - await this.ConnectWithRetryAsync(); - }); - } - } - finally - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected))); - } - } - - private void SendSocketData(int action, string body = "") - { - const int param = 1; - const short magic = 16; - const short ver = 1; - - var playload = Encoding.UTF8.GetBytes(body); - var buffer = new byte[(playload.Length + 16)]; - - using (var ms = new MemoryStream(buffer)) - { - var b = BitConverter.GetBytes(buffer.Length).ToBE(); - ms.Write(b, 0, 4); - b = BitConverter.GetBytes(magic).ToBE(); - ms.Write(b, 0, 2); - b = BitConverter.GetBytes(ver).ToBE(); - ms.Write(b, 0, 2); - b = BitConverter.GetBytes(action).ToBE(); - ms.Write(b, 0, 4); - b = BitConverter.GetBytes(param).ToBE(); - ms.Write(b, 0, 4); - if (playload.Length > 0) - { - ms.Write(playload, 0, playload.Length); - } - this.dmNetStream.Write(buffer, 0, buffer.Length); - this.dmNetStream.Flush(); - } - } - - private static unsafe void Parse2Protocol(byte[] buffer, out DanmakuProtocol protocol) - { - fixed (byte* ptr = buffer) - { - protocol = *(DanmakuProtocol*)ptr; - } - protocol.ChangeEndian(); - } - - private struct DanmakuProtocol - { - /// - /// 消息总长度 (协议头 + 数据长度) - /// - 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 - - #region IDisposable Support - private bool disposedValue = false; // 要检测冗余调用 - - protected virtual void Dispose(bool disposing) - { - if (!this.disposedValue) - { - if (disposing) - { - this.dmTokenSource?.Cancel(); - this.dmTokenSource?.Dispose(); - this.httpTimer?.Dispose(); - this.dmClient?.Close(); - } - - this.dmNetStream = null; - this.disposedValue = true; - } - } - - public void Dispose() - { - // 请勿更改此代码。将清理代码放入以上 Dispose(bool disposing) 中。 - this.Dispose(true); - } - #endregion - } -} diff --git a/BililiveRecorder.Core/StreamStatus.cs b/BililiveRecorder.Core/StreamStatus.cs deleted file mode 100644 index 046e15e..0000000 --- a/BililiveRecorder.Core/StreamStatus.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace BililiveRecorder.Core -{ - public enum TriggerType - { - Danmaku, - HttpApi, - HttpApiRecheck, - Manual, - } -} diff --git a/BililiveRecorder.Core/Utils.cs b/BililiveRecorder.Core/Utils.cs deleted file mode 100644 index c1db43e..0000000 --- a/BililiveRecorder.Core/Utils.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Net.Sockets; -using NLog; - -namespace BililiveRecorder.Core -{ - public static class Utils - { - internal static byte[] ToBE(this byte[] b) - { - if (BitConverter.IsLittleEndian) - { - return b.Reverse().ToArray(); - } - else - { - return b; - } - } - - internal static void ReadB(this NetworkStream stream, byte[] buffer, int offset, int count) - { - if (offset + count > buffer.Length) - { - throw new ArgumentException(); - } - - int read = 0; - while (read < count) - { - var available = stream.Read(buffer, offset, count - read); - if (available == 0) - { - throw new ObjectDisposedException(null); - } - read += available; - offset += available; - } - } - - internal static string RemoveInvalidFileName(this string name, bool ignore_slash = false) - { - foreach (char c in Path.GetInvalidFileNameChars()) - { - if (ignore_slash && (c == '\\' || c == '/')) - continue; - name = name.Replace(c, '_'); - } - return name; - } - - public static bool CopyPropertiesTo(this T source, T target) where T : class - { - if (source == null || target == null || source == target) { return false; } - foreach (var p in source.GetType().GetProperties()) - { - if (Attribute.IsDefined(p, typeof(DoNotCopyProperty))) - { - continue; - } - - var val = p.GetValue(source); - if (val == null || !val.Equals(p.GetValue(target))) - { - p.SetValue(target, val); - } - } - return true; - } - - [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] - public class DoNotCopyProperty : Attribute { } - - internal static void Log(this Logger logger, int id, LogLevel level, string message, Exception exception = null) - { - var log = new LogEventInfo() - { - Level = level, - Message = message, - Exception = exception, - }; - log.Properties["roomid"] = id; - logger.Log(log); - } - - private static string _useragent; - internal static string UserAgent - { - get - { - if (string.IsNullOrWhiteSpace(_useragent)) - { - string version = typeof(Utils).Assembly.GetName().Version.ToString(); - _useragent = $"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.122 Safari/537.36 BililiveRecorder/{version} (+https://github.com/Bililive/BililiveRecorder;bliverec@danmuji.org)"; - } - return _useragent; - } - } - - } -} diff --git a/BililiveRecorder.Flv/Amf/ScriptTagBody.cs b/BililiveRecorder.Flv/Amf/ScriptTagBody.cs index ec23d95..783659a 100644 --- a/BililiveRecorder.Flv/Amf/ScriptTagBody.cs +++ b/BililiveRecorder.Flv/Amf/ScriptTagBody.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.IO; using System.Text; using System.Xml; @@ -17,13 +19,23 @@ namespace BililiveRecorder.Flv.Amf DateTimeZoneHandling = DateTimeZoneHandling.RoundtripKind, }; - public ScriptDataString Name { get; set; } = string.Empty; + public ScriptTagBody() + { + this.Values = new List(); + } - public ScriptDataObject Value { get; set; } = new ScriptDataObject(); + public ScriptTagBody(List values) + { + this.Values = values ?? throw new ArgumentNullException(nameof(values)); + } - public static ScriptTagBody Parse(string json) => JsonConvert.DeserializeObject(json, jsonSerializerSettings)!; + public List Values { get; set; } - public string ToJson() => JsonConvert.SerializeObject(this, jsonSerializerSettings); + public static ScriptTagBody Parse(string json) => + new ScriptTagBody(JsonConvert.DeserializeObject>(json, jsonSerializerSettings) + ?? throw new Exception("JsonConvert.DeserializeObject returned null")); + + public string ToJson() => JsonConvert.SerializeObject(this.Values, jsonSerializerSettings); public static ScriptTagBody Parse(byte[] bytes) { @@ -31,26 +43,16 @@ namespace BililiveRecorder.Flv.Amf return Parse(ms); } - public static ScriptTagBody Parse(Stream stream) - { - return Parse(new BigEndianBinaryReader(stream, Encoding.UTF8, true)); - } + public static ScriptTagBody Parse(Stream stream) => Parse(new BigEndianBinaryReader(stream, Encoding.UTF8, true)); public static ScriptTagBody Parse(BigEndianBinaryReader binaryReader) { - if (ParseValue(binaryReader) is ScriptDataString stringName) - return new ScriptTagBody - { - Name = stringName, - Value = ((ParseValue(binaryReader)) switch - { - ScriptDataEcmaArray value => value, - ScriptDataObject value => value, - _ => throw new AmfException("type of ScriptTagBody.Value is not supported"), - }) - }; - else - throw new AmfException("ScriptTagBody.Name is not String"); + var list = new List(); + + while (binaryReader.BaseStream.Position < binaryReader.BaseStream.Length) + list.Add(ParseValue(binaryReader)); + + return new ScriptTagBody(list); } public byte[] ToBytes() @@ -62,8 +64,10 @@ namespace BililiveRecorder.Flv.Amf public void WriteTo(Stream stream) { - this.Name.WriteTo(stream); - this.Value.WriteTo(stream); + foreach (var value in this.Values) + { + value.WriteTo(stream); + } } public static IScriptDataValue ParseValue(BigEndianBinaryReader binaryReader) @@ -144,6 +148,7 @@ namespace BililiveRecorder.Flv.Amf { var bytes = binaryReader.ReadBytes((int)length); var str = Encoding.UTF8.GetString(bytes); + str = str.Replace("\0", ""); return (ScriptDataLongString)str; } } @@ -160,7 +165,7 @@ namespace BililiveRecorder.Flv.Amf throw new AmfException("ObjectEndMarker not matched."); return null; } - return Encoding.UTF8.GetString(binaryReader.ReadBytes(length)); + return Encoding.UTF8.GetString(binaryReader.ReadBytes(length)).Replace("\0", ""); ; } } @@ -169,8 +174,7 @@ namespace BililiveRecorder.Flv.Amf { var str = reader.ReadElementContentAsString(); var obj = Parse(str); - this.Name = obj.Name; - this.Value = obj.Value; + this.Values = obj.Values; } void IXmlSerializable.WriteXml(XmlWriter writer) => writer.WriteString(this.ToJson()); } diff --git a/BililiveRecorder.Flv/Amf/ScriptTagBodyExtensions.cs b/BililiveRecorder.Flv/Amf/ScriptTagBodyExtensions.cs new file mode 100644 index 0000000..ca64aed --- /dev/null +++ b/BililiveRecorder.Flv/Amf/ScriptTagBodyExtensions.cs @@ -0,0 +1,7 @@ +namespace BililiveRecorder.Flv.Amf +{ + public static class ScriptTagBodyExtensions + { + public static ScriptDataEcmaArray? GetMetadataValue(this ScriptTagBody body) => body.Values.Count > 1 ? body.Values[1] as ScriptDataEcmaArray : null; + } +} diff --git a/BililiveRecorder.Flv/BililiveRecorder.Flv.csproj b/BililiveRecorder.Flv/BililiveRecorder.Flv.csproj index 1d9d0e4..7703c65 100644 --- a/BililiveRecorder.Flv/BililiveRecorder.Flv.csproj +++ b/BililiveRecorder.Flv/BililiveRecorder.Flv.csproj @@ -16,6 +16,7 @@ all runtime; build; native; contentfiles; analyzers + diff --git a/BililiveRecorder.Flv/DependencyInjectionExtensions.cs b/BililiveRecorder.Flv/DependencyInjectionExtensions.cs index bf304e5..9af8d8c 100644 --- a/BililiveRecorder.Flv/DependencyInjectionExtensions.cs +++ b/BililiveRecorder.Flv/DependencyInjectionExtensions.cs @@ -1,13 +1,12 @@ +using BililiveRecorder.Flv.Pipeline; using Microsoft.Extensions.DependencyInjection; namespace BililiveRecorder.DependencyInjection { public static class DependencyInjectionExtensions { - public static IServiceCollection AddFlv(this IServiceCollection services) - { - - return services; - } + public static IServiceCollection AddFlv(this IServiceCollection services) => services + .AddTransient() + ; } } diff --git a/BililiveRecorder.Flv/FileClosedEventArgs.cs b/BililiveRecorder.Flv/FileClosedEventArgs.cs new file mode 100644 index 0000000..b3b6b46 --- /dev/null +++ b/BililiveRecorder.Flv/FileClosedEventArgs.cs @@ -0,0 +1,13 @@ +using System; + +namespace BililiveRecorder.Flv +{ + public class FileClosedEventArgs : EventArgs + { + public long FileSize { get; set; } + + public double Duration { get; set; } + + public object? State { get; set; } + } +} diff --git a/BililiveRecorder.Flv/Grouping/Rules/DataGroupingRule.cs b/BililiveRecorder.Flv/Grouping/Rules/DataGroupingRule.cs index 71957a0..3246691 100644 --- a/BililiveRecorder.Flv/Grouping/Rules/DataGroupingRule.cs +++ b/BililiveRecorder.Flv/Grouping/Rules/DataGroupingRule.cs @@ -7,7 +7,8 @@ namespace BililiveRecorder.Flv.Grouping.Rules { public bool StartWith(Tag tag) => tag.IsData(); - public bool AppendWith(Tag tag) => tag.IsNonKeyframeData(); + public bool AppendWith(Tag tag, List tags) => tag.IsNonKeyframeData() + || (tag.Type == TagType.Audio && tag.Flag == TagFlag.Header && tags.TrueForAll(x => x.Type != TagType.Audio)); public PipelineAction CreatePipelineAction(List tags) => new PipelineDataAction(tags); } diff --git a/BililiveRecorder.Flv/Grouping/Rules/HeaderGroupingRule.cs b/BililiveRecorder.Flv/Grouping/Rules/HeaderGroupingRule.cs index 7bbc2cd..c46c6df 100644 --- a/BililiveRecorder.Flv/Grouping/Rules/HeaderGroupingRule.cs +++ b/BililiveRecorder.Flv/Grouping/Rules/HeaderGroupingRule.cs @@ -7,7 +7,7 @@ namespace BililiveRecorder.Flv.Grouping.Rules { public bool StartWith(Tag tag) => tag.IsHeader(); - public bool AppendWith(Tag tag) => tag.IsHeader(); + public bool AppendWith(Tag tag, List tags) => tag.IsHeader(); public PipelineAction CreatePipelineAction(List tags) => new PipelineHeaderAction(tags); } diff --git a/BililiveRecorder.Flv/Grouping/Rules/ScriptGroupingRule.cs b/BililiveRecorder.Flv/Grouping/Rules/ScriptGroupingRule.cs index 2f043fb..ce92222 100644 --- a/BililiveRecorder.Flv/Grouping/Rules/ScriptGroupingRule.cs +++ b/BililiveRecorder.Flv/Grouping/Rules/ScriptGroupingRule.cs @@ -8,7 +8,7 @@ namespace BililiveRecorder.Flv.Grouping.Rules { public bool StartWith(Tag tag) => tag.IsScript(); - public bool AppendWith(Tag tag) => false; + public bool AppendWith(Tag tag, List tags) => false; public PipelineAction CreatePipelineAction(List tags) => new PipelineScriptAction(tags.First()); } diff --git a/BililiveRecorder.Flv/Grouping/TagGroupReader.cs b/BililiveRecorder.Flv/Grouping/TagGroupReader.cs index 90fb56d..49e3573 100644 --- a/BililiveRecorder.Flv/Grouping/TagGroupReader.cs +++ b/BililiveRecorder.Flv/Grouping/TagGroupReader.cs @@ -34,7 +34,7 @@ namespace BililiveRecorder.Flv.Grouping }; } - public async Task ReadGroupAsync() + public async Task ReadGroupAsync(CancellationToken token) { if (!this.semaphoreSlim.Wait(0)) { @@ -44,7 +44,7 @@ namespace BililiveRecorder.Flv.Grouping { var tags = new List(); - var firstTag = await this.TagReader.ReadTagAsync().ConfigureAwait(false); + var firstTag = await this.TagReader.ReadTagAsync(token).ConfigureAwait(false); // 数据已经全部读完 if (firstTag is null) @@ -57,13 +57,13 @@ namespace BililiveRecorder.Flv.Grouping tags.Add(firstTag); - while (true) + while (!token.IsCancellationRequested) { - var tag = await this.TagReader.PeekTagAsync().ConfigureAwait(false); + var tag = await this.TagReader.PeekTagAsync(token).ConfigureAwait(false); - if (tag != null && rule.AppendWith(tag)) + if (tag != null && rule.AppendWith(tag, tags)) { - await this.TagReader.ReadTagAsync().ConfigureAwait(false); + await this.TagReader.ReadTagAsync(token).ConfigureAwait(false); tags.Add(tag); } else @@ -93,13 +93,13 @@ namespace BililiveRecorder.Flv.Grouping this.TagReader.Dispose(); } - // TODO: free unmanaged resources (unmanaged objects) and override finalizer - // TODO: set large fields to null + // free unmanaged resources (unmanaged objects) and override finalizer + // set large fields to null this.disposedValue = true; } } - // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources + // override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources // ~TagGroupReader() // { // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method diff --git a/BililiveRecorder.Flv/IFlvProcessingContextWriter.cs b/BililiveRecorder.Flv/IFlvProcessingContextWriter.cs index 7a1e35e..6675c0f 100644 --- a/BililiveRecorder.Flv/IFlvProcessingContextWriter.cs +++ b/BililiveRecorder.Flv/IFlvProcessingContextWriter.cs @@ -1,11 +1,17 @@ using System; using System.Threading.Tasks; +using BililiveRecorder.Flv.Amf; using BililiveRecorder.Flv.Pipeline; namespace BililiveRecorder.Flv { public interface IFlvProcessingContextWriter : IDisposable { + Action? BeforeScriptTagWrite { get; set; } + Action? BeforeScriptTagRewrite { get; set; } + + event EventHandler FileClosed; + Task WriteAsync(FlvProcessingContext context); } } diff --git a/BililiveRecorder.Flv/IFlvTagReader.cs b/BililiveRecorder.Flv/IFlvTagReader.cs index a677ed7..33e8e97 100644 --- a/BililiveRecorder.Flv/IFlvTagReader.cs +++ b/BililiveRecorder.Flv/IFlvTagReader.cs @@ -12,12 +12,12 @@ namespace BililiveRecorder.Flv /// Returns the next available Flv Tag but does not consume it. ///
/// - Task PeekTagAsync(); + Task PeekTagAsync(System.Threading.CancellationToken token); /// /// Reads the next Flv Tag. /// /// - Task ReadTagAsync(); + Task ReadTagAsync(System.Threading.CancellationToken token); } } diff --git a/BililiveRecorder.Flv/IFlvWriterTargetProvider.cs b/BililiveRecorder.Flv/IFlvWriterTargetProvider.cs index 85abec5..3a24ce4 100644 --- a/BililiveRecorder.Flv/IFlvWriterTargetProvider.cs +++ b/BililiveRecorder.Flv/IFlvWriterTargetProvider.cs @@ -1,11 +1,14 @@ +using System.Collections.Generic; using System.IO; namespace BililiveRecorder.Flv { public interface IFlvWriterTargetProvider { - Stream CreateOutputStream(); + (Stream stream, object state) CreateOutputStream(); Stream CreateAlternativeHeaderStream(); + + bool ShouldCreateNewFile(Stream outputStream, IList tags); } } diff --git a/BililiveRecorder.Flv/IGroupingRule.cs b/BililiveRecorder.Flv/IGroupingRule.cs index 4c452df..9391029 100644 --- a/BililiveRecorder.Flv/IGroupingRule.cs +++ b/BililiveRecorder.Flv/IGroupingRule.cs @@ -6,7 +6,7 @@ namespace BililiveRecorder.Flv public interface IGroupingRule { bool StartWith(Tag tag); - bool AppendWith(Tag tag); + bool AppendWith(Tag tag, List tags); PipelineAction CreatePipelineAction(List tags); } } diff --git a/BililiveRecorder.Flv/ITagGroupReader.cs b/BililiveRecorder.Flv/ITagGroupReader.cs index f3430bb..56cfea0 100644 --- a/BililiveRecorder.Flv/ITagGroupReader.cs +++ b/BililiveRecorder.Flv/ITagGroupReader.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Threading.Tasks; using BililiveRecorder.Flv.Pipeline; @@ -6,6 +7,6 @@ namespace BililiveRecorder.Flv { public interface ITagGroupReader : IDisposable { - Task ReadGroupAsync(); + Task ReadGroupAsync(CancellationToken token); } } diff --git a/BililiveRecorder.Flv/Parser/FlvTagPipeReader.cs b/BililiveRecorder.Flv/Parser/FlvTagPipeReader.cs index 22a10a9..477f6bd 100644 --- a/BililiveRecorder.Flv/Parser/FlvTagPipeReader.cs +++ b/BililiveRecorder.Flv/Parser/FlvTagPipeReader.cs @@ -7,6 +7,7 @@ using System.IO; using System.IO.Pipelines; using System.Threading; using System.Threading.Tasks; +using Serilog; namespace BililiveRecorder.Flv.Parser { @@ -16,7 +17,7 @@ namespace BililiveRecorder.Flv.Parser public class FlvTagPipeReader : IFlvTagReader, IDisposable { private static int memoryCreateCounter = 0; - + private readonly ILogger? logger; private readonly IMemoryStreamProvider memoryStreamProvider; private readonly bool skipData; private readonly bool leaveOpen; @@ -29,12 +30,13 @@ namespace BililiveRecorder.Flv.Parser public PipeReader Reader { get; } - public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider) : this(reader, memoryStreamProvider, false) { } + public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, ILogger? logger = null) : this(reader, memoryStreamProvider, false, logger) { } - public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, bool skipData = false) : this(reader, memoryStreamProvider, skipData, false) { } + public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, bool skipData = false, ILogger? logger = null) : this(reader, memoryStreamProvider, skipData, false, logger) { } - public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, bool skipData = false, bool leaveOpen = false) + public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, bool skipData = false, bool leaveOpen = false, ILogger? logger = null) { + this.logger = logger?.ForContext(); this.Reader = reader ?? throw new ArgumentNullException(nameof(reader)); this.memoryStreamProvider = memoryStreamProvider ?? throw new ArgumentNullException(nameof(memoryStreamProvider)); @@ -270,7 +272,14 @@ namespace BililiveRecorder.Flv.Parser if (tag.Type == TagType.Script) { - tag.ScriptData = Amf.ScriptTagBody.Parse(tagBodyStream); + try + { + tag.ScriptData = Amf.ScriptTagBody.Parse(tagBodyStream); + } + catch (Exception ex) + { + this.logger?.Debug(ex, "Error parsing script tag body"); + } } else if (tag.Type == TagType.Video && !tag.Flag.HasFlag(TagFlag.Header)) { @@ -288,7 +297,7 @@ namespace BililiveRecorder.Flv.Parser } /// - public async Task PeekTagAsync() + public async Task PeekTagAsync(CancellationToken token) { try { @@ -300,7 +309,7 @@ namespace BililiveRecorder.Flv.Parser } else { - this.peekTag = await this.ReadNextTagAsync(); + this.peekTag = await this.ReadNextTagAsync(token); this.peek = true; return this.peekTag; } @@ -312,7 +321,7 @@ namespace BililiveRecorder.Flv.Parser } /// - public async Task ReadTagAsync() + public async Task ReadTagAsync(CancellationToken token) { try { @@ -326,7 +335,7 @@ namespace BililiveRecorder.Flv.Parser } else { - return await this.ReadNextTagAsync(); + return await this.ReadNextTagAsync(token); } } finally diff --git a/BililiveRecorder.Flv/Pipeline/IProcessingPipelineBuilderExtensions.cs b/BililiveRecorder.Flv/Pipeline/IProcessingPipelineBuilderExtensions.cs index 09ef3f8..ac0e6de 100644 --- a/BililiveRecorder.Flv/Pipeline/IProcessingPipelineBuilderExtensions.cs +++ b/BililiveRecorder.Flv/Pipeline/IProcessingPipelineBuilderExtensions.cs @@ -24,6 +24,7 @@ namespace BililiveRecorder.Flv.Pipeline public static IProcessingPipelineBuilder AddDefault(this IProcessingPipelineBuilder builder) => builder + .Add() .Add() .Add() .Add() diff --git a/BililiveRecorder.Flv/Pipeline/Rules/HandleDelayedAudioHeaderRule.cs b/BililiveRecorder.Flv/Pipeline/Rules/HandleDelayedAudioHeaderRule.cs new file mode 100644 index 0000000..c07352e --- /dev/null +++ b/BililiveRecorder.Flv/Pipeline/Rules/HandleDelayedAudioHeaderRule.cs @@ -0,0 +1,61 @@ +using System.Linq; +using System.Threading.Tasks; + +namespace BililiveRecorder.Flv.Pipeline.Rules +{ + /// + /// 处理延后收到的音频头 + /// + /// + /// 本规则应该放在所有规则前面 + /// + public class HandleDelayedAudioHeaderRule : IFullProcessingRule + { + public Task RunAsync(FlvProcessingContext context, ProcessingDelegate next) + { + if (context.OriginalInput is PipelineDataAction dataAction) + { + if (!dataAction.Tags.Any(x => x.IsHeader())) + return next(context); + else + return this.RunAsyncCore(dataAction, context, next); + } + else + return next(context); + } + + private async Task RunAsyncCore(PipelineDataAction dataAction, FlvProcessingContext context, ProcessingDelegate next) + { + context.ClearOutput(); + context.AddComment("检测到延后收到的音频头"); + + var tags = dataAction.Tags; + var index = tags.IndexOf(tags.Last(x => x.Flag == TagFlag.Header)); + for (var i = 0; i < index; i++) + { + if (tags[i].Type == TagType.Audio) + { + context.AddDisconnectAtStart(); + return; + } + } + + var headerTags = tags.Where(x => x.Flag == TagFlag.Header).ToList(); + var newHeaderAction = new PipelineHeaderAction(headerTags); + var dataTags = tags.Where(x => x.Flag != TagFlag.Header).ToList(); + var newDataAction = new PipelineDataAction(dataTags); + + var localContext = new FlvProcessingContext(newHeaderAction, context.SessionItems); + + await next(localContext).ConfigureAwait(false); + context.Output.AddRange(localContext.Output); + context.Comments.AddRange(localContext.Comments); + + localContext.Reset(newDataAction, context.SessionItems); + + await next(localContext).ConfigureAwait(false); + context.Output.AddRange(localContext.Output); + context.Comments.AddRange(localContext.Comments); + } + } +} diff --git a/BililiveRecorder.Flv/Pipeline/Rules/HandleNewScriptRule.cs b/BililiveRecorder.Flv/Pipeline/Rules/HandleNewScriptRule.cs index b94a1f2..5fb53b1 100644 --- a/BililiveRecorder.Flv/Pipeline/Rules/HandleNewScriptRule.cs +++ b/BililiveRecorder.Flv/Pipeline/Rules/HandleNewScriptRule.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; +using BililiveRecorder.Flv.Amf; namespace BililiveRecorder.Flv.Pipeline.Rules { @@ -13,12 +15,45 @@ namespace BililiveRecorder.Flv.Pipeline.Rules { public Task RunAsync(FlvProcessingContext context, Func next) { - if (context.OriginalInput is PipelineScriptAction) + if (context.OriginalInput is PipelineScriptAction scriptAction) { - context.AddNewFileAtStart(); + var data = scriptAction.Tag.ScriptData; + if (!(data is null) + && data.Values.Count == 2 + && data.Values[0] is ScriptDataString name + && name == "onMetaData") + { + ScriptDataEcmaArray? value = data.Values[1] switch + { + ScriptDataObject obj => obj, + ScriptDataEcmaArray arr => arr, + _ => null + }; + + if (value is null) + value = new ScriptDataEcmaArray(); + + context.ClearOutput(); + context.AddNewFileAtStart(); + context.Output.Add(new PipelineScriptAction(new Tag + { + Type = TagType.Script, + ScriptData = new ScriptTagBody(new List + { + name, + value + }) + })); + } + else + { + context.AddComment("收到了非 onMetaData 的 Script Tag"); + context.ClearOutput(); + } return Task.CompletedTask; } - else return next(); + else + return next(); } } } diff --git a/BililiveRecorder.Flv/TagExtentions.cs b/BililiveRecorder.Flv/TagExtentions.cs index 946ad38..95b7eb6 100644 --- a/BililiveRecorder.Flv/TagExtentions.cs +++ b/BililiveRecorder.Flv/TagExtentions.cs @@ -20,7 +20,7 @@ namespace BililiveRecorder.Flv [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsData(this Tag tag) - => tag.Type != TagType.Script; + => tag.Type != TagType.Script && !tag.Flag.HasFlag(TagFlag.Header); [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool IsNonKeyframeData(this Tag tag) diff --git a/BililiveRecorder.Flv/Writer/FlvProcessingContextWriter.cs b/BililiveRecorder.Flv/Writer/FlvProcessingContextWriter.cs index fa11ba5..22d7d15 100644 --- a/BililiveRecorder.Flv/Writer/FlvProcessingContextWriter.cs +++ b/BililiveRecorder.Flv/Writer/FlvProcessingContextWriter.cs @@ -3,10 +3,12 @@ using System.Buffers; using System.Buffers.Binary; using System.Diagnostics; using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; using BililiveRecorder.Flv.Amf; using BililiveRecorder.Flv.Pipeline; +using Serilog; namespace BililiveRecorder.Flv.Writer { @@ -17,10 +19,12 @@ namespace BililiveRecorder.Flv.Writer private readonly SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1, 1); private readonly IFlvWriterTargetProvider targetProvider; private readonly IMemoryStreamProvider memoryStreamProvider; - + private readonly ILogger? logger; + private bool disposedValue; private WriterState state = WriterState.EmptyFileOrNotOpen; private Stream? stream = null; + private object? streamState = null; private Tag? nextScriptTag = null; private Tag? nextAudioHeaderTag = null; @@ -28,14 +32,18 @@ namespace BililiveRecorder.Flv.Writer private ScriptTagBody? lastScriptBody = null; private uint lastScriptBodyLength = 0; + private double lastDuration; + + public event EventHandler? FileClosed; public Action? BeforeScriptTagWrite { get; set; } public Action? BeforeScriptTagRewrite { get; set; } - public FlvProcessingContextWriter(IFlvWriterTargetProvider targetProvider, IMemoryStreamProvider memoryStreamProvider) + public FlvProcessingContextWriter(IFlvWriterTargetProvider targetProvider, IMemoryStreamProvider memoryStreamProvider, ILogger? logger) { this.targetProvider = targetProvider ?? throw new ArgumentNullException(nameof(targetProvider)); this.memoryStreamProvider = memoryStreamProvider ?? throw new ArgumentNullException(nameof(memoryStreamProvider)); + this.logger = logger?.ForContext(); } public async Task WriteAsync(FlvProcessingContext context) @@ -51,7 +59,6 @@ namespace BililiveRecorder.Flv.Writer //} await this.semaphoreSlim.WaitAsync().ConfigureAwait(false); - try { foreach (var item in context.Output) @@ -71,6 +78,12 @@ namespace BililiveRecorder.Flv.Writer { this.semaphoreSlim.Release(); } + + // Dispose tags + foreach (var action in context.Output) + if (action is PipelineDataAction dataAction) + foreach (var tag in dataAction.Tags) + tag.BinaryData?.Dispose(); } #region Flv Writer Implementation @@ -85,16 +98,29 @@ namespace BililiveRecorder.Flv.Writer _ => Task.CompletedTask, }; - private Task WriteAlternativeHeader(PipelineLogAlternativeHeaderAction logAlternativeHeaderAction) + private async Task WriteAlternativeHeader(PipelineLogAlternativeHeaderAction logAlternativeHeaderAction) { - throw new NotImplementedException(); + using var writer = new StreamWriter(this.targetProvider.CreateAlternativeHeaderStream(), Encoding.UTF8); + await writer.WriteLineAsync("----- Group Start -----").ConfigureAwait(false); + await writer.WriteLineAsync("连续遇到了多个不同的音视频Header,如果录制的文件不能正常播放可以尝试用这里的数据进行修复").ConfigureAwait(false); + await writer.WriteLineAsync(DateTimeOffset.Now.ToString("O")).ConfigureAwait(false); + + foreach (var tag in logAlternativeHeaderAction.Tags) + { + await writer.WriteLineAsync().ConfigureAwait(false); + await writer.WriteLineAsync(tag.ToString()).ConfigureAwait(false); + await writer.WriteLineAsync(tag.BinaryDataForSerializationUseOnly).ConfigureAwait(false); + } + + await writer.WriteLineAsync("----- Group End -----").ConfigureAwait(false); } - private async Task OpenNewFile() + private Task OpenNewFile() { - await this.CloseCurrentFileImpl().ConfigureAwait(false); + this.CloseCurrentFileImpl(); // delay open until write this.state = WriterState.EmptyFileOrNotOpen; + return Task.CompletedTask; } private Task WriteScriptTag(PipelineScriptAction scriptAction) @@ -118,25 +144,38 @@ namespace BililiveRecorder.Flv.Writer return Task.CompletedTask; } - private async Task CloseCurrentFileImpl() + private void CloseCurrentFileImpl() { if (this.stream is null) return; - await this.RewriteScriptTagImpl(0).ConfigureAwait(false); - await this.stream.FlushAsync().ConfigureAwait(false); + //await this.RewriteScriptTagImpl(0).ConfigureAwait(false); + //await this.stream.FlushAsync().ConfigureAwait(false); + + var eventArgs = new FileClosedEventArgs + { + FileSize = this.stream.Length, + Duration = this.lastDuration, + State = this.streamState, + }; + this.stream.Close(); this.stream.Dispose(); this.stream = null; + + this.streamState = null; + this.lastDuration = 0d; + + FileClosed?.Invoke(this, eventArgs); } private async Task OpenNewFileImpl() { - await this.CloseCurrentFileImpl().ConfigureAwait(false); + this.CloseCurrentFileImpl(); - Debug.Assert(this.stream is null, "stream is null"); + Debug.Assert(this.stream is null, "stream is not null"); - this.stream = this.targetProvider.CreateOutputStream(); + (this.stream, this.streamState) = this.targetProvider.CreateOutputStream(); await this.stream.WriteAsync(FLV_FILE_HEADER, 0, FLV_FILE_HEADER.Length).ConfigureAwait(false); this.state = WriterState.BeforeScript; @@ -147,7 +186,10 @@ namespace BililiveRecorder.Flv.Writer if (this.stream is null || this.lastScriptBody is null) return; - this.lastScriptBody.Value["duration"] = (ScriptDataNumber)duration; + var value = this.lastScriptBody.GetMetadataValue(); + if (!(value is null)) + value["duration"] = (ScriptDataNumber)duration; + this.BeforeScriptTagRewrite?.Invoke(this.lastScriptBody); this.stream.Seek(9 + 4 + 11, SeekOrigin.Begin); @@ -163,7 +205,7 @@ namespace BililiveRecorder.Flv.Writer } else { - // TODO logging + this.logger?.Warning("因 Script tag 输出长度不一致跳过修改"); } } @@ -183,7 +225,10 @@ namespace BililiveRecorder.Flv.Writer this.lastScriptBody = this.nextScriptTag.ScriptData; - this.lastScriptBody.Value["duration"] = (ScriptDataNumber)0; + var value = this.lastScriptBody.GetMetadataValue(); + if (!(value is null)) + value["duration"] = (ScriptDataNumber)0; + this.BeforeScriptTagWrite?.Invoke(this.lastScriptBody); var bytes = ArrayPool.Shared.Rent(11); @@ -254,6 +299,15 @@ namespace BililiveRecorder.Flv.Writer await this.WriteHeaderTagsImpl().ConfigureAwait(false); break; case WriterState.Writing: + if (this.stream is null) + throw new Exception("stream is null"); + + if (this.targetProvider.ShouldCreateNewFile(this.stream, dataAction.Tags)) + { + await this.OpenNewFileImpl().ConfigureAwait(false); + await this.WriteScriptTagImpl().ConfigureAwait(false); + await this.WriteHeaderTagsImpl().ConfigureAwait(false); + } break; default: throw new InvalidOperationException($"Can't write data tag with current state ({this.state})"); @@ -266,6 +320,7 @@ namespace BililiveRecorder.Flv.Writer await tag.WriteTo(this.stream, tag.Timestamp, this.memoryStreamProvider).ConfigureAwait(false); var duration = dataAction.Tags[dataAction.Tags.Count - 1].Timestamp / 1000d; + this.lastDuration = duration; await this.RewriteScriptTagImpl(duration).ConfigureAwait(false); } @@ -273,24 +328,23 @@ namespace BililiveRecorder.Flv.Writer #region IDisposable - private bool disposedValue; - protected virtual void Dispose(bool disposing) { if (!this.disposedValue) { if (disposing) { - // TODO: dispose managed state (managed objects) + // dispose managed state (managed objects) + this.CloseCurrentFileImpl(); } - // TODO: free unmanaged resources (unmanaged objects) and override finalizer - // TODO: set large fields to null + // free unmanaged resources (unmanaged objects) and override finalizer + // set large fields to null this.disposedValue = true; } } - // // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources + // override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources // ~FlvProcessingContextWriter() // { // // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method diff --git a/BililiveRecorder.Flv/Xml/FlvTagListReader.cs b/BililiveRecorder.Flv/Xml/FlvTagListReader.cs index 5207b32..80c423e 100644 --- a/BililiveRecorder.Flv/Xml/FlvTagListReader.cs +++ b/BililiveRecorder.Flv/Xml/FlvTagListReader.cs @@ -20,9 +20,9 @@ namespace BililiveRecorder.Flv.Xml this.tags = tags ?? throw new ArgumentNullException(nameof(tags)); } - public Task PeekTagAsync() => Task.FromResult(this.index < this.tags.Count ? this.tags[this.index] : null)!; + public Task PeekTagAsync(System.Threading.CancellationToken token) => Task.FromResult(this.index < this.tags.Count ? this.tags[this.index] : null)!; - public Task ReadTagAsync() => Task.FromResult(this.index < this.tags.Count ? this.tags[this.index++] : null); + public Task ReadTagAsync(System.Threading.CancellationToken token) => Task.FromResult(this.index < this.tags.Count ? this.tags[this.index++] : null); public void Dispose() { } } diff --git a/BililiveRecorder.FlvProcessor/DependencyInjectionExtensions.cs b/BililiveRecorder.FlvProcessor/DependencyInjectionExtensions.cs index 2be7c15..63ff2f9 100644 --- a/BililiveRecorder.FlvProcessor/DependencyInjectionExtensions.cs +++ b/BililiveRecorder.FlvProcessor/DependencyInjectionExtensions.cs @@ -6,11 +6,10 @@ namespace BililiveRecorder.DependencyInjection { public static class DependencyInjectionExtensions { - public static void AddFlvProcessor(this IServiceCollection services) - { - services.AddSingleton>(() => new FlvTag()); - services.AddSingleton(); - services.AddSingleton(); - } + public static IServiceCollection AddFlvProcessor(this IServiceCollection services) => services + .AddSingleton>(() => new FlvTag()) + .AddSingleton() + .AddSingleton() + ; } } diff --git a/BililiveRecorder.WPF/App.xaml b/BililiveRecorder.WPF/App.xaml index 097df9c..25ce22c 100644 --- a/BililiveRecorder.WPF/App.xaml +++ b/BililiveRecorder.WPF/App.xaml @@ -6,7 +6,6 @@ xmlns:l="https://github.com/XAMLMarkupExtensions/WPFLocalizationExtension" xmlns:m="clr-namespace:BililiveRecorder.WPF.Models" x:Class="BililiveRecorder.WPF.App" - Startup="CheckUpdate" SessionEnding="Application_SessionEnding" StartupUri="NewMainWindow.xaml"> @@ -24,6 +23,24 @@ l:ResxLocalizationProvider.DefaultDictionary="Strings"> + + + + + + + + + + + + + + + + + + diff --git a/BililiveRecorder.WPF/App.xaml.cs b/BililiveRecorder.WPF/App.xaml.cs index d5d4ffe..313aea7 100644 --- a/BililiveRecorder.WPF/App.xaml.cs +++ b/BililiveRecorder.WPF/App.xaml.cs @@ -1,11 +1,6 @@ -using System; -using System.IO; -using System.Linq; -using System.Threading.Tasks; using System.Windows; -using NLog; -using Squirrel; +#nullable enable namespace BililiveRecorder.WPF { /// @@ -13,78 +8,6 @@ namespace BililiveRecorder.WPF /// public partial class App : Application { - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); - - private void CheckUpdate(object sender, StartupEventArgs e) - { - logger.Debug($"Starting. FileV:{typeof(App).Assembly.GetName().Version.ToString(4)}, BuildV:{BuildInfo.Version}, Hash:{BuildInfo.HeadSha1}"); - logger.Debug("Environment.CommandLine: " + Environment.CommandLine); - logger.Debug("Environment.CurrentDirectory: " + Environment.CurrentDirectory); -#if !DEBUG - Task.Run(RunCheckUpdate); -#endif - } - - private async Task RunCheckUpdate() - { - try - { - if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("BILILIVE_RECORDER_DISABLE_UPDATE")) - || File.Exists("BILILIVE_RECORDER_DISABLE_UPDATE")) - { - return; - } - - var envPath = Environment.GetEnvironmentVariable("BILILIVE_RECORDER_OVERWRITE_UPDATE"); - var serverUrl = @"https://soft.danmuji.org/BililiveRecorder/"; - if (!string.IsNullOrWhiteSpace(envPath)) { serverUrl = envPath; } - logger.Debug("Checking updates."); - using (var manager = new UpdateManager(urlOrPath: serverUrl)) - { - var update = await manager.CheckForUpdate(); - if (update.CurrentlyInstalledVersion == null) - { - logger.Debug("Squirrel 无当前版本"); - } - - if (!update.ReleasesToApply.Any()) - { - logger.Info($@"当前运行的是最新版本 ({ - update.CurrentlyInstalledVersion?.Version?.ToString() ?? "×" - }\{ - typeof(App).Assembly.GetName().Version.ToString(4) - })"); - } - else - { - if (update.CurrentlyInstalledVersion != null - && update.FutureReleaseEntry.Version < update.CurrentlyInstalledVersion.Version) - { - logger.Warn("服务器回滚了一个更新,本地版本比服务器版本高。"); - } - - logger.Info($@"服务器最新版本: { - update.FutureReleaseEntry?.Version?.ToString() ?? "×" - } 当前本地版本: { - update.CurrentlyInstalledVersion?.Version?.ToString() ?? "×" - }"); - - logger.Info("开始后台下载新版本(不会影响软件运行)"); - await manager.DownloadReleases(update.ReleasesToApply); - logger.Info("新版本下载完成,开始安装(不会影响软件运行)"); - await manager.ApplyReleases(update); - logger.Info("新版本安装完毕,你可以暂时继续使用当前版本。下次启动时会自动启动最新版本。"); - } - } - } - catch (Exception ex) - { - logger.Warn(ex, "检查更新时出错,如持续出错请联系开发者 rec@danmuji.org"); - } - - _ = Task.Run(async () => { await Task.Delay(TimeSpan.FromDays(1)); await this.RunCheckUpdate(); }); - } - private void Application_SessionEnding(object sender, SessionEndingCancelEventArgs e) { if (e != null) diff --git a/BililiveRecorder.WPF/BililiveRecorder.WPF.csproj b/BililiveRecorder.WPF/BililiveRecorder.WPF.csproj index b5b4102..53d74ae 100644 --- a/BililiveRecorder.WPF/BililiveRecorder.WPF.csproj +++ b/BililiveRecorder.WPF/BililiveRecorder.WPF.csproj @@ -8,14 +8,13 @@ WinExe BililiveRecorder.WPF BililiveRecorder.WPF - v4.6.2 + v4.7.2 512 {60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} 4 9.0 + enable true - -
AnyCPU @@ -96,6 +95,9 @@ DeleteRoomConfirmDialog.xaml + + LogPanel.xaml + PerRoomSettingsDialog.xaml @@ -111,9 +113,11 @@ WorkDirectorySelectorDialog.xaml + + @@ -130,9 +134,11 @@ - + - + + ToolboxAutoFixPage.xaml + @@ -140,6 +146,8 @@ True Strings.resx + + Designer MSBuild:Compile @@ -156,6 +164,10 @@ Designer MSBuild:Compile + + Designer + MSBuild:Compile + Designer MSBuild:Compile @@ -212,6 +224,10 @@ Designer MSBuild:Compile + + Designer + MSBuild:Compile + Designer MSBuild:Compile @@ -274,15 +290,12 @@ {cb9f2d58-181d-49f7-9560-d35a9b9c1d8c} BililiveRecorder.Core - - {51748048-1949-4218-8ded-94014abe7633} - BililiveRecorder.FlvProcessor + + {7610e19c-d3ab-4cbc-983e-6fda36f4d4b3} + BililiveRecorder.Flv - - 2.4.3 - 1.0.8 @@ -296,13 +309,7 @@ 0.9.2 - 11.0.2 - - - 4.7.6 - - - 4.7.6 + 12.0.3 4.7.1 @@ -310,14 +317,38 @@ all - 2.1.8 + 3.0.6 - - 2.1.8 + + 3.0.6 + + + 2.0.1 + + + 3.1.0 + + + 6.0.0 + + + 1.1.0 + + + 3.1.1 + + + 2.0.0 + + + 4.1.0 2.0.1 + + 2.0.0-beta1.20574.7 + 1.1.2 diff --git a/BililiveRecorder.WPF/CommandLineOption.cs b/BililiveRecorder.WPF/CommandLineOption.cs deleted file mode 100644 index be0ad91..0000000 --- a/BililiveRecorder.WPF/CommandLineOption.cs +++ /dev/null @@ -1,10 +0,0 @@ -using CommandLine; - -namespace BililiveRecorder.WPF -{ - public class CommandLineOption - { - [Option('w', "workdirectory", Default = null, HelpText = "设置工作目录并跳过选择目录 GUI", Required = false)] - public string WorkDirectory { get; set; } - } -} diff --git a/BililiveRecorder.WPF/Controls/DeleteRoomConfirmDialog.xaml b/BililiveRecorder.WPF/Controls/DeleteRoomConfirmDialog.xaml index e5a14ec..a8e3393 100644 --- a/BililiveRecorder.WPF/Controls/DeleteRoomConfirmDialog.xaml +++ b/BililiveRecorder.WPF/Controls/DeleteRoomConfirmDialog.xaml @@ -10,11 +10,11 @@ l:ResxLocalizationProvider.DefaultAssembly="BililiveRecorder.WPF" l:ResxLocalizationProvider.DefaultDictionary="Strings" xmlns:local="clr-namespace:BililiveRecorder.WPF.Controls" - xmlns:mock="clr-namespace:BililiveRecorder.WPF.MockData" + DefaultButton="Close" PrimaryButtonText="{l:Loc Global_Delete}" CloseButtonText="{l:Loc Global_Cancel}" - d:DataContext="{d:DesignInstance Type=mock:MockRecordedRoom,IsDesignTimeCreatable=True}" + mc:Ignorable="d"> diff --git a/BililiveRecorder.WPF/Controls/LogPanel.xaml b/BililiveRecorder.WPF/Controls/LogPanel.xaml new file mode 100644 index 0000000..20cd4ba --- /dev/null +++ b/BililiveRecorder.WPF/Controls/LogPanel.xaml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BililiveRecorder.WPF/Controls/LogPanel.xaml.cs b/BililiveRecorder.WPF/Controls/LogPanel.xaml.cs new file mode 100644 index 0000000..f16580d --- /dev/null +++ b/BililiveRecorder.WPF/Controls/LogPanel.xaml.cs @@ -0,0 +1,30 @@ +using System.Windows; +using System.Windows.Controls; + +#nullable enable +namespace BililiveRecorder.WPF.Controls +{ + /// + /// Interaction logic for LogPanel.xaml + /// + public partial class LogPanel : UserControl + { + public LogPanel() + { + this.InitializeComponent(); + } + + private void ListView_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (sender is not ListView listView) return; + if (listView.View is not GridView view) return; + + var w = listView.ActualWidth - SystemParameters.VerticalScrollBarWidth - 105 - 60 - 105; + + view.Columns[0].Width = 105; + view.Columns[1].Width = 60; + view.Columns[2].Width = 105; + view.Columns[3].Width = w; + } + } +} diff --git a/BililiveRecorder.WPF/Controls/PerRoomSettingsDialog.xaml b/BililiveRecorder.WPF/Controls/PerRoomSettingsDialog.xaml index a1df411..0d6008a 100644 --- a/BililiveRecorder.WPF/Controls/PerRoomSettingsDialog.xaml +++ b/BililiveRecorder.WPF/Controls/PerRoomSettingsDialog.xaml @@ -10,11 +10,9 @@ l:ResxLocalizationProvider.DefaultAssembly="BililiveRecorder.WPF" l:ResxLocalizationProvider.DefaultDictionary="Strings" xmlns:local="clr-namespace:BililiveRecorder.WPF.Controls" - xmlns:mock="clr-namespace:BililiveRecorder.WPF.MockData" - xmlns:flv="clr-namespace:BililiveRecorder.FlvProcessor;assembly=BililiveRecorder.FlvProcessor" + xmlns:confiv2="clr-namespace:BililiveRecorder.Core.Config.V2;assembly=BililiveRecorder.Core" DefaultButton="Close" CloseButtonText="{l:Loc Global_Close}" - d:DataContext="{d:DesignInstance Type=mock:MockRecordedRoom,IsDesignTimeCreatable=True}" mc:Ignorable="d"> @@ -61,13 +59,13 @@ + ConverterParameter={x:Static confiv2:CuttingMode.Disabled}}" /> + ConverterParameter={x:Static confiv2:CuttingMode.BySize}}" /> + ConverterParameter={x:Static confiv2:CuttingMode.ByTime}}" /> diff --git a/BililiveRecorder.WPF/Controls/RoomCard.xaml b/BililiveRecorder.WPF/Controls/RoomCard.xaml index d92e479..b189120 100644 --- a/BililiveRecorder.WPF/Controls/RoomCard.xaml +++ b/BililiveRecorder.WPF/Controls/RoomCard.xaml @@ -10,9 +10,9 @@ l:ResxLocalizationProvider.DefaultAssembly="BililiveRecorder.WPF" l:ResxLocalizationProvider.DefaultDictionary="Strings" xmlns:local="clr-namespace:BililiveRecorder.WPF.Controls" - xmlns:mock="clr-namespace:BililiveRecorder.WPF.MockData" + xmlns:core="clr-namespace:BililiveRecorder.Core;assembly=BililiveRecorder.Core" d:DesignWidth="220" d:DesignHeight="110" - d:DataContext="{d:DesignInstance Type=mock:MockRecordedRoom,IsDesignTimeCreatable=True}" + d:DataContext="{d:DesignInstance core:Room}" mc:Ignorable="d"> @@ -44,25 +44,25 @@ TextTrimming="CharacterEllipsis" ContextMenu="{StaticResource CopyTextContextMenu}"/> - - - + + Foreground="{Binding Streaming,Converter={StaticResource BooleanToLiveStatusColorBrushConverter}}" + ToolTip="{Binding Streaming,Converter={StaticResource BooleanToLiveStatusTooltipConverter}}"/> - + - + Visibility="{Binding ShortId,Converter={StaticResource ShortRoomIdToVisibilityConverter}}"/> + @@ -96,12 +96,12 @@ - + - + @@ -122,56 +122,51 @@ - - + + + + Visibility="{Binding Recording, Converter={StaticResource BooleanToVisibilityCollapsedConverter},Mode=OneWay}"> - - + + + + + + - - + + - - - - - - - - - - + + - - diff --git a/BililiveRecorder.WPF/Controls/RoomCard.xaml.cs b/BililiveRecorder.WPF/Controls/RoomCard.xaml.cs index 444f73e..2c81dc6 100644 --- a/BililiveRecorder.WPF/Controls/RoomCard.xaml.cs +++ b/BililiveRecorder.WPF/Controls/RoomCard.xaml.cs @@ -20,29 +20,41 @@ namespace BililiveRecorder.WPF.Controls public event EventHandler ShowSettingsRequested; - private void MenuItem_StartRecording_Click(object sender, RoutedEventArgs e) => (this.DataContext as IRecordedRoom)?.StartRecord(); + private void MenuItem_StartRecording_Click(object sender, RoutedEventArgs e) => (this.DataContext as IRoom)?.StartRecord(); - private void MenuItem_StopRecording_Click(object sender, RoutedEventArgs e) => (this.DataContext as IRecordedRoom)?.StopRecord(); + private void MenuItem_StopRecording_Click(object sender, RoutedEventArgs e) => (this.DataContext as IRoom)?.StopRecord(); - private void MenuItem_RefreshInfo_Click(object sender, RoutedEventArgs e) => (this.DataContext as IRecordedRoom)?.RefreshRoomInfo(); + private void MenuItem_RefreshInfo_Click(object sender, RoutedEventArgs e) => (this.DataContext as IRoom)?.RefreshRoomInfoAsync(); - private void MenuItem_StartMonitor_Click(object sender, RoutedEventArgs e) => (this.DataContext as IRecordedRoom)?.Start(); + private void MenuItem_StartMonitor_Click(object sender, RoutedEventArgs e) + { + if (this.DataContext is IRoom room) + room.RoomConfig.AutoRecord = true; + } - private void MenuItem_StopMonitor_Click(object sender, RoutedEventArgs e) => (this.DataContext as IRecordedRoom)?.Stop(); + private void MenuItem_StopMonitor_Click(object sender, RoutedEventArgs e) + { + if (this.DataContext is IRoom room) + room.RoomConfig.AutoRecord = false; + } private void MenuItem_DeleteRoom_Click(object sender, RoutedEventArgs e) => DeleteRequested?.Invoke(this.DataContext, EventArgs.Empty); private void MenuItem_ShowSettings_Click(object sender, RoutedEventArgs e) => ShowSettingsRequested?.Invoke(this.DataContext, EventArgs.Empty); - private void Button_Clip_Click(object sender, RoutedEventArgs e) => (this.DataContext as IRecordedRoom)?.Clip(); + private void Button_Split_Click(object sender, RoutedEventArgs e) + { + if (this.DataContext is IRoom room) + room.SplitOutput(); + } private void MenuItem_OpenInBrowser_Click(object sender, RoutedEventArgs e) { - if (this.DataContext is IRecordedRoom r && r is not null) + if (this.DataContext is IRoom r && r is not null) { try { - Process.Start("https://live.bilibili.com/" + r.RoomId); + Process.Start("https://live.bilibili.com/" + r.RoomConfig.RoomId); } catch (Exception) { } } diff --git a/BililiveRecorder.WPF/Controls/WorkDirectorySelectorDialog.xaml b/BililiveRecorder.WPF/Controls/WorkDirectorySelectorDialog.xaml index 4bb13f9..ed1e487 100644 --- a/BililiveRecorder.WPF/Controls/WorkDirectorySelectorDialog.xaml +++ b/BililiveRecorder.WPF/Controls/WorkDirectorySelectorDialog.xaml @@ -14,6 +14,7 @@ d:DataContext="{d:DesignInstance Type=local:WorkDirectorySelectorDialog}" DefaultButton="Primary" PrimaryButtonText="{l:Loc Global_Confirm}" + SecondaryButtonText="工具箱模式" CloseButtonText="{l:Loc Global_Quit}"> diff --git a/BililiveRecorder.WPF/Controls/WorkDirectorySelectorDialog.xaml.cs b/BililiveRecorder.WPF/Controls/WorkDirectorySelectorDialog.xaml.cs index 810c03f..e03d02e 100644 --- a/BililiveRecorder.WPF/Controls/WorkDirectorySelectorDialog.xaml.cs +++ b/BililiveRecorder.WPF/Controls/WorkDirectorySelectorDialog.xaml.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using Microsoft.WindowsAPICodePack.Dialogs; using WPFLocalizeExtension.Extensions; +#nullable enable namespace BililiveRecorder.WPF.Controls { /// @@ -34,7 +35,7 @@ namespace BililiveRecorder.WPF.Controls FailedToLoadConfig, } - public event PropertyChangedEventHandler PropertyChanged; + public event PropertyChangedEventHandler? PropertyChanged; protected virtual void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); protected bool SetField(ref T field, T value, [CallerMemberName] string propertyName = "") { diff --git a/BililiveRecorder.WPF/Converters/ByteUnitsConverter.cs b/BililiveRecorder.WPF/Converters/ByteUnitsConverter.cs new file mode 100644 index 0000000..e0501b3 --- /dev/null +++ b/BililiveRecorder.WPF/Converters/ByteUnitsConverter.cs @@ -0,0 +1,35 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace BililiveRecorder.WPF.Converters +{ + public class ByteUnitsConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + const long Bytes = 1; + const long KiB = Bytes * 1024; + const long MiB = KiB * 1024; + const long GiB = MiB * 1024; + const long TiB = GiB * 1024; + const double d_KiB = KiB; + const double d_MiB = MiB; + const double d_GiB = GiB; + const double d_TiB = TiB; + + var input = (long)value; + + return input switch + { + < KiB => $"{input} {nameof(Bytes)}", + < MiB => $"{input / d_KiB:F2} {nameof(KiB)}", + < GiB => $"{input / d_MiB:F2} {nameof(MiB)}", + < TiB => $"{input / d_GiB:F2} {nameof(GiB)}", + _ => $"{input / d_TiB:F2} {nameof(TiB)}" + }; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException(); + } +} diff --git a/BililiveRecorder.WPF/Converters/ClipEnabledToBooleanConverter.cs b/BililiveRecorder.WPF/Converters/ClipEnabledToBooleanConverter.cs index d68f7a7..25532a5 100644 --- a/BililiveRecorder.WPF/Converters/ClipEnabledToBooleanConverter.cs +++ b/BililiveRecorder.WPF/Converters/ClipEnabledToBooleanConverter.cs @@ -1,7 +1,6 @@ using System; using System.Globalization; using System.Windows.Data; -using BililiveRecorder.FlvProcessor; namespace BililiveRecorder.WPF.Converters { @@ -9,7 +8,9 @@ namespace BililiveRecorder.WPF.Converters { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { - return !(value is EnabledFeature v) || (EnabledFeature.RecordOnly != v); + return false; + // return !(value is EnabledFeature v) || (EnabledFeature.RecordOnly != v); + // TODO fix me } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) diff --git a/BililiveRecorder.WPF/Converters/MultiBoolToValueConverter.cs b/BililiveRecorder.WPF/Converters/MultiBoolToValueConverter.cs index 6f848aa..ffe33e0 100644 --- a/BililiveRecorder.WPF/Converters/MultiBoolToValueConverter.cs +++ b/BililiveRecorder.WPF/Converters/MultiBoolToValueConverter.cs @@ -2,14 +2,15 @@ using System; using System.Globalization; using System.Windows.Data; +#nullable enable namespace BililiveRecorder.WPF.Converters { public class MultiBoolToValueConverter : IMultiValueConverter { - public object FalseValue { get; set; } - public object TrueValue { get; set; } + public object? FalseValue { get; set; } + public object? TrueValue { get; set; } - public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + public object? Convert(object[] values, Type targetType, object parameter, CultureInfo culture) { foreach (var value in values) { diff --git a/BililiveRecorder.WPF/Converters/NullValueTemplateSelector.cs b/BililiveRecorder.WPF/Converters/NullValueTemplateSelector.cs index eac1918..abc763c 100644 --- a/BililiveRecorder.WPF/Converters/NullValueTemplateSelector.cs +++ b/BililiveRecorder.WPF/Converters/NullValueTemplateSelector.cs @@ -1,13 +1,14 @@ using System.Windows; using System.Windows.Controls; +#nullable enable namespace BililiveRecorder.WPF.Converters { internal class NullValueTemplateSelector : DataTemplateSelector { - public DataTemplate Normal { get; set; } - public DataTemplate Null { get; set; } + public DataTemplate? Normal { get; set; } + public DataTemplate? Null { get; set; } - public override DataTemplate SelectTemplate(object item, DependencyObject container) => item is null ? this.Null : this.Normal; + public override DataTemplate? SelectTemplate(object item, DependencyObject container) => item is null ? this.Null : this.Normal; } } diff --git a/BililiveRecorder.WPF/Converters/PercentageToColorBrushConverter.cs b/BililiveRecorder.WPF/Converters/PercentageToColorBrushConverter.cs deleted file mode 100644 index ced2c7b..0000000 --- a/BililiveRecorder.WPF/Converters/PercentageToColorBrushConverter.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; -using System.Windows.Media; - -namespace BililiveRecorder.WPF.Converters -{ - internal class PercentageToColorBrushConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - const double a = 1d; - const double b = 6d; - const double c = 100d; - const double d = 2.2d; - - var x = (double)value * 100d; - var y = x < (c - a) ? c - Math.Pow(Math.Abs(x - c + a), d) / b : x > (c + a) ? c - Math.Pow(x - c - a, d) / b : c; - return new SolidColorBrush(GradientPick(Math.Max(y, 0d) / 100d, Colors.Red, Colors.Yellow, Colors.Lime)); - Color GradientPick(double percentage, Color c1, Color c2, Color c3) => percentage < 0.5 ? ColorInterp(c1, c2, percentage / 0.5) : percentage == 0.5 ? c2 : ColorInterp(c2, c3, (percentage - 0.5) / 0.5); - Color ColorInterp(Color start, Color end, double percentage) => Color.FromRgb(LinearInterp(start.R, end.R, percentage), LinearInterp(start.G, end.G, percentage), LinearInterp(start.B, end.B, percentage)); - byte LinearInterp(byte start, byte end, double percentage) => (byte)(start + Math.Round(percentage * (end - start))); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} diff --git a/BililiveRecorder.WPF/Converters/RatioToArrowIconConverter.cs b/BililiveRecorder.WPF/Converters/RatioToArrowIconConverter.cs new file mode 100644 index 0000000..68c2f8f --- /dev/null +++ b/BililiveRecorder.WPF/Converters/RatioToArrowIconConverter.cs @@ -0,0 +1,21 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace BililiveRecorder.WPF.Converters +{ + public class RatioToArrowIconConverter : DependencyObject, IValueConverter + { + public static readonly DependencyProperty UpArrowProperty = DependencyProperty.Register(nameof(UpArrow), typeof(object), typeof(BoolToValueConverter), new PropertyMetadata(null)); + public static readonly DependencyProperty DownArrowProperty = DependencyProperty.Register(nameof(DownArrow), typeof(object), typeof(BoolToValueConverter), new PropertyMetadata(null)); + + public object UpArrow { get => this.GetValue(UpArrowProperty); set => this.SetValue(UpArrowProperty, value); } + public object DownArrow { get => this.GetValue(DownArrowProperty); set => this.SetValue(DownArrowProperty, value); } + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => + value is double num ? num < 0.97 ? this.DownArrow : num > 1.03 ? this.UpArrow : null : null; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException(); + } +} diff --git a/BililiveRecorder.WPF/Converters/RatioToColorBrushConverter.cs b/BililiveRecorder.WPF/Converters/RatioToColorBrushConverter.cs new file mode 100644 index 0000000..874e0ca --- /dev/null +++ b/BililiveRecorder.WPF/Converters/RatioToColorBrushConverter.cs @@ -0,0 +1,43 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Windows.Data; +using System.Windows.Media; + +namespace BililiveRecorder.WPF.Converters +{ + internal class RatioToColorBrushConverter : IValueConverter + { + private static readonly SolidColorBrush[] ColorMap; + + static RatioToColorBrushConverter() + { + ColorMap = Enumerable + .Range(0, 21) + .Select(i => new SolidColorBrush(GradientPick(i / 20d, Colors.Red, Colors.Yellow, Colors.Lime))) + .ToArray(); + + static Color GradientPick(double percentage, Color c1, Color c2, Color c3) => + percentage < 0.5 ? ColorInterp(c1, c2, percentage / 0.5) : percentage == 0.5 ? c2 : ColorInterp(c2, c3, (percentage - 0.5) / 0.5); + + static Color ColorInterp(Color start, Color end, double percentage) => + Color.FromRgb(LinearInterp(start.R, end.R, percentage), LinearInterp(start.G, end.G, percentage), LinearInterp(start.B, end.B, percentage)); + + static byte LinearInterp(byte start, byte end, double percentage) => + (byte)(start + Math.Round(percentage * (end - start))); + } + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var i = (int)Math.Ceiling((1.1d - Math.Abs((1d - (double)value) * 4d)) * 20d); + return i switch + { + < 0 => ColorMap[0], + > 20 => ColorMap[20], + _ => ColorMap[i] + }; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException(); + } +} diff --git a/BililiveRecorder.WPF/Converters/RoomListInterceptConverter.cs b/BililiveRecorder.WPF/Converters/RoomListInterceptConverter.cs index 5a3dc14..2874d15 100644 --- a/BililiveRecorder.WPF/Converters/RoomListInterceptConverter.cs +++ b/BililiveRecorder.WPF/Converters/RoomListInterceptConverter.cs @@ -19,39 +19,40 @@ namespace BililiveRecorder.WPF.Converters return value; } - private class RecorderWrapper : ObservableCollection + private class RecorderWrapper : ObservableCollection { private readonly IRecorder recorder; - public RecorderWrapper(IRecorder recorder) : base(recorder) + public RecorderWrapper(IRecorder recorder) : base(recorder.Rooms) { this.recorder = recorder; this.Add(null); - recorder.CollectionChanged += (sender, e) => - { - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - if (e.NewItems.Count != 1) throw new NotImplementedException("Wrapper Add Item Count != 1"); - this.InsertItem(e.NewStartingIndex, e.NewItems[0] as IRecordedRoom); - break; - case NotifyCollectionChangedAction.Remove: - if (e.OldItems.Count != 1) throw new NotImplementedException("Wrapper Remove Item Count != 1"); - if (!this.Remove(e.OldItems[0] as IRecordedRoom)) throw new NotImplementedException("Wrapper Remove Item Sync Fail"); - break; - case NotifyCollectionChangedAction.Replace: - throw new NotImplementedException("Wrapper Replace Item"); - case NotifyCollectionChangedAction.Move: - throw new NotImplementedException("Wrapper Move Item"); - case NotifyCollectionChangedAction.Reset: - this.ClearItems(); - this.Add(null); - break; - default: - break; - } - }; + // TODO fix me + //recorder.Rooms.CollectionChanged += (sender, e) => + //{ + // switch (e.Action) + // { + // case NotifyCollectionChangedAction.Add: + // if (e.NewItems.Count != 1) throw new NotImplementedException("Wrapper Add Item Count != 1"); + // this.InsertItem(e.NewStartingIndex, e.NewItems[0] as IRecordedRoom); + // break; + // case NotifyCollectionChangedAction.Remove: + // if (e.OldItems.Count != 1) throw new NotImplementedException("Wrapper Remove Item Count != 1"); + // if (!this.Remove(e.OldItems[0] as IRecordedRoom)) throw new NotImplementedException("Wrapper Remove Item Sync Fail"); + // break; + // case NotifyCollectionChangedAction.Replace: + // throw new NotImplementedException("Wrapper Replace Item"); + // case NotifyCollectionChangedAction.Move: + // throw new NotImplementedException("Wrapper Move Item"); + // case NotifyCollectionChangedAction.Reset: + // this.ClearItems(); + // this.Add(null); + // break; + // default: + // break; + // } + //}; } } } diff --git a/BililiveRecorder.WPF/MockData/MockRecordedRoom.cs b/BililiveRecorder.WPF/MockData/MockRecordedRoom.cs index 048297b..739945a 100644 --- a/BililiveRecorder.WPF/MockData/MockRecordedRoom.cs +++ b/BililiveRecorder.WPF/MockData/MockRecordedRoom.cs @@ -1,14 +1,12 @@ using System; using System.ComponentModel; using BililiveRecorder.Core; -using BililiveRecorder.Core.Callback; using BililiveRecorder.Core.Config.V2; -using BililiveRecorder.FlvProcessor; #nullable enable namespace BililiveRecorder.WPF.MockData { -#if DEBUG +#if false && DEBUG internal class MockRecordedRoom : IRecordedRoom { private bool disposedValue; diff --git a/BililiveRecorder.WPF/MockData/MockRecorder.cs b/BililiveRecorder.WPF/MockData/MockRecorder.cs index faa7a01..0b639b1 100644 --- a/BililiveRecorder.WPF/MockData/MockRecorder.cs +++ b/BililiveRecorder.WPF/MockData/MockRecorder.cs @@ -9,7 +9,7 @@ using BililiveRecorder.Core.Config.V2; namespace BililiveRecorder.WPF.MockData { -#if DEBUG +#if false && DEBUG internal class MockRecorder : IRecorder { private bool disposedValue; diff --git a/BililiveRecorder.WPF/Models/Commands.cs b/BililiveRecorder.WPF/Models/Commands.cs index 23bc27d..f3a79d4 100644 --- a/BililiveRecorder.WPF/Models/Commands.cs +++ b/BililiveRecorder.WPF/Models/Commands.cs @@ -4,6 +4,7 @@ using System.Windows; using System.Windows.Input; using ModernWpf.Controls; +#nullable enable namespace BililiveRecorder.WPF.Models { public class Commands : ICommand @@ -17,7 +18,9 @@ namespace BililiveRecorder.WPF.Models public static Commands OpenContentDialog { get; } = new Commands { - ExecuteDelegate = async o => { try { await (o as ContentDialog)?.ShowAsync(); } catch (Exception) { } } +#pragma warning disable VSTHRD101 // Avoid unsupported async delegates + ExecuteDelegate = async o => { try { await (o as ContentDialog)!.ShowAsync(); } catch (Exception) { } } +#pragma warning restore VSTHRD101 // Avoid unsupported async delegates }; public static Commands Copy { get; } = new Commands @@ -27,8 +30,8 @@ namespace BililiveRecorder.WPF.Models #endregion - public Predicate CanExecuteDelegate { get; set; } - public Action ExecuteDelegate { get; set; } + public Predicate? CanExecuteDelegate { get; set; } + public Action? ExecuteDelegate { get; set; } #region ICommand Members diff --git a/BililiveRecorder.WPF/Models/LogModel.cs b/BililiveRecorder.WPF/Models/LogModel.cs index 297a695..5b0aea1 100644 --- a/BililiveRecorder.WPF/Models/LogModel.cs +++ b/BililiveRecorder.WPF/Models/LogModel.cs @@ -1,67 +1,11 @@ -using System; using System.Collections.ObjectModel; -using System.Windows; -using System.Windows.Threading; namespace BililiveRecorder.WPF.Models { - internal class LogModel : ObservableCollection, IDisposable + internal class LogModel : ReadOnlyObservableCollection { - private const int MAX_LINE = 50; - - private bool disposedValue; - - public static void AddLog(string log) => LogReceived?.Invoke(null, log); - public static event EventHandler LogReceived; - - public LogModel() : base(new[] { "" }) + public LogModel() : base(WpfLogEventSink.Logs) { - LogReceived += this.LogModel_LogReceived; - } - - private void LogModel_LogReceived(object sender, string e) - { - _ = Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.DataBind, (Action)this.AddLogToCollection, e); - } - - private void AddLogToCollection(string e) - { - this.Add(e); - while (this.Count > MAX_LINE) - { - this.RemoveItem(0); - } - } - - protected virtual void Dispose(bool disposing) - { - if (!this.disposedValue) - { - if (disposing) - { - // dispose managed state (managed objects) - LogReceived -= this.LogModel_LogReceived; - this.ClearItems(); - } - - // 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 - // ~LogModel() - // { - // // 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.WPF/Models/RootModel.cs b/BililiveRecorder.WPF/Models/RootModel.cs index 01e7737..735c85b 100644 --- a/BililiveRecorder.WPF/Models/RootModel.cs +++ b/BililiveRecorder.WPF/Models/RootModel.cs @@ -13,8 +13,6 @@ namespace BililiveRecorder.WPF.Models public event PropertyChangedEventHandler PropertyChanged; - public LogModel Logs { get; } = new LogModel(); - public IRecorder Recorder { get => this.recorder; internal set => this.SetField(ref this.recorder, value); } public RootModel() @@ -37,7 +35,6 @@ namespace BililiveRecorder.WPF.Models { // dispose managed state (managed objects) this.Recorder?.Dispose(); - this.Logs.Dispose(); } // free unmanaged resources (unmanaged objects) and override finalizer diff --git a/BililiveRecorder.WPF/NewMainWindow.xaml.cs b/BililiveRecorder.WPF/NewMainWindow.xaml.cs index 92e04c6..bb1489c 100644 --- a/BililiveRecorder.WPF/NewMainWindow.xaml.cs +++ b/BililiveRecorder.WPF/NewMainWindow.xaml.cs @@ -8,6 +8,7 @@ using ModernWpf.Controls; using WPFLocalizeExtension.Engine; using WPFLocalizeExtension.Extensions; +#nullable enable namespace BililiveRecorder.WPF { /// @@ -35,9 +36,9 @@ namespace BililiveRecorder.WPF private void SingleInstance_NotificationReceived(object sender, EventArgs e) => this.SuperActivateAction(); - public event EventHandler NativeBeforeWindowClose; + public event EventHandler? NativeBeforeWindowClose; - internal Action ShowBalloonTipCallback { get; set; } + internal Action? ShowBalloonTipCallback { get; set; } internal void CloseWithoutConfirmAction() { diff --git a/BililiveRecorder.WPF/Pages/AnnouncementPage.xaml.cs b/BililiveRecorder.WPF/Pages/AnnouncementPage.xaml.cs index adf4744..92ac522 100644 --- a/BililiveRecorder.WPF/Pages/AnnouncementPage.xaml.cs +++ b/BililiveRecorder.WPF/Pages/AnnouncementPage.xaml.cs @@ -9,6 +9,7 @@ using System.Windows.Threading; using System.Xaml; using Microsoft.WindowsAPICodePack.Dialogs; +#nullable enable namespace BililiveRecorder.WPF.Pages { /// @@ -18,7 +19,7 @@ namespace BililiveRecorder.WPF.Pages { private static readonly HttpClient client; - private static MemoryStream AnnouncementCache = null; + private static MemoryStream? AnnouncementCache = null; private static DateTimeOffset AnnouncementCacheTime = DateTimeOffset.MinValue; internal static CultureInfo CultureInfo = CultureInfo.CurrentUICulture; @@ -31,14 +32,14 @@ namespace BililiveRecorder.WPF.Pages public AnnouncementPage() { this.InitializeComponent(); - this.Dispatcher.BeginInvoke(DispatcherPriority.Normal, new Action(async () => await this.LoadAnnouncementAsync(ignore_cache: false, show_error: false))); + _ = this.Dispatcher.BeginInvoke(DispatcherPriority.Normal, (Func)(async () => await this.LoadAnnouncementAsync(ignore_cache: false, show_error: false))); } private async void Button_Click(object sender, RoutedEventArgs e) => await this.LoadAnnouncementAsync(ignore_cache: true, show_error: Keyboard.Modifiers.HasFlag(ModifierKeys.Control)); private async Task LoadAnnouncementAsync(bool ignore_cache, bool show_error) { - MemoryStream data; + MemoryStream? data; bool success; this.Container.Child = null; @@ -75,7 +76,7 @@ namespace BililiveRecorder.WPF.Pages } } - if (success) + if (success && data is not null) { try { diff --git a/BililiveRecorder.WPF/Pages/LogPage.xaml b/BililiveRecorder.WPF/Pages/LogPage.xaml index 222dfc0..28729a4 100644 --- a/BililiveRecorder.WPF/Pages/LogPage.xaml +++ b/BililiveRecorder.WPF/Pages/LogPage.xaml @@ -11,11 +11,9 @@ l:ResxLocalizationProvider.DefaultDictionary="Strings" xmlns:wpf="clr-namespace:BililiveRecorder.WPF" xmlns:local="clr-namespace:BililiveRecorder.WPF.Pages" - xmlns:models="clr-namespace:BililiveRecorder.WPF.Models" + xmlns:control="clr-namespace:BililiveRecorder.WPF.Controls" mc:Ignorable="d" d:DesignHeight="450" d:DesignWidth="800" - d:DataContext="{d:DesignInstance Type=models:LogModel}" - DataContext="{Binding RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=local:RootPage},Path=DataContext.Logs,Mode=OneWay}" > @@ -61,27 +59,7 @@ - - - - - - - - - - - - - - - - - - - - - + diff --git a/BililiveRecorder.WPF/Pages/LogPage.xaml.cs b/BililiveRecorder.WPF/Pages/LogPage.xaml.cs index ddf6cb1..3c1f012 100644 --- a/BililiveRecorder.WPF/Pages/LogPage.xaml.cs +++ b/BililiveRecorder.WPF/Pages/LogPage.xaml.cs @@ -1,8 +1,3 @@ -using System; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; - namespace BililiveRecorder.WPF.Pages { /// @@ -15,24 +10,5 @@ namespace BililiveRecorder.WPF.Pages this.InitializeComponent(); this.VersionTextBlock.Text = " " + BuildInfo.Version + " " + BuildInfo.HeadShaShort; } - - private void TextBlock_MouseRightButtonUp(object sender, MouseButtonEventArgs e) - { - try - { - if (sender is TextBlock textBlock) - { - Clipboard.SetText(textBlock.Text); - } - } - catch (Exception) - { - } - } - - private void ScrollViewer_Loaded(object sender, RoutedEventArgs e) - { - (sender as ScrollViewer)?.ScrollToEnd(); - } } } diff --git a/BililiveRecorder.WPF/Pages/RoomListPage.xaml b/BililiveRecorder.WPF/Pages/RoomListPage.xaml index d4319d0..821cbdc 100644 --- a/BililiveRecorder.WPF/Pages/RoomListPage.xaml +++ b/BililiveRecorder.WPF/Pages/RoomListPage.xaml @@ -10,13 +10,11 @@ l:ResxLocalizationProvider.DefaultAssembly="BililiveRecorder.WPF" l:ResxLocalizationProvider.DefaultDictionary="Strings" xmlns:local="clr-namespace:BililiveRecorder.WPF.Pages" - xmlns:mock="clr-namespace:BililiveRecorder.WPF.MockData" xmlns:c="clr-namespace:BililiveRecorder.WPF.Controls" xmlns:converters="clr-namespace:BililiveRecorder.WPF.Converters" mc:Ignorable="d" x:Name="TheRoomListPage" d:DesignHeight="1000" d:DesignWidth="960" - d:DataContext="{d:DesignInstance mock:MockRecorder,IsDesignTimeCreatable=True}" DataContext="{Binding RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=local:RootPage},Path=DataContext.Recorder,Mode=OneWay}" > @@ -84,32 +82,11 @@ - + - - - - - - - - - - - - - - - - - - - - - + diff --git a/BililiveRecorder.WPF/Pages/RoomListPage.xaml.cs b/BililiveRecorder.WPF/Pages/RoomListPage.xaml.cs index 7eed229..7dc6841 100644 --- a/BililiveRecorder.WPF/Pages/RoomListPage.xaml.cs +++ b/BililiveRecorder.WPF/Pages/RoomListPage.xaml.cs @@ -5,14 +5,14 @@ using System.Collections.Specialized; using System.Diagnostics; using System.Linq; using System.Text.RegularExpressions; -using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using BililiveRecorder.Core; using BililiveRecorder.WPF.Controls; using ModernWpf.Controls; -using NLog; +using Serilog; +#nullable enable namespace BililiveRecorder.WPF.Pages { /// @@ -20,12 +20,12 @@ namespace BililiveRecorder.WPF.Pages /// public partial class RoomListPage { - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); + private static readonly ILogger logger = Log.ForContext(); private static readonly Regex RoomIdRegex = new Regex(@"^(?:https?:\/\/)?live\.bilibili\.com\/(?:blanc\/|h5\/)?(\d*)(?:\?.*)?$", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); - private readonly IRecordedRoom[] NullRoom = new IRecordedRoom[] { null }; + private readonly IRoom?[] NullRoom = new IRoom?[] { null }; private readonly KeyIndexMappingReadOnlyList NullRoomWithMapping; @@ -40,8 +40,8 @@ namespace BililiveRecorder.WPF.Pages private void RoomListPage_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e) { - if (e.OldValue is INotifyCollectionChanged data_old) data_old.CollectionChanged -= this.DataSource_CollectionChanged; - if (e.NewValue is INotifyCollectionChanged data_new) data_new.CollectionChanged += this.DataSource_CollectionChanged; + if (e.OldValue is IRecorder data_old) ((INotifyCollectionChanged)data_old.Rooms).CollectionChanged -= this.DataSource_CollectionChanged; + if (e.NewValue is IRecorder data_new) ((INotifyCollectionChanged)data_new.Rooms).CollectionChanged += this.DataSource_CollectionChanged; this.ApplySort(); } @@ -85,16 +85,17 @@ namespace BililiveRecorder.WPF.Pages { try { - if (this.DataContext is not ICollection data) + if (this.DataContext is not IRecorder recorder || recorder.Rooms.Count == 0) { this.RoomList = this.NullRoomWithMapping; } else { - IEnumerable orderedData = this.SortBy switch + var data = recorder.Rooms; + IEnumerable orderedData = this.SortBy switch { - SortedBy.RoomId => data.OrderBy(x => x.ShortRoomId == 0 ? x.RoomId : x.ShortRoomId), - SortedBy.Status => from x in data orderby x.IsRecording descending, x.IsMonitoring descending, x.IsStreaming descending select x, + SortedBy.RoomId => data.OrderBy(x => x.ShortId == 0 ? x.RoomConfig.RoomId : x.ShortId), + SortedBy.Status => from x in data orderby x.Recording descending, x.RoomConfig.AutoRecord descending, x.Streaming descending select x, _ => data, }; var result = new KeyIndexMappingReadOnlyList(orderedData.Concat(this.NullRoom).ToArray()); @@ -107,9 +108,11 @@ namespace BililiveRecorder.WPF.Pages } } +#pragma warning disable VSTHRD100 // Avoid async void methods private async void RoomCard_DeleteRequested(object sender, EventArgs e) +#pragma warning restore VSTHRD100 // Avoid async void methods { - if (this.DataContext is IRecorder rec && sender is IRecordedRoom room) + if (this.DataContext is IRecorder rec && sender is IRoom room) { try { @@ -123,14 +126,16 @@ namespace BililiveRecorder.WPF.Pages if (result == ContentDialogResult.Primary) { rec.RemoveRoom(room); - rec.SaveConfigToFile(); + rec.SaveConfig(); } } catch (Exception) { } } } +#pragma warning disable VSTHRD100 // Avoid async void methods private async void RoomCard_ShowSettingsRequested(object sender, EventArgs e) +#pragma warning restore VSTHRD100 // Avoid async void methods { try { @@ -139,7 +144,9 @@ namespace BililiveRecorder.WPF.Pages catch (Exception) { } } +#pragma warning disable VSTHRD100 // Avoid async void methods private async void AddRoomCard_AddRoomRequested(object sender, string e) +#pragma warning restore VSTHRD100 // Avoid async void methods { var input = e.Trim(); if (string.IsNullOrWhiteSpace(input) || this.DataContext is not IRecorder rec) return; @@ -181,7 +188,7 @@ namespace BililiveRecorder.WPF.Pages return; } - if (rec.Any(x => x.RoomId == roomid || x.ShortRoomId == roomid)) + if (rec.Rooms.Any(x => x.RoomConfig.RoomId == roomid || x.ShortId == roomid)) { try { @@ -192,23 +199,27 @@ namespace BililiveRecorder.WPF.Pages } rec.AddRoom(roomid); - rec.SaveConfigToFile(); + rec.SaveConfig(); } - private async void MenuItem_EnableAutoRecAll_Click(object sender, RoutedEventArgs e) + private void MenuItem_EnableAutoRecAll_Click(object sender, RoutedEventArgs e) { if (this.DataContext is not IRecorder rec) return; - await Task.WhenAll(rec.ToList().Select(rr => Task.Run(() => rr.Start()))); - rec.SaveConfigToFile(); + foreach (var room in rec.Rooms) + room.RoomConfig.AutoRecord = true; + + rec.SaveConfig(); } - private async void MenuItem_DisableAutoRecAll_Click(object sender, RoutedEventArgs e) + private void MenuItem_DisableAutoRecAll_Click(object sender, RoutedEventArgs e) { if (this.DataContext is not IRecorder rec) return; - await Task.WhenAll(rec.ToList().Select(rr => Task.Run(() => rr.Stop()))); - rec.SaveConfigToFile(); + foreach (var room in rec.Rooms) + room.RoomConfig.AutoRecord = false; + + rec.SaveConfig(); } private void MenuItem_SortBy_Click(object sender, RoutedEventArgs e) => this.SortBy = (SortedBy)((MenuItem)sender).Tag; @@ -271,20 +282,20 @@ namespace BililiveRecorder.WPF.Pages Status, } - internal class KeyIndexMappingReadOnlyList : IReadOnlyList, IKeyIndexMapping + internal class KeyIndexMappingReadOnlyList : IReadOnlyList, IKeyIndexMapping { - private readonly IReadOnlyList data; + private readonly IReadOnlyList data; - public KeyIndexMappingReadOnlyList(IReadOnlyList data) + public KeyIndexMappingReadOnlyList(IReadOnlyList data) { this.data = data; } - public IRecordedRoom this[int index] => this.data[index]; + public IRoom? this[int index] => this.data[index]; public int Count => this.data.Count; - public IEnumerator GetEnumerator() => this.data.GetEnumerator(); + public IEnumerator GetEnumerator() => this.data.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)this.data).GetEnumerator(); #region IKeyIndexMapping @@ -308,7 +319,7 @@ namespace BililiveRecorder.WPF.Pages var start = this.lastRequestedIndex; for (var i = start; i < this.Count; i++) { - if ((this[i]?.Guid ?? Guid.Empty).Equals(uniqueId)) + if ((this[i]?.ObjectId ?? Guid.Empty).Equals(uniqueId)) return i; } @@ -316,7 +327,7 @@ namespace BililiveRecorder.WPF.Pages start = Math.Min(this.Count - 1, this.lastRequestedIndex); for (var i = start; i >= 0; i--) { - if ((this[i]?.Guid ?? Guid.Empty).Equals(uniqueId)) + if ((this[i]?.ObjectId ?? Guid.Empty).Equals(uniqueId)) return i; } @@ -325,7 +336,7 @@ namespace BililiveRecorder.WPF.Pages public string KeyFromIndex(int index) { - var key = this[index]?.Guid ?? Guid.Empty; + var key = this[index]?.ObjectId ?? Guid.Empty; this.lastRequestedIndex = index; return key.ToString(); } diff --git a/BililiveRecorder.WPF/Pages/RootPage.xaml b/BililiveRecorder.WPF/Pages/RootPage.xaml index caf5370..05ccd37 100644 --- a/BililiveRecorder.WPF/Pages/RootPage.xaml +++ b/BililiveRecorder.WPF/Pages/RootPage.xaml @@ -72,11 +72,23 @@ - + + + + + + + + + + + + + @@ -89,7 +101,10 @@ - + diff --git a/BililiveRecorder.WPF/Pages/RootPage.xaml.cs b/BililiveRecorder.WPF/Pages/RootPage.xaml.cs index a7ee942..b23645a 100644 --- a/BililiveRecorder.WPF/Pages/RootPage.xaml.cs +++ b/BililiveRecorder.WPF/Pages/RootPage.xaml.cs @@ -8,16 +8,17 @@ using System.Windows.Controls; using System.Windows.Input; using System.Windows.Threading; using BililiveRecorder.Core; +using BililiveRecorder.Core.Config; using BililiveRecorder.DependencyInjection; using BililiveRecorder.WPF.Controls; using BililiveRecorder.WPF.Models; -using CommandLine; using Microsoft.Extensions.DependencyInjection; using ModernWpf.Controls; using ModernWpf.Media.Animation; -using NLog; +using Serilog; using Path = System.IO.Path; +#nullable enable namespace BililiveRecorder.WPF.Pages { /// @@ -25,16 +26,18 @@ namespace BililiveRecorder.WPF.Pages /// public partial class RootPage : UserControl { - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); + private static readonly ILogger logger = Log.ForContext(); + + internal static string? CommandArgumentRecorderPath = null; + internal static bool CommandArgumentFirstRun = false; // TODO private readonly Dictionary PageMap = new Dictionary(); private readonly string lastdir_path = Path.Combine(Path.GetDirectoryName(typeof(RootPage).Assembly.Location), "lastdir.txt"); private readonly NavigationTransitionInfo transitionInfo = new DrillInNavigationTransitionInfo(); - private ServiceProvider ServiceProvider { get; } - private int SettingsClickCount = 0; + private ServiceProvider serviceProvider; internal RootModel Model { get; private set; } public RootPage() @@ -45,15 +48,19 @@ namespace BililiveRecorder.WPF.Pages AddType(typeof(SettingsPage)); AddType(typeof(AdvancedSettingsPage)); AddType(typeof(AnnouncementPage)); + AddType(typeof(ToolboxAutoFixPage)); this.Model = new RootModel(); this.DataContext = this.Model; { var services = new ServiceCollection(); - services.AddFlvProcessor(); - services.AddCore(); - this.ServiceProvider = services.BuildServiceProvider(); + services + .AddFlv() + .AddRecorder() + ; + + this.serviceProvider = services.BuildServiceProvider(); } this.InitializeComponent(); @@ -74,29 +81,27 @@ namespace BililiveRecorder.WPF.Pages private async void RootPage_Loaded(object sender, RoutedEventArgs e) { - var recorder = this.ServiceProvider.GetRequiredService(); var first_time = true; + var from_argument = false; var error = WorkDirectorySelectorDialog.WorkDirectorySelectorDialogError.None; string path; + while (true) { try { - CommandLineOption commandLineOption = null; if (first_time) { // while 循环第一次运行时检查命令行参数 try { first_time = false; - Parser.Default - .ParseArguments(Environment.GetCommandLineArgs()) - .WithParsed(x => commandLineOption = x); - if (!string.IsNullOrWhiteSpace(commandLineOption?.WorkDirectory)) + if (!string.IsNullOrWhiteSpace(CommandArgumentRecorderPath)) { // 如果有参数直接跳到检查路径 - path = Path.GetFullPath(commandLineOption.WorkDirectory); + path = Path.GetFullPath(CommandArgumentRecorderPath); + from_argument = true; } else { @@ -127,11 +132,17 @@ namespace BililiveRecorder.WPF.Pages Error = error, Path = lastdir }; - - if (await dialog.ShowAsync() != ContentDialogResult.Primary) + var dialogResult = await dialog.ShowAsync(); + switch (dialogResult) { - (Application.Current.MainWindow as NewMainWindow).CloseWithoutConfirmAction(); - return; + case ContentDialogResult.Primary: + break; + case ContentDialogResult.Secondary: + return; + case ContentDialogResult.None: + default: + (Application.Current.MainWindow as NewMainWindow)!.CloseWithoutConfirmAction(); + return; } try @@ -143,7 +154,7 @@ namespace BililiveRecorder.WPF.Pages } } - var config = Path.Combine(path, "config.json"); + var configFilePath = Path.Combine(path, "config.json"); if (!Directory.Exists(path)) { @@ -154,7 +165,7 @@ namespace BililiveRecorder.WPF.Pages { // 可用的空文件夹 } - else if (!File.Exists(config)) + else if (!File.Exists(configFilePath)) { error = WorkDirectorySelectorDialog.WorkDirectorySelectorDialogError.PathContainsFiles; continue; @@ -164,49 +175,64 @@ namespace BililiveRecorder.WPF.Pages // 如果不是从命令行参数传入的路径,写入 lastdir_path 记录 try - { if (string.IsNullOrWhiteSpace(commandLineOption?.WorkDirectory)) File.WriteAllText(this.lastdir_path, path); } + { + if (!from_argument) + File.WriteAllText(this.lastdir_path, path); + } catch (Exception) { } - // 检查已经在同目录运行的其他进程 - if (SingleInstance.CheckMutex(path)) + // 加载配置文件 + var config = ConfigParser.LoadFrom(path); + if (config is null) { - // 无已经在同目录运行的进程 - if (recorder.Initialize(path)) - { - this.Model.Recorder = recorder; - - _ = Task.Run(async () => - { - await Task.Delay(100); - _ = this.Dispatcher.BeginInvoke(DispatcherPriority.Normal, method: new Action(() => - { - this.RoomListPageNavigationViewItem.IsSelected = true; - })); - }); - break; - } - else - { - error = WorkDirectorySelectorDialog.WorkDirectorySelectorDialogError.FailedToLoadConfig; - continue; - } + error = WorkDirectorySelectorDialog.WorkDirectorySelectorDialogError.FailedToLoadConfig; + continue; } - else + config.Global.WorkDirectory = path; + + // 检查已经在同目录运行的其他进程 + if (!SingleInstance.CheckMutex(path)) { // 有已经在其他目录运行的进程,已经通知该进程,本进程退出 - (Application.Current.MainWindow as NewMainWindow).CloseWithoutConfirmAction(); + (Application.Current.MainWindow as NewMainWindow)!.CloseWithoutConfirmAction(); return; } + + // 无已经在同目录运行的进程 + this.serviceProvider = this.BuildServiceProvider(config, logger); + var recorder = this.serviceProvider.GetRequiredService(); + + this.Model.Recorder = recorder; + this.RoomListPageNavigationViewItem.IsEnabled = true; + this.SettingsPageNavigationViewItem.IsEnabled = true; + + _ = Task.Run(async () => + { + await Task.Delay(100); + _ = this.Dispatcher.BeginInvoke(DispatcherPriority.Normal, method: new Action(() => + { + this.RoomListPageNavigationViewItem.IsSelected = true; + })); + }); + + break; } catch (Exception ex) { error = WorkDirectorySelectorDialog.WorkDirectorySelectorDialogError.UnknownError; - logger.Warn(ex, "选择工作目录时发生了未知错误"); + logger.Warning(ex, "选择工作目录时发生了未知错误"); continue; } } } + private ServiceProvider BuildServiceProvider(Core.Config.V2.ConfigV2 config, ILogger logger) => new ServiceCollection() + .AddSingleton(logger) + .AddRecorderConfig(config) + .AddFlv() + .AddRecorder() + .BuildServiceProvider(); + private void NavigationView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) { this.SettingsClickCount = 0; diff --git a/BililiveRecorder.WPF/Pages/SettingsPage.xaml b/BililiveRecorder.WPF/Pages/SettingsPage.xaml index 5483d52..15bedcc 100644 --- a/BililiveRecorder.WPF/Pages/SettingsPage.xaml +++ b/BililiveRecorder.WPF/Pages/SettingsPage.xaml @@ -12,7 +12,7 @@ xmlns:c="clr-namespace:BililiveRecorder.WPF.Controls" xmlns:local="clr-namespace:BililiveRecorder.WPF.Pages" xmlns:config="clr-namespace:BililiveRecorder.Core.Config.V2;assembly=BililiveRecorder.Core" - xmlns:flv="clr-namespace:BililiveRecorder.FlvProcessor;assembly=BililiveRecorder.FlvProcessor" + xmlns:confiv2="clr-namespace:BililiveRecorder.Core.Config.V2;assembly=BililiveRecorder.Core" mc:Ignorable="d" d:DesignHeight="1500" d:DesignWidth="500" DataContext="{Binding RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=local:RootPage},Path=DataContext.Recorder.Config.Global}" @@ -45,13 +45,13 @@ + ConverterParameter={x:Static confiv2:CuttingMode.Disabled}}" /> + ConverterParameter={x:Static confiv2:CuttingMode.BySize}}" /> + ConverterParameter={x:Static confiv2:CuttingMode.ByTime}}" /> @@ -84,7 +84,11 @@ - + + + + @@ -92,15 +96,6 @@ - - - diff --git a/BililiveRecorder.WPF/Pages/ToolboxAutoFixPage.xaml b/BililiveRecorder.WPF/Pages/ToolboxAutoFixPage.xaml new file mode 100644 index 0000000..eda04a7 --- /dev/null +++ b/BililiveRecorder.WPF/Pages/ToolboxAutoFixPage.xaml @@ -0,0 +1,20 @@ + + + + + + diff --git a/BililiveRecorder.WPF/Pages/ToolboxAutoFixPage.xaml.cs b/BililiveRecorder.WPF/Pages/ToolboxAutoFixPage.xaml.cs new file mode 100644 index 0000000..caef62a --- /dev/null +++ b/BililiveRecorder.WPF/Pages/ToolboxAutoFixPage.xaml.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace BililiveRecorder.WPF.Pages +{ + /// + /// Interaction logic for ToolboxAutoFixPage.xaml + /// + public partial class ToolboxAutoFixPage + { + public ToolboxAutoFixPage() + { + InitializeComponent(); + } + } +} diff --git a/BililiveRecorder.WPF/Program.cs b/BililiveRecorder.WPF/Program.cs index b8ec859..3553d0c 100644 --- a/BililiveRecorder.WPF/Program.cs +++ b/BililiveRecorder.WPF/Program.cs @@ -1,80 +1,179 @@ using System; +using System.CommandLine; +using System.CommandLine.Invocation; using System.IO; using System.Runtime.ExceptionServices; using System.Security; +using System.Threading; +using System.Threading.Tasks; using System.Windows.Threading; -using NLog; using Sentry; +using Serilog; +using Serilog.Core; +using Serilog.Exceptions; +using Serilog.Formatting.Compact; +#nullable enable namespace BililiveRecorder.WPF { - public static class Program + internal static class Program { - private static readonly Logger logger = LogManager.GetCurrentClassLogger(); + private const int CODE__WPF = 0x5F_57_50_46; + + internal static readonly LoggingLevelSwitch levelSwitchGlobal; + internal static readonly LoggingLevelSwitch levelSwitchConsole; + internal static readonly Logger logger; + internal static readonly Update update; + internal static Task? updateTask; + + static Program() + { + levelSwitchGlobal = new LoggingLevelSwitch(Serilog.Events.LogEventLevel.Debug); + levelSwitchConsole = new LoggingLevelSwitch(Serilog.Events.LogEventLevel.Error); + logger = BuildLogger(); + AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; + Log.Logger = logger; + SentrySdk.ConfigureScope(s => + { + try + { + var path = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(typeof(Program).Assembly.Location), "..", "packages", ".betaId")); + if (!File.Exists(path)) return; + var content = File.ReadAllText(path); + if (Guid.TryParse(content, out var id)) s.User = new User { Id = id.ToString() }; + } + catch (Exception) { } + }); + update = new Update(logger); + } [STAThread] - public static int Main() + public static int Main(string[] args) { - AppDomain.CurrentDomain.ProcessExit += CurrentDomain_ProcessExit; - AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; - using (SentrySdk.Init(o => + try { - if (!File.Exists("BILILIVE_RECORDER_DISABLE_SENTRY") - && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("BILILIVE_RECORDER_DISABLE_SENTRY"))) - { - o.Dsn = new Dsn("https://efc16b0fd5604608b811c3b358e9d1f1@o210546.ingest.sentry.io/5556540"); - } - - var v = typeof(Program).Assembly.GetName().Version; - if (v.Major != 0) - { - o.Release = "BililiveRecorder@" + v.ToString(); - } - o.DisableAppDomainUnhandledExceptionCapture(); - o.AddExceptionFilterForType(); - })) + logger.Debug("Starting, CurrentDirectory: {CurrentDirectory}, CommandLine: {CommandLine}", Environment.CurrentDirectory, Environment.CommandLine); + var code = BuildCommand().Invoke(args); + return code == CODE__WPF ? Commands.RunWpfReal() : code; + } + finally { - SentrySdk.ConfigureScope(s => - { - try - { - var path = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(typeof(Program).Assembly.Location), "..", "packages", ".betaId")); - if (!File.Exists(path)) return; - var content = File.ReadAllText(path); - if (Guid.TryParse(content, out var id)) - s.User = new Sentry.Protocol.User { Id = id.ToString() }; - } - catch (Exception) { } - }); - - var app = new App(); - app.InitializeComponent(); - app.DispatcherUnhandledException += App_DispatcherUnhandledException; - return app.Run(); + logger.Dispose(); } } - private static void CurrentDomain_ProcessExit(object sender, EventArgs e) + private static RootCommand BuildCommand() { - LogManager.Flush(); - LogManager.Shutdown(); + var run = new Command("run", "Run BililiveRecorder at path") + { + new Argument("path","Work directory") + + }; + run.Handler = CommandHandler.Create((string path) => Commands.RunWpfHandler(path, false)); + + var root = new RootCommand("") + { + run, + new Option("--squirrel-firstrun") + { + IsHidden = true + } + }; + root.Handler = CommandHandler.Create((bool squirrelFirstrun) => Commands.RunWpfHandler(null, squirrelFirstrun)); + return root; } + private static class Commands + { + internal static int RunWpfHandler(string? path, bool squirrelFirstrun) + { + Pages.RootPage.CommandArgumentRecorderPath = path; + Pages.RootPage.CommandArgumentFirstRun = squirrelFirstrun; + return CODE__WPF; + } + + internal static int RunWpfReal() + { + var cancel = new CancellationTokenSource(); + var token = cancel.Token; + try + { + var app = new App(); + app.InitializeComponent(); + app.DispatcherUnhandledException += App_DispatcherUnhandledException; + + updateTask = Task.Run(async () => + { + while (!token.IsCancellationRequested) + { + await update.UpdateAsync().ConfigureAwait(false); + await Task.Delay(TimeSpan.FromDays(1), token).ConfigureAwait(false); + } + }); + + return app.Run(); + } + finally + { + cancel.Cancel(); +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + update.WaitForUpdatesOnShutdownAsync().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + } + + internal static int Tool_Fix(string path) + { + levelSwitchConsole.MinimumLevel = Serilog.Events.LogEventLevel.Information; + // run code + return 0; + } + + internal static int Tool_Parse(string path) + { + levelSwitchConsole.MinimumLevel = Serilog.Events.LogEventLevel.Information; + // run code + return 0; + } + } + + private static Logger BuildLogger() => new LoggerConfiguration() + .MinimumLevel.ControlledBy(levelSwitchGlobal) + .Enrich.WithProcessId() + .Enrich.WithThreadId() + .Enrich.WithThreadName() + .Enrich.FromLogContext() + .Enrich.WithExceptionDetails() + .WriteTo.Debug() + .WriteTo.Console(levelSwitch: levelSwitchConsole) + .WriteTo.Sink(Serilog.Events.LogEventLevel.Debug) // TODO level + .WriteTo.File(new CompactJsonFormatter(), "./logs/bilirec.txt", shared: true, rollingInterval: RollingInterval.Day) + .WriteTo.Sentry(o => + { + o.Dsn = "https://efc16b0fd5604608b811c3b358e9d1f1@o210546.ingest.sentry.io/5556540"; + + var v = typeof(Program).Assembly.GetName().Version; + if (v.Major != 0) + o.Release = "BililiveRecorder@" + v.ToString(); + + o.DisableAppDomainUnhandledExceptionCapture(); + o.AddExceptionFilterForType(); + + o.MinimumBreadcrumbLevel = Serilog.Events.LogEventLevel.Debug; + o.MinimumEventLevel = Serilog.Events.LogEventLevel.Error; + // TODO 测试调整 sentry + }) + .CreateLogger(); + [HandleProcessCorruptedStateExceptions, SecurityCritical] private static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) { if (e.ExceptionObject is Exception ex) - { logger.Fatal(ex, "Unhandled exception from Application.UnhandledException"); - LogManager.Flush(); - } } [HandleProcessCorruptedStateExceptions, SecurityCritical] - private static void App_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) - { + private static void App_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) => logger.Fatal(e.Exception, "Unhandled exception from AppDomain.DispatcherUnhandledException"); - LogManager.Flush(); - } } } diff --git a/BililiveRecorder.WPF/Properties/Strings.Designer.cs b/BililiveRecorder.WPF/Properties/Strings.Designer.cs index e2341ab..74b82df 100644 --- a/BililiveRecorder.WPF/Properties/Strings.Designer.cs +++ b/BililiveRecorder.WPF/Properties/Strings.Designer.cs @@ -366,6 +366,15 @@ namespace BililiveRecorder.WPF.Properties { } } + /// + /// Looks up a localized string similar to 切割录制输出文件. + /// + public static string RoomCard_SplitButton_Tooltip { + get { + return ResourceManager.GetString("RoomCard_SplitButton_Tooltip", resourceCulture); + } + } + /// /// Looks up a localized string similar to 弹幕连接被断开 ///录播姬通过弹幕服务器接收开播状态推送. diff --git a/BililiveRecorder.WPF/Properties/Strings.resx b/BililiveRecorder.WPF/Properties/Strings.resx index 229044c..4cae6cb 100644 --- a/BililiveRecorder.WPF/Properties/Strings.resx +++ b/BililiveRecorder.WPF/Properties/Strings.resx @@ -206,6 +206,9 @@ 操作 + + 切割录制输出文件 + 弹幕连接被断开 录播姬通过弹幕服务器接收开播状态推送 diff --git a/BililiveRecorder.WPF/Resources/ConverterResources.xaml b/BililiveRecorder.WPF/Resources/ConverterResources.xaml index 995af1a..5f4b58d 100644 --- a/BililiveRecorder.WPF/Resources/ConverterResources.xaml +++ b/BililiveRecorder.WPF/Resources/ConverterResources.xaml @@ -51,7 +51,8 @@ - + + - + diff --git a/BililiveRecorder.WPF/Resources/IconResources.xaml b/BililiveRecorder.WPF/Resources/IconResources.xaml index 38000cc..e607552 100644 --- a/BililiveRecorder.WPF/Resources/IconResources.xaml +++ b/BililiveRecorder.WPF/Resources/IconResources.xaml @@ -67,6 +67,18 @@ + + + + diff --git a/BililiveRecorder.WPF/SingleInstance.cs b/BililiveRecorder.WPF/SingleInstance.cs index b625a84..14568ec 100644 --- a/BililiveRecorder.WPF/SingleInstance.cs +++ b/BililiveRecorder.WPF/SingleInstance.cs @@ -9,14 +9,15 @@ using System.Threading; using System.Windows; using System.Windows.Threading; +#nullable enable namespace BililiveRecorder.WPF { public static class SingleInstance { - private static Mutex singleInstanceMutex; - private static IpcServerChannel channel; + private static Mutex? singleInstanceMutex; + private static IpcServerChannel? channel; - public static event EventHandler NotificationReceived; + public static event EventHandler? NotificationReceived; public static bool CheckMutex(string path) { @@ -68,7 +69,7 @@ namespace BililiveRecorder.WPF private class IPCRemoteService : MarshalByRefObject { public void Notify() => Application.Current?.Dispatcher.BeginInvoke(DispatcherPriority.Normal, (Action)ActivateFirstInstanceCallback); - public override object InitializeLifetimeService() => null; + public override object? InitializeLifetimeService() => null; } } } diff --git a/BililiveRecorder.WPF/Update.cs b/BililiveRecorder.WPF/Update.cs new file mode 100644 index 0000000..11dcdda --- /dev/null +++ b/BililiveRecorder.WPF/Update.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading.Tasks; +using Serilog; +using Squirrel; + +#nullable enable +namespace BililiveRecorder.WPF +{ + internal class Update + { + private readonly ILogger logger; + + private Task updateInProgress = Task.CompletedTask; + + public Update(ILogger logger) + { + this.logger = logger; + } + + public async Task UpdateAsync() + { + if (!this.updateInProgress.IsCompleted) + await this.updateInProgress; + this.updateInProgress = this.RealUpdateAsync(); + await this.updateInProgress; + } + + public async Task WaitForUpdatesOnShutdownAsync() => await this.updateInProgress.ContinueWith(ex => { }, TaskScheduler.Default).ConfigureAwait(false); + + private async Task RealUpdateAsync() + { + this.logger.Debug("Checking updates"); + try + { + using var updateManager = new UpdateManager(@"https://soft.danmuji.org/BililiveRecorder/"); + + var ignoreDeltaUpdates = false; + + retry: + try + { + var updateInfo = await updateManager.CheckForUpdate(ignoreDeltaUpdates); + + if (updateInfo.ReleasesToApply.Count == 0) + { + this.logger.Information("当前运行的是最新版本 {BuildVersion}/{InstalledVersion}", + typeof(Update).Assembly.GetName().Version.ToString(4), + updateInfo.CurrentlyInstalledVersion?.Version?.ToString() ?? "×"); + } + else + { + this.logger.Information("有新版本 {RemoteVersion},当前本地 {BuildVersion}/{InstalledVersion}", + updateInfo.FutureReleaseEntry?.Version?.ToString() ?? "×", + typeof(Update).Assembly.GetName().Version.ToString(4), + updateInfo.CurrentlyInstalledVersion?.Version?.ToString() ?? "×"); + + await updateManager.DownloadReleases(updateInfo.ReleasesToApply); + await updateManager.ApplyReleases(updateInfo); + + this.logger.Information("更新完成"); + } + } + catch (Exception ex) + { + if (ignoreDeltaUpdates == false) + { + ignoreDeltaUpdates = true; + this.logger.Debug(ex, "第一次检查更新出错"); + goto retry; + } + + throw; + } + } + catch (Exception ex) + { + this.logger.Warning(ex, "检查更新时出错"); + } + } + } +} diff --git a/BililiveRecorder.WPF/WpfLogEventSink.cs b/BililiveRecorder.WPF/WpfLogEventSink.cs new file mode 100644 index 0000000..915e554 --- /dev/null +++ b/BililiveRecorder.WPF/WpfLogEventSink.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows; +using BililiveRecorder.Core; +using Serilog.Core; +using Serilog.Events; + +#nullable enable +namespace BililiveRecorder.WPF +{ + internal class WpfLogEventSink : ILogEventSink + { + private const int MAX_LINE = 60; + internal static object _lock = new object(); + internal static ObservableCollection Logs = new ObservableCollection(); + + public WpfLogEventSink() { } + + public void Emit(LogEvent logEvent) + { + var msg = logEvent.RenderMessage(); + if (logEvent.Exception != null) + msg += " " + logEvent.Exception.Message; + + var m = new LogModel + { + Timestamp = logEvent.Timestamp, + Level = logEvent.Level switch + { + LogEventLevel.Verbose => "Verbose", + LogEventLevel.Debug => "Debug", + LogEventLevel.Information => "Info", + LogEventLevel.Warning => "Warn", + LogEventLevel.Error => "Error", + LogEventLevel.Fatal => "Fatal", + _ => string.Empty, + }, + Message = msg, + }; + + if (logEvent.Properties.TryGetValue(LoggingContext.RoomId, out var propertyValue) + && propertyValue is ScalarValue scalarValue + && scalarValue.Value is int roomid) + { + m.RoomId = roomid.ToString(); + } + + var current = Application.Current; + if (current is null) + lock (_lock) + this.AddLogToCollection(m); + else + _ = current.Dispatcher.BeginInvoke((Action)this.AddLogToCollection, m); + } + + private void AddLogToCollection(LogModel model) + { + try + { + Logs.Add(model); + while (Logs.Count > MAX_LINE) + Logs.RemoveAt(0); + } + catch (Exception) { } + } + + public class LogModel : INotifyPropertyChanged + { + public DateTimeOffset Timestamp { get; set; } + + public string Level { get; set; } = string.Empty; + + public string RoomId { get; set; } = string.Empty; + + public string Message { get; set; } = string.Empty; +#pragma warning disable CS0067 + public event PropertyChangedEventHandler? PropertyChanged; +#pragma warning restore CS0067 + } + } +} diff --git a/BililiveRecorder.sln b/BililiveRecorder.sln index d48c355..86bd629 100644 --- a/BililiveRecorder.sln +++ b/BililiveRecorder.sln @@ -17,6 +17,7 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.Core", "BililiveRecorder.Core\BililiveRecorder.Core.csproj", "{CB9F2D58-181D-49F7-9560-D35A9B9C1D8C}" ProjectSection(ProjectDependencies) = postProject {51748048-1949-4218-8DED-94014ABE7633} = {51748048-1949-4218-8DED-94014ABE7633} + {7610E19C-D3AB-4CBC-983E-6FDA36F4D4B3} = {7610E19C-D3AB-4CBC-983E-6FDA36F4D4B3} EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.FlvProcessor", "BililiveRecorder.FlvProcessor\BililiveRecorder.FlvProcessor.csproj", "{51748048-1949-4218-8DED-94014ABE7633}" @@ -25,7 +26,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.Cli", "Bil EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.Flv", "BililiveRecorder.Flv\BililiveRecorder.Flv.csproj", "{7610E19C-D3AB-4CBC-983E-6FDA36F4D4B3}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.UnitTest.Core", "test\BililiveRecorder.UnitTest.Core\BililiveRecorder.UnitTest.Core.csproj", "{521EC763-5694-45A8-B87F-6E6B7F2A3BD4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.Core.UnitTests", "test\BililiveRecorder.Core.UnitTests\BililiveRecorder.Core.UnitTests.csproj", "{521EC763-5694-45A8-B87F-6E6B7F2A3BD4}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.Flv.UnitTests", "test\BililiveRecorder.Flv.UnitTests\BililiveRecorder.Flv.UnitTests.csproj", "{560E8483-9293-410E-81E9-AB36B49F8A7C}" EndProject diff --git a/test/BililiveRecorder.UnitTest.Core/BililiveRecorder.UnitTest.Core.csproj b/test/BililiveRecorder.Core.UnitTests/BililiveRecorder.Core.UnitTests.csproj similarity index 100% rename from test/BililiveRecorder.UnitTest.Core/BililiveRecorder.UnitTest.Core.csproj rename to test/BililiveRecorder.Core.UnitTests/BililiveRecorder.Core.UnitTests.csproj diff --git a/test/BililiveRecorder.UnitTest.Core/ConfigTests.cs b/test/BililiveRecorder.Core.UnitTests/Config/ConfigTests.cs similarity index 98% rename from test/BililiveRecorder.UnitTest.Core/ConfigTests.cs rename to test/BililiveRecorder.Core.UnitTests/Config/ConfigTests.cs index 28899ce..b6f1dad 100644 --- a/test/BililiveRecorder.UnitTest.Core/ConfigTests.cs +++ b/test/BililiveRecorder.Core.UnitTests/Config/ConfigTests.cs @@ -7,7 +7,7 @@ using BililiveRecorder.Core.Config.V2; using Newtonsoft.Json; using Xunit; -namespace BililiveRecorder.UnitTest.Core +namespace BililiveRecorder.Core.UnitTests.Config { public class ConfigTests { diff --git a/test/BililiveRecorder.Core.UnitTests/Danmaku/ManualTests.cs b/test/BililiveRecorder.Core.UnitTests/Danmaku/ManualTests.cs new file mode 100644 index 0000000..bc035bd --- /dev/null +++ b/test/BililiveRecorder.Core.UnitTests/Danmaku/ManualTests.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using BililiveRecorder.Core.Api; +using BililiveRecorder.Core.Api.Danmaku; +using BililiveRecorder.Core.Api.Http; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace BililiveRecorder.Core.UnitTests.Danmaku +{ + public class ManualTests + { + [Fact] + public async Task TestAsync() + { + var client = new DanmakuClient(new HttpApiClient(null!), null); + + client.StatusChanged += this.Client_StatusChanged; + client.DanmakuReceived += this.Client_DanmakuReceived; + + await Task.Yield(); + + throw new NotImplementedException(); + // await client.ConnectAsync().ConfigureAwait(false); + + // await Task.Delay(TimeSpan.FromMinutes(5)).ConfigureAwait(false); + } + + private void Client_DanmakuReceived(object sender, DanmakuReceivedEventArgs e) + { + } + + private void Client_StatusChanged(object sender, StatusChangedEventArgs e) + { + } + } +} diff --git a/test/BililiveRecorder.Core.UnitTests/Recording/CheckIsWithinPathTests.cs b/test/BililiveRecorder.Core.UnitTests/Recording/CheckIsWithinPathTests.cs new file mode 100644 index 0000000..90530a5 --- /dev/null +++ b/test/BililiveRecorder.Core.UnitTests/Recording/CheckIsWithinPathTests.cs @@ -0,0 +1,37 @@ +using System.IO; +using Xunit; + +namespace BililiveRecorder.Core.UnitTests.Recording +{ + public class CheckIsWithinPathTests + { + [Theory] + [InlineData(@"C:\", @"C:\", false)] + [InlineData(@"C:", @"C:\foo", true)] + [InlineData(@"C:\", @"C:\foo", true)] + [InlineData(@"C:\foo", @"C:\foo", false)] + [InlineData(@"C:\foo\", @"C:\foo", false)] + [InlineData(@"C:\foo", @"C:\foo\", true)] + [InlineData(@"C:\foo\", @"C:\foo\bar\", true)] + [InlineData(@"C:\foo\", @"C:\foo\bar", true)] + [InlineData(@"C:\foo", @"C:\FOO\bar", false)] + [InlineData(@"C:\foo", @"C:/foo/bar", true)] + [InlineData(@"C:\foo", @"C:\foobar", false)] + [InlineData(@"C:\foo", @"C:\foobar\baz", false)] + [InlineData(@"C:\foo\", @"C:\foobar\baz", false)] + [InlineData(@"C:\foobar", @"C:\foo\bar", false)] + [InlineData(@"C:\foobar\", @"C:\foo\bar", false)] + [InlineData(@"C:\foo", @"C:\foo\..\bar\baz", false)] + [InlineData(@"C:\bar", @"C:\foo\..\bar\baz", true)] + [InlineData(@"C:\barr", @"C:\foo\..\bar\baz", false)] + [InlineData(@"C:\foo\", @"D:\foo\bar", false)] + [InlineData(@"\\server1\vol1\foo", @"\\server1\vol1\foo", false)] + [InlineData(@"\\server1\vol1\foo", @"\\server1\vol1\bar", false)] + [InlineData(@"\\server1\vol1\foo", @"\\server1\vol1\foo\bar", true)] + [InlineData(@"\\server1\vol1\foo", @"\\server1\vol1\foo\..\bar", false)] + public void Test(string parent, string child, bool result) + { + Assert.Equal(result, Core.Recording.RecordTask.WriterTargetProvider.CheckIsWithinPath(parent, Path.GetDirectoryName(child)!)); + } + } +} diff --git a/test/BililiveRecorder.Flv.UnitTests/Amf/ParserTests.cs b/test/BililiveRecorder.Flv.UnitTests/Amf/ParserTests.cs new file mode 100644 index 0000000..84a215f --- /dev/null +++ b/test/BililiveRecorder.Flv.UnitTests/Amf/ParserTests.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using BililiveRecorder.Flv.Amf; +using FluentAssertions; +using Xunit; + +namespace BililiveRecorder.Flv.UnitTests.Amf +{ + public class ParserTests + { + [Theory, MemberData(nameof(JsonData))] + public void ParseJson(ScriptTagBody expectation, string input) + { + var result = ScriptTagBody.Parse(input); + result.Should().BeEquivalentTo(expectation: expectation); + } + + [Theory, MemberData(nameof(BinaryData))] + public void ParseBinary(ScriptTagBody expectation, byte[] input) + { + var result = ScriptTagBody.Parse(input); + result.Should().BeEquivalentTo(expectation: expectation); + } + + public static IEnumerable JsonData() + { + yield return new object[] { + SerializationTests.CreateTestObject1(), + "[{\"Type\":\"String\",\"Value\":\"test\"},{\"Type\":\"Object\",\"Value\":{\"bool_true\":{\"Type\":\"Boolean\",\"Value\":true},\"bool_false\":{\"Type\":\"Boolean\",\"Value\":false},\"date1\":{\"Type\":\"Date\",\"Value\":\"2021-02-08T14:43:58.257+00:00\"},\"date2\":{\"Type\":\"Date\",\"Value\":\"2345-03-14T07:08:09.012+04:00\"},\"ecmaarray\":{\"Type\":\"EcmaArray\",\"Value\":{\"element1\":{\"Type\":\"String\",\"Value\":\"element1\"},\"element2\":{\"Type\":\"String\",\"Value\":\"element2\"},\"element3\":{\"Type\":\"String\",\"Value\":\"element3\"}}},\"longstring1\":{\"Type\":\"LongString\",\"Value\":\"longstring1\"},\"longstring2\":{\"Type\":\"LongString\",\"Value\":\"longstring2\"},\"null\":{\"Type\":\"Null\"},\"number1\":{\"Type\":\"Number\",\"Value\":0.0},\"number2\":{\"Type\":\"Number\",\"Value\":197653.845},\"number3\":{\"Type\":\"Number\",\"Value\":-95.7},\"number4\":{\"Type\":\"Number\",\"Value\":5E-324},\"strictarray\":{\"Type\":\"StrictArray\",\"Value\":[{\"Type\":\"String\",\"Value\":\"element1\"},{\"Type\":\"String\",\"Value\":\"element2\"},{\"Type\":\"String\",\"Value\":\"element3\"}]},\"string1\":{\"Type\":\"String\",\"Value\":\"string1\"},\"string2\":{\"Type\":\"String\",\"Value\":\"string2\"},\"undefined\":{\"Type\":\"Undefined\"}}}]", + }; + yield return new object[] { + SerializationTests.CreateTestObject2(), + "[{\"Type\":\"String\",\"Value\":\"test\"},{\"Type\":\"Boolean\",\"Value\":true},{\"Type\":\"Boolean\",\"Value\":false},{\"Type\":\"Number\",\"Value\":0.0},{\"Type\":\"Number\",\"Value\":-95.7}]", + }; + } + + public static IEnumerable BinaryData() + { + yield return new object[] { + SerializationTests.CreateTestObject1(), + new byte[]{2,0,4,116,101,115,116,3,0,9,98,111,111,108,95,116,114,117,101,1,1,0,10,98,111,111,108,95,102,97,108,115,101,1,0,0,5,100,97,116,101,49,11,66,119,120,33,150,75,16,0,0,0,0,5,100,97,116,101,50,11,66,165,137,121,64,147,104,0,0,240,0,9,101,99,109,97,97,114,114,97,121,8,0,0,0,3,0,8,101,108,101,109,101,110,116,49,2,0,8,101,108,101,109,101,110,116,49,0,8,101,108,101,109,101,110,116,50,2,0,8,101,108,101,109,101,110,116,50,0,8,101,108,101,109,101,110,116,51,2,0,8,101,108,101,109,101,110,116,51,0,0,9,0,11,108,111,110,103,115,116,114,105,110,103,49,12,0,0,0,11,108,111,110,103,115,116,114,105,110,103,49,0,11,108,111,110,103,115,116,114,105,110,103,50,12,0,0,0,11,108,111,110,103,115,116,114,105,110,103,50,0,4,110,117,108,108,5,0,7,110,117,109,98,101,114,49,0,0,0,0,0,0,0,0,0,0,7,110,117,109,98,101,114,50,0,65,8,32,174,194,143,92,41,0,7,110,117,109,98,101,114,51,0,192,87,236,204,204,204,204,205,0,7,110,117,109,98,101,114,52,0,0,0,0,0,0,0,0,1,0,11,115,116,114,105,99,116,97,114,114,97,121,10,0,0,0,3,2,0,8,101,108,101,109,101,110,116,49,2,0,8,101,108,101,109,101,110,116,50,2,0,8,101,108,101,109,101,110,116,51,0,7,115,116,114,105,110,103,49,2,0,7,115,116,114,105,110,103,49,0,7,115,116,114,105,110,103,50,2,0,7,115,116,114,105,110,103,50,0,9,117,110,100,101,102,105,110,101,100,6,0,0,9}, + }; + yield return new object[] { + SerializationTests.CreateTestObject2(), + new byte[]{ 2, 0, 4, 116, 101, 115, 116, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 192, 87, 236, 204, 204, 204, 204, 205 }, + }; + } + } +} diff --git a/test/BililiveRecorder.Flv.UnitTests/Amf/AmfTests.cs b/test/BililiveRecorder.Flv.UnitTests/Amf/SerializationTests.cs similarity index 59% rename from test/BililiveRecorder.Flv.UnitTests/Amf/AmfTests.cs rename to test/BililiveRecorder.Flv.UnitTests/Amf/SerializationTests.cs index b922a7f..970ca8c 100644 --- a/test/BililiveRecorder.Flv.UnitTests/Amf/AmfTests.cs +++ b/test/BililiveRecorder.Flv.UnitTests/Amf/SerializationTests.cs @@ -1,16 +1,63 @@ using System; +using System.Collections.Generic; using BililiveRecorder.Flv.Amf; using FluentAssertions; using Xunit; namespace BililiveRecorder.Flv.UnitTests.Amf { - public class AmfTests + public class SerializationTests { - private static ScriptTagBody CreateTestObject() => new ScriptTagBody + [Theory, MemberData(nameof(GetTestData))] + public void EqualAfterJsonSerialization(ScriptTagBody input) { - Name = "test", - Value = new ScriptDataObject + var json = input.ToJson(); + var body2 = ScriptTagBody.Parse(json); + var json2 = body2.ToJson(); + + body2.Should().BeEquivalentTo(input, options => options.RespectingRuntimeTypes()); + json2.Should().Be(json); + } + + [Theory, MemberData(nameof(GetTestData))] + public void EqualAfterBinarySerialization(ScriptTagBody input) + { + var bytes = input.ToBytes(); + var body2 = ScriptTagBody.Parse(bytes); + var bytes2 = body2.ToBytes(); + + body2.Should().BeEquivalentTo(input, options => options.RespectingRuntimeTypes()); + bytes2.Should().BeEquivalentTo(bytes2, options => options.RespectingRuntimeTypes()); + } + + [Theory, MemberData(nameof(GetTestData))] + public void EqualAfterMixedSerialization(ScriptTagBody input) + { + var a_json = input.ToJson(); + var a_body = ScriptTagBody.Parse(a_json); + var a_byte = a_body.ToBytes(); + + var b_byte = input.ToBytes(); + var b_body = ScriptTagBody.Parse(b_byte); + var b_json = b_body.ToJson(); + + b_json.Should().Be(a_json); + a_byte.Should().BeEquivalentTo(b_byte); + + a_body.Should().BeEquivalentTo(input, options => options.RespectingRuntimeTypes()); + b_body.Should().BeEquivalentTo(input, options => options.RespectingRuntimeTypes()); + a_body.Should().BeEquivalentTo(b_body, options => options.RespectingRuntimeTypes()); + } + + public static IEnumerable GetTestData() + { + yield return new object[] { CreateTestObject1() }; + yield return new object[] { CreateTestObject2() }; + } + + public static ScriptTagBody CreateTestObject1() => new ScriptTagBody(new List { + (ScriptDataString) "test", + new ScriptDataObject { ["bool_true"] = (ScriptDataBoolean)true, ["bool_false"] = (ScriptDataBoolean)false, @@ -38,52 +85,16 @@ namespace BililiveRecorder.Flv.UnitTests.Amf ["string1"] = (ScriptDataString)"string1", ["string2"] = (ScriptDataString)"string2", ["undefined"] = new ScriptDataUndefined(), - }, - }; + } + }); - [Fact] - public void EqualAfterJsonSerialization() + public static ScriptTagBody CreateTestObject2() => new ScriptTagBody(new List { - var body = CreateTestObject(); - var json = body.ToJson(); - var body2 = ScriptTagBody.Parse(json); - var json2 = body2.ToJson(); - - body2.Should().BeEquivalentTo(body, options => options.RespectingRuntimeTypes()); - json2.Should().Be(json); - } - - [Fact] - public void EqualAfterBinarySerialization() - { - var body = CreateTestObject(); - var bytes = body.ToBytes(); - var body2 = ScriptTagBody.Parse(bytes); - var bytes2 = body2.ToBytes(); - - body2.Should().BeEquivalentTo(body, options => options.RespectingRuntimeTypes()); - bytes2.Should().BeEquivalentTo(bytes2, options => options.RespectingRuntimeTypes()); - } - - [Fact] - public void EqualAfterMixedSerialization() - { - var original = CreateTestObject(); - - var a_json = original.ToJson(); - var a_body = ScriptTagBody.Parse(a_json); - var a_byte = a_body.ToBytes(); - - var b_byte = original.ToBytes(); - var b_body = ScriptTagBody.Parse(b_byte); - var b_json = b_body.ToJson(); - - b_json.Should().Be(a_json); - a_byte.Should().BeEquivalentTo(b_byte); - - a_body.Should().BeEquivalentTo(original, options => options.RespectingRuntimeTypes()); - b_body.Should().BeEquivalentTo(original, options => options.RespectingRuntimeTypes()); - a_body.Should().BeEquivalentTo(b_body, options => options.RespectingRuntimeTypes()); - } + (ScriptDataString) "test", + (ScriptDataBoolean)true, + (ScriptDataBoolean)false, + (ScriptDataNumber)0, + (ScriptDataNumber)(-95.7), + }); } } diff --git a/test/BililiveRecorder.Flv.UnitTests/Flv/ParsingTest.cs b/test/BililiveRecorder.Flv.UnitTests/Flv/ParsingTest.cs index f0e373d..8982b3c 100644 --- a/test/BililiveRecorder.Flv.UnitTests/Flv/ParsingTest.cs +++ b/test/BililiveRecorder.Flv.UnitTests/Flv/ParsingTest.cs @@ -28,11 +28,11 @@ namespace BililiveRecorder.Flv.UnitTests.Flv var tags = new List(); - var reader = new FlvTagPipeReader(PipeReader.Create(File.OpenRead(path)), new TestRecyclableMemoryStreamProvider(), skipData: true); + var reader = new FlvTagPipeReader(PipeReader.Create(File.OpenRead(path)), new TestRecyclableMemoryStreamProvider(), skipData: true, logger: null); while (true) { - var tag = await reader.ReadTagAsync().ConfigureAwait(false); + var tag = await reader.ReadTagAsync(default).ConfigureAwait(false); if (tag is null) break; diff --git a/test/BililiveRecorder.Flv.UnitTests/Grouping/GroupingTest.cs b/test/BililiveRecorder.Flv.UnitTests/Grouping/GroupingTest.cs index 31e7015..8f6fc91 100644 --- a/test/BililiveRecorder.Flv.UnitTests/Grouping/GroupingTest.cs +++ b/test/BililiveRecorder.Flv.UnitTests/Grouping/GroupingTest.cs @@ -20,7 +20,8 @@ namespace BililiveRecorder.Flv.UnitTests.Grouping public class TestOutputProvider : IFlvWriterTargetProvider { public Stream CreateAlternativeHeaderStream() => throw new NotImplementedException(); - public Stream CreateOutputStream() => File.Open(Path.Combine(TEST_OUTPUT_PATH, DateTimeOffset.Now.ToString("s").Replace(':', '-') + ".flv"), FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None); + public (Stream, object) CreateOutputStream() => (File.Open(Path.Combine(TEST_OUTPUT_PATH, DateTimeOffset.Now.ToString("s").Replace(':', '-') + ".flv"), FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None), null); + public bool ShouldCreateNewFile(Stream outputStream, IList tags) => false; } [Fact(Skip = "Not ready")] @@ -30,7 +31,7 @@ namespace BililiveRecorder.Flv.UnitTests.Grouping var results = new List(); - var grouping = new TagGroupReader(new FlvTagPipeReader(PipeReader.Create(File.OpenRead(path)), new TestRecyclableMemoryStreamProvider(), skipData: true)); + var grouping = new TagGroupReader(new FlvTagPipeReader(PipeReader.Create(File.OpenRead(path)), new TestRecyclableMemoryStreamProvider(), skipData: true, logger: null)); var context = new FlvProcessingContext(); var session = new Dictionary(); @@ -40,7 +41,7 @@ namespace BililiveRecorder.Flv.UnitTests.Grouping while (true) { - var g = await grouping.ReadGroupAsync().ConfigureAwait(false); + var g = await grouping.ReadGroupAsync(default).ConfigureAwait(false); if (g is null) break; @@ -70,7 +71,7 @@ namespace BililiveRecorder.Flv.UnitTests.Grouping { const string PATH = @""; - using var grouping = new TagGroupReader(new FlvTagPipeReader(PipeReader.Create(File.OpenRead(PATH)), new TestRecyclableMemoryStreamProvider(), skipData: false)); + using var grouping = new TagGroupReader(new FlvTagPipeReader(PipeReader.Create(File.OpenRead(PATH)), new TestRecyclableMemoryStreamProvider(), skipData: false, logger: null)); var comments = new List(); @@ -80,18 +81,18 @@ namespace BililiveRecorder.Flv.UnitTests.Grouping var sp = new ServiceCollection().BuildServiceProvider(); var pipeline = new ProcessingPipelineBuilder(sp).AddDefault().AddRemoveFillerData().Build(); - using var writer = new FlvProcessingContextWriter(new TestOutputProvider(), new TestRecyclableMemoryStreamProvider()); + using var writer = new FlvProcessingContextWriter(new TestOutputProvider(), new TestRecyclableMemoryStreamProvider(), null); while (true) { - var g = await grouping.ReadGroupAsync().ConfigureAwait(false); + var g = await grouping.ReadGroupAsync(default).ConfigureAwait(false); if (g is null) break; context.Reset(g, session); - await pipeline(context); + await pipeline(context).ConfigureAwait(false); comments.AddRange(context.Comments); await writer.WriteAsync(context).ConfigureAwait(false); diff --git a/test/BililiveRecorder.Flv.UnitTests/TestRecyclableMemoryStreamProvider.cs b/test/BililiveRecorder.Flv.UnitTests/TestRecyclableMemoryStreamProvider.cs index 9359c68..8821252 100644 --- a/test/BililiveRecorder.Flv.UnitTests/TestRecyclableMemoryStreamProvider.cs +++ b/test/BililiveRecorder.Flv.UnitTests/TestRecyclableMemoryStreamProvider.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.IO; using Microsoft.IO; @@ -12,6 +13,18 @@ namespace BililiveRecorder.Flv.UnitTests MaximumFreeLargePoolBytes = 64 * 1024 * 32, }; + static TestRecyclableMemoryStreamProvider() + { + manager.StreamFinalized += () => + { + Debug.WriteLine("TestRecyclableMemoryStreamProvider: Stream Finalized"); + }; + manager.StreamDisposed += () => + { + // Debug.WriteLine("TestRecyclableMemoryStreamProvider: Stream Disposed"); + }; + } + public Stream CreateMemoryStream(string tag) => manager.GetStream(tag); } } diff --git a/test/BililiveRecorder.Flv.UnitTests/Xml/XmlTests.cs b/test/BililiveRecorder.Flv.UnitTests/Xml/XmlTests.cs index b916348..4c8af59 100644 --- a/test/BililiveRecorder.Flv.UnitTests/Xml/XmlTests.cs +++ b/test/BililiveRecorder.Flv.UnitTests/Xml/XmlTests.cs @@ -25,28 +25,28 @@ namespace BililiveRecorder.Flv.UnitTests.Xml { Type = TagType.Script, Size=4321, - ScriptData = new ScriptTagBody + ScriptData = new ScriptTagBody(new List { - Name = "test1", - Value = new ScriptDataObject + (ScriptDataString)"test1", + new ScriptDataObject { ["key1"] = (ScriptDataNumber)5, ["key2"] = (ScriptDataString)"testTest" } - } + }) }, new Tag { Type = TagType.Audio, - ScriptData = new ScriptTagBody + ScriptData = new ScriptTagBody(new List { - Name = "test2", - Value = new ScriptDataObject + (ScriptDataString)"test2", + new ScriptDataObject { ["key1"] = (ScriptDataNumber)5, ["key2"] = (ScriptDataString)"testTest" } - } + }) }, new Tag { @@ -99,7 +99,7 @@ namespace BililiveRecorder.Flv.UnitTests.Xml { var PATH = @""; - var reader = new FlvTagPipeReader(PipeReader.Create(File.OpenRead(PATH)), new TestRecyclableMemoryStreamProvider(), skipData: true); + var reader = new FlvTagPipeReader(PipeReader.Create(File.OpenRead(PATH)), new TestRecyclableMemoryStreamProvider(), skipData: true, logger: null); var source = new XmlFlvFile { @@ -108,7 +108,7 @@ namespace BililiveRecorder.Flv.UnitTests.Xml while (true) { - var tag = await reader.ReadTagAsync().ConfigureAwait(false); + var tag = await reader.ReadTagAsync(default).ConfigureAwait(false); if (tag is null) break;