mirror of
https://github.com/BililiveRecorder/BililiveRecorder.git
synced 2024-11-15 19:22:19 +08:00
Merge code into dev-1.3
This commit is contained in:
parent
48c8612f95
commit
58970c217b
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -263,3 +263,4 @@ __pycache__/
|
|||
|
||||
TempBuildInfo/*.cs
|
||||
BililiveRecorder.WPF/Nlog.config
|
||||
BililiveRecorder.Cli/Properties/launchSettings.json
|
||||
|
|
|
@ -28,15 +28,19 @@
|
|||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommandLineParser" Version="2.4.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="NLog" Version="4.7.6" />
|
||||
<PackageReference Include="NLog.Config" Version="4.7.6" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Process" Version="2.0.1" />
|
||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
|
||||
<PackageReference Include="Serilog.Exceptions" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="4.1.0" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.0-beta1.20574.7" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\BililiveRecorder.Core\BililiveRecorder.Core.csproj" />
|
||||
<ProjectReference Include="..\BililiveRecorder.FlvProcessor\BililiveRecorder.FlvProcessor.csproj" />
|
||||
</ItemGroup>
|
||||
<Target Name="PreBuild" BeforeTargets="PreBuildEvent">
|
||||
<Exec Command="cd $(SolutionDir)
powershell -ExecutionPolicy Bypass -File .\CI\patch_buildinfo.ps1 Cli" />
|
||||
|
|
|
@ -1,30 +1,70 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Invocation;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using BililiveRecorder.Core;
|
||||
using BililiveRecorder.Core.Config;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
using BililiveRecorder.DependencyInjection;
|
||||
using CommandLine;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Exceptions;
|
||||
|
||||
namespace BililiveRecorder.Cli
|
||||
{
|
||||
internal class Program
|
||||
{
|
||||
private static int Main(string[] args)
|
||||
=> Parser.Default
|
||||
.ParseArguments<CmdVerbConfigMode, CmdVerbPortableMode>(args)
|
||||
.MapResult<CmdVerbConfigMode, CmdVerbPortableMode, int>(RunConfigMode, RunPortableMode, err => 1);
|
||||
|
||||
private static int RunConfigMode(CmdVerbConfigMode opts)
|
||||
{
|
||||
var semaphore = new SemaphoreSlim(0, 1);
|
||||
var cmd_run = new Command("run", "Run BililiveRecorder in standard mode")
|
||||
{
|
||||
new Argument<string>("path"),
|
||||
};
|
||||
cmd_run.Handler = CommandHandler.Create<string>(RunConfigMode);
|
||||
|
||||
var serviceProvider = BuildServiceProvider();
|
||||
var cmd_portable = new Command("portable", "Run BililiveRecorder in config-less mode")
|
||||
{
|
||||
new Option<string>(new []{ "--cookie", "-c" }, "Cookie string for api requests"),
|
||||
new Option<string>("--live-api-host"),
|
||||
new Option<string>(new[]{ "--filename-format", "-f" }, "File name format"),
|
||||
new Argument<string>("output path"),
|
||||
new Argument<int[]>("room ids")
|
||||
};
|
||||
cmd_portable.Handler = CommandHandler.Create<PortableModeArguments>(RunPortableMode);
|
||||
|
||||
var root = new RootCommand("A Stream Recorder For Bilibili Live")
|
||||
{
|
||||
cmd_run,
|
||||
cmd_portable
|
||||
};
|
||||
|
||||
return root.Invoke(args);
|
||||
}
|
||||
|
||||
private static int RunConfigMode(string path)
|
||||
{
|
||||
var logger = BuildLogger();
|
||||
Log.Logger = logger;
|
||||
|
||||
path = Path.GetFullPath(path);
|
||||
var config = ConfigParser.LoadFrom(path);
|
||||
if (config is null)
|
||||
{
|
||||
logger.Error("Initialize Error");
|
||||
return -1;
|
||||
}
|
||||
|
||||
config.Global.WorkDirectory = path;
|
||||
|
||||
var serviceProvider = BuildServiceProvider(config, logger);
|
||||
var recorder = serviceProvider.GetRequiredService<IRecorder>();
|
||||
|
||||
ConsoleCancelEventHandler p = null!;
|
||||
var semaphore = new SemaphoreSlim(0, 1);
|
||||
p = (sender, e) =>
|
||||
{
|
||||
Console.CancelKeyPress -= p;
|
||||
|
@ -34,21 +74,21 @@ namespace BililiveRecorder.Cli
|
|||
};
|
||||
Console.CancelKeyPress += p;
|
||||
|
||||
if (!recorder.Initialize(opts.WorkDirectory))
|
||||
{
|
||||
Console.WriteLine("Initialize Error");
|
||||
return -1;
|
||||
}
|
||||
|
||||
semaphore.Wait();
|
||||
Thread.Sleep(1000 * 2);
|
||||
Console.ReadLine();
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int RunPortableMode(CmdVerbPortableMode opts)
|
||||
private static int RunPortableMode(PortableModeArguments opts)
|
||||
{
|
||||
var semaphore = new SemaphoreSlim(0, 1);
|
||||
throw new NotImplementedException();
|
||||
|
||||
var serviceProvider = BuildServiceProvider();
|
||||
#pragma warning disable CS0162 // Unreachable code detected
|
||||
var semaphore = new SemaphoreSlim(0, 1);
|
||||
#pragma warning restore CS0162 // Unreachable code detected
|
||||
|
||||
var serviceProvider = BuildServiceProvider(null, null);
|
||||
var recorder = serviceProvider.GetRequiredService<IRecorder>();
|
||||
|
||||
var config = new ConfigV2()
|
||||
|
@ -60,10 +100,10 @@ namespace BililiveRecorder.Cli
|
|||
config.Global.Cookie = opts.Cookie;
|
||||
if (!string.IsNullOrWhiteSpace(opts.LiveApiHost))
|
||||
config.Global.LiveApiHost = opts.LiveApiHost;
|
||||
if (!string.IsNullOrWhiteSpace(opts.RecordFilenameFormat))
|
||||
config.Global.RecordFilenameFormat = opts.RecordFilenameFormat;
|
||||
if (!string.IsNullOrWhiteSpace(opts.FilenameFormat))
|
||||
config.Global.RecordFilenameFormat = opts.FilenameFormat;
|
||||
|
||||
config.Global.WorkDirectory = opts.OutputDirectory;
|
||||
config.Global.WorkDirectory = opts.OutputPath;
|
||||
config.Rooms = opts.RoomIds.Select(x => new RoomConfig { RoomId = x, AutoRecord = true }).ToList();
|
||||
|
||||
ConsoleCancelEventHandler p = null!;
|
||||
|
@ -76,48 +116,44 @@ namespace BililiveRecorder.Cli
|
|||
};
|
||||
Console.CancelKeyPress += p;
|
||||
|
||||
if (!((Recorder)recorder).InitializeWithConfig(config))
|
||||
{
|
||||
Console.WriteLine("Initialize Error");
|
||||
return -1;
|
||||
}
|
||||
//if (!((DeadCodeRecorder)recorder).InitializeWithConfig(config))
|
||||
//{
|
||||
// Console.WriteLine("Initialize Error");
|
||||
// return -1;
|
||||
//}
|
||||
|
||||
semaphore.Wait();
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static IServiceProvider BuildServiceProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddFlvProcessor();
|
||||
services.AddCore();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
private static IServiceProvider BuildServiceProvider(ConfigV2 config, ILogger logger) => new ServiceCollection()
|
||||
.AddSingleton(logger)
|
||||
.AddFlv()
|
||||
.AddRecorderConfig(config)
|
||||
.AddRecorder()
|
||||
.BuildServiceProvider();
|
||||
|
||||
[Verb("portable", HelpText = "Run recorder. Ignore config file in output directory")]
|
||||
public class CmdVerbPortableMode
|
||||
{
|
||||
[Option('o', "dir", Default = ".", HelpText = "Output directory", Required = false)]
|
||||
public string OutputDirectory { get; set; } = ".";
|
||||
private static ILogger BuildLogger() => new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.Enrich.WithProcessId()
|
||||
.Enrich.WithThreadId()
|
||||
.Enrich.WithThreadName()
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithExceptionDetails()
|
||||
.WriteTo.Console(restrictedToMinimumLevel: LogEventLevel.Verbose, outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] [{RoomId}] {Message:lj}{NewLine}{Exception}")
|
||||
.CreateLogger();
|
||||
|
||||
public class PortableModeArguments
|
||||
{
|
||||
public string OutputPath { get; set; } = string.Empty;
|
||||
|
||||
[Option("cookie", HelpText = "Provide custom cookies", Required = false)]
|
||||
public string? Cookie { get; set; }
|
||||
|
||||
[Option("live_api_host", HelpText = "Use custom api host", Required = false)]
|
||||
public string? LiveApiHost { get; set; }
|
||||
|
||||
[Option("record_filename_format", HelpText = "Recording name format", Required = false)]
|
||||
public string? RecordFilenameFormat { get; set; }
|
||||
public string? FilenameFormat { get; set; }
|
||||
|
||||
[Value(0, Min = 1, Required = true, HelpText = "List of room id")]
|
||||
public IEnumerable<int> RoomIds { get; set; } = Enumerable.Empty<int>();
|
||||
}
|
||||
|
||||
[Verb("run", HelpText = "Run recorder with config file")]
|
||||
public class CmdVerbConfigMode
|
||||
{
|
||||
[Value(0, HelpText = "Target directory", Required = true)]
|
||||
public string WorkDirectory { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
|
|
448
BililiveRecorder.Core/Api/Danmaku/DanmakuClient.cs
Normal file
448
BililiveRecorder.Core/Api/Danmaku/DanmakuClient.cs
Normal file
|
@ -0,0 +1,448 @@
|
|||
using System;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.IO.Pipelines;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using Nerdbank.Streams;
|
||||
using Newtonsoft.Json;
|
||||
using Serilog;
|
||||
using Timer = System.Timers.Timer;
|
||||
|
||||
namespace BililiveRecorder.Core.Api.Danmaku
|
||||
{
|
||||
public class DanmakuClient : IDanmakuClient, IDisposable
|
||||
{
|
||||
private readonly ILogger logger;
|
||||
private readonly IDanmakuServerApiClient apiClient;
|
||||
private readonly Timer timer;
|
||||
private readonly SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1, 1);
|
||||
|
||||
private Stream? danmakuStream;
|
||||
private bool disposedValue;
|
||||
|
||||
public bool Connected => this.danmakuStream != null;
|
||||
|
||||
public event EventHandler<StatusChangedEventArgs>? StatusChanged;
|
||||
public event EventHandler<DanmakuReceivedEventArgs>? DanmakuReceived;
|
||||
|
||||
public DanmakuClient(IDanmakuServerApiClient apiClient, ILogger logger)
|
||||
{
|
||||
this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
|
||||
this.logger = logger?.ForContext<DanmakuClient>() ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
this.timer = new Timer(interval: 1000 * 30)
|
||||
{
|
||||
AutoReset = true,
|
||||
Enabled = false
|
||||
};
|
||||
this.timer.Elapsed += this.SendPingMessageTimerCallback;
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
await this.semaphoreSlim.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
this.danmakuStream?.Dispose();
|
||||
this.danmakuStream = null;
|
||||
|
||||
this.timer.Stop();
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.semaphoreSlim.Release();
|
||||
}
|
||||
|
||||
StatusChanged?.Invoke(this, StatusChangedEventArgs.False);
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(int roomid, CancellationToken cancellationToken)
|
||||
{
|
||||
if (this.disposedValue)
|
||||
throw new ObjectDisposedException(nameof(DanmakuClient));
|
||||
|
||||
await this.semaphoreSlim.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (this.danmakuStream != null)
|
||||
return;
|
||||
|
||||
var serverInfo = await this.apiClient.GetDanmakuServerAsync(roomid).ConfigureAwait(false);
|
||||
if (serverInfo.Data is null)
|
||||
return;
|
||||
serverInfo.Data.ChooseOne(out var host, out var port, out var token);
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
var tcp = new TcpClient();
|
||||
await tcp.ConnectAsync(host, port).ConfigureAwait(false);
|
||||
|
||||
this.danmakuStream = tcp.GetStream();
|
||||
|
||||
await SendHelloAsync(this.danmakuStream, roomid, token).ConfigureAwait(false);
|
||||
await SendPingAsync(this.danmakuStream);
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
tcp.Dispose();
|
||||
this.danmakuStream.Dispose();
|
||||
this.danmakuStream = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.timer.Start();
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessDataAsync(this.danmakuStream, this.ProcessCommand).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger.Warning(ex, "Error running ProcessDataAsync");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await this.DisconnectAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception) { }
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.semaphoreSlim.Release();
|
||||
}
|
||||
|
||||
StatusChanged?.Invoke(this, StatusChangedEventArgs.True);
|
||||
}
|
||||
|
||||
private void ProcessCommand(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var d = new DanmakuModel(json);
|
||||
DanmakuReceived?.Invoke(this, new DanmakuReceivedEventArgs(d));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger.Warning(ex, "Error running ProcessCommand");
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable VSTHRD100 // Avoid async void methods
|
||||
private async void SendPingMessageTimerCallback(object sender, ElapsedEventArgs e)
|
||||
#pragma warning restore VSTHRD100 // Avoid async void methods
|
||||
{
|
||||
try
|
||||
{
|
||||
await this.semaphoreSlim.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (this.danmakuStream is null)
|
||||
return;
|
||||
|
||||
await SendPingAsync(this.danmakuStream).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.semaphoreSlim.Release();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger.Warning(ex, "Error running SendPingMessageTimerCallback");
|
||||
}
|
||||
}
|
||||
|
||||
#region Dispose
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!this.disposedValue)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
// dispose managed state (managed objects)
|
||||
this.timer.Dispose();
|
||||
this.danmakuStream?.Dispose();
|
||||
this.semaphoreSlim.Dispose();
|
||||
}
|
||||
|
||||
// free unmanaged resources (unmanaged objects) and override finalizer
|
||||
// set large fields to null
|
||||
this.disposedValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
// override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
|
||||
// ~DanmakuClient()
|
||||
// {
|
||||
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
|
||||
// Dispose(disposing: false);
|
||||
// }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
|
||||
this.Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Send
|
||||
|
||||
private static Task SendHelloAsync(Stream stream, int roomid, string token) =>
|
||||
SendMessageAsync(stream, 7, JsonConvert.SerializeObject(new
|
||||
{
|
||||
uid = 0,
|
||||
roomid = roomid,
|
||||
protover = 0,
|
||||
platform = "web",
|
||||
clientver = "2.6.25",
|
||||
type = 2,
|
||||
key = token,
|
||||
}, Formatting.None));
|
||||
|
||||
private static Task SendPingAsync(Stream stream) =>
|
||||
SendMessageAsync(stream, 2);
|
||||
|
||||
private static async Task SendMessageAsync(Stream stream, int action, string body = "")
|
||||
{
|
||||
if (stream is null)
|
||||
throw new ArgumentNullException(nameof(stream));
|
||||
|
||||
var playload = Encoding.UTF8.GetBytes(body);
|
||||
var size = playload.Length + 16;
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(16);
|
||||
try
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32BigEndian(new Span<byte>(buffer, 0, 4), (uint)size);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(new Span<byte>(buffer, 4, 2), 16);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(new Span<byte>(buffer, 6, 2), 1);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(new Span<byte>(buffer, 8, 4), (uint)action);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(new Span<byte>(buffer, 12, 4), 1);
|
||||
|
||||
await stream.WriteAsync(buffer, 0, buffer.Length).ConfigureAwait(false);
|
||||
if (playload.Length > 0)
|
||||
await stream.WriteAsync(playload, 0, playload.Length).ConfigureAwait(false);
|
||||
await stream.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Receive
|
||||
|
||||
private static async Task ProcessDataAsync(Stream stream, Action<string> callback)
|
||||
{
|
||||
var reader = PipeReader.Create(stream);
|
||||
await ReadPipeAsync(reader, callback).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task FillPipeAsync(Stream stream, PipeWriter writer)
|
||||
{
|
||||
const int minimumBufferSize = 512;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var memory = writer.GetMemory(minimumBufferSize);
|
||||
try
|
||||
{
|
||||
var bytesRead = await stream.ReadAsync(memory).ConfigureAwait(false);
|
||||
if (bytesRead == 0)
|
||||
break;
|
||||
writer.Advance(bytesRead);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// TODO logger.Log("Debug", ex);
|
||||
break;
|
||||
}
|
||||
|
||||
var result = await writer.FlushAsync();
|
||||
if (result.IsCompleted)
|
||||
break;
|
||||
}
|
||||
|
||||
await writer.CompleteAsync();
|
||||
}
|
||||
|
||||
private static async Task ReadPipeAsync(PipeReader reader, Action<string> callback)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var result = await reader.ReadAsync();
|
||||
var buffer = result.Buffer;
|
||||
|
||||
while (TryParseCommand(ref buffer, callback)) { }
|
||||
|
||||
reader.AdvanceTo(buffer.Start, buffer.End);
|
||||
|
||||
if (result.IsCompleted)
|
||||
break;
|
||||
}
|
||||
await reader.CompleteAsync();
|
||||
}
|
||||
|
||||
private static bool TryParseCommand(ref ReadOnlySequence<byte> buffer, Action<string> callback)
|
||||
{
|
||||
if (buffer.Length < 4)
|
||||
return false;
|
||||
|
||||
int length;
|
||||
{
|
||||
var lengthSlice = buffer.Slice(buffer.Start, 4);
|
||||
if (lengthSlice.IsSingleSegment)
|
||||
{
|
||||
length = BinaryPrimitives.ReadInt32BigEndian(lengthSlice.First.Span);
|
||||
}
|
||||
else
|
||||
{
|
||||
Span<byte> stackBuffer = stackalloc byte[4];
|
||||
lengthSlice.CopyTo(stackBuffer);
|
||||
length = BinaryPrimitives.ReadInt32BigEndian(stackBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.Length < length)
|
||||
return false;
|
||||
|
||||
var headerSlice = buffer.Slice(buffer.Start, 16);
|
||||
buffer = buffer.Slice(headerSlice.End);
|
||||
var bodySlice = buffer.Slice(buffer.Start, length - 16);
|
||||
buffer = buffer.Slice(bodySlice.End);
|
||||
|
||||
DanmakuProtocol header;
|
||||
if (headerSlice.IsSingleSegment)
|
||||
{
|
||||
Parse2Protocol(headerSlice.First.Span, out header);
|
||||
}
|
||||
else
|
||||
{
|
||||
Span<byte> stackBuffer = stackalloc byte[16];
|
||||
headerSlice.CopyTo(stackBuffer);
|
||||
Parse2Protocol(stackBuffer, out header);
|
||||
}
|
||||
|
||||
if (header.Version == 2 && header.Action == 5)
|
||||
ParseCommandDeflateBody(ref bodySlice, callback);
|
||||
else
|
||||
ParseCommandNormalBody(ref bodySlice, header.Action, callback);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ParseCommandDeflateBody(ref ReadOnlySequence<byte> buffer, Action<string> callback)
|
||||
{
|
||||
using var deflate = new DeflateStream(buffer.Slice(2, buffer.End).AsStream(), CompressionMode.Decompress, leaveOpen: false);
|
||||
var reader = PipeReader.Create(deflate);
|
||||
while (true)
|
||||
{
|
||||
#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits
|
||||
// 全内存内运行同步返回,所以不会有问题
|
||||
var result = reader.ReadAsync().Result;
|
||||
#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits
|
||||
var inner_buffer = result.Buffer;
|
||||
|
||||
while (TryParseCommand(ref inner_buffer, callback)) { }
|
||||
|
||||
reader.AdvanceTo(inner_buffer.Start, inner_buffer.End);
|
||||
|
||||
if (result.IsCompleted)
|
||||
break;
|
||||
}
|
||||
reader.Complete();
|
||||
}
|
||||
|
||||
private static void ParseCommandNormalBody(ref ReadOnlySequence<byte> buffer, int action, Action<string> callback)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case 5:
|
||||
{
|
||||
if (buffer.Length > int.MaxValue)
|
||||
throw new ArgumentOutOfRangeException("ParseCommandNormalBody buffer length larger than int.MaxValue");
|
||||
|
||||
var b = ArrayPool<byte>.Shared.Rent((int)buffer.Length);
|
||||
try
|
||||
{
|
||||
buffer.CopyTo(b);
|
||||
var json = Encoding.UTF8.GetString(b, 0, (int)buffer.Length);
|
||||
callback(json);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(b);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static unsafe void Parse2Protocol(ReadOnlySpan<byte> buffer, out DanmakuProtocol protocol)
|
||||
{
|
||||
fixed (byte* ptr = buffer)
|
||||
{
|
||||
protocol = *(DanmakuProtocol*)ptr;
|
||||
}
|
||||
protocol.ChangeEndian();
|
||||
}
|
||||
|
||||
private struct DanmakuProtocol
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息总长度 (协议头 + 数据长度)
|
||||
/// </summary>
|
||||
public int PacketLength;
|
||||
/// <summary>
|
||||
/// 消息头长度 (固定为16[sizeof(DanmakuProtocol)])
|
||||
/// </summary>
|
||||
public short HeaderLength;
|
||||
/// <summary>
|
||||
/// 消息版本号
|
||||
/// </summary>
|
||||
public short Version;
|
||||
/// <summary>
|
||||
/// 消息类型
|
||||
/// </summary>
|
||||
public int Action;
|
||||
/// <summary>
|
||||
/// 参数, 固定为1
|
||||
/// </summary>
|
||||
public int Parameter;
|
||||
/// <summary>
|
||||
/// 转为本机字节序
|
||||
/// </summary>
|
||||
public void ChangeEndian()
|
||||
{
|
||||
this.PacketLength = IPAddress.HostToNetworkOrder(this.PacketLength);
|
||||
this.HeaderLength = IPAddress.HostToNetworkOrder(this.HeaderLength);
|
||||
this.Version = IPAddress.HostToNetworkOrder(this.Version);
|
||||
this.Action = IPAddress.HostToNetworkOrder(this.Action);
|
||||
this.Parameter = IPAddress.HostToNetworkOrder(this.Parameter);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
238
BililiveRecorder.Core/Api/Danmaku/DanmakuModel.cs
Normal file
238
BililiveRecorder.Core/Api/Danmaku/DanmakuModel.cs
Normal file
|
@ -0,0 +1,238 @@
|
|||
using Newtonsoft.Json.Linq;
|
||||
|
||||
#nullable enable
|
||||
namespace BililiveRecorder.Core.Api.Danmaku
|
||||
{
|
||||
public enum DanmakuMsgType
|
||||
{
|
||||
/// <summary>
|
||||
/// 彈幕
|
||||
/// </summary>
|
||||
Comment,
|
||||
/// <summary>
|
||||
/// 禮物
|
||||
/// </summary>
|
||||
GiftSend,
|
||||
/// <summary>
|
||||
/// 直播開始
|
||||
/// </summary>
|
||||
LiveStart,
|
||||
/// <summary>
|
||||
/// 直播結束
|
||||
/// </summary>
|
||||
LiveEnd,
|
||||
/// <summary>
|
||||
/// 其他
|
||||
/// </summary>
|
||||
Unknown,
|
||||
/// <summary>
|
||||
/// 购买船票(上船)
|
||||
/// </summary>
|
||||
GuardBuy,
|
||||
/// <summary>
|
||||
/// SuperChat
|
||||
/// </summary>
|
||||
SuperChat,
|
||||
/// <summary>
|
||||
/// 房间信息更新
|
||||
/// </summary>
|
||||
RoomChange
|
||||
}
|
||||
|
||||
public class DanmakuModel
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息類型
|
||||
/// </summary>
|
||||
public DanmakuMsgType MsgType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 房间标题
|
||||
/// </summary>
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 大分区
|
||||
/// </summary>
|
||||
public string? ParentAreaName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 子分区
|
||||
/// </summary>
|
||||
public string? AreaName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 彈幕內容
|
||||
/// <para>此项有值的消息类型:<list type="bullet">
|
||||
/// <item><see cref="DanmakuMsgType.Comment"/></item>
|
||||
/// </list></para>
|
||||
/// </summary>
|
||||
public string? CommentText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消息触发者用户名
|
||||
/// <para>此项有值的消息类型:<list type="bullet">
|
||||
/// <item><see cref="DanmakuMsgType.Comment"/></item>
|
||||
/// <item><see cref="DanmakuMsgType.GiftSend"/></item>
|
||||
/// <item><see cref="DanmakuMsgType.Welcome"/></item>
|
||||
/// <item><see cref="DanmakuMsgType.WelcomeGuard"/></item>
|
||||
/// <item><see cref="DanmakuMsgType.GuardBuy"/></item>
|
||||
/// </list></para>
|
||||
/// </summary>
|
||||
public string? UserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SC 价格
|
||||
/// </summary>
|
||||
public double Price { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SC 保持时间
|
||||
/// </summary>
|
||||
public int SCKeepTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消息触发者用户ID
|
||||
/// <para>此项有值的消息类型:<list type="bullet">
|
||||
/// <item><see cref="DanmakuMsgType.Comment"/></item>
|
||||
/// <item><see cref="DanmakuMsgType.GiftSend"/></item>
|
||||
/// <item><see cref="DanmakuMsgType.Welcome"/></item>
|
||||
/// <item><see cref="DanmakuMsgType.WelcomeGuard"/></item>
|
||||
/// <item><see cref="DanmakuMsgType.GuardBuy"/></item>
|
||||
/// </list></para>
|
||||
/// </summary>
|
||||
public int UserID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户舰队等级
|
||||
/// <para>0 为非船员 1 为总督 2 为提督 3 为舰长</para>
|
||||
/// <para>此项有值的消息类型:<list type="bullet">
|
||||
/// <item><see cref="DanmakuMsgType.Comment"/></item>
|
||||
/// <item><see cref="DanmakuMsgType.WelcomeGuard"/></item>
|
||||
/// <item><see cref="DanmakuMsgType.GuardBuy"/></item>
|
||||
/// </list></para>
|
||||
/// </summary>
|
||||
public int UserGuardLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 禮物名稱
|
||||
/// </summary>
|
||||
public string? GiftName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼物数量
|
||||
/// <para>此项有值的消息类型:<list type="bullet">
|
||||
/// <item><see cref="DanmakuMsgType.GiftSend"/></item>
|
||||
/// <item><see cref="DanmakuMsgType.GuardBuy"/></item>
|
||||
/// </list></para>
|
||||
/// <para>此字段也用于标识上船 <see cref="DanmakuMsgType.GuardBuy"/> 的数量(月数)</para>
|
||||
/// </summary>
|
||||
public int GiftCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 该用户是否为房管(包括主播)
|
||||
/// <para>此项有值的消息类型:<list type="bullet">
|
||||
/// <item><see cref="DanmakuMsgType.Comment"/></item>
|
||||
/// <item><see cref="DanmakuMsgType.GiftSend"/></item>
|
||||
/// </list></para>
|
||||
/// </summary>
|
||||
public bool IsAdmin { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否VIP用戶(老爺)
|
||||
/// <para>此项有值的消息类型:<list type="bullet">
|
||||
/// <item><see cref="DanmakuMsgType.Comment"/></item>
|
||||
/// <item><see cref="DanmakuMsgType.Welcome"/></item>
|
||||
/// </list></para>
|
||||
/// </summary>
|
||||
public bool IsVIP { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="DanmakuMsgType.LiveStart"/>,<see cref="DanmakuMsgType.LiveEnd"/> 事件对应的房间号
|
||||
/// </summary>
|
||||
public string? RoomID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始数据
|
||||
/// </summary>
|
||||
public string? RawString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始数据
|
||||
/// </summary>
|
||||
public JObject? RawObject { get; set; }
|
||||
|
||||
public DanmakuModel()
|
||||
{ }
|
||||
|
||||
public DanmakuModel(string json)
|
||||
{
|
||||
this.RawString = json;
|
||||
|
||||
var obj = JObject.Parse(json);
|
||||
this.RawObject = obj;
|
||||
|
||||
var cmd = obj["cmd"]?.ToObject<string>();
|
||||
switch (cmd)
|
||||
{
|
||||
case "LIVE":
|
||||
this.MsgType = DanmakuMsgType.LiveStart;
|
||||
this.RoomID = obj["roomid"]?.ToObject<string>();
|
||||
break;
|
||||
case "PREPARING":
|
||||
this.MsgType = DanmakuMsgType.LiveEnd;
|
||||
this.RoomID = obj["roomid"]?.ToObject<string>();
|
||||
break;
|
||||
case "DANMU_MSG":
|
||||
this.MsgType = DanmakuMsgType.Comment;
|
||||
this.CommentText = obj["info"]?[1]?.ToObject<string>();
|
||||
this.UserID = obj["info"]?[2]?[0]?.ToObject<int>() ?? 0;
|
||||
this.UserName = obj["info"]?[2]?[1]?.ToObject<string>();
|
||||
this.IsAdmin = obj["info"]?[2]?[2]?.ToObject<string>() == "1";
|
||||
this.IsVIP = obj["info"]?[2]?[3]?.ToObject<string>() == "1";
|
||||
this.UserGuardLevel = obj["info"]?[7]?.ToObject<int>() ?? 0;
|
||||
break;
|
||||
case "SEND_GIFT":
|
||||
this.MsgType = DanmakuMsgType.GiftSend;
|
||||
this.GiftName = obj["data"]?["giftName"]?.ToObject<string>();
|
||||
this.UserName = obj["data"]?["uname"]?.ToObject<string>();
|
||||
this.UserID = obj["data"]?["uid"]?.ToObject<int>() ?? 0;
|
||||
this.GiftCount = obj["data"]?["num"]?.ToObject<int>() ?? 0;
|
||||
break;
|
||||
case "GUARD_BUY":
|
||||
{
|
||||
this.MsgType = DanmakuMsgType.GuardBuy;
|
||||
this.UserID = obj["data"]?["uid"]?.ToObject<int>() ?? 0;
|
||||
this.UserName = obj["data"]?["username"]?.ToObject<string>();
|
||||
this.UserGuardLevel = obj["data"]?["guard_level"]?.ToObject<int>() ?? 0;
|
||||
this.GiftName = this.UserGuardLevel == 3 ? "舰长" : this.UserGuardLevel == 2 ? "提督" : this.UserGuardLevel == 1 ? "总督" : "";
|
||||
this.GiftCount = obj["data"]?["num"]?.ToObject<int>() ?? 0;
|
||||
break;
|
||||
}
|
||||
case "SUPER_CHAT_MESSAGE":
|
||||
{
|
||||
this.MsgType = DanmakuMsgType.SuperChat;
|
||||
this.CommentText = obj["data"]?["message"]?.ToString();
|
||||
this.UserID = obj["data"]?["uid"]?.ToObject<int>() ?? 0;
|
||||
this.UserName = obj["data"]?["user_info"]?["uname"]?.ToString();
|
||||
this.Price = obj["data"]?["price"]?.ToObject<double>() ?? 0;
|
||||
this.SCKeepTime = obj["data"]?["time"]?.ToObject<int>() ?? 0;
|
||||
break;
|
||||
}
|
||||
case "ROOM_CHANGE":
|
||||
{
|
||||
this.MsgType = DanmakuMsgType.RoomChange;
|
||||
this.Title = obj["data"]?["title"]?.ToObject<string>();
|
||||
this.AreaName = obj["data"]?["area_name"]?.ToObject<string>();
|
||||
this.ParentAreaName = obj["data"]?["parent_area_name"]?.ToObject<string>();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
this.MsgType = DanmakuMsgType.Unknown;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
18
BililiveRecorder.Core/Api/Danmaku/StatusChangedEventArgs.cs
Normal file
18
BililiveRecorder.Core/Api/Danmaku/StatusChangedEventArgs.cs
Normal file
|
@ -0,0 +1,18 @@
|
|||
using System;
|
||||
|
||||
namespace BililiveRecorder.Core.Api.Danmaku
|
||||
{
|
||||
public class StatusChangedEventArgs : EventArgs
|
||||
{
|
||||
public static readonly StatusChangedEventArgs True = new StatusChangedEventArgs
|
||||
{
|
||||
Connected = true
|
||||
};
|
||||
public static readonly StatusChangedEventArgs False = new StatusChangedEventArgs
|
||||
{
|
||||
Connected = false
|
||||
};
|
||||
|
||||
public bool Connected { get; set; }
|
||||
}
|
||||
}
|
152
BililiveRecorder.Core/Api/Http/HttpApiClient.cs
Normal file
152
BililiveRecorder.Core/Api/Http/HttpApiClient.cs
Normal file
|
@ -0,0 +1,152 @@
|
|||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Api.Model;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BililiveRecorder.Core.Api.Http
|
||||
{
|
||||
public class HttpApiClient : IApiClient, IDanmakuServerApiClient
|
||||
{
|
||||
private const string HttpHeaderAccept = "application/json, text/javascript, */*; q=0.01";
|
||||
private const string HttpHeaderOrigin = "https://live.bilibili.com";
|
||||
private const string HttpHeaderReferer = "https://live.bilibili.com/";
|
||||
private const string HttpHeaderUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36";
|
||||
private static readonly TimeSpan TimeOutTimeSpan = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly GlobalConfig config;
|
||||
private readonly HttpClient anonClient;
|
||||
private HttpClient mainClient;
|
||||
private bool disposedValue;
|
||||
|
||||
public HttpApiClient(GlobalConfig config)
|
||||
{
|
||||
this.config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
|
||||
config.PropertyChanged += this.Config_PropertyChanged;
|
||||
|
||||
this.mainClient = null!;
|
||||
this.SetCookie();
|
||||
|
||||
this.anonClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeOutTimeSpan
|
||||
};
|
||||
var headers = this.anonClient.DefaultRequestHeaders;
|
||||
headers.Add("Accept", HttpHeaderAccept);
|
||||
headers.Add("Origin", HttpHeaderOrigin);
|
||||
headers.Add("Referer", HttpHeaderReferer);
|
||||
headers.Add("User-Agent", HttpHeaderUserAgent);
|
||||
}
|
||||
|
||||
private void SetCookie()
|
||||
{
|
||||
var client = new HttpClient(new HttpClientHandler
|
||||
{
|
||||
UseCookies = false,
|
||||
UseDefaultCredentials = false,
|
||||
})
|
||||
{
|
||||
Timeout = TimeOutTimeSpan
|
||||
};
|
||||
var headers = client.DefaultRequestHeaders;
|
||||
headers.Add("Accept", HttpHeaderAccept);
|
||||
headers.Add("Origin", HttpHeaderOrigin);
|
||||
headers.Add("Referer", HttpHeaderReferer);
|
||||
headers.Add("User-Agent", HttpHeaderUserAgent);
|
||||
|
||||
var cookie_string = this.config.Cookie;
|
||||
if (!string.IsNullOrWhiteSpace(cookie_string))
|
||||
headers.Add("Cookie", cookie_string);
|
||||
|
||||
var old = Interlocked.Exchange(ref this.mainClient, client);
|
||||
old?.Dispose();
|
||||
}
|
||||
|
||||
private void Config_PropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(this.config.Cookie))
|
||||
this.SetCookie();
|
||||
}
|
||||
|
||||
private static async Task<BilibiliApiResponse<T>> FetchAsync<T>(HttpClient client, string url) where T : class
|
||||
{
|
||||
var text = await client.GetStringAsync(url).ConfigureAwait(false);
|
||||
var obj = JsonConvert.DeserializeObject<BilibiliApiResponse<T>>(text);
|
||||
if (obj.Code != 0)
|
||||
throw new BilibiliApiResponseCodeNotZeroException("Bilibili api code: " + (obj.Code?.ToString() ?? "(null)") + "\n" + text);
|
||||
return obj;
|
||||
}
|
||||
|
||||
public Task<BilibiliApiResponse<RoomInfo>> GetRoomInfoAsync(int roomid)
|
||||
{
|
||||
if (this.disposedValue)
|
||||
throw new ObjectDisposedException(nameof(HttpApiClient));
|
||||
|
||||
var url = $@"https://api.live.bilibili.com/room/v1/Room/get_info?id={roomid}";
|
||||
return FetchAsync<RoomInfo>(this.mainClient, url);
|
||||
}
|
||||
|
||||
public Task<BilibiliApiResponse<UserInfo>> GetUserInfoAsync(int roomid)
|
||||
{
|
||||
if (this.disposedValue)
|
||||
throw new ObjectDisposedException(nameof(HttpApiClient));
|
||||
|
||||
var url = $@"https://api.live.bilibili.com/live_user/v1/UserInfo/get_anchor_in_room?roomid={roomid}";
|
||||
return FetchAsync<UserInfo>(this.mainClient, url);
|
||||
}
|
||||
|
||||
public Task<BilibiliApiResponse<RoomPlayInfo>> GetStreamUrlAsync(int roomid)
|
||||
{
|
||||
if (this.disposedValue)
|
||||
throw new ObjectDisposedException(nameof(HttpApiClient));
|
||||
|
||||
var url = $@"https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?room_id={roomid}&protocol=0%2C1&format=0%2C2&codec=0%2C1&qn=10000&platform=web&ptype=16";
|
||||
return FetchAsync<RoomPlayInfo>(this.mainClient, url);
|
||||
}
|
||||
|
||||
public Task<BilibiliApiResponse<DanmuInfo>> GetDanmakuServerAsync(int roomid)
|
||||
{
|
||||
if (this.disposedValue)
|
||||
throw new ObjectDisposedException(nameof(HttpApiClient));
|
||||
|
||||
var url = $@"https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id={roomid}&type=0";
|
||||
return FetchAsync<DanmuInfo>(this.anonClient, url);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!this.disposedValue)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
// dispose managed state (managed objects)
|
||||
this.config.PropertyChanged -= this.Config_PropertyChanged;
|
||||
this.mainClient.Dispose();
|
||||
this.anonClient.Dispose();
|
||||
}
|
||||
|
||||
// free unmanaged resources (unmanaged objects) and override finalizer
|
||||
// set large fields to null
|
||||
this.disposedValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
// override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
|
||||
// ~HttpApiClient()
|
||||
// {
|
||||
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
|
||||
// Dispose(disposing: false);
|
||||
// }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
|
||||
this.Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
13
BililiveRecorder.Core/Api/IApiClient.cs
Normal file
13
BililiveRecorder.Core/Api/IApiClient.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Api.Model;
|
||||
|
||||
namespace BililiveRecorder.Core.Api
|
||||
{
|
||||
public interface IApiClient : IDisposable
|
||||
{
|
||||
Task<BilibiliApiResponse<RoomInfo>> GetRoomInfoAsync(int roomid);
|
||||
Task<BilibiliApiResponse<UserInfo>> GetUserInfoAsync(int roomid);
|
||||
Task<BilibiliApiResponse<RoomPlayInfo>> GetStreamUrlAsync(int roomid);
|
||||
}
|
||||
}
|
18
BililiveRecorder.Core/Api/IDanmakuClient.cs
Normal file
18
BililiveRecorder.Core/Api/IDanmakuClient.cs
Normal file
|
@ -0,0 +1,18 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Api.Danmaku;
|
||||
|
||||
namespace BililiveRecorder.Core.Api
|
||||
{
|
||||
public interface IDanmakuClient : IDisposable
|
||||
{
|
||||
bool Connected { get; }
|
||||
|
||||
event EventHandler<StatusChangedEventArgs>? StatusChanged;
|
||||
event EventHandler<DanmakuReceivedEventArgs>? DanmakuReceived;
|
||||
|
||||
Task ConnectAsync(int roomid, CancellationToken cancellationToken);
|
||||
Task DisconnectAsync();
|
||||
}
|
||||
}
|
11
BililiveRecorder.Core/Api/IDanmakuServerApiClient.cs
Normal file
11
BililiveRecorder.Core/Api/IDanmakuServerApiClient.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Api.Model;
|
||||
|
||||
namespace BililiveRecorder.Core.Api
|
||||
{
|
||||
public interface IDanmakuServerApiClient : IDisposable
|
||||
{
|
||||
Task<BilibiliApiResponse<DanmuInfo>> GetDanmakuServerAsync(int roomid);
|
||||
}
|
||||
}
|
13
BililiveRecorder.Core/Api/Model/BilibiliApiResponse.cs
Normal file
13
BililiveRecorder.Core/Api/Model/BilibiliApiResponse.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
using Newtonsoft.Json;
|
||||
|
||||
namespace BililiveRecorder.Core.Api.Model
|
||||
{
|
||||
public class BilibiliApiResponse<T> where T : class
|
||||
{
|
||||
[JsonProperty("code")]
|
||||
public int? Code { get; set; }
|
||||
|
||||
[JsonProperty("data")]
|
||||
public T? Data { get; set; }
|
||||
}
|
||||
}
|
|
@ -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) { }
|
||||
}
|
||||
}
|
23
BililiveRecorder.Core/Api/Model/DanmuInfo.cs
Normal file
23
BililiveRecorder.Core/Api/Model/DanmuInfo.cs
Normal file
|
@ -0,0 +1,23 @@
|
|||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BililiveRecorder.Core.Api.Model
|
||||
{
|
||||
public class DanmuInfo
|
||||
{
|
||||
[JsonProperty("host_list")]
|
||||
public HostListItem[] HostList { get; set; } = Array.Empty<HostListItem>();
|
||||
|
||||
[JsonProperty("token")]
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
public class HostListItem
|
||||
{
|
||||
[JsonProperty("host")]
|
||||
public string Host { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("port")]
|
||||
public int Port { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
31
BililiveRecorder.Core/Api/Model/RoomInfo.cs
Normal file
31
BililiveRecorder.Core/Api/Model/RoomInfo.cs
Normal file
|
@ -0,0 +1,31 @@
|
|||
using Newtonsoft.Json;
|
||||
|
||||
namespace BililiveRecorder.Core.Api.Model
|
||||
{
|
||||
public class RoomInfo
|
||||
{
|
||||
[JsonProperty("room_id")]
|
||||
public int RoomId { get; set; }
|
||||
|
||||
[JsonProperty("short_id")]
|
||||
public int ShortId { get; set; }
|
||||
|
||||
[JsonProperty("live_status")]
|
||||
public int LiveStatus { get; set; }
|
||||
|
||||
[JsonProperty("area_id")]
|
||||
public int AreaId { get; set; }
|
||||
|
||||
[JsonProperty("parent_area_id")]
|
||||
public int ParentAreaId { get; set; }
|
||||
|
||||
[JsonProperty("area_name")]
|
||||
public string AreaName { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("parent_area_name")]
|
||||
public string ParentAreaName { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
74
BililiveRecorder.Core/Api/Model/RoomPlayInfo.cs
Normal file
74
BililiveRecorder.Core/Api/Model/RoomPlayInfo.cs
Normal file
|
@ -0,0 +1,74 @@
|
|||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BililiveRecorder.Core.Api.Model
|
||||
{
|
||||
public class RoomPlayInfo
|
||||
{
|
||||
[JsonProperty("live_status")]
|
||||
public int LiveStatus { get; set; }
|
||||
|
||||
[JsonProperty("encrypted")]
|
||||
public bool Encrypted { get; set; }
|
||||
|
||||
[JsonProperty("playurl_info")]
|
||||
public PlayurlInfoClass? PlayurlInfo { get; set; }
|
||||
|
||||
public class PlayurlInfoClass
|
||||
{
|
||||
[JsonProperty("playurl")]
|
||||
public PlayurlClass? Playurl { get; set; }
|
||||
}
|
||||
|
||||
public class PlayurlClass
|
||||
{
|
||||
[JsonProperty("stream")]
|
||||
public StreamItem[]? Streams { get; set; } = Array.Empty<StreamItem>();
|
||||
}
|
||||
|
||||
public class StreamItem
|
||||
{
|
||||
[JsonProperty("protocol_name")]
|
||||
public string ProtocolName { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("format")]
|
||||
public FormatItem[]? Formats { get; set; } = Array.Empty<FormatItem>();
|
||||
}
|
||||
|
||||
public class FormatItem
|
||||
{
|
||||
[JsonProperty("format_name")]
|
||||
public string FormatName { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("codec")]
|
||||
public CodecItem[]? Codecs { get; set; } = Array.Empty<CodecItem>();
|
||||
}
|
||||
|
||||
public class CodecItem
|
||||
{
|
||||
[JsonProperty("codec_name")]
|
||||
public string CodecName { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("base_url")]
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("current_qn")]
|
||||
public int CurrentQn { get; set; }
|
||||
|
||||
[JsonProperty("accept_qn")]
|
||||
public int[] AcceptQn { get; set; } = Array.Empty<int>();
|
||||
|
||||
[JsonProperty("url_info")]
|
||||
public UrlInfoItem[]? UrlInfos { get; set; } = Array.Empty<UrlInfoItem>();
|
||||
}
|
||||
|
||||
public class UrlInfoItem
|
||||
{
|
||||
[JsonProperty("host")]
|
||||
public string Host { get; set; } = string.Empty;
|
||||
|
||||
[JsonProperty("extra")]
|
||||
public string Extra { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
16
BililiveRecorder.Core/Api/Model/UserInfo.cs
Normal file
16
BililiveRecorder.Core/Api/Model/UserInfo.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
using Newtonsoft.Json;
|
||||
|
||||
namespace BililiveRecorder.Core.Api.Model
|
||||
{
|
||||
public class UserInfo
|
||||
{
|
||||
[JsonProperty("info")]
|
||||
public InfoClass? Info { get; set; }
|
||||
|
||||
public class InfoClass
|
||||
{
|
||||
[JsonProperty("uname")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
32
BililiveRecorder.Core/Api/ModelExtensions.cs
Normal file
32
BililiveRecorder.Core/Api/ModelExtensions.cs
Normal file
|
@ -0,0 +1,32 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using BililiveRecorder.Core.Api.Model;
|
||||
|
||||
namespace BililiveRecorder.Core.Api
|
||||
{
|
||||
public static class ModelExtensions
|
||||
{
|
||||
private static readonly Random random = new Random();
|
||||
|
||||
public static void ChooseOne(this DanmuInfo danmuInfo, out string host, out int port, out string token)
|
||||
{
|
||||
const string DefaultServerHost = "broadcastlv.chat.bilibili.com";
|
||||
const int DefaultServerPort = 2243;
|
||||
|
||||
token = danmuInfo.Token;
|
||||
|
||||
var list = danmuInfo.HostList.Where(x => !string.IsNullOrWhiteSpace(x.Host) && x.Host != DefaultServerHost && x.Port > 0).ToArray();
|
||||
if (list.Length > 0)
|
||||
{
|
||||
var result = list[random.Next(list.Length)];
|
||||
host = result.Host;
|
||||
port = result.Port;
|
||||
}
|
||||
else
|
||||
{
|
||||
host = DefaultServerHost;
|
||||
port = DefaultServerPort;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
42
BililiveRecorder.Core/Api/PolicyWrappedApiClient.cs
Normal file
42
BililiveRecorder.Core/Api/PolicyWrappedApiClient.cs
Normal file
|
@ -0,0 +1,42 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Api.Model;
|
||||
using Polly;
|
||||
using Polly.Registry;
|
||||
|
||||
namespace BililiveRecorder.Core.Api
|
||||
{
|
||||
public class PolicyWrappedApiClient<T> : IApiClient, IDanmakuServerApiClient, IDisposable where T : class, IApiClient, IDanmakuServerApiClient, IDisposable
|
||||
{
|
||||
private readonly T client;
|
||||
private readonly IReadOnlyPolicyRegistry<string> policies;
|
||||
|
||||
public PolicyWrappedApiClient(T client, IReadOnlyPolicyRegistry<string> policies)
|
||||
{
|
||||
this.client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
this.policies = policies ?? throw new ArgumentNullException(nameof(policies));
|
||||
}
|
||||
|
||||
public async Task<BilibiliApiResponse<DanmuInfo>> GetDanmakuServerAsync(int roomid) => await this.policies
|
||||
.Get<IAsyncPolicy>(PolicyNames.PolicyDanmakuApiRequestAsync)
|
||||
.ExecuteAsync(_ => this.client.GetDanmakuServerAsync(roomid), new Context(PolicyNames.CacheKeyDanmaku + ":" + roomid))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
public async Task<BilibiliApiResponse<RoomInfo>> GetRoomInfoAsync(int roomid) => await this.policies
|
||||
.Get<IAsyncPolicy>(PolicyNames.PolicyRoomInfoApiRequestAsync)
|
||||
.ExecuteAsync(_ => this.client.GetRoomInfoAsync(roomid), new Context(PolicyNames.CacheKeyRoomInfo + ":" + roomid))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
public async Task<BilibiliApiResponse<RoomPlayInfo>> GetStreamUrlAsync(int roomid) => await this.policies
|
||||
.Get<IAsyncPolicy>(PolicyNames.PolicyStreamApiRequestAsync)
|
||||
.ExecuteAsync(_ => this.client.GetStreamUrlAsync(roomid), new Context(PolicyNames.CacheKeyStream + ":" + roomid))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
public async Task<BilibiliApiResponse<UserInfo>> GetUserInfoAsync(int roomid) => await this.policies
|
||||
.Get<IAsyncPolicy>(PolicyNames.PolicyRoomInfoApiRequestAsync)
|
||||
.ExecuteAsync(_ => this.client.GetUserInfoAsync(roomid), new Context(PolicyNames.CacheKeyUserInfo + ":" + roomid))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
public void Dispose() => this.client.Dispose();
|
||||
}
|
||||
}
|
|
@ -1,3 +1,3 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("BililiveRecorder.UnitTest.Core")]
|
||||
[assembly: InternalsVisibleTo("BililiveRecorder.Core.UnitTests")]
|
||||
|
|
|
@ -1,225 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NLog;
|
||||
|
||||
#nullable enable
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public class BililiveAPI
|
||||
{
|
||||
private const string HTTP_HEADER_ACCEPT = "application/json, text/javascript, */*; q=0.01";
|
||||
private const string HTTP_HEADER_REFERER = "https://live.bilibili.com/";
|
||||
private const string DEFAULT_SERVER_HOST = "broadcastlv.chat.bilibili.com";
|
||||
private const int DEFAULT_SERVER_PORT = 2243;
|
||||
|
||||
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
|
||||
private static readonly Random random = new Random();
|
||||
|
||||
private readonly GlobalConfig globalConfig;
|
||||
private readonly HttpClient danmakuhttpclient;
|
||||
private HttpClient httpclient = null!;
|
||||
|
||||
public BililiveAPI(GlobalConfig globalConfig)
|
||||
{
|
||||
this.globalConfig = globalConfig;
|
||||
this.globalConfig.PropertyChanged += (sender, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(this.globalConfig.Cookie))
|
||||
this.ApplyCookieSettings(this.globalConfig.Cookie);
|
||||
};
|
||||
this.ApplyCookieSettings(this.globalConfig.Cookie);
|
||||
|
||||
this.danmakuhttpclient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
this.danmakuhttpclient.DefaultRequestHeaders.Add("Accept", HTTP_HEADER_ACCEPT);
|
||||
this.danmakuhttpclient.DefaultRequestHeaders.Add("Referer", HTTP_HEADER_REFERER);
|
||||
this.danmakuhttpclient.DefaultRequestHeaders.Add("User-Agent", Utils.UserAgent);
|
||||
}
|
||||
|
||||
public void ApplyCookieSettings(string? cookie_string)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.Trace("设置 Cookie 信息...");
|
||||
if (!string.IsNullOrWhiteSpace(cookie_string))
|
||||
{
|
||||
var pclient = new HttpClient(handler: new HttpClientHandler
|
||||
{
|
||||
UseCookies = false,
|
||||
UseDefaultCredentials = false,
|
||||
}, disposeHandler: true)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
pclient.DefaultRequestHeaders.Add("Accept", HTTP_HEADER_ACCEPT);
|
||||
pclient.DefaultRequestHeaders.Add("Referer", HTTP_HEADER_REFERER);
|
||||
pclient.DefaultRequestHeaders.Add("User-Agent", Utils.UserAgent);
|
||||
pclient.DefaultRequestHeaders.Add("Cookie", cookie_string);
|
||||
this.httpclient = pclient;
|
||||
}
|
||||
else
|
||||
{
|
||||
var cleanclient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
cleanclient.DefaultRequestHeaders.Add("Accept", HTTP_HEADER_ACCEPT);
|
||||
cleanclient.DefaultRequestHeaders.Add("Referer", HTTP_HEADER_REFERER);
|
||||
cleanclient.DefaultRequestHeaders.Add("User-Agent", Utils.UserAgent);
|
||||
this.httpclient = cleanclient;
|
||||
}
|
||||
logger.Debug("设置 Cookie 成功");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error(ex, "设置 Cookie 时发生错误");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 下载json并解析
|
||||
/// </summary>
|
||||
/// <param name="url">下载路径</param>
|
||||
/// <returns>数据</returns>
|
||||
/// <exception cref="ArgumentNullException"/>
|
||||
/// <exception cref="WebException"/>
|
||||
private async Task<JObject?> HttpGetJsonAsync(HttpClient client, string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
var s = await client.GetStringAsync(url);
|
||||
var j = JObject.Parse(s);
|
||||
return j;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取直播间播放地址
|
||||
/// </summary>
|
||||
/// <param name="roomid">原房间号</param>
|
||||
/// <returns>FLV播放地址</returns>
|
||||
/// <exception cref="WebException"/>
|
||||
/// <exception cref="Exception"/>
|
||||
public async Task<string?> GetPlayUrlAsync(int roomid)
|
||||
{
|
||||
var url = $@"{this.globalConfig.LiveApiHost}/room/v1/Room/playUrl?cid={roomid}&quality=4&platform=web";
|
||||
// 随机选择一个 url
|
||||
if ((await this.HttpGetJsonAsync(this.httpclient, url))?["data"]?["durl"] is JArray array)
|
||||
{
|
||||
var urls = array.Select(t => t?["url"]?.ToObject<string>());
|
||||
var distinct = urls.Distinct().ToArray();
|
||||
if (distinct.Length > 0)
|
||||
{
|
||||
return distinct[random.Next(distinct.Length)];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取直播间信息
|
||||
/// </summary>
|
||||
/// <param name="roomid">房间号(允许短号)</param>
|
||||
/// <returns>直播间信息</returns>
|
||||
/// <exception cref="WebException"/>
|
||||
/// <exception cref="Exception"/>
|
||||
public async Task<RoomInfo?> GetRoomInfoAsync(int roomid)
|
||||
{
|
||||
try
|
||||
{
|
||||
var room = await this.HttpGetJsonAsync(this.httpclient, $@"https://api.live.bilibili.com/room/v1/Room/get_info?id={roomid}");
|
||||
if (room?["code"]?.ToObject<int>() != 0)
|
||||
{
|
||||
logger.Warn("不能获取 {roomid} 的信息1: {errormsg}", roomid, room?["message"]?.ToObject<string>() ?? "请求超时");
|
||||
return null;
|
||||
}
|
||||
|
||||
var user = await this.HttpGetJsonAsync(this.httpclient, $@"https://api.live.bilibili.com/live_user/v1/UserInfo/get_anchor_in_room?roomid={roomid}");
|
||||
if (user?["code"]?.ToObject<int>() != 0)
|
||||
{
|
||||
logger.Warn("不能获取 {roomid} 的信息2: {errormsg}", roomid, user?["message"]?.ToObject<string>() ?? "请求超时");
|
||||
return null;
|
||||
}
|
||||
|
||||
var i = new RoomInfo()
|
||||
{
|
||||
ShortRoomId = room?["data"]?["short_id"]?.ToObject<int>() ?? throw new Exception("未获取到直播间信息"),
|
||||
RoomId = room?["data"]?["room_id"]?.ToObject<int>() ?? throw new Exception("未获取到直播间信息"),
|
||||
IsStreaming = 1 == (room?["data"]?["live_status"]?.ToObject<int>() ?? throw new Exception("未获取到直播间信息")),
|
||||
UserName = user?["data"]?["info"]?["uname"]?.ToObject<string>() ?? throw new Exception("未获取到直播间信息"),
|
||||
Title = room?["data"]?["title"]?.ToObject<string>() ?? throw new Exception("未获取到直播间信息"),
|
||||
ParentAreaName = room?["data"]?["parent_area_name"]?.ToObject<string>() ?? throw new Exception("未获取到直播间信息"),
|
||||
AreaName = room?["data"]?["area_name"]?.ToObject<string>() ?? throw new Exception("未获取到直播间信息"),
|
||||
};
|
||||
return i;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warn(ex, "获取直播间 {roomid} 的信息时出错", roomid);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取弹幕连接信息
|
||||
/// </summary>
|
||||
/// <param name="roomid"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<(string token, string host, int port)> GetDanmuConf(int roomid)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await this.HttpGetJsonAsync(this.danmakuhttpclient, $@"https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id={roomid}&platform=pc&player=web");
|
||||
|
||||
if (result?["code"]?.ToObject<int>() == 0)
|
||||
{
|
||||
var token = result?["data"]?["token"]?.ToObject<string>() ?? string.Empty;
|
||||
|
||||
var servers = new List<(string? host, int port)>();
|
||||
|
||||
if (result?["data"]?["host_server_list"] is JArray host_server_list)
|
||||
{
|
||||
foreach (var host_server_jtoken in host_server_list)
|
||||
if (host_server_jtoken is JObject host_server)
|
||||
servers.Add((host_server["host"]?.ToObject<string>(), host_server["port"]?.ToObject<int>() ?? 0));
|
||||
}
|
||||
|
||||
if (result?["data"]?["server_list"] is JArray server_list)
|
||||
{
|
||||
foreach (var server_jtoken in server_list)
|
||||
if (server_jtoken is JObject server)
|
||||
servers.Add((server["host"]?.ToObject<string>(), server["port"]?.ToObject<int>() ?? 0));
|
||||
}
|
||||
|
||||
servers.RemoveAll(x => string.IsNullOrWhiteSpace(x.host) || x.port <= 0 || x.host == DEFAULT_SERVER_HOST);
|
||||
|
||||
if (servers.Count > 0)
|
||||
{
|
||||
var (host, port) = servers[random.Next(servers.Count)];
|
||||
return (token, host!, port);
|
||||
}
|
||||
else
|
||||
{
|
||||
return (token, DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Warn("获取直播间 {roomid} 的弹幕连接信息时返回了 {code}", roomid, result?["code"]?.ToObject<int>());
|
||||
return (string.Empty, DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warn(ex, "获取直播间 {roomid} 的弹幕连接信息时出错", roomid);
|
||||
return (string.Empty, DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@
|
|||
<FileVersion>0.0.0.0</FileVersion>
|
||||
<OldToolsVersion>2.0</OldToolsVersion>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<DebugType>portable</DebugType>
|
||||
|
@ -22,14 +23,20 @@
|
|||
<PackageReference Include="JsonSubTypes" Version="1.8.0" />
|
||||
<PackageReference Include="HierarchicalPropertyDefault" Version="0.1.1-beta-g721d36b97c" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="1.4.0" />
|
||||
<PackageReference Include="Nerdbank.Streams" Version="2.6.81" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
|
||||
<PackageReference Include="NLog" Version="4.7.6" />
|
||||
<PackageReference Include="Polly" Version="7.2.1" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="System.IO.Pipelines" Version="5.0.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\BililiveRecorder.FlvProcessor\BililiveRecorder.FlvProcessor.csproj" />
|
||||
<ProjectReference Include="..\BililiveRecorder.Flv\BililiveRecorder.Flv.csproj" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<PreBuildEvent>cd $(SolutionDir)
|
||||
powershell -ExecutionPolicy Bypass -File .\CI\patch_buildinfo.ps1 Core</PreBuildEvent>
|
||||
<PreBuildEvent>
|
||||
cd $(SolutionDir)
|
||||
powershell -ExecutionPolicy Bypass -File .\CI\patch_buildinfo.ps1 Core
|
||||
</PreBuildEvent>
|
||||
</PropertyGroup>
|
||||
</Project>
|
|
@ -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; }
|
||||
}
|
||||
}
|
|
@ -3,9 +3,8 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using BililiveRecorder.FlvProcessor;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
|
||||
#nullable enable
|
||||
#pragma warning disable CS0612 // obsolete
|
||||
namespace BililiveRecorder.Core.Config
|
||||
{
|
||||
|
@ -15,9 +14,6 @@ namespace BililiveRecorder.Core.Config
|
|||
{
|
||||
var map = new Dictionary<PropertyInfo, PropertyInfo>();
|
||||
|
||||
AddMap<V1.ConfigV1, V2.GlobalConfig, EnabledFeature>(map, x => x.EnabledFeature, x => x.EnabledFeature);
|
||||
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.ClipLengthPast, x => x.ClipLengthPast);
|
||||
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.ClipLengthFuture, x => x.ClipLengthFuture);
|
||||
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.TimingStreamRetry, x => x.TimingStreamRetry);
|
||||
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.TimingStreamConnect, x => x.TimingStreamConnect);
|
||||
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.TimingDanmakuRetry, x => x.TimingDanmakuRetry);
|
||||
|
@ -28,9 +24,8 @@ namespace BililiveRecorder.Core.Config
|
|||
AddMap<V1.ConfigV1, V2.GlobalConfig, string?>(map, x => x.WebHookUrls, x => x.WebHookUrls);
|
||||
AddMap<V1.ConfigV1, V2.GlobalConfig, string?>(map, x => x.LiveApiHost, x => x.LiveApiHost);
|
||||
AddMap<V1.ConfigV1, V2.GlobalConfig, string?>(map, x => x.RecordFilenameFormat, x => x.RecordFilenameFormat);
|
||||
AddMap<V1.ConfigV1, V2.GlobalConfig, string?>(map, x => x.ClipFilenameFormat, x => x.ClipFilenameFormat);
|
||||
|
||||
AddMap<V1.ConfigV1, V2.GlobalConfig, AutoCuttingMode>(map, x => x.CuttingMode, x => x.CuttingMode);
|
||||
AddMap<V1.ConfigV1, V2.GlobalConfig, CuttingMode>(map, x => x.CuttingMode, x => x.CuttingMode);
|
||||
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.CuttingNumber, x => x.CuttingNumber);
|
||||
AddMap<V1.ConfigV1, V2.GlobalConfig, bool>(map, x => x.RecordDanmaku, x => x.RecordDanmaku);
|
||||
AddMap<V1.ConfigV1, V2.GlobalConfig, bool>(map, x => x.RecordDanmakuRaw, x => x.RecordDanmakuRaw);
|
||||
|
|
|
@ -3,15 +3,20 @@ using System.IO;
|
|||
using System.Linq;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using NLog;
|
||||
using Serilog;
|
||||
|
||||
#nullable enable
|
||||
namespace BililiveRecorder.Core.Config
|
||||
{
|
||||
public static class ConfigParser
|
||||
public class ConfigParser
|
||||
{
|
||||
private const string CONFIG_FILE_NAME = "config.json";
|
||||
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
|
||||
private static readonly ILogger logger = Log.ForContext<ConfigParser>();
|
||||
private static readonly JsonSerializerSettings settings = new JsonSerializerSettings()
|
||||
{
|
||||
DefaultValueHandling = DefaultValueHandling.Ignore,
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
};
|
||||
|
||||
public static V2.ConfigV2? LoadFrom(string directory)
|
||||
{
|
||||
|
@ -24,11 +29,11 @@ namespace BililiveRecorder.Core.Config
|
|||
|
||||
if (!File.Exists(filepath))
|
||||
{
|
||||
logger.Debug("Config file does not exist. \"{path}\"", filepath);
|
||||
logger.Debug("Config file does not exist {Path}", filepath);
|
||||
return new V2.ConfigV2();
|
||||
}
|
||||
|
||||
logger.Debug("Loading config from path \"{path}\".", filepath);
|
||||
logger.Debug("Loading config from {Path}", filepath);
|
||||
var json = File.ReadAllText(filepath, Encoding.UTF8);
|
||||
return LoadJson(json);
|
||||
|
||||
|
@ -44,7 +49,7 @@ namespace BililiveRecorder.Core.Config
|
|||
{
|
||||
try
|
||||
{
|
||||
logger.Debug("Config json: {config}", json);
|
||||
logger.Debug("Config json: {Json}", json);
|
||||
|
||||
var configBase = JsonConvert.DeserializeObject<ConfigBase>(json);
|
||||
switch (configBase)
|
||||
|
@ -53,7 +58,7 @@ namespace BililiveRecorder.Core.Config
|
|||
{
|
||||
logger.Debug("读取到 config v1");
|
||||
#pragma warning disable CS0612
|
||||
var v1Data = JsonConvert.DeserializeObject<V1.ConfigV1>(v1.Data);
|
||||
var v1Data = JsonConvert.DeserializeObject<V1.ConfigV1>(v1.Data ?? string.Empty);
|
||||
#pragma warning restore CS0612
|
||||
var newConfig = ConfigMapper.Map1To2(v1Data);
|
||||
|
||||
|
@ -106,7 +111,7 @@ namespace BililiveRecorder.Core.Config
|
|||
{
|
||||
try
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(config);
|
||||
var json = JsonConvert.SerializeObject(config, Formatting.None, settings);
|
||||
return json;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
@ -146,6 +151,5 @@ namespace BililiveRecorder.Core.Config
|
|||
}
|
||||
private static readonly Random random = new Random();
|
||||
private static string RandomString(int length) => new string(Enumerable.Repeat("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", length).Select(s => s[random.Next(s.Length)]).ToArray());
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,25 +2,16 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using BililiveRecorder.FlvProcessor;
|
||||
using Newtonsoft.Json;
|
||||
using NLog;
|
||||
|
||||
#nullable disable
|
||||
namespace BililiveRecorder.Core.Config.V1
|
||||
{
|
||||
[Obsolete]
|
||||
[JsonObject(memberSerialization: MemberSerialization.OptIn)]
|
||||
public class ConfigV1 : INotifyPropertyChanged
|
||||
{
|
||||
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
|
||||
|
||||
/// <summary>
|
||||
/// 当前工作目录
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
[Utils.DoNotCopyProperty]
|
||||
public string WorkDirectory { get => this._workDirectory; set => this.SetField(ref this._workDirectory, value); }
|
||||
|
||||
//private static readonly Logger logger = LogManager.GetCurrentClassLogger();
|
||||
|
||||
/// <summary>
|
||||
/// 房间号列表
|
||||
|
@ -31,8 +22,8 @@ namespace BililiveRecorder.Core.Config.V1
|
|||
/// <summary>
|
||||
/// 启用的功能
|
||||
/// </summary>
|
||||
[JsonProperty("feature")]
|
||||
public EnabledFeature EnabledFeature { get => this._enabledFeature; set => this.SetField(ref this._enabledFeature, value); }
|
||||
//[JsonProperty("feature")]
|
||||
//public EnabledFeature EnabledFeature { get => this._enabledFeature; set => this.SetField(ref this._enabledFeature, value); }
|
||||
|
||||
/// <summary>
|
||||
/// 剪辑-过去的时长(秒)
|
||||
|
@ -50,7 +41,7 @@ namespace BililiveRecorder.Core.Config.V1
|
|||
/// 自动切割模式
|
||||
/// </summary>
|
||||
[JsonProperty("cutting_mode")]
|
||||
public AutoCuttingMode CuttingMode { get => this._cuttingMode; set => this.SetField(ref this._cuttingMode, value); }
|
||||
public V2.CuttingMode CuttingMode { get => this._cuttingMode; set => this.SetField(ref this._cuttingMode, value); }
|
||||
|
||||
/// <summary>
|
||||
/// 自动切割数值(分钟/MiB)
|
||||
|
@ -165,18 +156,17 @@ namespace BililiveRecorder.Core.Config.V1
|
|||
protected virtual void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false; logger.Trace("设置 [{0}] 的值已从 [{1}] 修改到 [{2}]", propertyName, field, value);
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||
// logger.Trace("设置 [{0}] 的值已从 [{1}] 修改到 [{2}]", propertyName, field, value);
|
||||
field = value; this.OnPropertyChanged(propertyName); return true;
|
||||
}
|
||||
#endregion
|
||||
|
||||
private string _workDirectory;
|
||||
|
||||
private uint _clipLengthPast = 20;
|
||||
private uint _clipLengthFuture = 10;
|
||||
private uint _cuttingNumber = 10;
|
||||
private EnabledFeature _enabledFeature = EnabledFeature.RecordOnly;
|
||||
private AutoCuttingMode _cuttingMode = AutoCuttingMode.Disabled;
|
||||
//private EnabledFeature _enabledFeature = EnabledFeature.RecordOnly;
|
||||
private V2.CuttingMode _cuttingMode = V2.CuttingMode.Disabled;
|
||||
|
||||
private uint _timingWatchdogTimeout = 10 * 1000;
|
||||
private uint _timingStreamRetry = 6 * 1000;
|
||||
|
|
|
@ -8,6 +8,6 @@ namespace BililiveRecorder.Core.Config.V1
|
|||
/// Config Data String
|
||||
/// </summary>
|
||||
[JsonProperty("data")]
|
||||
public string Data { get; set; }
|
||||
public string? Data { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
// RUN FORMATTER AFTER GENERATE
|
||||
// ******************************
|
||||
using System.ComponentModel;
|
||||
using BililiveRecorder.FlvProcessor;
|
||||
using HierarchicalPropertyDefault;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
@ -32,10 +31,10 @@ namespace BililiveRecorder.Core.Config.V2
|
|||
/// <summary>
|
||||
/// 录制文件自动切割模式
|
||||
/// </summary>
|
||||
public AutoCuttingMode CuttingMode { get => this.GetPropertyValue<AutoCuttingMode>(); set => this.SetPropertyValue(value); }
|
||||
public bool HasCuttingMode { get => this.GetPropertyHasValue(nameof(this.CuttingMode)); set => this.SetPropertyHasValue<AutoCuttingMode>(value, nameof(this.CuttingMode)); }
|
||||
public CuttingMode CuttingMode { get => this.GetPropertyValue<CuttingMode>(); set => this.SetPropertyValue(value); }
|
||||
public bool HasCuttingMode { get => this.GetPropertyHasValue(nameof(this.CuttingMode)); set => this.SetPropertyHasValue<CuttingMode>(value, nameof(this.CuttingMode)); }
|
||||
[JsonProperty(nameof(CuttingMode)), EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public Optional<AutoCuttingMode> OptionalCuttingMode { get => this.GetPropertyValueOptional<AutoCuttingMode>(nameof(this.CuttingMode)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingMode)); }
|
||||
public Optional<CuttingMode> OptionalCuttingMode { get => this.GetPropertyValueOptional<CuttingMode>(nameof(this.CuttingMode)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingMode)); }
|
||||
|
||||
/// <summary>
|
||||
/// 录制文件自动切割数值(分钟/MiB)
|
||||
|
@ -85,21 +84,6 @@ namespace BililiveRecorder.Core.Config.V2
|
|||
[JsonProperty(nameof(RecordDanmakuGuard)), EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public Optional<bool> OptionalRecordDanmakuGuard { get => this.GetPropertyValueOptional<bool>(nameof(this.RecordDanmakuGuard)); set => this.SetPropertyValueOptional(value, nameof(this.RecordDanmakuGuard)); }
|
||||
|
||||
/// <summary>
|
||||
/// 启用的功能
|
||||
/// </summary>
|
||||
public EnabledFeature EnabledFeature => this.GetPropertyValue<EnabledFeature>();
|
||||
|
||||
/// <summary>
|
||||
/// 剪辑-过去的时长(秒)
|
||||
/// </summary>
|
||||
public uint ClipLengthPast => this.GetPropertyValue<uint>();
|
||||
|
||||
/// <summary>
|
||||
/// 剪辑-将来的时长(秒)
|
||||
/// </summary>
|
||||
public uint ClipLengthFuture => this.GetPropertyValue<uint>();
|
||||
|
||||
/// <summary>
|
||||
/// 录制断开重连时间间隔 毫秒
|
||||
/// </summary>
|
||||
|
@ -140,6 +124,11 @@ namespace BililiveRecorder.Core.Config.V2
|
|||
/// </summary>
|
||||
public string? WebHookUrls => this.GetPropertyValue<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Webhook v2 地址 每行一个
|
||||
/// </summary>
|
||||
public string? WebHookUrlsV2 => this.GetPropertyValue<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 替换 api.live.bilibili.com 服务器为其他反代,可以支持在云服务器上录制
|
||||
/// </summary>
|
||||
|
@ -150,11 +139,6 @@ namespace BililiveRecorder.Core.Config.V2
|
|||
/// </summary>
|
||||
public string? RecordFilenameFormat => this.GetPropertyValue<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 剪辑文件名模板
|
||||
/// </summary>
|
||||
public string? ClipFilenameFormat => this.GetPropertyValue<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示直播间标题和分区
|
||||
/// </summary>
|
||||
|
@ -165,30 +149,6 @@ namespace BililiveRecorder.Core.Config.V2
|
|||
[JsonObject(MemberSerialization.OptIn)]
|
||||
public sealed partial class GlobalConfig : HierarchicalObject<DefaultConfig, GlobalConfig>
|
||||
{
|
||||
/// <summary>
|
||||
/// 启用的功能
|
||||
/// </summary>
|
||||
public EnabledFeature EnabledFeature { get => this.GetPropertyValue<EnabledFeature>(); set => this.SetPropertyValue(value); }
|
||||
public bool HasEnabledFeature { get => this.GetPropertyHasValue(nameof(this.EnabledFeature)); set => this.SetPropertyHasValue<EnabledFeature>(value, nameof(this.EnabledFeature)); }
|
||||
[JsonProperty(nameof(EnabledFeature)), EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public Optional<EnabledFeature> OptionalEnabledFeature { get => this.GetPropertyValueOptional<EnabledFeature>(nameof(this.EnabledFeature)); set => this.SetPropertyValueOptional(value, nameof(this.EnabledFeature)); }
|
||||
|
||||
/// <summary>
|
||||
/// 剪辑-过去的时长(秒)
|
||||
/// </summary>
|
||||
public uint ClipLengthPast { get => this.GetPropertyValue<uint>(); set => this.SetPropertyValue(value); }
|
||||
public bool HasClipLengthPast { get => this.GetPropertyHasValue(nameof(this.ClipLengthPast)); set => this.SetPropertyHasValue<uint>(value, nameof(this.ClipLengthPast)); }
|
||||
[JsonProperty(nameof(ClipLengthPast)), EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public Optional<uint> OptionalClipLengthPast { get => this.GetPropertyValueOptional<uint>(nameof(this.ClipLengthPast)); set => this.SetPropertyValueOptional(value, nameof(this.ClipLengthPast)); }
|
||||
|
||||
/// <summary>
|
||||
/// 剪辑-将来的时长(秒)
|
||||
/// </summary>
|
||||
public uint ClipLengthFuture { get => this.GetPropertyValue<uint>(); set => this.SetPropertyValue(value); }
|
||||
public bool HasClipLengthFuture { get => this.GetPropertyHasValue(nameof(this.ClipLengthFuture)); set => this.SetPropertyHasValue<uint>(value, nameof(this.ClipLengthFuture)); }
|
||||
[JsonProperty(nameof(ClipLengthFuture)), EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public Optional<uint> OptionalClipLengthFuture { get => this.GetPropertyValueOptional<uint>(nameof(this.ClipLengthFuture)); set => this.SetPropertyValueOptional(value, nameof(this.ClipLengthFuture)); }
|
||||
|
||||
/// <summary>
|
||||
/// 录制断开重连时间间隔 毫秒
|
||||
/// </summary>
|
||||
|
@ -253,6 +213,14 @@ namespace BililiveRecorder.Core.Config.V2
|
|||
[JsonProperty(nameof(WebHookUrls)), EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public Optional<string?> OptionalWebHookUrls { get => this.GetPropertyValueOptional<string>(nameof(this.WebHookUrls)); set => this.SetPropertyValueOptional(value, nameof(this.WebHookUrls)); }
|
||||
|
||||
/// <summary>
|
||||
/// Webhook v2 地址 每行一个
|
||||
/// </summary>
|
||||
public string? WebHookUrlsV2 { get => this.GetPropertyValue<string>(); set => this.SetPropertyValue(value); }
|
||||
public bool HasWebHookUrlsV2 { get => this.GetPropertyHasValue(nameof(this.WebHookUrlsV2)); set => this.SetPropertyHasValue<string>(value, nameof(this.WebHookUrlsV2)); }
|
||||
[JsonProperty(nameof(WebHookUrlsV2)), EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public Optional<string?> OptionalWebHookUrlsV2 { get => this.GetPropertyValueOptional<string>(nameof(this.WebHookUrlsV2)); set => this.SetPropertyValueOptional(value, nameof(this.WebHookUrlsV2)); }
|
||||
|
||||
/// <summary>
|
||||
/// 替换 api.live.bilibili.com 服务器为其他反代,可以支持在云服务器上录制
|
||||
/// </summary>
|
||||
|
@ -269,14 +237,6 @@ namespace BililiveRecorder.Core.Config.V2
|
|||
[JsonProperty(nameof(RecordFilenameFormat)), EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public Optional<string?> OptionalRecordFilenameFormat { get => this.GetPropertyValueOptional<string>(nameof(this.RecordFilenameFormat)); set => this.SetPropertyValueOptional(value, nameof(this.RecordFilenameFormat)); }
|
||||
|
||||
/// <summary>
|
||||
/// 剪辑文件名模板
|
||||
/// </summary>
|
||||
public string? ClipFilenameFormat { get => this.GetPropertyValue<string>(); set => this.SetPropertyValue(value); }
|
||||
public bool HasClipFilenameFormat { get => this.GetPropertyHasValue(nameof(this.ClipFilenameFormat)); set => this.SetPropertyHasValue<string>(value, nameof(this.ClipFilenameFormat)); }
|
||||
[JsonProperty(nameof(ClipFilenameFormat)), EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public Optional<string?> OptionalClipFilenameFormat { get => this.GetPropertyValueOptional<string>(nameof(this.ClipFilenameFormat)); set => this.SetPropertyValueOptional(value, nameof(this.ClipFilenameFormat)); }
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示直播间标题和分区
|
||||
/// </summary>
|
||||
|
@ -288,10 +248,10 @@ namespace BililiveRecorder.Core.Config.V2
|
|||
/// <summary>
|
||||
/// 录制文件自动切割模式
|
||||
/// </summary>
|
||||
public AutoCuttingMode CuttingMode { get => this.GetPropertyValue<AutoCuttingMode>(); set => this.SetPropertyValue(value); }
|
||||
public bool HasCuttingMode { get => this.GetPropertyHasValue(nameof(this.CuttingMode)); set => this.SetPropertyHasValue<AutoCuttingMode>(value, nameof(this.CuttingMode)); }
|
||||
public CuttingMode CuttingMode { get => this.GetPropertyValue<CuttingMode>(); set => this.SetPropertyValue(value); }
|
||||
public bool HasCuttingMode { get => this.GetPropertyHasValue(nameof(this.CuttingMode)); set => this.SetPropertyHasValue<CuttingMode>(value, nameof(this.CuttingMode)); }
|
||||
[JsonProperty(nameof(CuttingMode)), EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public Optional<AutoCuttingMode> OptionalCuttingMode { get => this.GetPropertyValueOptional<AutoCuttingMode>(nameof(this.CuttingMode)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingMode)); }
|
||||
public Optional<CuttingMode> OptionalCuttingMode { get => this.GetPropertyValueOptional<CuttingMode>(nameof(this.CuttingMode)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingMode)); }
|
||||
|
||||
/// <summary>
|
||||
/// 录制文件自动切割数值(分钟/MiB)
|
||||
|
@ -348,12 +308,6 @@ namespace BililiveRecorder.Core.Config.V2
|
|||
internal static readonly DefaultConfig Instance = new DefaultConfig();
|
||||
private DefaultConfig() { }
|
||||
|
||||
public EnabledFeature EnabledFeature => EnabledFeature.RecordOnly;
|
||||
|
||||
public uint ClipLengthPast => 20;
|
||||
|
||||
public uint ClipLengthFuture => 10;
|
||||
|
||||
public uint TimingStreamRetry => 6 * 1000;
|
||||
|
||||
public uint TimingStreamConnect => 5 * 1000;
|
||||
|
@ -370,15 +324,15 @@ namespace BililiveRecorder.Core.Config.V2
|
|||
|
||||
public string WebHookUrls => string.Empty;
|
||||
|
||||
public string WebHookUrlsV2 => string.Empty;
|
||||
|
||||
public string LiveApiHost => "https://api.live.bilibili.com";
|
||||
|
||||
public string RecordFilenameFormat => @"{roomid}-{name}/录制-{roomid}-{date}-{time}-{title}.flv";
|
||||
|
||||
public string ClipFilenameFormat => @"{roomid}-{name}/剪辑片段-{roomid}-{date}-{time}-{title}.flv";
|
||||
public bool WpfShowTitleAndArea => true;
|
||||
|
||||
public bool WpfShowTitleAndArea => false;
|
||||
|
||||
public AutoCuttingMode CuttingMode => AutoCuttingMode.Disabled;
|
||||
public CuttingMode CuttingMode => CuttingMode.Disabled;
|
||||
|
||||
public uint CuttingNumber => 100;
|
||||
|
||||
|
|
18
BililiveRecorder.Core/Config/V2/CuttingMode.cs
Normal file
18
BililiveRecorder.Core/Config/V2/CuttingMode.cs
Normal file
|
@ -0,0 +1,18 @@
|
|||
namespace BililiveRecorder.Core.Config.V2
|
||||
{
|
||||
public enum CuttingMode : int
|
||||
{
|
||||
/// <summary>
|
||||
/// 禁用
|
||||
/// </summary>
|
||||
Disabled,
|
||||
/// <summary>
|
||||
/// 根据时间切割
|
||||
/// </summary>
|
||||
ByTime,
|
||||
/// <summary>
|
||||
/// 根据文件大小切割
|
||||
/// </summary>
|
||||
BySize,
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -4,10 +4,11 @@ using System.Text;
|
|||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Xml;
|
||||
using BililiveRecorder.Core.Api.Danmaku;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
|
||||
#nullable enable
|
||||
namespace BililiveRecorder.Core
|
||||
namespace BililiveRecorder.Core.Danmaku
|
||||
{
|
||||
public class BasicDanmakuWriter : IBasicDanmakuWriter
|
||||
{
|
||||
|
@ -26,16 +27,14 @@ namespace BililiveRecorder.Core
|
|||
private XmlWriter? xmlWriter = null;
|
||||
private DateTimeOffset offset = DateTimeOffset.UtcNow;
|
||||
private uint writeCount = 0;
|
||||
private readonly RoomConfig config;
|
||||
private RoomConfig? config;
|
||||
|
||||
public BasicDanmakuWriter(RoomConfig config)
|
||||
{
|
||||
this.config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
}
|
||||
public BasicDanmakuWriter()
|
||||
{ }
|
||||
|
||||
private readonly SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1, 1);
|
||||
|
||||
public void EnableWithPath(string path, IRecordedRoom recordedRoom)
|
||||
public void EnableWithPath(string path, IRoom room)
|
||||
{
|
||||
if (this.disposedValue) return;
|
||||
|
||||
|
@ -52,8 +51,10 @@ namespace BililiveRecorder.Core
|
|||
try { Directory.CreateDirectory(Path.GetDirectoryName(path)); } catch (Exception) { }
|
||||
var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.Read);
|
||||
|
||||
this.config = room.RoomConfig;
|
||||
|
||||
this.xmlWriter = XmlWriter.Create(stream, xmlWriterSettings);
|
||||
this.WriteStartDocument(this.xmlWriter, recordedRoom);
|
||||
this.WriteStartDocument(this.xmlWriter, room);
|
||||
this.offset = DateTimeOffset.UtcNow;
|
||||
this.writeCount = 0;
|
||||
}
|
||||
|
@ -86,6 +87,7 @@ namespace BililiveRecorder.Core
|
|||
public void Write(DanmakuModel danmakuModel)
|
||||
{
|
||||
if (this.disposedValue) return;
|
||||
if (this.config is null) return;
|
||||
|
||||
this.semaphoreSlim.Wait();
|
||||
try
|
||||
|
@ -96,24 +98,24 @@ namespace BililiveRecorder.Core
|
|||
var recordDanmakuRaw = this.config.RecordDanmakuRaw;
|
||||
switch (danmakuModel.MsgType)
|
||||
{
|
||||
case MsgTypeEnum.Comment:
|
||||
case DanmakuMsgType.Comment:
|
||||
{
|
||||
var type = danmakuModel.RawObj?["info"]?[0]?[1]?.ToObject<int>() ?? 1;
|
||||
var size = danmakuModel.RawObj?["info"]?[0]?[2]?.ToObject<int>() ?? 25;
|
||||
var color = danmakuModel.RawObj?["info"]?[0]?[3]?.ToObject<int>() ?? 0XFFFFFF;
|
||||
var st = danmakuModel.RawObj?["info"]?[0]?[4]?.ToObject<long>() ?? 0L;
|
||||
var type = danmakuModel.RawObject?["info"]?[0]?[1]?.ToObject<int>() ?? 1;
|
||||
var size = danmakuModel.RawObject?["info"]?[0]?[2]?.ToObject<int>() ?? 25;
|
||||
var color = danmakuModel.RawObject?["info"]?[0]?[3]?.ToObject<int>() ?? 0XFFFFFF;
|
||||
var st = danmakuModel.RawObject?["info"]?[0]?[4]?.ToObject<long>() ?? 0L;
|
||||
var ts = Math.Max((DateTimeOffset.FromUnixTimeMilliseconds(st) - this.offset).TotalSeconds, 0d);
|
||||
|
||||
this.xmlWriter.WriteStartElement("d");
|
||||
this.xmlWriter.WriteAttributeString("p", $"{ts},{type},{size},{color},{st},0,{danmakuModel.UserID},0");
|
||||
this.xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
|
||||
if (recordDanmakuRaw)
|
||||
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["info"]?.ToString(Newtonsoft.Json.Formatting.None));
|
||||
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObject?["info"]?.ToString(Newtonsoft.Json.Formatting.None));
|
||||
this.xmlWriter.WriteValue(RemoveInvalidXMLChars(danmakuModel.CommentText));
|
||||
this.xmlWriter.WriteEndElement();
|
||||
}
|
||||
break;
|
||||
case MsgTypeEnum.SuperChat:
|
||||
case DanmakuMsgType.SuperChat:
|
||||
if (this.config.RecordDanmakuSuperChat)
|
||||
{
|
||||
this.xmlWriter.WriteStartElement("sc");
|
||||
|
@ -123,12 +125,12 @@ namespace BililiveRecorder.Core
|
|||
this.xmlWriter.WriteAttributeString("price", danmakuModel.Price.ToString());
|
||||
this.xmlWriter.WriteAttributeString("time", danmakuModel.SCKeepTime.ToString());
|
||||
if (recordDanmakuRaw)
|
||||
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
|
||||
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObject?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
|
||||
this.xmlWriter.WriteValue(RemoveInvalidXMLChars(danmakuModel.CommentText));
|
||||
this.xmlWriter.WriteEndElement();
|
||||
}
|
||||
break;
|
||||
case MsgTypeEnum.GiftSend:
|
||||
case DanmakuMsgType.GiftSend:
|
||||
if (this.config.RecordDanmakuGift)
|
||||
{
|
||||
this.xmlWriter.WriteStartElement("gift");
|
||||
|
@ -138,11 +140,11 @@ namespace BililiveRecorder.Core
|
|||
this.xmlWriter.WriteAttributeString("giftname", danmakuModel.GiftName);
|
||||
this.xmlWriter.WriteAttributeString("giftcount", danmakuModel.GiftCount.ToString());
|
||||
if (recordDanmakuRaw)
|
||||
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
|
||||
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObject?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
|
||||
this.xmlWriter.WriteEndElement();
|
||||
}
|
||||
break;
|
||||
case MsgTypeEnum.GuardBuy:
|
||||
case DanmakuMsgType.GuardBuy:
|
||||
if (this.config.RecordDanmakuGuard)
|
||||
{
|
||||
this.xmlWriter.WriteStartElement("guard");
|
||||
|
@ -152,7 +154,7 @@ namespace BililiveRecorder.Core
|
|||
this.xmlWriter.WriteAttributeString("level", danmakuModel.UserGuardLevel.ToString()); ;
|
||||
this.xmlWriter.WriteAttributeString("count", danmakuModel.GiftCount.ToString());
|
||||
if (recordDanmakuRaw)
|
||||
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
|
||||
this.xmlWriter.WriteAttributeString("raw", danmakuModel.RawObject?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
|
||||
this.xmlWriter.WriteEndElement();
|
||||
}
|
||||
break;
|
||||
|
@ -174,7 +176,7 @@ namespace BililiveRecorder.Core
|
|||
}
|
||||
}
|
||||
|
||||
private void WriteStartDocument(XmlWriter writer, IRecordedRoom recordedRoom)
|
||||
private void WriteStartDocument(XmlWriter writer, IRoom room)
|
||||
{
|
||||
writer.WriteStartDocument();
|
||||
writer.WriteProcessingInstruction("xml-stylesheet", "type=\"text/xsl\" href=\"#s\"");
|
||||
|
@ -191,9 +193,10 @@ namespace BililiveRecorder.Core
|
|||
writer.WriteAttributeString("version", BuildInfo.Version + "-" + BuildInfo.HeadShaShort);
|
||||
writer.WriteEndElement();
|
||||
writer.WriteStartElement("BililiveRecorderRecordInfo");
|
||||
writer.WriteAttributeString("roomid", recordedRoom.RoomId.ToString());
|
||||
writer.WriteAttributeString("name", recordedRoom.StreamerName);
|
||||
writer.WriteAttributeString("roomid", room.RoomConfig.RoomId.ToString());
|
||||
writer.WriteAttributeString("name", room.Name);
|
||||
writer.WriteAttributeString("start_time", DateTimeOffset.Now.ToString("O"));
|
||||
// TODO 添加更多信息
|
||||
writer.WriteEndElement();
|
||||
const string style = @"<z:stylesheet version=""1.0"" id=""s"" xml:id=""s"" xmlns:z=""http://www.w3.org/1999/XSL/Transform""><z:output method=""html""/><z:template match=""/""><html><meta name=""viewport"" content=""width=device-width""/><title>B站录播姬弹幕文件 - <z:value-of select=""/i/BililiveRecorderRecordInfo/@name""/></title><style>body{margin:0}h1,h2,p,table{margin-left:5px}table{border-spacing:0}td,th{border:1px solid grey;padding:1px}th{position:sticky;top:0;background:#4098de}tr:hover{background:#d9f4ff}div{overflow:auto;max-height:80vh;max-width:100vw;width:fit-content}</style><h1>B站录播姬弹幕XML文件</h1><p>本文件的弹幕信息兼容B站主站视频弹幕XML格式,可以使用现有的转换工具把文件中的弹幕转为ass字幕文件</p><table><tr><td>录播姬版本</td><td><z:value-of select=""/i/BililiveRecorder/@version""/></td></tr><tr><td>房间号</td><td><z:value-of select=""/i/BililiveRecorderRecordInfo/@roomid""/></td></tr><tr><td>主播名</td><td><z:value-of select=""/i/BililiveRecorderRecordInfo/@name""/></td></tr><tr><td>录制开始时间</td><td><z:value-of select=""/i/BililiveRecorderRecordInfo/@start_time""/></td></tr><tr><td><a href=""#d"">弹幕</a></td><td>共 <z:value-of select=""count(/i/d)""/> 条记录</td></tr><tr><td><a href=""#guard"">上船</a></td><td>共 <z:value-of select=""count(/i/guard)""/> 条记录</td></tr><tr><td><a href=""#sc"">SC</a></td><td>共 <z:value-of select=""count(/i/sc)""/> 条记录</td></tr><tr><td><a href=""#gift"">礼物</a></td><td>共 <z:value-of select=""count(/i/gift)""/> 条记录</td></tr></table><h2 id=""d"">弹幕</h2><div><table><tr><th>用户名</th><th>弹幕</th><th>参数</th></tr><z:for-each select=""/i/d""><tr><td><z:value-of select=""@user""/></td><td><z:value-of select="".""/></td><td><z:value-of select=""@p""/></td></tr></z:for-each></table></div><h2 id=""guard"">舰长购买</h2><div><table><tr><th>用户名</th><th>舰长等级</th><th>购买数量</th><th>出现时间</th></tr><z:for-each select=""/i/guard""><tr><td><z:value-of select=""@user""/></td><td><z:value-of select=""@level""/></td><td><z:value-of select=""@count""/></td><td><z:value-of select=""@ts""/></td></tr></z:for-each></table></div><h2 id=""sc"">SuperChat 醒目留言</h2><div><table><tr><th>用户名</th><th>内容</th><th>显示时长</th><th>价格</th><th>出现时间</th></tr><z:for-each select=""/i/sc""><tr><td><z:value-of select=""@user""/></td><td><z:value-of select="".""/></td><td><z:value-of select=""@time""/></td><td><z:value-of select=""@price""/></td><td><z:value-of select=""@ts""/></td></tr></z:for-each></table></div><h2 id=""gift"">礼物</h2><div><table><tr><th>用户名</th><th>礼物名</th><th>礼物数量</th><th>出现时间</th></tr><z:for-each select=""/i/gift""><tr><td><z:value-of select=""@user""/></td><td><z:value-of select=""@giftname""/></td><td><z:value-of select=""@giftcount""/></td><td><z:value-of select=""@ts""/></td></tr></z:for-each></table></div></html></z:template></z:stylesheet>";
|
||||
writer.WriteStartElement("BililiveRecorderXmlStyle");
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,270 +0,0 @@
|
|||
using Newtonsoft.Json.Linq;
|
||||
|
||||
#nullable enable
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public enum MsgTypeEnum
|
||||
{
|
||||
/// <summary>
|
||||
/// 彈幕
|
||||
/// </summary>
|
||||
Comment,
|
||||
/// <summary>
|
||||
/// 禮物
|
||||
/// </summary>
|
||||
GiftSend,
|
||||
/// <summary>
|
||||
/// 歡迎老爷
|
||||
/// </summary>
|
||||
Welcome,
|
||||
/// <summary>
|
||||
/// 直播開始
|
||||
/// </summary>
|
||||
LiveStart,
|
||||
/// <summary>
|
||||
/// 直播結束
|
||||
/// </summary>
|
||||
LiveEnd,
|
||||
/// <summary>
|
||||
/// 其他
|
||||
/// </summary>
|
||||
Unknown,
|
||||
/// <summary>
|
||||
/// 欢迎船员
|
||||
/// </summary>
|
||||
WelcomeGuard,
|
||||
/// <summary>
|
||||
/// 购买船票(上船)
|
||||
/// </summary>
|
||||
GuardBuy,
|
||||
/// <summary>
|
||||
/// SuperChat
|
||||
/// </summary>
|
||||
SuperChat,
|
||||
/// <summary>
|
||||
/// 房间信息更新
|
||||
/// </summary>
|
||||
RoomChange
|
||||
}
|
||||
|
||||
public class DanmakuModel
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息類型
|
||||
/// </summary>
|
||||
public MsgTypeEnum MsgType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 房间标题
|
||||
/// </summary>
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 大分区
|
||||
/// </summary>
|
||||
public string? ParentAreaName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 子分区
|
||||
/// </summary>
|
||||
public string? AreaName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 彈幕內容
|
||||
/// <para>此项有值的消息类型:<list type="bullet">
|
||||
/// <item><see cref="MsgTypeEnum.Comment"/></item>
|
||||
/// </list></para>
|
||||
/// </summary>
|
||||
public string? CommentText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消息触发者用户名
|
||||
/// <para>此项有值的消息类型:<list type="bullet">
|
||||
/// <item><see cref="MsgTypeEnum.Comment"/></item>
|
||||
/// <item><see cref="MsgTypeEnum.GiftSend"/></item>
|
||||
/// <item><see cref="MsgTypeEnum.Welcome"/></item>
|
||||
/// <item><see cref="MsgTypeEnum.WelcomeGuard"/></item>
|
||||
/// <item><see cref="MsgTypeEnum.GuardBuy"/></item>
|
||||
/// </list></para>
|
||||
/// </summary>
|
||||
public string? UserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SC 价格
|
||||
/// </summary>
|
||||
public double Price { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SC 保持时间
|
||||
/// </summary>
|
||||
public int SCKeepTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消息触发者用户ID
|
||||
/// <para>此项有值的消息类型:<list type="bullet">
|
||||
/// <item><see cref="MsgTypeEnum.Comment"/></item>
|
||||
/// <item><see cref="MsgTypeEnum.GiftSend"/></item>
|
||||
/// <item><see cref="MsgTypeEnum.Welcome"/></item>
|
||||
/// <item><see cref="MsgTypeEnum.WelcomeGuard"/></item>
|
||||
/// <item><see cref="MsgTypeEnum.GuardBuy"/></item>
|
||||
/// </list></para>
|
||||
/// </summary>
|
||||
public int UserID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户舰队等级
|
||||
/// <para>0 为非船员 1 为总督 2 为提督 3 为舰长</para>
|
||||
/// <para>此项有值的消息类型:<list type="bullet">
|
||||
/// <item><see cref="MsgTypeEnum.Comment"/></item>
|
||||
/// <item><see cref="MsgTypeEnum.WelcomeGuard"/></item>
|
||||
/// <item><see cref="MsgTypeEnum.GuardBuy"/></item>
|
||||
/// </list></para>
|
||||
/// </summary>
|
||||
public int UserGuardLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 禮物名稱
|
||||
/// </summary>
|
||||
public string? GiftName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 礼物数量
|
||||
/// <para>此项有值的消息类型:<list type="bullet">
|
||||
/// <item><see cref="MsgTypeEnum.GiftSend"/></item>
|
||||
/// <item><see cref="MsgTypeEnum.GuardBuy"/></item>
|
||||
/// </list></para>
|
||||
/// <para>此字段也用于标识上船 <see cref="MsgTypeEnum.GuardBuy"/> 的数量(月数)</para>
|
||||
/// </summary>
|
||||
public int GiftCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 该用户是否为房管(包括主播)
|
||||
/// <para>此项有值的消息类型:<list type="bullet">
|
||||
/// <item><see cref="MsgTypeEnum.Comment"/></item>
|
||||
/// <item><see cref="MsgTypeEnum.GiftSend"/></item>
|
||||
/// </list></para>
|
||||
/// </summary>
|
||||
public bool IsAdmin { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否VIP用戶(老爺)
|
||||
/// <para>此项有值的消息类型:<list type="bullet">
|
||||
/// <item><see cref="MsgTypeEnum.Comment"/></item>
|
||||
/// <item><see cref="MsgTypeEnum.Welcome"/></item>
|
||||
/// </list></para>
|
||||
/// </summary>
|
||||
public bool IsVIP { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="MsgTypeEnum.LiveStart"/>,<see cref="MsgTypeEnum.LiveEnd"/> 事件对应的房间号
|
||||
/// </summary>
|
||||
public string? RoomID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始数据, 高级开发用
|
||||
/// </summary>
|
||||
public string? RawData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始数据, 高级开发用
|
||||
/// </summary>
|
||||
public JObject? RawObj { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内部用, JSON数据版本号 通常应该是2
|
||||
/// </summary>
|
||||
public int JSON_Version { get; set; }
|
||||
|
||||
public DanmakuModel()
|
||||
{ }
|
||||
|
||||
public DanmakuModel(string JSON)
|
||||
{
|
||||
this.RawData = JSON;
|
||||
this.JSON_Version = 2;
|
||||
|
||||
var obj = JObject.Parse(JSON);
|
||||
this.RawObj = obj;
|
||||
var cmd = obj["cmd"]?.ToObject<string>();
|
||||
switch (cmd)
|
||||
{
|
||||
case "LIVE":
|
||||
this.MsgType = MsgTypeEnum.LiveStart;
|
||||
this.RoomID = obj["roomid"].ToObject<string>();
|
||||
break;
|
||||
case "PREPARING":
|
||||
this.MsgType = MsgTypeEnum.LiveEnd;
|
||||
this.RoomID = obj["roomid"].ToObject<string>();
|
||||
break;
|
||||
case "DANMU_MSG":
|
||||
this.MsgType = MsgTypeEnum.Comment;
|
||||
this.CommentText = obj["info"][1].ToObject<string>();
|
||||
this.UserID = obj["info"][2][0].ToObject<int>();
|
||||
this.UserName = obj["info"][2][1].ToObject<string>();
|
||||
this.IsAdmin = obj["info"][2][2].ToObject<string>() == "1";
|
||||
this.IsVIP = obj["info"][2][3].ToObject<string>() == "1";
|
||||
this.UserGuardLevel = obj["info"][7].ToObject<int>();
|
||||
break;
|
||||
case "SEND_GIFT":
|
||||
this.MsgType = MsgTypeEnum.GiftSend;
|
||||
this.GiftName = obj["data"]["giftName"].ToObject<string>();
|
||||
this.UserName = obj["data"]["uname"].ToObject<string>();
|
||||
this.UserID = obj["data"]["uid"].ToObject<int>();
|
||||
this.GiftCount = obj["data"]["num"].ToObject<int>();
|
||||
break;
|
||||
case "GUARD_BUY":
|
||||
{
|
||||
this.MsgType = MsgTypeEnum.GuardBuy;
|
||||
this.UserID = obj["data"]["uid"].ToObject<int>();
|
||||
this.UserName = obj["data"]["username"].ToObject<string>();
|
||||
this.UserGuardLevel = obj["data"]["guard_level"].ToObject<int>();
|
||||
this.GiftName = this.UserGuardLevel == 3 ? "舰长" : this.UserGuardLevel == 2 ? "提督" : this.UserGuardLevel == 1 ? "总督" : "";
|
||||
this.GiftCount = obj["data"]["num"].ToObject<int>();
|
||||
break;
|
||||
}
|
||||
case "SUPER_CHAT_MESSAGE":
|
||||
{
|
||||
this.MsgType = MsgTypeEnum.SuperChat;
|
||||
this.CommentText = obj["data"]["message"]?.ToString();
|
||||
this.UserID = obj["data"]["uid"].ToObject<int>();
|
||||
this.UserName = obj["data"]["user_info"]["uname"].ToString();
|
||||
this.Price = obj["data"]["price"].ToObject<double>();
|
||||
this.SCKeepTime = obj["data"]["time"].ToObject<int>();
|
||||
break;
|
||||
}
|
||||
case "ROOM_CHANGE":
|
||||
{
|
||||
this.MsgType = MsgTypeEnum.RoomChange;
|
||||
this.Title = obj["data"]?["title"]?.ToObject<string>();
|
||||
this.AreaName = obj["data"]?["area_name"]?.ToObject<string>();
|
||||
this.ParentAreaName = obj["data"]?["parent_area_name"]?.ToObject<string>();
|
||||
break;
|
||||
}
|
||||
/*
|
||||
case "WELCOME":
|
||||
{
|
||||
MsgType = MsgTypeEnum.Welcome;
|
||||
UserName = obj["data"]["uname"].ToObject<string>();
|
||||
UserID = obj["data"]["uid"].ToObject<int>();
|
||||
IsVIP = true;
|
||||
IsAdmin = obj["data"]?["is_admin"]?.ToObject<bool>() ?? obj["data"]?["isadmin"]?.ToObject<string>() == "1";
|
||||
break;
|
||||
}
|
||||
case "WELCOME_GUARD":
|
||||
{
|
||||
MsgType = MsgTypeEnum.WelcomeGuard;
|
||||
UserName = obj["data"]["username"].ToObject<string>();
|
||||
UserID = obj["data"]["uid"].ToObject<int>();
|
||||
UserGuardLevel = obj["data"]["guard_level"].ToObject<int>();
|
||||
break;
|
||||
}
|
||||
*/
|
||||
default:
|
||||
{
|
||||
this.MsgType = MsgTypeEnum.Unknown;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,20 +1,60 @@
|
|||
using BililiveRecorder.Core;
|
||||
using BililiveRecorder.Core.Api;
|
||||
using BililiveRecorder.Core.Api.Danmaku;
|
||||
using BililiveRecorder.Core.Api.Http;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
using BililiveRecorder.Core.Danmaku;
|
||||
using BililiveRecorder.Core.Recording;
|
||||
using BililiveRecorder.Flv;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Polly;
|
||||
using Polly.Registry;
|
||||
|
||||
namespace BililiveRecorder.DependencyInjection
|
||||
{
|
||||
public static class DependencyInjectionExtensions
|
||||
{
|
||||
public static void AddCore(this IServiceCollection services)
|
||||
public static IServiceCollection AddRecorderConfig(this IServiceCollection services, ConfigV2 config) => services
|
||||
.AddSingleton(config)
|
||||
.AddSingleton(sp => sp.GetRequiredService<ConfigV2>().Global)
|
||||
;
|
||||
|
||||
public static IServiceCollection AddRecorder(this IServiceCollection services) => services
|
||||
.AddSingleton<IMemoryStreamProvider, RecyclableMemoryStreamProvider>()
|
||||
.AddRecorderPollyPolicy()
|
||||
.AddRecorderApiClients()
|
||||
.AddRecorderRecording()
|
||||
.AddSingleton<IRecorder, Recorder>()
|
||||
.AddSingleton<IRoomFactory, RoomFactory>()
|
||||
.AddScoped<IRoom, Room>()
|
||||
.AddScoped<IBasicDanmakuWriter, BasicDanmakuWriter>()
|
||||
;
|
||||
|
||||
private static IServiceCollection AddRecorderPollyPolicy(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IRecorder, Recorder>();
|
||||
#pragma warning disable IDE0001
|
||||
services.AddSingleton<ConfigV2>(x => x.GetRequiredService<IRecorder>().Config);
|
||||
services.AddSingleton<GlobalConfig>(x => x.GetRequiredService<ConfigV2>().Global);
|
||||
#pragma warning restore IDE0001
|
||||
services.AddSingleton<BililiveAPI>();
|
||||
services.AddSingleton<IRecordedRoomFactory, RecordedRoomFactory>();
|
||||
var registry = new PolicyRegistry // TODO
|
||||
{
|
||||
[PolicyNames.PolicyRoomInfoApiRequestAsync] = Policy.NoOpAsync(),
|
||||
[PolicyNames.PolicyDanmakuApiRequestAsync] = Policy.NoOpAsync(),
|
||||
[PolicyNames.PolicyStreamApiRequestAsync] = Policy.NoOpAsync(),
|
||||
};
|
||||
|
||||
return services.AddSingleton<IReadOnlyPolicyRegistry<string>>(registry);
|
||||
}
|
||||
|
||||
public static IServiceCollection AddRecorderApiClients(this IServiceCollection services) => services
|
||||
.AddSingleton<HttpApiClient>()
|
||||
.AddSingleton<PolicyWrappedApiClient<HttpApiClient>>()
|
||||
.AddSingleton<IApiClient>(sp => sp.GetRequiredService<PolicyWrappedApiClient<HttpApiClient>>())
|
||||
.AddSingleton<IDanmakuServerApiClient>(sp => sp.GetRequiredService<PolicyWrappedApiClient<HttpApiClient>>())
|
||||
.AddScoped<IDanmakuClient, DanmakuClient>()
|
||||
;
|
||||
|
||||
public static IServiceCollection AddRecorderRecording(this IServiceCollection services) => services
|
||||
.AddScoped<IRecordTaskFactory, RecordTaskFactory>()
|
||||
.AddScoped<IFlvProcessingContextWriterFactory, FlvProcessingContextWriterFactory>()
|
||||
.AddScoped<IFlvTagReaderFactory, FlvTagReaderFactory>()
|
||||
.AddScoped<ITagGroupReaderFactory, TagGroupReaderFactory>()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
|
17
BililiveRecorder.Core/Event/AggregatedRoomEventArgs.cs
Normal file
17
BililiveRecorder.Core/Event/AggregatedRoomEventArgs.cs
Normal file
|
@ -0,0 +1,17 @@
|
|||
using System;
|
||||
|
||||
namespace BililiveRecorder.Core.Event
|
||||
{
|
||||
public class AggregatedRoomEventArgs<T>
|
||||
{
|
||||
public AggregatedRoomEventArgs(IRoom room, T @event)
|
||||
{
|
||||
this.Room = room ?? throw new ArgumentNullException(nameof(room));
|
||||
this.Event = @event;
|
||||
}
|
||||
|
||||
public IRoom Room { get; }
|
||||
|
||||
public T Event { get; }
|
||||
}
|
||||
}
|
17
BililiveRecorder.Core/Event/NetworkingStatsEventArgs.cs
Normal file
17
BililiveRecorder.Core/Event/NetworkingStatsEventArgs.cs
Normal file
|
@ -0,0 +1,17 @@
|
|||
using System;
|
||||
|
||||
namespace BililiveRecorder.Core.Event
|
||||
{
|
||||
public class NetworkingStatsEventArgs : EventArgs
|
||||
{
|
||||
public DateTimeOffset StartTime { get; set; }
|
||||
|
||||
public DateTimeOffset EndTime { get; set; }
|
||||
|
||||
public TimeSpan Duration { get; set; }
|
||||
|
||||
public int BytesDownloaded { get; set; }
|
||||
|
||||
public double Mbps { get; set; }
|
||||
}
|
||||
}
|
33
BililiveRecorder.Core/Event/RecordEventArgsBase.cs
Normal file
33
BililiveRecorder.Core/Event/RecordEventArgsBase.cs
Normal file
|
@ -0,0 +1,33 @@
|
|||
using System;
|
||||
|
||||
namespace BililiveRecorder.Core.Event
|
||||
{
|
||||
public abstract class RecordEventArgsBase : EventArgs
|
||||
{
|
||||
public RecordEventArgsBase() { }
|
||||
|
||||
public RecordEventArgsBase(IRoom room)
|
||||
{
|
||||
this.RoomId = room.RoomConfig.RoomId;
|
||||
this.ShortId = room.ShortId;
|
||||
this.Name = room.Name;
|
||||
this.Title = room.Title;
|
||||
this.AreaNameParent = room.AreaNameParent;
|
||||
this.AreaNameChild = room.AreaNameChild;
|
||||
}
|
||||
|
||||
public Guid SessionId { get; set; }
|
||||
|
||||
public int RoomId { get; set; }
|
||||
|
||||
public int ShortId { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public string AreaNameParent { get; set; } = string.Empty;
|
||||
|
||||
public string AreaNameChild { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
25
BililiveRecorder.Core/Event/RecordFileClosedEventArgs.cs
Normal file
25
BililiveRecorder.Core/Event/RecordFileClosedEventArgs.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BililiveRecorder.Core.Event
|
||||
{
|
||||
public class RecordFileClosedEventArgs : RecordEventArgsBase
|
||||
{
|
||||
public RecordFileClosedEventArgs() { }
|
||||
|
||||
public RecordFileClosedEventArgs(IRoom room) : base(room) { }
|
||||
|
||||
[JsonIgnore]
|
||||
public string FullPath { get; set; } = string.Empty;
|
||||
|
||||
public string RelativePath { get; set; } = string.Empty;
|
||||
|
||||
public long FileSize { get; set; }
|
||||
|
||||
public double Duration { get; set; }
|
||||
|
||||
public DateTimeOffset FileOpenTime { get; set; }
|
||||
|
||||
public DateTimeOffset FileCloseTime { get; set; }
|
||||
}
|
||||
}
|
19
BililiveRecorder.Core/Event/RecordFileOpeningEventArgs.cs
Normal file
19
BililiveRecorder.Core/Event/RecordFileOpeningEventArgs.cs
Normal file
|
@ -0,0 +1,19 @@
|
|||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BililiveRecorder.Core.Event
|
||||
{
|
||||
public class RecordFileOpeningEventArgs : RecordEventArgsBase
|
||||
{
|
||||
public RecordFileOpeningEventArgs() { }
|
||||
|
||||
public RecordFileOpeningEventArgs(IRoom room) : base(room) { }
|
||||
|
||||
[JsonIgnore]
|
||||
public string FullPath { get; set; } = string.Empty;
|
||||
|
||||
public string RelativePath { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset FileOpenTime { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
namespace BililiveRecorder.Core.Event
|
||||
{
|
||||
public class RecordSessionEndedEventArgs : RecordEventArgsBase
|
||||
{
|
||||
public RecordSessionEndedEventArgs() { }
|
||||
|
||||
public RecordSessionEndedEventArgs(IRoom room) : base(room) { }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
namespace BililiveRecorder.Core.Event
|
||||
{
|
||||
public class RecordSessionStartedEventArgs : RecordEventArgsBase
|
||||
{
|
||||
public RecordSessionStartedEventArgs() { }
|
||||
|
||||
public RecordSessionStartedEventArgs(IRoom room) : base(room) { }
|
||||
}
|
||||
}
|
29
BililiveRecorder.Core/Event/RecordingStatsEventArgs.cs
Normal file
29
BililiveRecorder.Core/Event/RecordingStatsEventArgs.cs
Normal file
|
@ -0,0 +1,29 @@
|
|||
using System;
|
||||
|
||||
namespace BililiveRecorder.Core.Event
|
||||
{
|
||||
public class RecordingStatsEventArgs : EventArgs
|
||||
{
|
||||
public long InputVideoByteCount { get; set; }
|
||||
public long InputAudioByteCount { get; set; }
|
||||
|
||||
public int OutputVideoFrameCount { get; set; }
|
||||
public int OutputAudioFrameCount { get; set; }
|
||||
public long OutputVideoByteCount { get; set; }
|
||||
public long OutputAudioByteCount { get; set; }
|
||||
|
||||
public long TotalInputVideoByteCount { get; set; }
|
||||
public long TotalInputAudioByteCount { get; set; }
|
||||
|
||||
public int TotalOutputVideoFrameCount { get; set; }
|
||||
public int TotalOutputAudioFrameCount { get; set; }
|
||||
public long TotalOutputVideoByteCount { get; set; }
|
||||
public long TotalOutputAudioByteCount { get; set; }
|
||||
|
||||
public double AddedDuration { get; set; }
|
||||
public double PassedTime { get; set; }
|
||||
public double DuraionRatio { get; set; }
|
||||
public int SessionMaxTimestamp { get; set; }
|
||||
public int FileMaxTimestamp { get; set; }
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
using System;
|
||||
using System.ComponentModel;
|
||||
using BililiveRecorder.Core.Callback;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
using BililiveRecorder.FlvProcessor;
|
||||
|
||||
#nullable enable
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public interface IRecordedRoom : INotifyPropertyChanged, IDisposable
|
||||
{
|
||||
Guid Guid { get; }
|
||||
|
||||
RoomConfig RoomConfig { get; }
|
||||
|
||||
int ShortRoomId { get; }
|
||||
int RoomId { get; }
|
||||
string StreamerName { get; }
|
||||
string Title { get; }
|
||||
|
||||
event EventHandler<RecordEndData>? RecordEnded;
|
||||
|
||||
IStreamMonitor StreamMonitor { get; }
|
||||
IFlvStreamProcessor? Processor { get; }
|
||||
|
||||
bool IsMonitoring { get; }
|
||||
bool IsRecording { get; }
|
||||
bool IsDanmakuConnected { get; }
|
||||
bool IsStreaming { get; }
|
||||
|
||||
double DownloadSpeedPersentage { get; }
|
||||
double DownloadSpeedMegaBitps { get; }
|
||||
DateTime LastUpdateDateTime { get; }
|
||||
|
||||
void Clip();
|
||||
|
||||
bool Start();
|
||||
void Stop();
|
||||
|
||||
void StartRecord();
|
||||
void StopRecord();
|
||||
|
||||
void RefreshRoomInfo();
|
||||
|
||||
void Shutdown();
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
using BililiveRecorder.Core.Config.V2;
|
||||
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public interface IRecordedRoomFactory
|
||||
{
|
||||
IRecordedRoom CreateRecordedRoom(RoomConfig roomConfig);
|
||||
}
|
||||
}
|
|
@ -1,26 +1,27 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
using BililiveRecorder.Core.Event;
|
||||
|
||||
#nullable enable
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public interface IRecorder : INotifyPropertyChanged, INotifyCollectionChanged, IEnumerable<IRecordedRoom>, ICollection<IRecordedRoom>, IDisposable
|
||||
public interface IRecorder : INotifyPropertyChanged, IDisposable
|
||||
{
|
||||
ConfigV2? Config { get; }
|
||||
ConfigV2 Config { get; }
|
||||
ReadOnlyObservableCollection<IRoom> Rooms { get; }
|
||||
|
||||
bool Initialize(string workdir);
|
||||
event EventHandler<AggregatedRoomEventArgs<RecordSessionStartedEventArgs>>? RecordSessionStarted;
|
||||
event EventHandler<AggregatedRoomEventArgs<RecordSessionEndedEventArgs>>? RecordSessionEnded;
|
||||
event EventHandler<AggregatedRoomEventArgs<RecordFileOpeningEventArgs>>? RecordFileOpening;
|
||||
event EventHandler<AggregatedRoomEventArgs<RecordFileClosedEventArgs>>? RecordFileClosed;
|
||||
event EventHandler<AggregatedRoomEventArgs<NetworkingStatsEventArgs>>? NetworkingStats;
|
||||
event EventHandler<AggregatedRoomEventArgs<RecordingStatsEventArgs>>? RecordingStats;
|
||||
|
||||
void AddRoom(int roomid);
|
||||
|
||||
void AddRoom(int roomid, bool enabled);
|
||||
void RemoveRoom(IRoom room);
|
||||
|
||||
void RemoveRoom(IRecordedRoom rr);
|
||||
|
||||
void SaveConfigToFile();
|
||||
|
||||
// void Shutdown();
|
||||
void SaveConfig();
|
||||
}
|
||||
}
|
||||
|
|
38
BililiveRecorder.Core/IRoom.cs
Normal file
38
BililiveRecorder.Core/IRoom.cs
Normal file
|
@ -0,0 +1,38 @@
|
|||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
using BililiveRecorder.Core.Event;
|
||||
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public interface IRoom : INotifyPropertyChanged, IDisposable
|
||||
{
|
||||
Guid ObjectId { get; }
|
||||
|
||||
RoomConfig RoomConfig { get; }
|
||||
|
||||
int ShortId { get; }
|
||||
string Name { get; }
|
||||
string Title { get; }
|
||||
string AreaNameParent { get; }
|
||||
string AreaNameChild { get; }
|
||||
|
||||
bool Recording { get; }
|
||||
bool Streaming { get; }
|
||||
bool DanmakuConnected { get; }
|
||||
RecordingStats Stats { get; }
|
||||
|
||||
event EventHandler<RecordSessionStartedEventArgs>? RecordSessionStarted;
|
||||
event EventHandler<RecordSessionEndedEventArgs>? RecordSessionEnded;
|
||||
event EventHandler<RecordFileOpeningEventArgs>? RecordFileOpening;
|
||||
event EventHandler<RecordFileClosedEventArgs>? RecordFileClosed;
|
||||
event EventHandler<RecordingStatsEventArgs>? RecordingStats;
|
||||
event EventHandler<NetworkingStatsEventArgs>? NetworkingStats;
|
||||
|
||||
void StartRecord();
|
||||
void StopRecord();
|
||||
void SplitOutput();
|
||||
Task RefreshRoomInfoAsync();
|
||||
}
|
||||
}
|
9
BililiveRecorder.Core/IRoomFactory.cs
Normal file
9
BililiveRecorder.Core/IRoomFactory.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
using BililiveRecorder.Core.Config.V2;
|
||||
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public interface IRoomFactory
|
||||
{
|
||||
IRoom CreateRoom(RoomConfig roomConfig);
|
||||
}
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public interface IStreamMonitor : IDisposable, INotifyPropertyChanged
|
||||
{
|
||||
bool IsMonitoring { get; }
|
||||
bool IsDanmakuConnected { get; }
|
||||
event RoomInfoUpdatedEvent RoomInfoUpdated;
|
||||
event StreamStartedEvent StreamStarted;
|
||||
event ReceivedDanmakuEvt ReceivedDanmaku;
|
||||
|
||||
bool Start();
|
||||
void Stop();
|
||||
void Check(TriggerType type, int millisecondsDelay = 0);
|
||||
Task<RoomInfo> FetchRoomInfoAsync();
|
||||
}
|
||||
}
|
7
BililiveRecorder.Core/LoggingContext.cs
Normal file
7
BililiveRecorder.Core/LoggingContext.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public class LoggingContext
|
||||
{
|
||||
public const string RoomId = nameof(RoomId);
|
||||
}
|
||||
}
|
14
BililiveRecorder.Core/PolicyNames.cs
Normal file
14
BililiveRecorder.Core/PolicyNames.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
namespace BililiveRecorder.Core
|
||||
{
|
||||
internal static class PolicyNames
|
||||
{
|
||||
internal const string PolicyRoomInfoApiRequestAsync = nameof(PolicyRoomInfoApiRequestAsync);
|
||||
internal const string PolicyDanmakuApiRequestAsync = nameof(PolicyDanmakuApiRequestAsync);
|
||||
internal const string PolicyStreamApiRequestAsync = nameof(PolicyStreamApiRequestAsync);
|
||||
|
||||
internal const string CacheKeyUserInfo = nameof(CacheKeyUserInfo);
|
||||
internal const string CacheKeyRoomInfo = nameof(CacheKeyRoomInfo);
|
||||
internal const string CacheKeyDanmaku = nameof(CacheKeyDanmaku);
|
||||
internal const string CacheKeyStream = nameof(CacheKeyStream);
|
||||
}
|
||||
}
|
28
BililiveRecorder.Core/ProcessingRules/SplitRule.cs
Normal file
28
BililiveRecorder.Core/ProcessingRules/SplitRule.cs
Normal file
|
@ -0,0 +1,28 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Flv.Pipeline;
|
||||
|
||||
namespace BililiveRecorder.Core.ProcessingRules
|
||||
{
|
||||
public class SplitRule : IFullProcessingRule
|
||||
{
|
||||
private static readonly FlvProcessingContext NewFileContext = new FlvProcessingContext(PipelineNewFileAction.Instance, new Dictionary<object, object?>());
|
||||
|
||||
// 0 = false, 1 = true
|
||||
private int splitFlag = 0;
|
||||
|
||||
public async Task RunAsync(FlvProcessingContext context, ProcessingDelegate next)
|
||||
{
|
||||
await next(context).ConfigureAwait(false);
|
||||
|
||||
if (1 == Interlocked.Exchange(ref this.splitFlag, 0))
|
||||
{
|
||||
await next(NewFileContext).ConfigureAwait(false);
|
||||
context.AddNewFileAtStart();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetSplitFlag() => Interlocked.Exchange(ref this.splitFlag, 1);
|
||||
}
|
||||
}
|
107
BililiveRecorder.Core/ProcessingRules/StatsRule.cs
Normal file
107
BililiveRecorder.Core/ProcessingRules/StatsRule.cs
Normal file
|
@ -0,0 +1,107 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Event;
|
||||
using BililiveRecorder.Flv;
|
||||
using BililiveRecorder.Flv.Pipeline;
|
||||
|
||||
namespace BililiveRecorder.Core.ProcessingRules
|
||||
{
|
||||
public class StatsRule : ISimpleProcessingRule
|
||||
{
|
||||
public event EventHandler<RecordingStatsEventArgs>? StatsUpdated;
|
||||
|
||||
public long TotalInputVideoByteCount { get; private set; }
|
||||
public long TotalInputAudioByteCount { get; private set; }
|
||||
// 两个值相加可得出总数据量
|
||||
|
||||
public int TotalOutputVideoFrameCount { get; private set; }
|
||||
public int TotalOutputAudioFrameCount { get; private set; }
|
||||
public long TotalOutputVideoByteCount { get; private set; }
|
||||
public long TotalOutputAudioByteCount { get; private set; }
|
||||
|
||||
public int SumOfMaxTimestampOfClosedFiles { get; private set; }
|
||||
public int CurrentFileMaxTimestamp { get; private set; }
|
||||
|
||||
public DateTimeOffset LastWriteTime { get; private set; }
|
||||
|
||||
public async Task RunAsync(FlvProcessingContext context, Func<Task> next)
|
||||
{
|
||||
var e = new RecordingStatsEventArgs();
|
||||
|
||||
if (context.OriginalInput is PipelineDataAction data)
|
||||
{
|
||||
e.TotalInputVideoByteCount = this.TotalInputVideoByteCount += e.InputVideoByteCount = data.Tags.Where(x => x.Type == TagType.Video).Sum(x => x.Size + (11 + 4));
|
||||
e.TotalInputAudioByteCount = this.TotalInputAudioByteCount += e.InputAudioByteCount = data.Tags.Where(x => x.Type == TagType.Audio).Sum(x => x.Size + (11 + 4));
|
||||
}
|
||||
|
||||
await next().ConfigureAwait(false);
|
||||
|
||||
var groups = new List<List<PipelineDataAction>?>();
|
||||
{
|
||||
List<PipelineDataAction>? curr = null;
|
||||
foreach (var action in context.Output)
|
||||
{
|
||||
if (action is PipelineDataAction dataAction)
|
||||
{
|
||||
if (curr is null)
|
||||
{
|
||||
curr = new List<PipelineDataAction>();
|
||||
groups.Add(curr);
|
||||
}
|
||||
curr.Add(dataAction);
|
||||
}
|
||||
else if (action is PipelineNewFileAction)
|
||||
{
|
||||
curr = null;
|
||||
groups.Add(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var maxTimestampBeforeCalc = this.CurrentFileMaxTimestamp;
|
||||
|
||||
foreach (var item in groups)
|
||||
{
|
||||
if (item is null)
|
||||
NewFile();
|
||||
else
|
||||
CalcStats(e, item);
|
||||
}
|
||||
|
||||
e.AddedDuration = (this.CurrentFileMaxTimestamp - maxTimestampBeforeCalc) / 1000d;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
e.PassedTime = (now - this.LastWriteTime).TotalSeconds;
|
||||
this.LastWriteTime = now;
|
||||
e.DuraionRatio = e.AddedDuration / e.PassedTime;
|
||||
|
||||
StatsUpdated?.Invoke(this, e);
|
||||
|
||||
return;
|
||||
void CalcStats(RecordingStatsEventArgs e, IReadOnlyList<PipelineDataAction> dataActions)
|
||||
{
|
||||
if (dataActions.Count > 0)
|
||||
{
|
||||
e.TotalOutputVideoFrameCount = this.TotalOutputVideoFrameCount += e.OutputVideoFrameCount = dataActions.Sum(x => x.Tags.Count(x => x.Type == TagType.Video));
|
||||
e.TotalOutputAudioFrameCount = this.TotalOutputAudioFrameCount += e.OutputAudioFrameCount = dataActions.Sum(x => x.Tags.Count(x => x.Type == TagType.Audio));
|
||||
e.TotalOutputVideoByteCount = this.TotalOutputVideoByteCount += e.OutputVideoByteCount = dataActions.Sum(x => x.Tags.Where(x => x.Type == TagType.Video).Sum(x => (x.Nalus == null ? x.Size : (5 + x.Nalus.Sum(n => n.FullSize + 4))) + (11 + 4)));
|
||||
e.TotalOutputAudioByteCount = this.TotalOutputAudioByteCount += e.OutputAudioByteCount = dataActions.Sum(x => x.Tags.Where(x => x.Type == TagType.Audio).Sum(x => x.Size + (11 + 4)));
|
||||
|
||||
var lastTags = dataActions[dataActions.Count - 1].Tags;
|
||||
if (lastTags.Count > 0)
|
||||
this.CurrentFileMaxTimestamp = e.FileMaxTimestamp = lastTags[lastTags.Count - 1].Timestamp;
|
||||
|
||||
}
|
||||
|
||||
e.SessionMaxTimestamp = this.SumOfMaxTimestampOfClosedFiles + this.CurrentFileMaxTimestamp;
|
||||
}
|
||||
|
||||
void NewFile()
|
||||
{
|
||||
this.SumOfMaxTimestampOfClosedFiles += this.CurrentFileMaxTimestamp;
|
||||
this.CurrentFileMaxTimestamp = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
# BililiveRecorder.Core
|
||||
|
||||
TODO
|
|
@ -1,666 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Callback;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
using BililiveRecorder.FlvProcessor;
|
||||
using NLog;
|
||||
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public class RecordedRoom : IRecordedRoom
|
||||
{
|
||||
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
|
||||
private static readonly Random random = new Random();
|
||||
private static readonly Version VERSION_1_0 = new Version(1, 0);
|
||||
|
||||
#nullable enable
|
||||
|
||||
private int _shortRoomid;
|
||||
private string _streamerName;
|
||||
private string _title;
|
||||
private string _parentAreaName = string.Empty;
|
||||
private string _areaName = string.Empty;
|
||||
private bool _isStreaming;
|
||||
|
||||
public int ShortRoomId
|
||||
{
|
||||
get => this._shortRoomid;
|
||||
private set
|
||||
{
|
||||
if (value == this._shortRoomid) { return; }
|
||||
this._shortRoomid = value;
|
||||
this.TriggerPropertyChanged(nameof(this.ShortRoomId));
|
||||
}
|
||||
}
|
||||
public int RoomId
|
||||
{
|
||||
get => this.RoomConfig.RoomId;
|
||||
private set
|
||||
{
|
||||
if (value == this.RoomConfig.RoomId) { return; }
|
||||
this.RoomConfig.RoomId = value;
|
||||
this.TriggerPropertyChanged(nameof(this.RoomId));
|
||||
}
|
||||
}
|
||||
public string StreamerName
|
||||
{
|
||||
get => this._streamerName;
|
||||
private set
|
||||
{
|
||||
if (value == this._streamerName) { return; }
|
||||
this._streamerName = value;
|
||||
this.TriggerPropertyChanged(nameof(this.StreamerName));
|
||||
}
|
||||
}
|
||||
public string Title
|
||||
{
|
||||
get => this._title;
|
||||
private set
|
||||
{
|
||||
if (value == this._title) { return; }
|
||||
this._title = value;
|
||||
this.TriggerPropertyChanged(nameof(this.Title));
|
||||
}
|
||||
}
|
||||
public string ParentAreaName
|
||||
{
|
||||
get => this._parentAreaName;
|
||||
private set
|
||||
{
|
||||
if (value == this._parentAreaName) { return; }
|
||||
this._parentAreaName = value;
|
||||
this.TriggerPropertyChanged(nameof(this.ParentAreaName));
|
||||
}
|
||||
}
|
||||
public string AreaName
|
||||
{
|
||||
get => this._areaName;
|
||||
private set
|
||||
{
|
||||
if (value == this._areaName) { return; }
|
||||
this._areaName = value;
|
||||
this.TriggerPropertyChanged(nameof(this.AreaName));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsMonitoring => this.StreamMonitor.IsMonitoring;
|
||||
public bool IsRecording => !(this.StreamDownloadTask?.IsCompleted ?? true);
|
||||
public bool IsDanmakuConnected => this.StreamMonitor.IsDanmakuConnected;
|
||||
public bool IsStreaming
|
||||
{
|
||||
get => this._isStreaming;
|
||||
private set
|
||||
{
|
||||
if (value == this._isStreaming) { return; }
|
||||
this._isStreaming = value;
|
||||
this.TriggerPropertyChanged(nameof(this.IsStreaming));
|
||||
}
|
||||
}
|
||||
|
||||
public RoomConfig RoomConfig { get; }
|
||||
|
||||
#nullable restore
|
||||
|
||||
private RecordEndData recordEndData;
|
||||
public event EventHandler<RecordEndData> RecordEnded;
|
||||
|
||||
private readonly IBasicDanmakuWriter basicDanmakuWriter;
|
||||
private readonly IProcessorFactory processorFactory;
|
||||
private IFlvStreamProcessor _processor;
|
||||
public IFlvStreamProcessor Processor
|
||||
{
|
||||
get => this._processor;
|
||||
private set
|
||||
{
|
||||
if (value == this._processor) { return; }
|
||||
this._processor = value;
|
||||
this.TriggerPropertyChanged(nameof(this.Processor));
|
||||
}
|
||||
}
|
||||
|
||||
private BililiveAPI BililiveAPI { get; }
|
||||
public IStreamMonitor StreamMonitor { get; }
|
||||
|
||||
private bool _retry = true;
|
||||
private HttpResponseMessage _response;
|
||||
private Stream _stream;
|
||||
private Task StartupTask = null;
|
||||
private readonly object StartupTaskLock = new object();
|
||||
public Task StreamDownloadTask = null;
|
||||
public CancellationTokenSource cancellationTokenSource = null;
|
||||
|
||||
private double _DownloadSpeedPersentage = 0;
|
||||
private double _DownloadSpeedMegaBitps = 0;
|
||||
private long _lastUpdateSize = 0;
|
||||
private int _lastUpdateTimestamp = 0;
|
||||
public DateTime LastUpdateDateTime { get; private set; } = DateTime.Now;
|
||||
public double DownloadSpeedPersentage
|
||||
{
|
||||
get { return this._DownloadSpeedPersentage; }
|
||||
private set { if (value != this._DownloadSpeedPersentage) { this._DownloadSpeedPersentage = value; this.TriggerPropertyChanged(nameof(this.DownloadSpeedPersentage)); } }
|
||||
}
|
||||
public double DownloadSpeedMegaBitps
|
||||
{
|
||||
get { return this._DownloadSpeedMegaBitps; }
|
||||
private set { if (value != this._DownloadSpeedMegaBitps) { this._DownloadSpeedMegaBitps = value; this.TriggerPropertyChanged(nameof(this.DownloadSpeedMegaBitps)); } }
|
||||
}
|
||||
|
||||
public Guid Guid { get; } = Guid.NewGuid();
|
||||
|
||||
// TODO: 重构 DI
|
||||
public RecordedRoom(IBasicDanmakuWriter basicDanmakuWriter,
|
||||
IStreamMonitor streamMonitor,
|
||||
IProcessorFactory processorFactory,
|
||||
BililiveAPI bililiveAPI,
|
||||
RoomConfig roomConfig)
|
||||
{
|
||||
this.RoomConfig = roomConfig;
|
||||
this.StreamerName = "获取中...";
|
||||
|
||||
this.BililiveAPI = bililiveAPI;
|
||||
|
||||
this.processorFactory = processorFactory;
|
||||
|
||||
this.basicDanmakuWriter = basicDanmakuWriter;
|
||||
|
||||
this.StreamMonitor = streamMonitor;
|
||||
this.StreamMonitor.RoomInfoUpdated += this.StreamMonitor_RoomInfoUpdated;
|
||||
this.StreamMonitor.StreamStarted += this.StreamMonitor_StreamStarted;
|
||||
this.StreamMonitor.ReceivedDanmaku += this.StreamMonitor_ReceivedDanmaku;
|
||||
this.StreamMonitor.PropertyChanged += this.StreamMonitor_PropertyChanged;
|
||||
|
||||
this.PropertyChanged += this.RecordedRoom_PropertyChanged;
|
||||
|
||||
this.StreamMonitor.FetchRoomInfoAsync();
|
||||
|
||||
if (this.RoomConfig.AutoRecord)
|
||||
this.Start();
|
||||
}
|
||||
|
||||
private void RecordedRoom_PropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
switch (e.PropertyName)
|
||||
{
|
||||
case nameof(this.IsMonitoring):
|
||||
this.RoomConfig.AutoRecord = this.IsMonitoring;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void StreamMonitor_PropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
switch (e.PropertyName)
|
||||
{
|
||||
case nameof(IStreamMonitor.IsDanmakuConnected):
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected)));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void StreamMonitor_ReceivedDanmaku(object sender, ReceivedDanmakuArgs e)
|
||||
{
|
||||
switch (e.Danmaku.MsgType)
|
||||
{
|
||||
case MsgTypeEnum.LiveStart:
|
||||
this.IsStreaming = true;
|
||||
break;
|
||||
case MsgTypeEnum.LiveEnd:
|
||||
this.IsStreaming = false;
|
||||
break;
|
||||
case MsgTypeEnum.RoomChange:
|
||||
this.Title = e.Danmaku.Title ?? string.Empty;
|
||||
this.ParentAreaName = e.Danmaku.ParentAreaName ?? string.Empty;
|
||||
this.AreaName = e.Danmaku.AreaName ?? string.Empty;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
this.basicDanmakuWriter.Write(e.Danmaku);
|
||||
}
|
||||
|
||||
private void StreamMonitor_RoomInfoUpdated(object sender, RoomInfoUpdatedArgs e)
|
||||
{
|
||||
// TODO: StreamMonitor 里的 RoomInfoUpdated Handler 也会设置一次 RoomId
|
||||
// 暂时保持不变,此处的 RoomId 需要触发 PropertyChanged 事件
|
||||
this.RoomId = e.RoomInfo.RoomId;
|
||||
this.ShortRoomId = e.RoomInfo.ShortRoomId;
|
||||
this.IsStreaming = e.RoomInfo.IsStreaming;
|
||||
this.StreamerName = e.RoomInfo.UserName;
|
||||
this.Title = e.RoomInfo.Title;
|
||||
this.ParentAreaName = e.RoomInfo.ParentAreaName;
|
||||
this.AreaName = e.RoomInfo.AreaName;
|
||||
}
|
||||
|
||||
public bool Start()
|
||||
{
|
||||
// TODO: 重构: 删除 Start() Stop() 通过 RoomConfig.AutoRecord 控制监控状态和逻辑
|
||||
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
|
||||
|
||||
var r = this.StreamMonitor.Start();
|
||||
this.TriggerPropertyChanged(nameof(this.IsMonitoring));
|
||||
return r;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
// TODO: 见 Start()
|
||||
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
|
||||
|
||||
this.StreamMonitor.Stop();
|
||||
this.TriggerPropertyChanged(nameof(this.IsMonitoring));
|
||||
}
|
||||
|
||||
public void RefreshRoomInfo()
|
||||
{
|
||||
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
|
||||
this.StreamMonitor.FetchRoomInfoAsync();
|
||||
}
|
||||
|
||||
private void StreamMonitor_StreamStarted(object sender, StreamStartedArgs e)
|
||||
{
|
||||
lock (this.StartupTaskLock)
|
||||
if (!this.IsRecording && (this.StartupTask?.IsCompleted ?? true))
|
||||
this.StartupTask = this._StartRecordAsync();
|
||||
}
|
||||
|
||||
public void StartRecord()
|
||||
{
|
||||
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
|
||||
this.StreamMonitor.Check(TriggerType.Manual);
|
||||
}
|
||||
|
||||
public void StopRecord()
|
||||
{
|
||||
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
|
||||
|
||||
this._retry = false;
|
||||
try
|
||||
{
|
||||
if (this.cancellationTokenSource != null)
|
||||
{
|
||||
this.cancellationTokenSource.Cancel();
|
||||
if (!(this.StreamDownloadTask?.Wait(TimeSpan.FromSeconds(2)) ?? true))
|
||||
{
|
||||
logger.Log(this.RoomId, LogLevel.Warn, "停止录制超时,尝试强制关闭连接,请检查网络连接是否稳定");
|
||||
|
||||
this._stream?.Close();
|
||||
this._stream?.Dispose();
|
||||
this._response?.Dispose();
|
||||
this.StreamDownloadTask?.Wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Log(this.RoomId, LogLevel.Warn, "在尝试停止录制时发生错误,请检查网络连接是否稳定", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._retry = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task _StartRecordAsync()
|
||||
{
|
||||
if (this.IsRecording)
|
||||
{
|
||||
// TODO: 这里逻辑可能有问题,StartupTask 会变成当前这个已经结束的
|
||||
logger.Log(this.RoomId, LogLevel.Warn, "已经在录制中了");
|
||||
return;
|
||||
}
|
||||
|
||||
this.cancellationTokenSource = new CancellationTokenSource();
|
||||
var token = this.cancellationTokenSource.Token;
|
||||
try
|
||||
{
|
||||
var flv_path = await this.BililiveAPI.GetPlayUrlAsync(this.RoomId);
|
||||
if (string.IsNullOrWhiteSpace(flv_path))
|
||||
{
|
||||
if (this._retry)
|
||||
{
|
||||
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
unwrap_redir:
|
||||
|
||||
using (var client = new HttpClient(new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
|
||||
}))
|
||||
{
|
||||
|
||||
client.Timeout = TimeSpan.FromMilliseconds(this.RoomConfig.TimingStreamConnect);
|
||||
client.DefaultRequestHeaders.Accept.Clear();
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*"));
|
||||
client.DefaultRequestHeaders.UserAgent.Clear();
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(Utils.UserAgent);
|
||||
client.DefaultRequestHeaders.Referrer = new Uri("https://live.bilibili.com");
|
||||
client.DefaultRequestHeaders.Add("Origin", "https://live.bilibili.com");
|
||||
|
||||
|
||||
logger.Log(this.RoomId, LogLevel.Info, "连接直播服务器 " + new Uri(flv_path).Host);
|
||||
logger.Log(this.RoomId, LogLevel.Debug, "直播流地址: " + flv_path);
|
||||
|
||||
this._response = await client.GetAsync(flv_path, HttpCompletionOption.ResponseHeadersRead);
|
||||
}
|
||||
|
||||
if (this._response.StatusCode == HttpStatusCode.Redirect || this._response.StatusCode == HttpStatusCode.Moved)
|
||||
{
|
||||
// workaround for missing Referrer
|
||||
flv_path = this._response.Headers.Location.OriginalString;
|
||||
this._response.Dispose();
|
||||
goto unwrap_redir;
|
||||
}
|
||||
else if (this._response.StatusCode != HttpStatusCode.OK)
|
||||
{
|
||||
logger.Log(this.RoomId, LogLevel.Info, string.Format("尝试下载直播流时服务器返回了 ({0}){1}", this._response.StatusCode, this._response.ReasonPhrase));
|
||||
|
||||
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
|
||||
|
||||
_CleanupFlvRequest();
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Processor = this.processorFactory.CreateStreamProcessor().Initialize(this.GetStreamFilePath, this.GetClipFilePath, this.RoomConfig.EnabledFeature, this.RoomConfig.CuttingMode);
|
||||
this.Processor.ClipLengthFuture = this.RoomConfig.ClipLengthFuture;
|
||||
this.Processor.ClipLengthPast = this.RoomConfig.ClipLengthPast;
|
||||
this.Processor.CuttingNumber = this.RoomConfig.CuttingNumber;
|
||||
this.Processor.StreamFinalized += (sender, e) => { this.basicDanmakuWriter.Disable(); };
|
||||
this.Processor.FileFinalized += (sender, size) =>
|
||||
{
|
||||
if (this.recordEndData is null) return;
|
||||
var data = this.recordEndData;
|
||||
this.recordEndData = null;
|
||||
|
||||
data.EndRecordTime = DateTimeOffset.Now;
|
||||
data.FileSize = size;
|
||||
RecordEnded?.Invoke(this, data);
|
||||
};
|
||||
this.Processor.OnMetaData += (sender, e) =>
|
||||
{
|
||||
e.Metadata["BililiveRecorder"] = new Dictionary<string, object>()
|
||||
{
|
||||
{
|
||||
"starttime",
|
||||
DateTime.UtcNow
|
||||
},
|
||||
{
|
||||
"version",
|
||||
BuildInfo.Version + " " + BuildInfo.HeadShaShort
|
||||
},
|
||||
{
|
||||
"roomid",
|
||||
this.RoomId.ToString()
|
||||
},
|
||||
{
|
||||
"streamername",
|
||||
this.StreamerName
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
this._stream = await this._response.Content.ReadAsStreamAsync();
|
||||
|
||||
try
|
||||
{
|
||||
if (this._response.Headers.ConnectionClose == false || (this._response.Headers.ConnectionClose is null && this._response.Version != VERSION_1_0))
|
||||
this._stream.ReadTimeout = 3 * 1000;
|
||||
}
|
||||
catch (InvalidOperationException) { }
|
||||
|
||||
this.StreamDownloadTask = Task.Run(_ReadStreamLoop);
|
||||
this.TriggerPropertyChanged(nameof(this.IsRecording));
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// client.GetAsync timed out
|
||||
// useless exception message :/
|
||||
|
||||
_CleanupFlvRequest();
|
||||
logger.Log(this.RoomId, LogLevel.Warn, "连接直播服务器超时。");
|
||||
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_CleanupFlvRequest();
|
||||
logger.Log(this.RoomId, LogLevel.Error, "启动直播流下载出错。" + (this._retry ? "将重试启动。" : ""), ex);
|
||||
if (this._retry)
|
||||
{
|
||||
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
async Task _ReadStreamLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
const int BUF_SIZE = 1024 * 8;
|
||||
byte[] buffer = new byte[BUF_SIZE];
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
int bytesRead = await this._stream.ReadAsync(buffer, 0, BUF_SIZE, token);
|
||||
_UpdateDownloadSpeed(bytesRead);
|
||||
if (bytesRead != 0)
|
||||
{
|
||||
if (bytesRead != BUF_SIZE)
|
||||
{
|
||||
this.Processor.AddBytes(buffer.Take(bytesRead).ToArray());
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Processor.AddBytes(buffer);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
logger.Log(this.RoomId, LogLevel.Info,
|
||||
(token.IsCancellationRequested ? "本地操作结束当前录制。" : "服务器关闭直播流,可能是直播已结束。")
|
||||
+ (this._retry ? "将重试启动。" : ""));
|
||||
if (this._retry)
|
||||
{
|
||||
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (e is ObjectDisposedException && token.IsCancellationRequested) { return; }
|
||||
|
||||
logger.Log(this.RoomId, LogLevel.Warn, "录播发生错误", e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_CleanupFlvRequest();
|
||||
}
|
||||
}
|
||||
void _CleanupFlvRequest()
|
||||
{
|
||||
if (this.Processor != null)
|
||||
{
|
||||
this.Processor.FinallizeFile();
|
||||
this.Processor.Dispose();
|
||||
this.Processor = null;
|
||||
}
|
||||
this._stream?.Dispose();
|
||||
this._stream = null;
|
||||
this._response?.Dispose();
|
||||
this._response = null;
|
||||
|
||||
this._lastUpdateTimestamp = 0;
|
||||
this.DownloadSpeedMegaBitps = 0d;
|
||||
this.DownloadSpeedPersentage = 0d;
|
||||
this.TriggerPropertyChanged(nameof(this.IsRecording));
|
||||
}
|
||||
void _UpdateDownloadSpeed(int bytesRead)
|
||||
{
|
||||
DateTime now = DateTime.Now;
|
||||
double passedSeconds = (now - this.LastUpdateDateTime).TotalSeconds;
|
||||
this._lastUpdateSize += bytesRead;
|
||||
if (passedSeconds > 1.5)
|
||||
{
|
||||
this.DownloadSpeedMegaBitps = this._lastUpdateSize / passedSeconds * 8d / 1_000_000d; // mega bit per second
|
||||
this.DownloadSpeedPersentage = (this.DownloadSpeedPersentage / 2) + ((this.Processor.TotalMaxTimestamp - this._lastUpdateTimestamp) / passedSeconds / 1000 / 2); // ((RecordedTime/1000) / RealTime)%
|
||||
this._lastUpdateTimestamp = this.Processor.TotalMaxTimestamp;
|
||||
this._lastUpdateSize = 0;
|
||||
this.LastUpdateDateTime = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called by API or GUI
|
||||
public void Clip() => this.Processor?.Clip();
|
||||
|
||||
public void Shutdown() => this.Dispose(true);
|
||||
|
||||
private (string fullPath, string relativePath) GetStreamFilePath()
|
||||
{
|
||||
var path = this.FormatFilename(this.RoomConfig.RecordFilenameFormat);
|
||||
|
||||
// 有点脏的写法,不过凑合吧
|
||||
if (this.RoomConfig.RecordDanmaku)
|
||||
{
|
||||
var xmlpath = Path.ChangeExtension(path.fullPath, "xml");
|
||||
this.basicDanmakuWriter.EnableWithPath(xmlpath, this);
|
||||
}
|
||||
|
||||
this.recordEndData = new RecordEndData
|
||||
{
|
||||
RoomId = RoomId,
|
||||
Title = Title,
|
||||
Name = StreamerName,
|
||||
StartRecordTime = DateTimeOffset.Now,
|
||||
RelativePath = path.relativePath,
|
||||
};
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private string GetClipFilePath() => this.FormatFilename(this.RoomConfig.ClipFilenameFormat).fullPath;
|
||||
|
||||
private (string fullPath, string relativePath) FormatFilename(string formatString)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var date = now.ToString("yyyyMMdd");
|
||||
var time = now.ToString("HHmmss");
|
||||
var randomStr = random.Next(100, 999).ToString();
|
||||
|
||||
var relativePath = formatString
|
||||
.Replace(@"{date}", date)
|
||||
.Replace(@"{time}", time)
|
||||
.Replace(@"{random}", randomStr)
|
||||
.Replace(@"{roomid}", this.RoomId.ToString())
|
||||
.Replace(@"{title}", this.Title.RemoveInvalidFileName())
|
||||
.Replace(@"{name}", this.StreamerName.RemoveInvalidFileName())
|
||||
.Replace(@"{parea}", this.ParentAreaName.RemoveInvalidFileName())
|
||||
.Replace(@"{area}", this.AreaName.RemoveInvalidFileName())
|
||||
;
|
||||
|
||||
if (!relativePath.EndsWith(".flv", StringComparison.OrdinalIgnoreCase))
|
||||
relativePath += ".flv";
|
||||
|
||||
relativePath = relativePath.RemoveInvalidFileName(ignore_slash: true);
|
||||
var workDirectory = this.RoomConfig.WorkDirectory;
|
||||
var fullPath = Path.Combine(workDirectory, relativePath);
|
||||
fullPath = Path.GetFullPath(fullPath);
|
||||
|
||||
if (!CheckPath(workDirectory, Path.GetDirectoryName(fullPath)))
|
||||
{
|
||||
logger.Log(this.RoomId, LogLevel.Warn, "录制文件位置超出允许范围,请检查设置。将写入到默认路径。");
|
||||
relativePath = Path.Combine(this.RoomId.ToString(), $"{this.RoomId}-{date}-{time}-{randomStr}.flv");
|
||||
fullPath = Path.Combine(workDirectory, relativePath);
|
||||
}
|
||||
|
||||
if (new FileInfo(relativePath).Exists)
|
||||
{
|
||||
logger.Log(this.RoomId, LogLevel.Warn, "录制文件名冲突,请检查设置。将写入到默认路径。");
|
||||
relativePath = Path.Combine(this.RoomId.ToString(), $"{this.RoomId}-{date}-{time}-{randomStr}.flv");
|
||||
fullPath = Path.Combine(workDirectory, relativePath);
|
||||
}
|
||||
|
||||
return (fullPath, relativePath);
|
||||
}
|
||||
|
||||
private static bool CheckPath(string parent, string child)
|
||||
{
|
||||
DirectoryInfo di_p = new DirectoryInfo(parent);
|
||||
DirectoryInfo di_c = new DirectoryInfo(child);
|
||||
|
||||
if (di_c.FullName == di_p.FullName)
|
||||
return true;
|
||||
|
||||
bool isParent = false;
|
||||
while (di_c.Parent != null)
|
||||
{
|
||||
if (di_c.Parent.FullName == di_p.FullName)
|
||||
{
|
||||
isParent = true;
|
||||
break;
|
||||
}
|
||||
else
|
||||
di_c = di_c.Parent;
|
||||
}
|
||||
return isParent;
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
protected void TriggerPropertyChanged(string propertyName)
|
||||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
|
||||
#region IDisposable Support
|
||||
private bool disposedValue = false; // 要检测冗余调用
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!this.disposedValue)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
this.Stop();
|
||||
this.StopRecord();
|
||||
this.Processor?.FinallizeFile();
|
||||
this.Processor?.Dispose();
|
||||
this.StreamMonitor?.Dispose();
|
||||
this._response?.Dispose();
|
||||
this._stream?.Dispose();
|
||||
this.cancellationTokenSource?.Dispose();
|
||||
this.basicDanmakuWriter?.Dispose();
|
||||
}
|
||||
|
||||
this.Processor = null;
|
||||
this._response = null;
|
||||
this._stream = null;
|
||||
this.cancellationTokenSource = null;
|
||||
|
||||
this.disposedValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// 请勿更改此代码。将清理代码放入以上 Dispose(bool disposing) 中。
|
||||
this.Dispose(true);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,240 +1,167 @@
|
|||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using BililiveRecorder.Core.Callback;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Config;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NLog;
|
||||
using BililiveRecorder.Core.Event;
|
||||
using BililiveRecorder.Core.SimpleWebhook;
|
||||
|
||||
#nullable enable
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public class Recorder : IRecorder
|
||||
{
|
||||
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
|
||||
private readonly object lockObject = new object();
|
||||
private readonly ObservableCollection<IRoom> roomCollection;
|
||||
private readonly IRoomFactory roomFactory;
|
||||
private readonly BasicWebhookV1 basicWebhookV1;
|
||||
private readonly BasicWebhookV2 basicWebhookV2;
|
||||
|
||||
private readonly CancellationTokenSource tokenSource;
|
||||
private readonly IServiceProvider serviceProvider;
|
||||
private IRecordedRoomFactory? recordedRoomFactory;
|
||||
private bool _valid = false;
|
||||
private bool disposedValue;
|
||||
|
||||
private ObservableCollection<IRecordedRoom> Rooms { get; } = new ObservableCollection<IRecordedRoom>();
|
||||
|
||||
public ConfigV2? Config { get; private set; }
|
||||
|
||||
private BasicWebhook? Webhook { get; set; }
|
||||
|
||||
public int Count => this.Rooms.Count;
|
||||
public bool IsReadOnly => true;
|
||||
public IRecordedRoom this[int index] => this.Rooms[index];
|
||||
|
||||
public Recorder(IServiceProvider serviceProvider)
|
||||
public Recorder(IRoomFactory roomFactory, ConfigV2 config)
|
||||
{
|
||||
this.serviceProvider = serviceProvider;
|
||||
this.tokenSource = new CancellationTokenSource();
|
||||
Repeat.Interval(TimeSpan.FromSeconds(3), this.DownloadWatchdog, this.tokenSource.Token);
|
||||
this.roomFactory = roomFactory ?? throw new ArgumentNullException(nameof(roomFactory));
|
||||
this.Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
this.roomCollection = new ObservableCollection<IRoom>();
|
||||
this.Rooms = new ReadOnlyObservableCollection<IRoom>(this.roomCollection);
|
||||
|
||||
this.Rooms.CollectionChanged += (sender, e) =>
|
||||
{
|
||||
logger.Trace($"Rooms.CollectionChanged;{e.Action};" +
|
||||
$"O:{e.OldItems?.Cast<IRecordedRoom>()?.Select(rr => rr.RoomId.ToString())?.Aggregate((current, next) => current + "," + next)};" +
|
||||
$"N:{e.NewItems?.Cast<IRecordedRoom>()?.Select(rr => rr.RoomId.ToString())?.Aggregate((current, next) => current + "," + next)}");
|
||||
};
|
||||
}
|
||||
this.basicWebhookV1 = new BasicWebhookV1(config);
|
||||
this.basicWebhookV2 = new BasicWebhookV2(config.Global);
|
||||
|
||||
public bool Initialize(string workdir)
|
||||
{
|
||||
if (this._valid)
|
||||
throw new InvalidOperationException("Recorder is in valid state");
|
||||
logger.Debug("Initialize: " + workdir);
|
||||
var config = ConfigParser.LoadFrom(directory: workdir);
|
||||
if (config is not null)
|
||||
{
|
||||
this.Config = config;
|
||||
this.Config.Global.WorkDirectory = workdir;
|
||||
this.Webhook = new BasicWebhook(this.Config);
|
||||
this.recordedRoomFactory = this.serviceProvider.GetRequiredService<IRecordedRoomFactory>();
|
||||
this._valid = true;
|
||||
this.Config.Rooms.ForEach(r => this.AddRoom(r));
|
||||
ConfigParser.SaveTo(this.Config.Global.WorkDirectory, this.Config);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
foreach (var item in config.Rooms)
|
||||
this.AddRoom(item);
|
||||
this.SaveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public bool InitializeWithConfig(ConfigV2 config)
|
||||
{
|
||||
// 脏写法 but it works
|
||||
if (this._valid)
|
||||
throw new InvalidOperationException("Recorder is in valid state");
|
||||
public event EventHandler<AggregatedRoomEventArgs<RecordSessionStartedEventArgs>>? RecordSessionStarted;
|
||||
public event EventHandler<AggregatedRoomEventArgs<RecordSessionEndedEventArgs>>? RecordSessionEnded;
|
||||
public event EventHandler<AggregatedRoomEventArgs<RecordFileOpeningEventArgs>>? RecordFileOpening;
|
||||
public event EventHandler<AggregatedRoomEventArgs<RecordFileClosedEventArgs>>? RecordFileClosed;
|
||||
public event EventHandler<AggregatedRoomEventArgs<NetworkingStatsEventArgs>>? NetworkingStats;
|
||||
public event EventHandler<AggregatedRoomEventArgs<RecordingStatsEventArgs>>? RecordingStats;
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
if (config is null)
|
||||
throw new ArgumentNullException(nameof(config));
|
||||
public ConfigV2 Config { get; }
|
||||
|
||||
logger.Debug("Initialize With Config.");
|
||||
this.Config = config;
|
||||
this.Webhook = new BasicWebhook(this.Config);
|
||||
this.recordedRoomFactory = this.serviceProvider.GetRequiredService<IRecordedRoomFactory>();
|
||||
this._valid = true;
|
||||
this.Config.Rooms.ForEach(r => this.AddRoom(r));
|
||||
return true;
|
||||
}
|
||||
public ReadOnlyObservableCollection<IRoom> Rooms { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 添加直播间到录播姬
|
||||
/// </summary>
|
||||
/// <param name="roomid">房间号(支持短号)</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException"/>
|
||||
public void AddRoom(int roomid) => this.AddRoom(roomid, true);
|
||||
|
||||
/// <summary>
|
||||
/// 添加直播间到录播姬
|
||||
/// </summary>
|
||||
/// <param name="roomid">房间号(支持短号)</param>
|
||||
/// <param name="enabled">是否默认启用</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException"/>
|
||||
public void AddRoom(int roomid, bool enabled)
|
||||
{
|
||||
try
|
||||
lock (this.lockObject)
|
||||
{
|
||||
if (!this._valid) { throw new InvalidOperationException("Not Initialized"); }
|
||||
if (roomid <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(roomid), "房间号需要大于0");
|
||||
}
|
||||
|
||||
var config = new RoomConfig
|
||||
{
|
||||
RoomId = roomid,
|
||||
AutoRecord = enabled,
|
||||
};
|
||||
|
||||
this.AddRoom(config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Debug(ex, "AddRoom 添加 {roomid} 直播间错误 ", roomid);
|
||||
var roomConfig = new RoomConfig { RoomId = roomid, AutoRecord = enabled };
|
||||
this.AddRoom(roomConfig);
|
||||
this.SaveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加直播间到录播姬
|
||||
/// </summary>
|
||||
/// <param name="roomConfig">房间设置</param>
|
||||
public void AddRoom(RoomConfig roomConfig)
|
||||
private void AddRoom(RoomConfig roomConfig)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!this._valid) { throw new InvalidOperationException("Not Initialized"); }
|
||||
|
||||
roomConfig.SetParent(this.Config?.Global);
|
||||
var rr = this.recordedRoomFactory!.CreateRecordedRoom(roomConfig);
|
||||
|
||||
logger.Debug("AddRoom 添加了 {roomid} 直播间 ", rr.RoomId);
|
||||
rr.RecordEnded += this.RecordedRoom_RecordEnded;
|
||||
this.Rooms.Add(rr);
|
||||
roomConfig.SetParent(this.Config.Global);
|
||||
var room = this.roomFactory.CreateRoom(roomConfig);
|
||||
this.roomCollection.Add(room);
|
||||
this.AddEventSubscription(room);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
public void RemoveRoom(IRoom room)
|
||||
{
|
||||
logger.Debug(ex, "AddRoom 添加 {roomid} 直播间错误 ", roomConfig.RoomId);
|
||||
lock (this.lockObject)
|
||||
{
|
||||
if (this.roomCollection.Remove(room))
|
||||
{
|
||||
this.RemoveEventSubscription(room);
|
||||
room.Dispose();
|
||||
}
|
||||
this.SaveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从录播姬移除直播间
|
||||
/// </summary>
|
||||
/// <param name="rr">直播间</param>
|
||||
public void RemoveRoom(IRecordedRoom rr)
|
||||
public void SaveConfig()
|
||||
{
|
||||
if (rr is null) return;
|
||||
if (!this._valid) { throw new InvalidOperationException("Not Initialized"); }
|
||||
rr.Shutdown();
|
||||
rr.RecordEnded -= this.RecordedRoom_RecordEnded;
|
||||
logger.Debug("RemoveRoom 移除了直播间 {roomid}", rr.RoomId);
|
||||
this.Rooms.Remove(rr);
|
||||
}
|
||||
|
||||
private void Shutdown()
|
||||
{
|
||||
if (!this._valid) { return; }
|
||||
logger.Debug("Shutdown called.");
|
||||
this.tokenSource.Cancel();
|
||||
|
||||
this.SaveConfigToFile();
|
||||
|
||||
this.Rooms.ToList().ForEach(rr =>
|
||||
{
|
||||
rr.Shutdown();
|
||||
});
|
||||
|
||||
this.Rooms.Clear();
|
||||
}
|
||||
|
||||
private void RecordedRoom_RecordEnded(object sender, RecordEndData e) => this.Webhook?.Send(e);
|
||||
|
||||
public void SaveConfigToFile()
|
||||
{
|
||||
if (this.Config is null) return;
|
||||
|
||||
this.Config.Rooms = this.Rooms.Select(x => x.RoomConfig).ToList();
|
||||
ConfigParser.SaveTo(this.Config.Global.WorkDirectory!, this.Config);
|
||||
}
|
||||
|
||||
private void DownloadWatchdog()
|
||||
#region Events
|
||||
|
||||
private void AddEventSubscription(IRoom room)
|
||||
{
|
||||
if (!this._valid) { return; }
|
||||
try
|
||||
{
|
||||
this.Rooms.ToList().ForEach(room =>
|
||||
{
|
||||
if (room.IsRecording)
|
||||
{
|
||||
if (DateTime.Now - room.LastUpdateDateTime > TimeSpan.FromMilliseconds(this.Config!.Global.TimingWatchdogTimeout))
|
||||
{
|
||||
logger.Warn("服务器未断开连接但停止提供 [{roomid}] 直播间的直播数据,通常是录制侧网络不稳定导致,将会断开重连", room.RoomId);
|
||||
room.StopRecord();
|
||||
room.StartRecord();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error(ex, "直播流下载监控出错");
|
||||
}
|
||||
room.RecordSessionStarted += this.Room_RecordSessionStarted;
|
||||
room.RecordSessionEnded += this.Room_RecordSessionEnded;
|
||||
room.RecordFileOpening += this.Room_RecordFileOpening;
|
||||
room.RecordFileClosed += this.Room_RecordFileClosed;
|
||||
room.NetworkingStats += this.Room_NetworkingStats;
|
||||
room.RecordingStats += this.Room_RecordingStats;
|
||||
room.PropertyChanged += this.Room_PropertyChanged;
|
||||
}
|
||||
|
||||
void ICollection<IRecordedRoom>.Add(IRecordedRoom item) => throw new NotSupportedException("Collection is readonly");
|
||||
void ICollection<IRecordedRoom>.Clear() => throw new NotSupportedException("Collection is readonly");
|
||||
bool ICollection<IRecordedRoom>.Remove(IRecordedRoom item) => throw new NotSupportedException("Collection is readonly");
|
||||
bool ICollection<IRecordedRoom>.Contains(IRecordedRoom item) => this.Rooms.Contains(item);
|
||||
void ICollection<IRecordedRoom>.CopyTo(IRecordedRoom[] array, int arrayIndex) => this.Rooms.CopyTo(array, arrayIndex);
|
||||
public IEnumerator<IRecordedRoom> GetEnumerator() => this.Rooms.GetEnumerator();
|
||||
IEnumerator<IRecordedRoom> IEnumerable<IRecordedRoom>.GetEnumerator() => this.Rooms.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => this.Rooms.GetEnumerator();
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged
|
||||
private void Room_NetworkingStats(object sender, NetworkingStatsEventArgs e)
|
||||
{
|
||||
add => (this.Rooms as INotifyPropertyChanged).PropertyChanged += value;
|
||||
remove => (this.Rooms as INotifyPropertyChanged).PropertyChanged -= value;
|
||||
var room = (IRoom)sender;
|
||||
NetworkingStats?.Invoke(this, new AggregatedRoomEventArgs<NetworkingStatsEventArgs>(room, e));
|
||||
}
|
||||
|
||||
public event NotifyCollectionChangedEventHandler CollectionChanged
|
||||
private void Room_RecordingStats(object sender, RecordingStatsEventArgs e)
|
||||
{
|
||||
add => (this.Rooms as INotifyCollectionChanged).CollectionChanged += value;
|
||||
remove => (this.Rooms as INotifyCollectionChanged).CollectionChanged -= value;
|
||||
var room = (IRoom)sender;
|
||||
RecordingStats?.Invoke(this, new AggregatedRoomEventArgs<RecordingStatsEventArgs>(room, e));
|
||||
}
|
||||
|
||||
private void Room_RecordFileClosed(object sender, RecordFileClosedEventArgs e)
|
||||
{
|
||||
var room = (IRoom)sender;
|
||||
_ = Task.Run(async () => await this.basicWebhookV2.SendFileClosedAsync(e).ConfigureAwait(false));
|
||||
_ = Task.Run(async () => await this.basicWebhookV1.SendAsync(new RecordEndData(e)).ConfigureAwait(false));
|
||||
RecordFileClosed?.Invoke(this, new AggregatedRoomEventArgs<RecordFileClosedEventArgs>(room, e));
|
||||
}
|
||||
|
||||
private void Room_RecordFileOpening(object sender, RecordFileOpeningEventArgs e)
|
||||
{
|
||||
var room = (IRoom)sender;
|
||||
_ = Task.Run(async () => await this.basicWebhookV2.SendFileOpeningAsync(e).ConfigureAwait(false));
|
||||
RecordFileOpening?.Invoke(this, new AggregatedRoomEventArgs<RecordFileOpeningEventArgs>(room, e));
|
||||
}
|
||||
|
||||
private void Room_RecordSessionStarted(object sender, RecordSessionStartedEventArgs e)
|
||||
{
|
||||
var room = (IRoom)sender;
|
||||
_ = Task.Run(async () => await this.basicWebhookV2.SendSessionStartedAsync(e).ConfigureAwait(false));
|
||||
RecordSessionStarted?.Invoke(this, new AggregatedRoomEventArgs<RecordSessionStartedEventArgs>(room, e));
|
||||
}
|
||||
|
||||
private void Room_RecordSessionEnded(object sender, RecordSessionEndedEventArgs e)
|
||||
{
|
||||
var room = (IRoom)sender;
|
||||
_ = Task.Run(async () => await this.basicWebhookV2.SendSessionEndedAsync(e).ConfigureAwait(false));
|
||||
RecordSessionEnded?.Invoke(this, new AggregatedRoomEventArgs<RecordSessionEndedEventArgs>(room, e));
|
||||
}
|
||||
|
||||
private void Room_PropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
// TODO
|
||||
// throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private void RemoveEventSubscription(IRoom room)
|
||||
{
|
||||
room.RecordSessionStarted -= this.Room_RecordSessionStarted;
|
||||
room.RecordSessionEnded -= this.Room_RecordSessionEnded;
|
||||
room.RecordFileOpening -= this.Room_RecordFileOpening;
|
||||
room.RecordFileClosed -= this.Room_RecordFileClosed;
|
||||
room.RecordingStats -= this.Room_RecordingStats;
|
||||
room.PropertyChanged -= this.Room_PropertyChanged;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dispose
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!this.disposedValue)
|
||||
|
@ -242,7 +169,9 @@ namespace BililiveRecorder.Core
|
|||
if (disposing)
|
||||
{
|
||||
// dispose managed state (managed objects)
|
||||
this.Shutdown();
|
||||
this.SaveConfig();
|
||||
foreach (var room in this.roomCollection)
|
||||
room.Dispose();
|
||||
}
|
||||
|
||||
// free unmanaged resources (unmanaged objects) and override finalizer
|
||||
|
@ -264,5 +193,7 @@ namespace BililiveRecorder.Core
|
|||
this.Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
using System;
|
||||
using BililiveRecorder.Flv;
|
||||
using BililiveRecorder.Flv.Writer;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace BililiveRecorder.Core.Recording
|
||||
{
|
||||
public class FlvProcessingContextWriterFactory : IFlvProcessingContextWriterFactory
|
||||
{
|
||||
private readonly IServiceProvider serviceProvider;
|
||||
|
||||
public FlvProcessingContextWriterFactory(IServiceProvider serviceProvider)
|
||||
{
|
||||
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
}
|
||||
|
||||
public IFlvProcessingContextWriter CreateWriter(IFlvWriterTargetProvider targetProvider) =>
|
||||
new FlvProcessingContextWriter(targetProvider, this.serviceProvider.GetRequiredService<IMemoryStreamProvider>(), null);
|
||||
}
|
||||
}
|
22
BililiveRecorder.Core/Recording/FlvTagReaderFactory.cs
Normal file
22
BililiveRecorder.Core/Recording/FlvTagReaderFactory.cs
Normal file
|
@ -0,0 +1,22 @@
|
|||
using System;
|
||||
using System.IO.Pipelines;
|
||||
using BililiveRecorder.Flv;
|
||||
using BililiveRecorder.Flv.Parser;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Serilog;
|
||||
|
||||
namespace BililiveRecorder.Core.Recording
|
||||
{
|
||||
public class FlvTagReaderFactory : IFlvTagReaderFactory
|
||||
{
|
||||
private readonly IServiceProvider serviceProvider;
|
||||
|
||||
public FlvTagReaderFactory(IServiceProvider serviceProvider)
|
||||
{
|
||||
this.serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public IFlvTagReader CreateFlvTagReader(PipeReader pipeReader) =>
|
||||
new FlvTagPipeReader(pipeReader, this.serviceProvider.GetRequiredService<IMemoryStreamProvider>(), this.serviceProvider.GetService<ILogger>());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
using BililiveRecorder.Flv;
|
||||
|
||||
namespace BililiveRecorder.Core.Recording
|
||||
{
|
||||
public interface IFlvProcessingContextWriterFactory
|
||||
{
|
||||
IFlvProcessingContextWriter CreateWriter(IFlvWriterTargetProvider targetProvider);
|
||||
}
|
||||
}
|
10
BililiveRecorder.Core/Recording/IFlvTagReaderFactory.cs
Normal file
10
BililiveRecorder.Core/Recording/IFlvTagReaderFactory.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
using System.IO.Pipelines;
|
||||
using BililiveRecorder.Flv;
|
||||
|
||||
namespace BililiveRecorder.Core.Recording
|
||||
{
|
||||
public interface IFlvTagReaderFactory
|
||||
{
|
||||
IFlvTagReader CreateFlvTagReader(PipeReader pipeReader);
|
||||
}
|
||||
}
|
21
BililiveRecorder.Core/Recording/IRecordTask.cs
Normal file
21
BililiveRecorder.Core/Recording/IRecordTask.cs
Normal file
|
@ -0,0 +1,21 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Event;
|
||||
|
||||
namespace BililiveRecorder.Core.Recording
|
||||
{
|
||||
public interface IRecordTask
|
||||
{
|
||||
Guid SessionId { get; }
|
||||
|
||||
event EventHandler<NetworkingStatsEventArgs>? NetworkingStats;
|
||||
event EventHandler<RecordingStatsEventArgs>? RecordingStats;
|
||||
event EventHandler<RecordFileOpeningEventArgs>? RecordFileOpening;
|
||||
event EventHandler<RecordFileClosedEventArgs>? RecordFileClosed;
|
||||
event EventHandler? RecordSessionEnded;
|
||||
|
||||
void SplitOutput();
|
||||
Task StartAsync();
|
||||
void RequestStop();
|
||||
}
|
||||
}
|
7
BililiveRecorder.Core/Recording/IRecordTaskFactory.cs
Normal file
7
BililiveRecorder.Core/Recording/IRecordTaskFactory.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace BililiveRecorder.Core.Recording
|
||||
{
|
||||
public interface IRecordTaskFactory
|
||||
{
|
||||
IRecordTask CreateRecordTask(IRoom room);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
using BililiveRecorder.Flv;
|
||||
|
||||
namespace BililiveRecorder.Core.Recording
|
||||
{
|
||||
public interface ITagGroupReaderFactory
|
||||
{
|
||||
ITagGroupReader CreateTagGroupReader(IFlvTagReader flvTagReader);
|
||||
}
|
||||
}
|
523
BililiveRecorder.Core/Recording/RecordTask.cs
Normal file
523
BililiveRecorder.Core/Recording/RecordTask.cs
Normal file
|
@ -0,0 +1,523 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Pipelines;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using BililiveRecorder.Core.Api;
|
||||
using BililiveRecorder.Core.Event;
|
||||
using BililiveRecorder.Core.ProcessingRules;
|
||||
using BililiveRecorder.Flv;
|
||||
using BililiveRecorder.Flv.Amf;
|
||||
using BililiveRecorder.Flv.Pipeline;
|
||||
using Serilog;
|
||||
using Timer = System.Timers.Timer;
|
||||
|
||||
namespace BililiveRecorder.Core.Recording
|
||||
{
|
||||
public class RecordTask : IRecordTask
|
||||
{
|
||||
private const string HttpHeaderAccept = "*/*";
|
||||
private const string HttpHeaderOrigin = "https://live.bilibili.com";
|
||||
private const string HttpHeaderReferer = "https://live.bilibili.com/";
|
||||
private const string HttpHeaderUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36";
|
||||
|
||||
private readonly Random random = new Random();
|
||||
private readonly Timer timer = new Timer(1000 * 2);
|
||||
private readonly CancellationTokenSource cts = new CancellationTokenSource();
|
||||
private readonly CancellationToken ct;
|
||||
|
||||
private readonly IRoom room;
|
||||
private readonly ILogger logger;
|
||||
private readonly IApiClient apiClient;
|
||||
private readonly IFlvTagReaderFactory flvTagReaderFactory;
|
||||
private readonly ITagGroupReaderFactory tagGroupReaderFactory;
|
||||
private readonly IFlvProcessingContextWriterFactory writerFactory;
|
||||
private readonly ProcessingDelegate pipeline;
|
||||
|
||||
private readonly IFlvWriterTargetProvider targetProvider;
|
||||
private readonly StatsRule statsRule;
|
||||
private readonly SplitRule splitFileRule;
|
||||
|
||||
private readonly FlvProcessingContext context = new FlvProcessingContext();
|
||||
private readonly IDictionary<object, object?> session = new Dictionary<object, object?>();
|
||||
|
||||
private bool started = false;
|
||||
private Task? filler;
|
||||
private ITagGroupReader? reader;
|
||||
private IFlvProcessingContextWriter? writer;
|
||||
|
||||
private readonly object fillerStatsLock = new object();
|
||||
private int fillerDownloadedBytes;
|
||||
private DateTimeOffset fillerLastStatsTrigger;
|
||||
|
||||
public RecordTask(IRoom room,
|
||||
ILogger logger,
|
||||
IProcessingPipelineBuilder builder,
|
||||
IApiClient apiClient,
|
||||
IFlvTagReaderFactory flvTagReaderFactory,
|
||||
ITagGroupReaderFactory tagGroupReaderFactory,
|
||||
IFlvProcessingContextWriterFactory writerFactory)
|
||||
{
|
||||
this.room = room ?? throw new ArgumentNullException(nameof(room));
|
||||
this.logger = logger?.ForContext<RecordTask>().ForContext(LoggingContext.RoomId, this.room.RoomConfig.RoomId) ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
|
||||
this.flvTagReaderFactory = flvTagReaderFactory ?? throw new ArgumentNullException(nameof(flvTagReaderFactory));
|
||||
this.tagGroupReaderFactory = tagGroupReaderFactory ?? throw new ArgumentNullException(nameof(tagGroupReaderFactory));
|
||||
this.writerFactory = writerFactory ?? throw new ArgumentNullException(nameof(writerFactory));
|
||||
if (builder is null)
|
||||
throw new ArgumentNullException(nameof(builder));
|
||||
|
||||
this.ct = this.cts.Token;
|
||||
|
||||
this.statsRule = new StatsRule();
|
||||
this.splitFileRule = new SplitRule();
|
||||
|
||||
this.statsRule.StatsUpdated += this.StatsRule_StatsUpdated;
|
||||
|
||||
this.pipeline = builder
|
||||
.Add(this.splitFileRule)
|
||||
.Add(this.statsRule)
|
||||
.AddDefault()
|
||||
.AddRemoveFillerData()
|
||||
.Build();
|
||||
|
||||
this.targetProvider = new WriterTargetProvider(this.room, this.logger.ForContext(LoggingContext.RoomId, this.room.RoomConfig.RoomId), paths =>
|
||||
{
|
||||
this.logger.ForContext(LoggingContext.RoomId, this.room.RoomConfig.RoomId).Debug("输出路径 {Path}", paths.fullPath);
|
||||
|
||||
var e = new RecordFileOpeningEventArgs(this.room)
|
||||
{
|
||||
SessionId = this.SessionId,
|
||||
FullPath = paths.fullPath,
|
||||
RelativePath = paths.relativePath,
|
||||
FileOpenTime = DateTimeOffset.Now,
|
||||
};
|
||||
RecordFileOpening?.Invoke(this, e);
|
||||
return e;
|
||||
});
|
||||
|
||||
this.timer.Elapsed += this.Timer_Elapsed_TriggerStats;
|
||||
}
|
||||
|
||||
public Guid SessionId { get; } = Guid.NewGuid();
|
||||
|
||||
public event EventHandler<NetworkingStatsEventArgs>? NetworkingStats;
|
||||
public event EventHandler<RecordingStatsEventArgs>? RecordingStats;
|
||||
public event EventHandler<RecordFileOpeningEventArgs>? RecordFileOpening;
|
||||
public event EventHandler<RecordFileClosedEventArgs>? RecordFileClosed;
|
||||
public event EventHandler? RecordSessionEnded;
|
||||
|
||||
public void SplitOutput() => this.splitFileRule.SetSplitFlag();
|
||||
|
||||
public void RequestStop() => this.cts.Cancel();
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (this.started)
|
||||
throw new InvalidOperationException("Only one StartAsync call allowed per instance.");
|
||||
this.started = true;
|
||||
|
||||
var fullUrl = await this.FetchStreamUrlAsync().ConfigureAwait(false);
|
||||
|
||||
this.logger.Information("连接直播服务器 {Host}", new Uri(fullUrl).Host);
|
||||
this.logger.Debug("直播流地址 {Url}", fullUrl);
|
||||
|
||||
var stream = await this.GetStreamAsync(fullUrl).ConfigureAwait(false);
|
||||
|
||||
var pipe = new Pipe(new PipeOptions(useSynchronizationContext: false));
|
||||
|
||||
this.reader = this.tagGroupReaderFactory.CreateTagGroupReader(this.flvTagReaderFactory.CreateFlvTagReader(pipe.Reader));
|
||||
|
||||
this.writer = this.writerFactory.CreateWriter(this.targetProvider);
|
||||
this.writer.BeforeScriptTagWrite = this.Writer_BeforeScriptTagWrite;
|
||||
this.writer.FileClosed += (sender, e) =>
|
||||
{
|
||||
var openingEventArgs = (RecordFileOpeningEventArgs)e.State!;
|
||||
RecordFileClosed?.Invoke(this, new RecordFileClosedEventArgs(this.room)
|
||||
{
|
||||
SessionId = this.SessionId,
|
||||
FullPath = openingEventArgs.FullPath,
|
||||
RelativePath = openingEventArgs.RelativePath,
|
||||
FileOpenTime = openingEventArgs.FileOpenTime,
|
||||
FileCloseTime = DateTimeOffset.Now,
|
||||
Duration = e.Duration,
|
||||
FileSize = e.FileSize,
|
||||
});
|
||||
};
|
||||
|
||||
this.fillerLastStatsTrigger = DateTimeOffset.UtcNow;
|
||||
this.filler = Task.Run(async () => await this.FillPipeAsync(stream, pipe.Writer).ConfigureAwait(false));
|
||||
|
||||
_ = Task.Run(this.RecordingLoopAsync);
|
||||
}
|
||||
|
||||
private async Task FillPipeAsync(Stream stream, PipeWriter writer)
|
||||
{
|
||||
const int minimumBufferSize = 1024;
|
||||
this.timer.Start();
|
||||
Exception? exception = null;
|
||||
try
|
||||
{
|
||||
while (!this.ct.IsCancellationRequested)
|
||||
{
|
||||
var memory = writer.GetMemory(minimumBufferSize);
|
||||
try
|
||||
{
|
||||
var bytesRead = await stream.ReadAsync(memory, this.ct).ConfigureAwait(false);
|
||||
if (bytesRead == 0)
|
||||
break;
|
||||
writer.Advance(bytesRead);
|
||||
Interlocked.Add(ref this.fillerDownloadedBytes, bytesRead);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
exception = ex;
|
||||
break;
|
||||
}
|
||||
|
||||
var result = await writer.FlushAsync(this.ct).ConfigureAwait(false);
|
||||
if (result.IsCompleted)
|
||||
break;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.timer.Stop();
|
||||
stream.Dispose();
|
||||
await writer.CompleteAsync(exception).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void Timer_Elapsed_TriggerStats(object sender, ElapsedEventArgs e)
|
||||
{
|
||||
int bytes;
|
||||
TimeSpan diff;
|
||||
DateTimeOffset start, end;
|
||||
|
||||
lock (this.fillerStatsLock)
|
||||
{
|
||||
bytes = Interlocked.Exchange(ref this.fillerDownloadedBytes, 0);
|
||||
end = DateTimeOffset.UtcNow;
|
||||
start = this.fillerLastStatsTrigger;
|
||||
this.fillerLastStatsTrigger = end;
|
||||
diff = end - start;
|
||||
}
|
||||
|
||||
var mbps = bytes * 8d / 1024d / 1024d / diff.TotalSeconds;
|
||||
|
||||
NetworkingStats?.Invoke(this, new NetworkingStatsEventArgs
|
||||
{
|
||||
BytesDownloaded = bytes,
|
||||
Duration = diff,
|
||||
StartTime = start,
|
||||
EndTime = end,
|
||||
Mbps = mbps
|
||||
});
|
||||
}
|
||||
|
||||
private void Writer_BeforeScriptTagWrite(ScriptTagBody scriptTagBody)
|
||||
{
|
||||
if (scriptTagBody.Values.Count == 2 && scriptTagBody.Values[1] is ScriptDataEcmaArray value)
|
||||
{
|
||||
var now = DateTimeOffset.Now;
|
||||
const string version = "TODO-dev-1.3.x";
|
||||
value["Title"] = (ScriptDataString)this.room.Title;
|
||||
value["Artist"] = (ScriptDataString)$"{this.room.Name} ({this.room.RoomConfig.RoomId})";
|
||||
value["Comment"] = (ScriptDataString)
|
||||
($"B站直播间 {this.room.RoomConfig.RoomId} 的直播录像\n" +
|
||||
$"主播名: {this.room.Name}\n" +
|
||||
$"直播标题: {this.room.Title}\n" +
|
||||
$"直播分区: {this.room.AreaNameParent}·{this.room.AreaNameChild}\n" +
|
||||
$"录制时间: {now:O}\n" +
|
||||
$"\n使用 B站录播姬 录制 https://rec.danmuji.org\n" +
|
||||
$"录播姬版本: {version}");
|
||||
value["BililiveRecorder"] = new ScriptDataEcmaArray
|
||||
{
|
||||
["RecordedBy"] = (ScriptDataString)"BililiveRecorder B站录播姬",
|
||||
["RecorderVersion"] = (ScriptDataString)version, // TODO fix version
|
||||
["StartTime"] = (ScriptDataDate)now,
|
||||
["RoomId"] = (ScriptDataString)this.room.RoomConfig.RoomId.ToString(),
|
||||
["ShortId"] = (ScriptDataString)this.room.ShortId.ToString(),
|
||||
["Name"] = (ScriptDataString)this.room.Name,
|
||||
["StreamTitle"] = (ScriptDataString)this.room.Title,
|
||||
["AreaNameParent"] = (ScriptDataString)this.room.AreaNameParent,
|
||||
["AreaNameChild"] = (ScriptDataString)this.room.AreaNameChild,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecordingLoopAsync()
|
||||
{
|
||||
if (this.reader is null) return;
|
||||
if (this.writer is null) return;
|
||||
try
|
||||
{
|
||||
while (!this.ct.IsCancellationRequested)
|
||||
{
|
||||
var group = await this.reader.ReadGroupAsync(this.ct).ConfigureAwait(false);
|
||||
|
||||
if (group is null)
|
||||
break;
|
||||
|
||||
this.context.Reset(group, this.session);
|
||||
|
||||
await this.pipeline(this.context).ConfigureAwait(false);
|
||||
|
||||
if (this.context.Comments.Count > 0)
|
||||
this.logger.Debug("修复逻辑输出 {Comments}", string.Join("\n", this.context.Comments));
|
||||
|
||||
await this.writer.WriteAsync(this.context).ConfigureAwait(false);
|
||||
|
||||
if (this.context.Output.Any(x => x is PipelineDisconnectAction))
|
||||
{
|
||||
this.logger.Information("根据修复逻辑的要求结束录制");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
this.logger.Debug(ex, "录制被取消");
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
this.logger.Warning(ex, "录制时发生IO错误");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger.Warning(ex, "录制时发生未知错误");
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.logger.Debug("录制退出");
|
||||
|
||||
this.reader?.Dispose();
|
||||
this.reader = null;
|
||||
this.writer?.Dispose();
|
||||
this.writer = null;
|
||||
this.cts.Cancel();
|
||||
|
||||
RecordSessionEnded?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Stream> GetStreamAsync(string fullUrl)
|
||||
{
|
||||
var client = CreateHttpClient();
|
||||
|
||||
while (true)
|
||||
{
|
||||
var resp = await client.GetAsync(fullUrl,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
new CancellationTokenSource((int)this.room.RoomConfig.TimingStreamConnect).Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
switch (resp.StatusCode)
|
||||
{
|
||||
case System.Net.HttpStatusCode.OK:
|
||||
{
|
||||
this.logger.Debug("开始接收直播流");
|
||||
var stream = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false);
|
||||
return stream;
|
||||
}
|
||||
case System.Net.HttpStatusCode.Moved:
|
||||
case System.Net.HttpStatusCode.Redirect:
|
||||
{
|
||||
fullUrl = resp.Headers.Location.OriginalString;
|
||||
this.logger.Debug("跳转到 {Url}", fullUrl);
|
||||
resp.Dispose();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Exception(string.Format("尝试下载直播流时服务器返回了 ({0}){1}", resp.StatusCode, resp.ReasonPhrase));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> FetchStreamUrlAsync()
|
||||
{
|
||||
var apiResp = await this.apiClient.GetStreamUrlAsync(this.room.RoomConfig.RoomId).ConfigureAwait(false);
|
||||
var url_data = apiResp?.Data?.PlayurlInfo?.Playurl?.Streams;
|
||||
|
||||
if (url_data is null)
|
||||
throw new Exception("playurl is null");
|
||||
|
||||
var url_http_stream_flv_avc =
|
||||
url_data.FirstOrDefault(x => x.ProtocolName == "http_stream")
|
||||
?.Formats?.FirstOrDefault(x => x.FormatName == "flv")
|
||||
?.Codecs?.FirstOrDefault(x => x.CodecName == "avc");
|
||||
|
||||
if (url_http_stream_flv_avc is null)
|
||||
throw new Exception("no supported stream url");
|
||||
|
||||
if (url_http_stream_flv_avc.CurrentQn != 10000)
|
||||
this.logger.Warning("当前录制的画质是 {CurrentQn}", url_http_stream_flv_avc.CurrentQn);
|
||||
|
||||
var url_infos = url_http_stream_flv_avc.UrlInfos;
|
||||
if (url_infos is null || url_infos.Length == 0)
|
||||
throw new Exception("no url_info");
|
||||
|
||||
var url_info = url_infos[this.random.Next(url_infos.Length)];
|
||||
|
||||
var fullUrl = url_info.Host + url_http_stream_flv_avc.BaseUrl + url_info.Extra;
|
||||
return fullUrl;
|
||||
}
|
||||
|
||||
private static HttpClient CreateHttpClient()
|
||||
{
|
||||
var httpClient = new HttpClient(new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
var headers = httpClient.DefaultRequestHeaders;
|
||||
headers.Add("Accept", HttpHeaderAccept);
|
||||
headers.Add("Origin", HttpHeaderOrigin);
|
||||
headers.Add("Referer", HttpHeaderReferer);
|
||||
headers.Add("User-Agent", HttpHeaderUserAgent);
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
private void StatsRule_StatsUpdated(object sender, RecordingStatsEventArgs e)
|
||||
{
|
||||
if (this.room.RoomConfig.CuttingMode == Config.V2.CuttingMode.ByTime)
|
||||
{
|
||||
if (e.FileMaxTimestamp > (this.room.RoomConfig.CuttingNumber * (60 * 1000)))
|
||||
{
|
||||
this.splitFileRule.SetSplitFlag();
|
||||
}
|
||||
}
|
||||
|
||||
RecordingStats?.Invoke(this, e);
|
||||
}
|
||||
|
||||
internal class WriterTargetProvider : IFlvWriterTargetProvider
|
||||
{
|
||||
private static readonly Random random = new Random();
|
||||
|
||||
private readonly IRoom room;
|
||||
private readonly ILogger logger;
|
||||
private readonly Func<(string fullPath, string relativePath), object> OnNewFile;
|
||||
|
||||
private string last_path = string.Empty;
|
||||
|
||||
public WriterTargetProvider(IRoom room, ILogger logger, Func<(string fullPath, string relativePath), object> onNewFile)
|
||||
{
|
||||
this.room = room ?? throw new ArgumentNullException(nameof(room));
|
||||
this.logger = logger?.ForContext<WriterTargetProvider>() ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.OnNewFile = onNewFile ?? throw new ArgumentNullException(nameof(onNewFile));
|
||||
}
|
||||
|
||||
public bool ShouldCreateNewFile(Stream outputStream, IList<Tag> tags)
|
||||
{
|
||||
if (this.room.RoomConfig.CuttingMode == Config.V2.CuttingMode.BySize)
|
||||
{
|
||||
var pendingSize = tags.Sum(x => (x.Nalus == null ? x.Size : (5 + x.Nalus.Sum(n => n.FullSize + 4))) + (11 + 4));
|
||||
return (outputStream.Length + pendingSize) > (this.room.RoomConfig.CuttingNumber * (1024 * 1024));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public (Stream stream, object state) CreateOutputStream()
|
||||
{
|
||||
var paths = this.FormatFilename(this.room.RoomConfig.RecordFilenameFormat!);
|
||||
|
||||
try
|
||||
{ Directory.CreateDirectory(Path.GetDirectoryName(paths.fullPath)); }
|
||||
catch (Exception) { }
|
||||
|
||||
this.last_path = paths.fullPath;
|
||||
var state = this.OnNewFile(paths);
|
||||
|
||||
var stream = new FileStream(paths.fullPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read | FileShare.Delete);
|
||||
return (stream, state);
|
||||
}
|
||||
|
||||
public Stream CreateAlternativeHeaderStream()
|
||||
{
|
||||
var path = string.IsNullOrWhiteSpace(this.last_path)
|
||||
? Path.ChangeExtension(this.FormatFilename(this.room.RoomConfig.RecordFilenameFormat!).fullPath, "headers.txt")
|
||||
: Path.ChangeExtension(this.last_path, "headers.txt");
|
||||
|
||||
try
|
||||
{ Directory.CreateDirectory(Path.GetDirectoryName(path)); }
|
||||
catch (Exception) { }
|
||||
|
||||
var stream = new FileStream(path, FileMode.Append, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
return stream;
|
||||
}
|
||||
|
||||
private (string fullPath, string relativePath) FormatFilename(string formatString)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var date = now.ToString("yyyyMMdd");
|
||||
var time = now.ToString("HHmmss");
|
||||
var randomStr = random.Next(100, 999).ToString();
|
||||
|
||||
var relativePath = formatString
|
||||
.Replace(@"{date}", date)
|
||||
.Replace(@"{time}", time)
|
||||
.Replace(@"{random}", randomStr)
|
||||
.Replace(@"{roomid}", this.room.RoomConfig.RoomId.ToString())
|
||||
.Replace(@"{title}", RemoveInvalidFileName(this.room.Title))
|
||||
.Replace(@"{name}", RemoveInvalidFileName(this.room.Name))
|
||||
.Replace(@"{parea}", RemoveInvalidFileName(this.room.AreaNameParent))
|
||||
.Replace(@"{area}", RemoveInvalidFileName(this.room.AreaNameChild))
|
||||
;
|
||||
|
||||
if (!relativePath.EndsWith(".flv", StringComparison.OrdinalIgnoreCase))
|
||||
relativePath += ".flv";
|
||||
|
||||
relativePath = RemoveInvalidFileName(relativePath, ignore_slash: true);
|
||||
var workDirectory = this.room.RoomConfig.WorkDirectory;
|
||||
var fullPath = Path.Combine(workDirectory, relativePath);
|
||||
fullPath = Path.GetFullPath(fullPath);
|
||||
|
||||
if (!CheckIsWithinPath(workDirectory!, Path.GetDirectoryName(fullPath)))
|
||||
{
|
||||
this.logger.Warning("录制文件位置超出允许范围,请检查设置。将写入到默认路径。");
|
||||
relativePath = Path.Combine(this.room.RoomConfig.RoomId.ToString(), $"{this.room.RoomConfig.RoomId}-{date}-{time}-{randomStr}.flv");
|
||||
fullPath = Path.Combine(workDirectory, relativePath);
|
||||
}
|
||||
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
this.logger.Warning("录制文件名冲突,请检查设置。将写入到默认路径。");
|
||||
relativePath = Path.Combine(this.room.RoomConfig.RoomId.ToString(), $"{this.room.RoomConfig.RoomId}-{date}-{time}-{randomStr}.flv");
|
||||
fullPath = Path.Combine(workDirectory, relativePath);
|
||||
}
|
||||
|
||||
return (fullPath, relativePath);
|
||||
}
|
||||
|
||||
internal static string RemoveInvalidFileName(string input, bool ignore_slash = false)
|
||||
{
|
||||
foreach (var c in Path.GetInvalidFileNameChars())
|
||||
if (!ignore_slash || c != '\\' && c != '/')
|
||||
input = input.Replace(c, '_');
|
||||
return input;
|
||||
}
|
||||
|
||||
internal static bool CheckIsWithinPath(string parent, string child)
|
||||
{
|
||||
if (parent is null || child is null)
|
||||
return false;
|
||||
|
||||
parent = parent.Replace('/', '\\');
|
||||
if (!parent.EndsWith("\\"))
|
||||
parent += "\\";
|
||||
parent = Path.GetFullPath(parent);
|
||||
|
||||
child = child.Replace('/', '\\');
|
||||
if (!child.EndsWith("\\"))
|
||||
child += "\\";
|
||||
child = Path.GetFullPath(child);
|
||||
|
||||
return child.StartsWith(parent, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
18
BililiveRecorder.Core/Recording/RecordTaskFactory.cs
Normal file
18
BililiveRecorder.Core/Recording/RecordTaskFactory.cs
Normal file
|
@ -0,0 +1,18 @@
|
|||
using System;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace BililiveRecorder.Core.Recording
|
||||
{
|
||||
public class RecordTaskFactory : IRecordTaskFactory
|
||||
{
|
||||
private readonly IServiceProvider serviceProvider;
|
||||
|
||||
public RecordTaskFactory(IServiceProvider serviceProvider)
|
||||
{
|
||||
this.serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
}
|
||||
|
||||
public IRecordTask CreateRecordTask(IRoom room) =>
|
||||
ActivatorUtilities.CreateInstance<RecordTask>(this.serviceProvider, room);
|
||||
}
|
||||
}
|
11
BililiveRecorder.Core/Recording/TagGroupReaderFactory.cs
Normal file
11
BililiveRecorder.Core/Recording/TagGroupReaderFactory.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
using BililiveRecorder.Flv;
|
||||
using BililiveRecorder.Flv.Grouping;
|
||||
|
||||
namespace BililiveRecorder.Core.Recording
|
||||
{
|
||||
public class TagGroupReaderFactory : ITagGroupReaderFactory
|
||||
{
|
||||
public ITagGroupReader CreateTagGroupReader(IFlvTagReader flvTagReader) =>
|
||||
new TagGroupReader(flvTagReader);
|
||||
}
|
||||
}
|
52
BililiveRecorder.Core/RecordingStats.cs
Normal file
52
BililiveRecorder.Core/RecordingStats.cs
Normal file
|
@ -0,0 +1,52 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
#nullable enable
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public class RecordingStats : INotifyPropertyChanged
|
||||
{
|
||||
private TimeSpan sessionMaxTimestamp;
|
||||
private TimeSpan fileMaxTimestamp;
|
||||
private TimeSpan sessionDuration;
|
||||
private double networkMbps;
|
||||
private long totalInputBytes;
|
||||
private long totalOutputBytes;
|
||||
private double duraionRatio;
|
||||
|
||||
public TimeSpan SessionDuration { get => this.sessionDuration; set => this.SetField(ref this.sessionDuration, value); }
|
||||
public TimeSpan SessionMaxTimestamp { get => this.sessionMaxTimestamp; set => this.SetField(ref this.sessionMaxTimestamp, value); }
|
||||
public TimeSpan FileMaxTimestamp { get => this.fileMaxTimestamp; set => this.SetField(ref this.fileMaxTimestamp, value); }
|
||||
|
||||
public double DuraionRatio { get => this.duraionRatio; set => this.SetField(ref this.duraionRatio, value); }
|
||||
|
||||
public long TotalInputBytes { get => this.totalInputBytes; set => this.SetField(ref this.totalInputBytes, value); }
|
||||
public long TotalOutputBytes { get => this.totalOutputBytes; set => this.SetField(ref this.totalOutputBytes, value); }
|
||||
|
||||
public double NetworkMbps { get => this.networkMbps; set => this.SetField(ref this.networkMbps, value); }
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
this.SessionDuration = TimeSpan.Zero;
|
||||
this.SessionMaxTimestamp = TimeSpan.Zero;
|
||||
this.FileMaxTimestamp = TimeSpan.Zero;
|
||||
this.DuraionRatio = 0;
|
||||
this.TotalInputBytes = 0;
|
||||
this.TotalOutputBytes = 0;
|
||||
this.NetworkMbps = 0;
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
protected virtual void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
protected bool SetField<T>(ref T location, T value, [CallerMemberName] string propertyName = "")
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(location, value))
|
||||
return false;
|
||||
location = value;
|
||||
this.OnPropertyChanged(propertyName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
29
BililiveRecorder.Core/RecyclableMemoryStreamProvider.cs
Normal file
29
BililiveRecorder.Core/RecyclableMemoryStreamProvider.cs
Normal file
|
@ -0,0 +1,29 @@
|
|||
using System.IO;
|
||||
using BililiveRecorder.Flv;
|
||||
using Microsoft.IO;
|
||||
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public class RecyclableMemoryStreamProvider : IMemoryStreamProvider
|
||||
{
|
||||
private readonly RecyclableMemoryStreamManager manager = new RecyclableMemoryStreamManager(32 * 1024, 64 * 1024, 64 * 1024 * 32)
|
||||
{
|
||||
MaximumFreeSmallPoolBytes = 64 * 1024 * 1024,
|
||||
MaximumFreeLargePoolBytes = 64 * 1024 * 32,
|
||||
};
|
||||
|
||||
public RecyclableMemoryStreamProvider()
|
||||
{
|
||||
//manager.StreamFinalized += () =>
|
||||
//{
|
||||
// Debug.WriteLine("TestRecyclableMemoryStreamProvider: Stream Finalized");
|
||||
//};
|
||||
//manager.StreamDisposed += () =>
|
||||
//{
|
||||
// // Debug.WriteLine("TestRecyclableMemoryStreamProvider: Stream Disposed");
|
||||
//};
|
||||
}
|
||||
|
||||
public Stream CreateMemoryStream(string tag) => this.manager.GetStream(tag);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
488
BililiveRecorder.Core/Room.cs
Normal file
488
BililiveRecorder.Core/Room.cs
Normal file
|
@ -0,0 +1,488 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Api;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
using BililiveRecorder.Core.Danmaku;
|
||||
using BililiveRecorder.Core.Event;
|
||||
using BililiveRecorder.Core.Recording;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Serilog;
|
||||
using Timer = System.Timers.Timer;
|
||||
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public class Room : IRoom
|
||||
{
|
||||
private readonly object recordStartLock = new object();
|
||||
private readonly SemaphoreSlim recordRetryDelaySemaphoreSlim = new SemaphoreSlim(1, 1);
|
||||
private readonly Timer timer;
|
||||
|
||||
private readonly IServiceScope scope;
|
||||
private readonly ILogger loggerWithoutContext;
|
||||
private readonly IDanmakuClient danmakuClient;
|
||||
private readonly IApiClient apiClient;
|
||||
private readonly IBasicDanmakuWriter basicDanmakuWriter;
|
||||
private readonly IRecordTaskFactory recordTaskFactory;
|
||||
private readonly CancellationTokenSource cts;
|
||||
private readonly CancellationToken ct;
|
||||
|
||||
private ILogger logger;
|
||||
private bool disposedValue;
|
||||
|
||||
private int shortId;
|
||||
private string name = string.Empty;
|
||||
private string title = string.Empty;
|
||||
private string areaNameParent = string.Empty;
|
||||
private string areaNameChild = string.Empty;
|
||||
private bool streaming;
|
||||
private bool danmakuConnected;
|
||||
|
||||
private IRecordTask? recordTask;
|
||||
private DateTimeOffset recordTaskStartTime;
|
||||
|
||||
public Room(IServiceScope scope, RoomConfig roomConfig, ILogger logger, IDanmakuClient danmakuClient, IApiClient apiClient, IBasicDanmakuWriter basicDanmakuWriter, IRecordTaskFactory recordTaskFactory)
|
||||
{
|
||||
this.scope = scope ?? throw new ArgumentNullException(nameof(scope));
|
||||
this.RoomConfig = roomConfig ?? throw new ArgumentNullException(nameof(roomConfig));
|
||||
this.loggerWithoutContext = logger?.ForContext<Room>() ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.logger = this.loggerWithoutContext.ForContext(LoggingContext.RoomId, this.RoomConfig.RoomId);
|
||||
this.danmakuClient = danmakuClient ?? throw new ArgumentNullException(nameof(danmakuClient));
|
||||
this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
|
||||
this.basicDanmakuWriter = basicDanmakuWriter ?? throw new ArgumentNullException(nameof(basicDanmakuWriter));
|
||||
this.recordTaskFactory = recordTaskFactory ?? throw new ArgumentNullException(nameof(recordTaskFactory));
|
||||
|
||||
this.timer = new Timer(this.RoomConfig.TimingCheckInterval * 1000);
|
||||
this.cts = new CancellationTokenSource();
|
||||
this.ct = this.cts.Token;
|
||||
|
||||
this.RoomConfig.PropertyChanged += this.RoomConfig_PropertyChanged;
|
||||
this.timer.Elapsed += this.Timer_Elapsed;
|
||||
this.danmakuClient.StatusChanged += this.DanmakuClient_StatusChanged;
|
||||
this.danmakuClient.DanmakuReceived += this.DanmakuClient_DanmakuReceived;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
await this.RefreshRoomInfoAsync();
|
||||
});
|
||||
}
|
||||
|
||||
public int ShortId { get => this.shortId; private set => this.SetField(ref this.shortId, value); }
|
||||
public string Name { get => this.name; private set => this.SetField(ref this.name, value); }
|
||||
public string Title { get => this.title; private set => this.SetField(ref this.title, value); }
|
||||
public string AreaNameParent { get => this.areaNameParent; private set => this.SetField(ref this.areaNameParent, value); }
|
||||
public string AreaNameChild { get => this.areaNameChild; private set => this.SetField(ref this.areaNameChild, value); }
|
||||
public bool Streaming
|
||||
{
|
||||
get => this.streaming;
|
||||
private set
|
||||
{
|
||||
if (value == this.streaming) return;
|
||||
|
||||
// 从未开播状态切换为开播状态时重置允许录制状态
|
||||
var triggerRecord = value && !this.streaming;
|
||||
if (triggerRecord)
|
||||
this.AutoRecordAllowedForThisSession = true;
|
||||
|
||||
this.streaming = value;
|
||||
this.OnPropertyChanged(nameof(this.Streaming));
|
||||
if (triggerRecord && this.RoomConfig.AutoRecord)
|
||||
_ = Task.Run(() => this.CreateAndStartNewRecordTask());
|
||||
}
|
||||
}
|
||||
public bool DanmakuConnected { get => this.danmakuConnected; private set => this.SetField(ref this.danmakuConnected, value); }
|
||||
public bool Recording => this.recordTask != null;
|
||||
|
||||
public bool AutoRecordAllowedForThisSession { get; private set; }
|
||||
|
||||
public RoomConfig RoomConfig { get; }
|
||||
public RecordingStats Stats { get; } = new RecordingStats();
|
||||
|
||||
public Guid ObjectId { get; } = Guid.NewGuid();
|
||||
|
||||
public event EventHandler<RecordSessionStartedEventArgs>? RecordSessionStarted;
|
||||
public event EventHandler<RecordSessionEndedEventArgs>? RecordSessionEnded;
|
||||
public event EventHandler<RecordFileOpeningEventArgs>? RecordFileOpening;
|
||||
public event EventHandler<RecordFileClosedEventArgs>? RecordFileClosed;
|
||||
public event EventHandler<NetworkingStatsEventArgs>? NetworkingStats;
|
||||
public event EventHandler<RecordingStatsEventArgs>? RecordingStats;
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public void SplitOutput()
|
||||
{
|
||||
lock (this.recordStartLock)
|
||||
{
|
||||
this.recordTask?.SplitOutput();
|
||||
}
|
||||
}
|
||||
|
||||
public void StartRecord()
|
||||
{
|
||||
lock (this.recordStartLock)
|
||||
{
|
||||
this.AutoRecordAllowedForThisSession = true;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await this.FetchRoomInfoThenCreateRecordTaskAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger.Warning(ex, "尝试开始录制时出错");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void StopRecord()
|
||||
{
|
||||
lock (this.recordStartLock)
|
||||
{
|
||||
this.AutoRecordAllowedForThisSession = false;
|
||||
|
||||
if (this.recordTask == null)
|
||||
return;
|
||||
|
||||
this.recordTask.RequestStop();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RefreshRoomInfoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await this.FetchUserInfoAsync().ConfigureAwait(false);
|
||||
await this.FetchRoomInfoAsync().ConfigureAwait(false);
|
||||
this.StartDamakuConnection(delay: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger.Warning(ex, "刷新房间信息时出错");
|
||||
}
|
||||
}
|
||||
|
||||
#region Recording
|
||||
|
||||
/// <exception cref="Exception"/>
|
||||
private async Task FetchRoomInfoAsync()
|
||||
{
|
||||
var room = (await this.apiClient.GetRoomInfoAsync(this.RoomConfig.RoomId).ConfigureAwait(false)).Data;
|
||||
if (room != null)
|
||||
{
|
||||
this.RoomConfig.RoomId = room.RoomId;
|
||||
this.ShortId = room.ShortId;
|
||||
this.Title = room.Title;
|
||||
this.AreaNameParent = room.ParentAreaName;
|
||||
this.AreaNameChild = room.AreaName;
|
||||
this.Streaming = room.LiveStatus == 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <exception cref="Exception"/>
|
||||
private async Task FetchUserInfoAsync()
|
||||
{
|
||||
var user = await this.apiClient.GetUserInfoAsync(this.RoomConfig.RoomId).ConfigureAwait(false);
|
||||
this.Name = user.Data?.Info?.Name ?? this.Name;
|
||||
}
|
||||
|
||||
/// <exception cref="Exception"/>
|
||||
private async Task FetchRoomInfoThenCreateRecordTaskAsync()
|
||||
{
|
||||
await this.FetchRoomInfoAsync().ConfigureAwait(false);
|
||||
this.CreateAndStartNewRecordTask();
|
||||
}
|
||||
|
||||
///
|
||||
private void CreateAndStartNewRecordTask()
|
||||
{
|
||||
lock (this.recordStartLock)
|
||||
{
|
||||
if (this.disposedValue)
|
||||
return;
|
||||
|
||||
if (!this.Streaming)
|
||||
return;
|
||||
|
||||
if (this.recordTask != null)
|
||||
return;
|
||||
|
||||
var task = this.recordTaskFactory.CreateRecordTask(this);
|
||||
task.NetworkingStats += this.RecordTask_NetworkingStats;
|
||||
task.RecordingStats += this.RecordTask_RecordingStats;
|
||||
task.RecordFileOpening += this.RecordTask_RecordFileOpening;
|
||||
task.RecordFileClosed += this.RecordTask_RecordFileClosed;
|
||||
task.RecordSessionEnded += this.RecordTask_RecordSessionEnded;
|
||||
this.recordTask = task;
|
||||
this.recordTaskStartTime = DateTimeOffset.UtcNow;
|
||||
this.OnPropertyChanged(nameof(this.Recording));
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await this.recordTask.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("启动录制出错 " + ex.ToString());
|
||||
this.recordTask = null;
|
||||
_ = Task.Run(async () => await this.TryRestartRecordingAsync().ConfigureAwait(false));
|
||||
this.OnPropertyChanged(nameof(this.Recording));
|
||||
return;
|
||||
}
|
||||
RecordSessionStarted?.Invoke(this, new RecordSessionStartedEventArgs(this)
|
||||
{
|
||||
SessionId = this.recordTask.SessionId
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
private async Task TryRestartRecordingAsync(bool delay = true)
|
||||
{
|
||||
if (this.AutoRecordAllowedForThisSession)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (delay)
|
||||
{
|
||||
if (!await this.recordRetryDelaySemaphoreSlim.WaitAsync(0).ConfigureAwait(false))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay((int)this.RoomConfig.TimingStreamRetry, this.ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.recordRetryDelaySemaphoreSlim.Release();
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.AutoRecordAllowedForThisSession)
|
||||
return;
|
||||
|
||||
await this.FetchRoomInfoThenCreateRecordTaskAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger.Warning(ex, "重试开始录制时出错");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
private void StartDamakuConnection(bool delay = true) =>
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (delay)
|
||||
await Task.Delay((int)this.RoomConfig.TimingDanmakuRetry, this.ct).ConfigureAwait(false);
|
||||
|
||||
await this.danmakuClient.ConnectAsync(this.RoomConfig.RoomId, this.ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger.Warning(ex, "连接弹幕服务器时出错");
|
||||
|
||||
if (!this.ct.IsCancellationRequested)
|
||||
this.StartDamakuConnection();
|
||||
}
|
||||
});
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
|
||||
///
|
||||
private void RecordTask_NetworkingStats(object sender, NetworkingStatsEventArgs e)
|
||||
{
|
||||
this.logger.Verbose("Networking stats: {@stats}", e);
|
||||
|
||||
this.Stats.NetworkMbps = e.Mbps;
|
||||
|
||||
NetworkingStats?.Invoke(this, e);
|
||||
}
|
||||
|
||||
///
|
||||
private void RecordTask_RecordingStats(object sender, RecordingStatsEventArgs e)
|
||||
{
|
||||
this.logger.Verbose("Recording stats: {@stats}", e);
|
||||
|
||||
var diff = DateTimeOffset.UtcNow - this.recordTaskStartTime;
|
||||
this.Stats.SessionDuration = diff.Subtract(TimeSpan.FromMilliseconds(diff.Milliseconds));
|
||||
this.Stats.FileMaxTimestamp = TimeSpan.FromMilliseconds(e.FileMaxTimestamp);
|
||||
this.Stats.SessionMaxTimestamp = TimeSpan.FromMilliseconds(e.SessionMaxTimestamp);
|
||||
this.Stats.DuraionRatio = e.DuraionRatio;
|
||||
|
||||
this.Stats.TotalInputBytes = e.TotalInputVideoByteCount + e.TotalInputAudioByteCount;
|
||||
this.Stats.TotalOutputBytes = e.TotalOutputVideoByteCount + e.TotalOutputAudioByteCount;
|
||||
|
||||
RecordingStats?.Invoke(this, e);
|
||||
}
|
||||
|
||||
///
|
||||
private void RecordTask_RecordFileClosed(object sender, RecordFileClosedEventArgs e)
|
||||
{
|
||||
this.basicDanmakuWriter.Disable();
|
||||
RecordFileClosed?.Invoke(this, e);
|
||||
}
|
||||
|
||||
///
|
||||
private void RecordTask_RecordFileOpening(object sender, RecordFileOpeningEventArgs e)
|
||||
{
|
||||
if (this.RoomConfig.RecordDanmaku)
|
||||
this.basicDanmakuWriter.EnableWithPath(Path.ChangeExtension(e.FullPath, "xml"), this);
|
||||
RecordFileOpening?.Invoke(this, e);
|
||||
}
|
||||
|
||||
///
|
||||
private void RecordTask_RecordSessionEnded(object sender, EventArgs e)
|
||||
{
|
||||
Guid id;
|
||||
lock (this.recordStartLock)
|
||||
{
|
||||
id = this.recordTask?.SessionId ?? default;
|
||||
this.recordTask = null;
|
||||
_ = Task.Run(async () => await this.TryRestartRecordingAsync().ConfigureAwait(false));
|
||||
}
|
||||
|
||||
this.OnPropertyChanged(nameof(this.Recording));
|
||||
RecordSessionEnded?.Invoke(this, new RecordSessionEndedEventArgs(this)
|
||||
{
|
||||
SessionId = id
|
||||
});
|
||||
}
|
||||
|
||||
private void DanmakuClient_DanmakuReceived(object sender, Api.Danmaku.DanmakuReceivedEventArgs e)
|
||||
{
|
||||
var d = e.Danmaku;
|
||||
|
||||
switch (d.MsgType)
|
||||
{
|
||||
case Api.Danmaku.DanmakuMsgType.LiveStart:
|
||||
this.Streaming = true;
|
||||
break;
|
||||
case Api.Danmaku.DanmakuMsgType.LiveEnd:
|
||||
this.Streaming = false;
|
||||
break;
|
||||
case Api.Danmaku.DanmakuMsgType.RoomChange:
|
||||
this.Title = d.Title ?? this.Title;
|
||||
this.AreaNameParent = d.ParentAreaName ?? this.AreaNameParent;
|
||||
this.AreaNameChild = d.AreaName ?? this.AreaNameChild;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
this.basicDanmakuWriter.Write(d);
|
||||
}
|
||||
|
||||
private void DanmakuClient_StatusChanged(object sender, Api.Danmaku.StatusChangedEventArgs e)
|
||||
{
|
||||
if (e.Connected)
|
||||
{
|
||||
this.DanmakuConnected = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.DanmakuConnected = false;
|
||||
this.StartDamakuConnection();
|
||||
}
|
||||
}
|
||||
|
||||
private void Timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
|
||||
{
|
||||
if (this.RoomConfig.AutoRecord)
|
||||
_ = Task.Run(async () => await this.TryRestartRecordingAsync(delay: false).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
private void RoomConfig_PropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
switch (e.PropertyName)
|
||||
{
|
||||
case nameof(this.RoomConfig.RoomId):
|
||||
this.logger = this.loggerWithoutContext.ForContext(LoggingContext.RoomId, this.RoomConfig.RoomId);
|
||||
break;
|
||||
case nameof(this.RoomConfig.TimingCheckInterval):
|
||||
this.timer.Interval = this.RoomConfig.TimingCheckInterval * 1000;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PropertyChanged
|
||||
|
||||
protected void SetField<T>(ref T location, T value, [CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(location, value))
|
||||
return;
|
||||
|
||||
location = value;
|
||||
|
||||
if (propertyName != null)
|
||||
this.OnPropertyChanged(propertyName);
|
||||
}
|
||||
|
||||
protected void OnPropertyChanged(string propertyName) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName: propertyName));
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dispose
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!this.disposedValue)
|
||||
{
|
||||
this.disposedValue = true;
|
||||
if (disposing)
|
||||
{
|
||||
// dispose managed state (managed objects)
|
||||
this.cts.Cancel();
|
||||
this.cts.Dispose();
|
||||
this.recordTask?.RequestStop();
|
||||
this.basicDanmakuWriter.Disable();
|
||||
this.scope.Dispose();
|
||||
}
|
||||
|
||||
// free unmanaged resources (unmanaged objects) and override finalizer
|
||||
// set large fields to null
|
||||
}
|
||||
}
|
||||
|
||||
// override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
|
||||
// ~Room()
|
||||
// {
|
||||
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
|
||||
// Dispose(disposing: false);
|
||||
// }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
|
||||
this.Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
24
BililiveRecorder.Core/RoomFactory.cs
Normal file
24
BililiveRecorder.Core/RoomFactory.cs
Normal file
|
@ -0,0 +1,24 @@
|
|||
using System;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public class RoomFactory : IRoomFactory
|
||||
{
|
||||
private readonly IServiceProvider serviceProvider;
|
||||
|
||||
public RoomFactory(IServiceProvider serviceProvider)
|
||||
{
|
||||
this.serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public IRoom CreateRoom(RoomConfig roomConfig)
|
||||
{
|
||||
var scope = this.serviceProvider.CreateScope();
|
||||
var sp = scope.ServiceProvider;
|
||||
|
||||
return ActivatorUtilities.CreateInstance<Room>(sp, scope, roomConfig);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -5,30 +5,29 @@ using System.Text;
|
|||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
using Newtonsoft.Json;
|
||||
using NLog;
|
||||
using Serilog;
|
||||
|
||||
#nullable enable
|
||||
namespace BililiveRecorder.Core.Callback
|
||||
namespace BililiveRecorder.Core.SimpleWebhook
|
||||
{
|
||||
public class BasicWebhook
|
||||
public class BasicWebhookV1
|
||||
{
|
||||
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
|
||||
private static readonly ILogger logger = Log.ForContext<BasicWebhookV1>();
|
||||
private static readonly HttpClient client;
|
||||
|
||||
private readonly ConfigV2 Config;
|
||||
|
||||
static BasicWebhook()
|
||||
static BasicWebhookV1()
|
||||
{
|
||||
client = new HttpClient();
|
||||
client.DefaultRequestHeaders.Add("User-Agent", $"BililiveRecorder/{typeof(BasicWebhook).Assembly.GetName().Version}-{BuildInfo.HeadShaShort}");
|
||||
client.DefaultRequestHeaders.Add("User-Agent", $"BililiveRecorder/{typeof(BasicWebhookV1).Assembly.GetName().Version}-{BuildInfo.HeadShaShort}");
|
||||
}
|
||||
|
||||
public BasicWebhook(ConfigV2 config)
|
||||
public BasicWebhookV1(ConfigV2 config)
|
||||
{
|
||||
this.Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
}
|
||||
|
||||
public async void Send(RecordEndData data)
|
||||
public async Task SendAsync(RecordEndData data)
|
||||
{
|
||||
var urls = this.Config.Global.WebHookUrls;
|
||||
if (string.IsNullOrWhiteSpace(urls)) return;
|
||||
|
@ -57,7 +56,7 @@ namespace BililiveRecorder.Core.Callback
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warn(ex, "发送 Webhook 到 {url} 失败", url);
|
||||
logger.Warning(ex, "发送 Webhook 到 {Url} 失败", url);
|
||||
}
|
||||
}
|
||||
}
|
73
BililiveRecorder.Core/SimpleWebhook/BasicWebhookV2.cs
Normal file
73
BililiveRecorder.Core/SimpleWebhook/BasicWebhookV2.cs
Normal file
|
@ -0,0 +1,73 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
using BililiveRecorder.Core.Event;
|
||||
using Newtonsoft.Json;
|
||||
using Serilog;
|
||||
|
||||
namespace BililiveRecorder.Core.SimpleWebhook
|
||||
{
|
||||
public class BasicWebhookV2
|
||||
{
|
||||
private static readonly ILogger logger = Log.ForContext<BasicWebhookV2>();
|
||||
|
||||
private readonly HttpClient client;
|
||||
private readonly GlobalConfig config;
|
||||
|
||||
public BasicWebhookV2(GlobalConfig config)
|
||||
{
|
||||
this.config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
|
||||
this.client = new HttpClient();
|
||||
this.client.DefaultRequestHeaders.Add("User-Agent", $"BililiveRecorder/{typeof(BasicWebhookV1).Assembly.GetName().Version}-{BuildInfo.HeadShaShort}");
|
||||
}
|
||||
|
||||
public Task SendSessionStartedAsync(RecordSessionStartedEventArgs args) =>
|
||||
this.SendAsync(new EventWrapper<RecordSessionStartedEventArgs>(args) { EventType = EventType.SessionStarted });
|
||||
|
||||
public Task SendSessionEndedAsync(RecordSessionEndedEventArgs args) =>
|
||||
this.SendAsync(new EventWrapper<RecordSessionEndedEventArgs>(args) { EventType = EventType.SessionEnded });
|
||||
|
||||
public Task SendFileOpeningAsync(RecordFileOpeningEventArgs args) =>
|
||||
this.SendAsync(new EventWrapper<RecordFileOpeningEventArgs>(args) { EventType = EventType.FileOpening });
|
||||
|
||||
public Task SendFileClosedAsync(RecordFileClosedEventArgs args) =>
|
||||
this.SendAsync(new EventWrapper<RecordFileClosedEventArgs>(args) { EventType = EventType.FileClosed });
|
||||
|
||||
private async Task SendAsync(object data)
|
||||
{
|
||||
var urls = this.config.WebHookUrlsV2;
|
||||
if (string.IsNullOrWhiteSpace(urls)) return;
|
||||
|
||||
var dataStr = JsonConvert.SerializeObject(data, Formatting.None);
|
||||
using var body = new ByteArrayContent(Encoding.UTF8.GetBytes(dataStr));
|
||||
body.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
|
||||
var tasks = urls!
|
||||
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(x => x.Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(x => this.SendImplAsync(x, body));
|
||||
|
||||
await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task SendImplAsync(string url, HttpContent data)
|
||||
{
|
||||
for (var i = 0; i < 3; i++)
|
||||
try
|
||||
{
|
||||
var result = await this.client.PostAsync(url, data).ConfigureAwait(false);
|
||||
result.EnsureSuccessStatusCode();
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warning(ex, "发送 Webhook 到 {Url} 失败", url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
15
BililiveRecorder.Core/SimpleWebhook/EventType.cs
Normal file
15
BililiveRecorder.Core/SimpleWebhook/EventType.cs
Normal file
|
@ -0,0 +1,15 @@
|
|||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace BililiveRecorder.Core.SimpleWebhook
|
||||
{
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public enum EventType
|
||||
{
|
||||
Unknown,
|
||||
SessionStarted,
|
||||
SessionEnded,
|
||||
FileOpening,
|
||||
FileClosed,
|
||||
}
|
||||
}
|
24
BililiveRecorder.Core/SimpleWebhook/EventWrapper.cs
Normal file
24
BililiveRecorder.Core/SimpleWebhook/EventWrapper.cs
Normal file
|
@ -0,0 +1,24 @@
|
|||
using System;
|
||||
|
||||
namespace BililiveRecorder.Core.SimpleWebhook
|
||||
{
|
||||
public class EventWrapper<T> where T : class
|
||||
{
|
||||
public EventWrapper()
|
||||
{
|
||||
}
|
||||
|
||||
public EventWrapper(T data)
|
||||
{
|
||||
this.EventData = data;
|
||||
}
|
||||
|
||||
public EventType EventType { get; set; }
|
||||
|
||||
public DateTimeOffset EventTimestamp { get; set; } = DateTimeOffset.Now;
|
||||
|
||||
public Guid EventId { get; set; } = Guid.NewGuid();
|
||||
|
||||
public T? EventData { get; set; }
|
||||
}
|
||||
}
|
33
BililiveRecorder.Core/SimpleWebhook/RecordEndData.cs
Normal file
33
BililiveRecorder.Core/SimpleWebhook/RecordEndData.cs
Normal file
|
@ -0,0 +1,33 @@
|
|||
using System;
|
||||
using BililiveRecorder.Core.Event;
|
||||
|
||||
#nullable enable
|
||||
namespace BililiveRecorder.Core.SimpleWebhook
|
||||
{
|
||||
public class RecordEndData
|
||||
{
|
||||
public RecordEndData(RecordFileClosedEventArgs args)
|
||||
{
|
||||
if (args is null)
|
||||
throw new ArgumentNullException(nameof(args));
|
||||
|
||||
this.RoomId = args.RoomId;
|
||||
this.Name = args.Name;
|
||||
this.Title = args.Title;
|
||||
this.RelativePath = args.RelativePath;
|
||||
this.FileSize = args.FileSize;
|
||||
this.StartRecordTime = args.FileOpenTime;
|
||||
this.EndRecordTime = args.FileCloseTime;
|
||||
}
|
||||
|
||||
public Guid EventRandomId { get; set; } = Guid.NewGuid();
|
||||
|
||||
public int RoomId { get; set; } = 0;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string RelativePath { get; set; } = string.Empty;
|
||||
public long FileSize { get; set; }
|
||||
public DateTimeOffset StartRecordTime { get; set; }
|
||||
public DateTimeOffset EndRecordTime { get; set; }
|
||||
}
|
||||
}
|
40
BililiveRecorder.Core/StreamExtensions.cs
Normal file
40
BililiveRecorder.Core/StreamExtensions.cs
Normal file
|
@ -0,0 +1,40 @@
|
|||
using System;
|
||||
using System.Buffers;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
internal static class StreamExtensions
|
||||
{
|
||||
// modified from dotnet/runtime 8a52f1e948b6f22f418817ec1068f07b8dae2aa5
|
||||
// file: src/libraries/System.Private.CoreLib/src/System/IO/Stream.cs
|
||||
// licensed under the MIT license
|
||||
public static ValueTask<int> ReadAsync(this Stream stream, Memory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (MemoryMarshal.TryGetArray(buffer, out ArraySegment<byte> array))
|
||||
{
|
||||
return new ValueTask<int>(stream.ReadAsync(array.Array!, array.Offset, array.Count, cancellationToken));
|
||||
}
|
||||
|
||||
var sharedBuffer = ArrayPool<byte>.Shared.Rent(buffer.Length);
|
||||
return FinishReadAsync(stream.ReadAsync(sharedBuffer, 0, buffer.Length, cancellationToken), sharedBuffer, buffer);
|
||||
|
||||
static async ValueTask<int> FinishReadAsync(Task<int> readTask, byte[] localBuffer, Memory<byte> localDestination)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await readTask.ConfigureAwait(false);
|
||||
new ReadOnlySpan<byte>(localBuffer, 0, result).CopyTo(localDestination.Span);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(localBuffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,463 +0,0 @@
|
|||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Core.Config.V2;
|
||||
using Newtonsoft.Json;
|
||||
using NLog;
|
||||
using Timer = System.Timers.Timer;
|
||||
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
/**
|
||||
* 直播状态监控
|
||||
* 分为弹幕连接和HTTP轮询两部分
|
||||
*
|
||||
* 弹幕连接:
|
||||
* 一直保持连接,并把收到的弹幕保存到数据库
|
||||
*
|
||||
* HTTP轮询:
|
||||
* 只有在监控启动时运行,根据直播状态触发事件
|
||||
*
|
||||
*
|
||||
* */
|
||||
public class StreamMonitor : IStreamMonitor
|
||||
{
|
||||
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
|
||||
|
||||
private readonly RoomConfig roomConfig;
|
||||
private readonly BililiveAPI bililiveAPI;
|
||||
|
||||
private Exception dmError = null;
|
||||
private TcpClient dmClient;
|
||||
private NetworkStream dmNetStream;
|
||||
private Thread dmReceiveMessageLoopThread;
|
||||
private readonly CancellationTokenSource dmTokenSource = null;
|
||||
private bool dmConnectionTriggered = false;
|
||||
private readonly Timer httpTimer;
|
||||
|
||||
private int RoomId { get => this.roomConfig.RoomId; set => this.roomConfig.RoomId = value; }
|
||||
|
||||
public bool IsMonitoring { get; private set; } = false;
|
||||
public bool IsDanmakuConnected => this.dmClient?.Connected ?? false;
|
||||
public event RoomInfoUpdatedEvent RoomInfoUpdated;
|
||||
public event StreamStartedEvent StreamStarted;
|
||||
public event ReceivedDanmakuEvt ReceivedDanmaku;
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
public StreamMonitor(RoomConfig roomConfig, BililiveAPI bililiveAPI)
|
||||
{
|
||||
this.roomConfig = roomConfig;
|
||||
this.bililiveAPI = bililiveAPI;
|
||||
|
||||
ReceivedDanmaku += this.Receiver_ReceivedDanmaku;
|
||||
RoomInfoUpdated += this.StreamMonitor_RoomInfoUpdated;
|
||||
|
||||
this.dmTokenSource = new CancellationTokenSource();
|
||||
Repeat.Interval(TimeSpan.FromSeconds(30), () =>
|
||||
{
|
||||
if (this.dmNetStream != null && this.dmNetStream.CanWrite)
|
||||
{
|
||||
try
|
||||
{
|
||||
this.SendSocketData(2);
|
||||
}
|
||||
catch (Exception) { }
|
||||
}
|
||||
}, this.dmTokenSource.Token);
|
||||
|
||||
this.httpTimer = new Timer(roomConfig.TimingCheckInterval * 1000)
|
||||
{
|
||||
Enabled = false,
|
||||
AutoReset = true,
|
||||
SynchronizingObject = null,
|
||||
Site = null
|
||||
};
|
||||
this.httpTimer.Elapsed += (sender, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
this.Check(TriggerType.HttpApi);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Log(this.RoomId, LogLevel.Warn, "获取直播间开播状态出错", ex);
|
||||
}
|
||||
};
|
||||
|
||||
roomConfig.PropertyChanged += (sender, e) =>
|
||||
{
|
||||
if (e.PropertyName.Equals(nameof(roomConfig.TimingCheckInterval)))
|
||||
{
|
||||
this.httpTimer.Interval = roomConfig.TimingCheckInterval * 1000;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void StreamMonitor_RoomInfoUpdated(object sender, RoomInfoUpdatedArgs e)
|
||||
{
|
||||
this.RoomId = e.RoomInfo.RoomId;
|
||||
// TODO: RecordedRoom 里的 RoomInfoUpdated Handler 也会设置一次 RoomId
|
||||
// 暂时保持不变,此处需要使用请求返回的房间号连接弹幕服务器
|
||||
if (!this.dmConnectionTriggered)
|
||||
{
|
||||
this.dmConnectionTriggered = true;
|
||||
Task.Run(() => this.ConnectWithRetryAsync());
|
||||
}
|
||||
}
|
||||
|
||||
private void Receiver_ReceivedDanmaku(object sender, ReceivedDanmakuArgs e)
|
||||
{
|
||||
switch (e.Danmaku.MsgType)
|
||||
{
|
||||
case MsgTypeEnum.LiveStart:
|
||||
if (this.IsMonitoring)
|
||||
{
|
||||
Task.Run(() => StreamStarted?.Invoke(this, new StreamStartedArgs() { type = TriggerType.Danmaku }));
|
||||
}
|
||||
break;
|
||||
case MsgTypeEnum.LiveEnd:
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#region 对外API
|
||||
|
||||
public bool Start()
|
||||
{
|
||||
if (this.disposedValue)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(StreamMonitor));
|
||||
}
|
||||
|
||||
this.IsMonitoring = true;
|
||||
this.httpTimer.Start();
|
||||
this.Check(TriggerType.HttpApi);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (this.disposedValue)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(StreamMonitor));
|
||||
}
|
||||
|
||||
this.IsMonitoring = false;
|
||||
this.httpTimer.Stop();
|
||||
}
|
||||
|
||||
public void Check(TriggerType type, int millisecondsDelay = 0)
|
||||
{
|
||||
if (this.disposedValue)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(StreamMonitor));
|
||||
}
|
||||
|
||||
if (millisecondsDelay < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(millisecondsDelay), "不能小于0");
|
||||
}
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(millisecondsDelay).ConfigureAwait(false);
|
||||
if ((await this.FetchRoomInfoAsync().ConfigureAwait(false))?.IsStreaming ?? false)
|
||||
{
|
||||
StreamStarted?.Invoke(this, new StreamStartedArgs() { type = type });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<RoomInfo> FetchRoomInfoAsync()
|
||||
{
|
||||
RoomInfo roomInfo = await this.bililiveAPI.GetRoomInfoAsync(this.RoomId).ConfigureAwait(false);
|
||||
if (roomInfo != null)
|
||||
RoomInfoUpdated?.Invoke(this, new RoomInfoUpdatedArgs { RoomInfo = roomInfo });
|
||||
return roomInfo;
|
||||
}
|
||||
|
||||
#endregion
|
||||
#region 弹幕连接
|
||||
|
||||
private async Task ConnectWithRetryAsync()
|
||||
{
|
||||
bool connect_result = false;
|
||||
while (!this.IsDanmakuConnected && !this.dmTokenSource.Token.IsCancellationRequested)
|
||||
{
|
||||
logger.Log(this.RoomId, LogLevel.Info, "连接弹幕服务器...");
|
||||
connect_result = await this.ConnectAsync().ConfigureAwait(false);
|
||||
if (!connect_result)
|
||||
await Task.Delay((int)Math.Max(this.roomConfig.TimingDanmakuRetry, 0));
|
||||
}
|
||||
|
||||
if (connect_result)
|
||||
{
|
||||
logger.Log(this.RoomId, LogLevel.Info, "弹幕服务器连接成功");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ConnectAsync()
|
||||
{
|
||||
if (this.IsDanmakuConnected) { return true; }
|
||||
|
||||
try
|
||||
{
|
||||
var (token, host, port) = await this.bililiveAPI.GetDanmuConf(this.RoomId);
|
||||
|
||||
logger.Log(this.RoomId, LogLevel.Debug, $"连接弹幕服务器 {host}:{port} {(string.IsNullOrWhiteSpace(token) ? "无" : "有")} token");
|
||||
|
||||
this.dmClient = new TcpClient();
|
||||
await this.dmClient.ConnectAsync(host, port).ConfigureAwait(false);
|
||||
this.dmNetStream = this.dmClient.GetStream();
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected)));
|
||||
|
||||
this.dmReceiveMessageLoopThread = new Thread(this.ReceiveMessageLoop)
|
||||
{
|
||||
Name = "ReceiveMessageLoop " + this.RoomId,
|
||||
IsBackground = true
|
||||
};
|
||||
this.dmReceiveMessageLoopThread.Start();
|
||||
|
||||
var hello = JsonConvert.SerializeObject(new
|
||||
{
|
||||
uid = 0,
|
||||
roomid = this.RoomId,
|
||||
protover = 2,
|
||||
platform = "web",
|
||||
clientver = "1.11.0",
|
||||
type = 2,
|
||||
key = token,
|
||||
|
||||
}, Formatting.None);
|
||||
this.SendSocketData(7, hello);
|
||||
this.SendSocketData(2);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.dmError = ex;
|
||||
logger.Log(this.RoomId, LogLevel.Warn, "连接弹幕服务器错误", ex);
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReceiveMessageLoop()
|
||||
{
|
||||
logger.Log(this.RoomId, LogLevel.Trace, "ReceiveMessageLoop Started");
|
||||
try
|
||||
{
|
||||
var stableBuffer = new byte[16];
|
||||
var buffer = new byte[4096];
|
||||
while (this.IsDanmakuConnected)
|
||||
{
|
||||
this.dmNetStream.ReadB(stableBuffer, 0, 16);
|
||||
Parse2Protocol(stableBuffer, out DanmakuProtocol protocol);
|
||||
|
||||
if (protocol.PacketLength < 16)
|
||||
{
|
||||
throw new NotSupportedException("协议失败: (L:" + protocol.PacketLength + ")");
|
||||
}
|
||||
|
||||
var payloadlength = protocol.PacketLength - 16;
|
||||
if (payloadlength == 0)
|
||||
{
|
||||
continue;//没有内容了
|
||||
}
|
||||
|
||||
if (buffer.Length < payloadlength) // 不够长再申请
|
||||
{
|
||||
buffer = new byte[payloadlength];
|
||||
}
|
||||
|
||||
this.dmNetStream.ReadB(buffer, 0, payloadlength);
|
||||
|
||||
if (protocol.Version == 2 && protocol.Action == 5) // 处理deflate消息
|
||||
{
|
||||
// Skip 0x78 0xDA
|
||||
using (DeflateStream deflate = new DeflateStream(new MemoryStream(buffer, 2, payloadlength - 2), CompressionMode.Decompress))
|
||||
{
|
||||
while (deflate.Read(stableBuffer, 0, 16) > 0)
|
||||
{
|
||||
Parse2Protocol(stableBuffer, out protocol);
|
||||
payloadlength = protocol.PacketLength - 16;
|
||||
if (payloadlength == 0)
|
||||
{
|
||||
continue; // 没有内容了
|
||||
}
|
||||
if (buffer.Length < payloadlength) // 不够长再申请
|
||||
{
|
||||
buffer = new byte[payloadlength];
|
||||
}
|
||||
deflate.Read(buffer, 0, payloadlength);
|
||||
ProcessDanmaku(protocol.Action, buffer, payloadlength);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ProcessDanmaku(protocol.Action, buffer, payloadlength);
|
||||
}
|
||||
|
||||
void ProcessDanmaku(int action, byte[] local_buffer, int length)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case 3:
|
||||
// var viewer = BitConverter.ToUInt32(local_buffer.Take(4).Reverse().ToArray(), 0); //观众人数
|
||||
break;
|
||||
case 5://playerCommand
|
||||
var json = Encoding.UTF8.GetString(local_buffer, 0, length);
|
||||
try
|
||||
{
|
||||
ReceivedDanmaku?.Invoke(this, new ReceivedDanmakuArgs() { Danmaku = new DanmakuModel(json) });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Log(this.RoomId, LogLevel.Warn, "", ex);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.dmError = ex;
|
||||
// logger.Error(ex);
|
||||
|
||||
logger.Log(this.RoomId, LogLevel.Debug, "Disconnected");
|
||||
this.dmClient?.Close();
|
||||
this.dmNetStream = null;
|
||||
if (!(this.dmTokenSource?.IsCancellationRequested ?? true))
|
||||
{
|
||||
logger.Log(this.RoomId, LogLevel.Warn, "弹幕连接被断开,将尝试重连", ex);
|
||||
Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay((int)Math.Max(this.roomConfig.TimingDanmakuRetry, 0));
|
||||
await this.ConnectWithRetryAsync();
|
||||
});
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected)));
|
||||
}
|
||||
}
|
||||
|
||||
private void SendSocketData(int action, string body = "")
|
||||
{
|
||||
const int param = 1;
|
||||
const short magic = 16;
|
||||
const short ver = 1;
|
||||
|
||||
var playload = Encoding.UTF8.GetBytes(body);
|
||||
var buffer = new byte[(playload.Length + 16)];
|
||||
|
||||
using (var ms = new MemoryStream(buffer))
|
||||
{
|
||||
var b = BitConverter.GetBytes(buffer.Length).ToBE();
|
||||
ms.Write(b, 0, 4);
|
||||
b = BitConverter.GetBytes(magic).ToBE();
|
||||
ms.Write(b, 0, 2);
|
||||
b = BitConverter.GetBytes(ver).ToBE();
|
||||
ms.Write(b, 0, 2);
|
||||
b = BitConverter.GetBytes(action).ToBE();
|
||||
ms.Write(b, 0, 4);
|
||||
b = BitConverter.GetBytes(param).ToBE();
|
||||
ms.Write(b, 0, 4);
|
||||
if (playload.Length > 0)
|
||||
{
|
||||
ms.Write(playload, 0, playload.Length);
|
||||
}
|
||||
this.dmNetStream.Write(buffer, 0, buffer.Length);
|
||||
this.dmNetStream.Flush();
|
||||
}
|
||||
}
|
||||
|
||||
private static unsafe void Parse2Protocol(byte[] buffer, out DanmakuProtocol protocol)
|
||||
{
|
||||
fixed (byte* ptr = buffer)
|
||||
{
|
||||
protocol = *(DanmakuProtocol*)ptr;
|
||||
}
|
||||
protocol.ChangeEndian();
|
||||
}
|
||||
|
||||
private struct DanmakuProtocol
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息总长度 (协议头 + 数据长度)
|
||||
/// </summary>
|
||||
public int PacketLength;
|
||||
/// <summary>
|
||||
/// 消息头长度 (固定为16[sizeof(DanmakuProtocol)])
|
||||
/// </summary>
|
||||
public short HeaderLength;
|
||||
/// <summary>
|
||||
/// 消息版本号
|
||||
/// </summary>
|
||||
public short Version;
|
||||
/// <summary>
|
||||
/// 消息类型
|
||||
/// </summary>
|
||||
public int Action;
|
||||
/// <summary>
|
||||
/// 参数, 固定为1
|
||||
/// </summary>
|
||||
public int Parameter;
|
||||
/// <summary>
|
||||
/// 转为本机字节序
|
||||
/// </summary>
|
||||
public void ChangeEndian()
|
||||
{
|
||||
this.PacketLength = IPAddress.HostToNetworkOrder(this.PacketLength);
|
||||
this.HeaderLength = IPAddress.HostToNetworkOrder(this.HeaderLength);
|
||||
this.Version = IPAddress.HostToNetworkOrder(this.Version);
|
||||
this.Action = IPAddress.HostToNetworkOrder(this.Action);
|
||||
this.Parameter = IPAddress.HostToNetworkOrder(this.Parameter);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
#region IDisposable Support
|
||||
private bool disposedValue = false; // 要检测冗余调用
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!this.disposedValue)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
this.dmTokenSource?.Cancel();
|
||||
this.dmTokenSource?.Dispose();
|
||||
this.httpTimer?.Dispose();
|
||||
this.dmClient?.Close();
|
||||
}
|
||||
|
||||
this.dmNetStream = null;
|
||||
this.disposedValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// 请勿更改此代码。将清理代码放入以上 Dispose(bool disposing) 中。
|
||||
this.Dispose(true);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public enum TriggerType
|
||||
{
|
||||
Danmaku,
|
||||
HttpApi,
|
||||
HttpApiRecheck,
|
||||
Manual,
|
||||
}
|
||||
}
|
|
@ -1,103 +0,0 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Sockets;
|
||||
using NLog;
|
||||
|
||||
namespace BililiveRecorder.Core
|
||||
{
|
||||
public static class Utils
|
||||
{
|
||||
internal static byte[] ToBE(this byte[] b)
|
||||
{
|
||||
if (BitConverter.IsLittleEndian)
|
||||
{
|
||||
return b.Reverse().ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
return b;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ReadB(this NetworkStream stream, byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (offset + count > buffer.Length)
|
||||
{
|
||||
throw new ArgumentException();
|
||||
}
|
||||
|
||||
int read = 0;
|
||||
while (read < count)
|
||||
{
|
||||
var available = stream.Read(buffer, offset, count - read);
|
||||
if (available == 0)
|
||||
{
|
||||
throw new ObjectDisposedException(null);
|
||||
}
|
||||
read += available;
|
||||
offset += available;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string RemoveInvalidFileName(this string name, bool ignore_slash = false)
|
||||
{
|
||||
foreach (char c in Path.GetInvalidFileNameChars())
|
||||
{
|
||||
if (ignore_slash && (c == '\\' || c == '/'))
|
||||
continue;
|
||||
name = name.Replace(c, '_');
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
public static bool CopyPropertiesTo<T>(this T source, T target) where T : class
|
||||
{
|
||||
if (source == null || target == null || source == target) { return false; }
|
||||
foreach (var p in source.GetType().GetProperties())
|
||||
{
|
||||
if (Attribute.IsDefined(p, typeof(DoNotCopyProperty)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var val = p.GetValue(source);
|
||||
if (val == null || !val.Equals(p.GetValue(target)))
|
||||
{
|
||||
p.SetValue(target, val);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
|
||||
public class DoNotCopyProperty : Attribute { }
|
||||
|
||||
internal static void Log(this Logger logger, int id, LogLevel level, string message, Exception exception = null)
|
||||
{
|
||||
var log = new LogEventInfo()
|
||||
{
|
||||
Level = level,
|
||||
Message = message,
|
||||
Exception = exception,
|
||||
};
|
||||
log.Properties["roomid"] = id;
|
||||
logger.Log(log);
|
||||
}
|
||||
|
||||
private static string _useragent;
|
||||
internal static string UserAgent
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_useragent))
|
||||
{
|
||||
string version = typeof(Utils).Assembly.GetName().Version.ToString();
|
||||
_useragent = $"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.122 Safari/537.36 BililiveRecorder/{version} (+https://github.com/Bililive/BililiveRecorder;bliverec@danmuji.org)";
|
||||
}
|
||||
return _useragent;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
|
@ -17,13 +19,23 @@ namespace BililiveRecorder.Flv.Amf
|
|||
DateTimeZoneHandling = DateTimeZoneHandling.RoundtripKind,
|
||||
};
|
||||
|
||||
public ScriptDataString Name { get; set; } = string.Empty;
|
||||
public ScriptTagBody()
|
||||
{
|
||||
this.Values = new List<IScriptDataValue>();
|
||||
}
|
||||
|
||||
public ScriptDataObject Value { get; set; } = new ScriptDataObject();
|
||||
public ScriptTagBody(List<IScriptDataValue> values)
|
||||
{
|
||||
this.Values = values ?? throw new ArgumentNullException(nameof(values));
|
||||
}
|
||||
|
||||
public static ScriptTagBody Parse(string json) => JsonConvert.DeserializeObject<ScriptTagBody>(json, jsonSerializerSettings)!;
|
||||
public List<IScriptDataValue> Values { get; set; }
|
||||
|
||||
public string ToJson() => JsonConvert.SerializeObject(this, jsonSerializerSettings);
|
||||
public static ScriptTagBody Parse(string json) =>
|
||||
new ScriptTagBody(JsonConvert.DeserializeObject<List<IScriptDataValue>>(json, jsonSerializerSettings)
|
||||
?? throw new Exception("JsonConvert.DeserializeObject returned null"));
|
||||
|
||||
public string ToJson() => JsonConvert.SerializeObject(this.Values, jsonSerializerSettings);
|
||||
|
||||
public static ScriptTagBody Parse(byte[] bytes)
|
||||
{
|
||||
|
@ -31,26 +43,16 @@ namespace BililiveRecorder.Flv.Amf
|
|||
return Parse(ms);
|
||||
}
|
||||
|
||||
public static ScriptTagBody Parse(Stream stream)
|
||||
{
|
||||
return Parse(new BigEndianBinaryReader(stream, Encoding.UTF8, true));
|
||||
}
|
||||
public static ScriptTagBody Parse(Stream stream) => Parse(new BigEndianBinaryReader(stream, Encoding.UTF8, true));
|
||||
|
||||
public static ScriptTagBody Parse(BigEndianBinaryReader binaryReader)
|
||||
{
|
||||
if (ParseValue(binaryReader) is ScriptDataString stringName)
|
||||
return new ScriptTagBody
|
||||
{
|
||||
Name = stringName,
|
||||
Value = ((ParseValue(binaryReader)) switch
|
||||
{
|
||||
ScriptDataEcmaArray value => value,
|
||||
ScriptDataObject value => value,
|
||||
_ => throw new AmfException("type of ScriptTagBody.Value is not supported"),
|
||||
})
|
||||
};
|
||||
else
|
||||
throw new AmfException("ScriptTagBody.Name is not String");
|
||||
var list = new List<IScriptDataValue>();
|
||||
|
||||
while (binaryReader.BaseStream.Position < binaryReader.BaseStream.Length)
|
||||
list.Add(ParseValue(binaryReader));
|
||||
|
||||
return new ScriptTagBody(list);
|
||||
}
|
||||
|
||||
public byte[] ToBytes()
|
||||
|
@ -62,8 +64,10 @@ namespace BililiveRecorder.Flv.Amf
|
|||
|
||||
public void WriteTo(Stream stream)
|
||||
{
|
||||
this.Name.WriteTo(stream);
|
||||
this.Value.WriteTo(stream);
|
||||
foreach (var value in this.Values)
|
||||
{
|
||||
value.WriteTo(stream);
|
||||
}
|
||||
}
|
||||
|
||||
public static IScriptDataValue ParseValue(BigEndianBinaryReader binaryReader)
|
||||
|
@ -144,6 +148,7 @@ namespace BililiveRecorder.Flv.Amf
|
|||
{
|
||||
var bytes = binaryReader.ReadBytes((int)length);
|
||||
var str = Encoding.UTF8.GetString(bytes);
|
||||
str = str.Replace("\0", "");
|
||||
return (ScriptDataLongString)str;
|
||||
}
|
||||
}
|
||||
|
@ -160,7 +165,7 @@ namespace BililiveRecorder.Flv.Amf
|
|||
throw new AmfException("ObjectEndMarker not matched.");
|
||||
return null;
|
||||
}
|
||||
return Encoding.UTF8.GetString(binaryReader.ReadBytes(length));
|
||||
return Encoding.UTF8.GetString(binaryReader.ReadBytes(length)).Replace("\0", ""); ;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -169,8 +174,7 @@ namespace BililiveRecorder.Flv.Amf
|
|||
{
|
||||
var str = reader.ReadElementContentAsString();
|
||||
var obj = Parse(str);
|
||||
this.Name = obj.Name;
|
||||
this.Value = obj.Value;
|
||||
this.Values = obj.Values;
|
||||
}
|
||||
void IXmlSerializable.WriteXml(XmlWriter writer) => writer.WriteString(this.ToJson());
|
||||
}
|
||||
|
|
7
BililiveRecorder.Flv/Amf/ScriptTagBodyExtensions.cs
Normal file
7
BililiveRecorder.Flv/Amf/ScriptTagBodyExtensions.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace BililiveRecorder.Flv.Amf
|
||||
{
|
||||
public static class ScriptTagBodyExtensions
|
||||
{
|
||||
public static ScriptDataEcmaArray? GetMetadataValue(this ScriptTagBody body) => body.Values.Count > 1 ? body.Values[1] as ScriptDataEcmaArray : null;
|
||||
}
|
||||
}
|
|
@ -16,6 +16,7 @@
|
|||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="System.IO.Pipelines" Version="5.0.1" />
|
||||
<PackageReference Include="System.Memory" Version="4.5.4" />
|
||||
</ItemGroup>
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
using BililiveRecorder.Flv.Pipeline;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace BililiveRecorder.DependencyInjection
|
||||
{
|
||||
public static class DependencyInjectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddFlv(this IServiceCollection services)
|
||||
{
|
||||
|
||||
return services;
|
||||
}
|
||||
public static IServiceCollection AddFlv(this IServiceCollection services) => services
|
||||
.AddTransient<IProcessingPipelineBuilder, ProcessingPipelineBuilder>()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
|
13
BililiveRecorder.Flv/FileClosedEventArgs.cs
Normal file
13
BililiveRecorder.Flv/FileClosedEventArgs.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
using System;
|
||||
|
||||
namespace BililiveRecorder.Flv
|
||||
{
|
||||
public class FileClosedEventArgs : EventArgs
|
||||
{
|
||||
public long FileSize { get; set; }
|
||||
|
||||
public double Duration { get; set; }
|
||||
|
||||
public object? State { get; set; }
|
||||
}
|
||||
}
|
|
@ -7,7 +7,8 @@ namespace BililiveRecorder.Flv.Grouping.Rules
|
|||
{
|
||||
public bool StartWith(Tag tag) => tag.IsData();
|
||||
|
||||
public bool AppendWith(Tag tag) => tag.IsNonKeyframeData();
|
||||
public bool AppendWith(Tag tag, List<Tag> tags) => tag.IsNonKeyframeData()
|
||||
|| (tag.Type == TagType.Audio && tag.Flag == TagFlag.Header && tags.TrueForAll(x => x.Type != TagType.Audio));
|
||||
|
||||
public PipelineAction CreatePipelineAction(List<Tag> tags) => new PipelineDataAction(tags);
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ namespace BililiveRecorder.Flv.Grouping.Rules
|
|||
{
|
||||
public bool StartWith(Tag tag) => tag.IsHeader();
|
||||
|
||||
public bool AppendWith(Tag tag) => tag.IsHeader();
|
||||
public bool AppendWith(Tag tag, List<Tag> tags) => tag.IsHeader();
|
||||
|
||||
public PipelineAction CreatePipelineAction(List<Tag> tags) => new PipelineHeaderAction(tags);
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ namespace BililiveRecorder.Flv.Grouping.Rules
|
|||
{
|
||||
public bool StartWith(Tag tag) => tag.IsScript();
|
||||
|
||||
public bool AppendWith(Tag tag) => false;
|
||||
public bool AppendWith(Tag tag, List<Tag> tags) => false;
|
||||
|
||||
public PipelineAction CreatePipelineAction(List<Tag> tags) => new PipelineScriptAction(tags.First());
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ namespace BililiveRecorder.Flv.Grouping
|
|||
};
|
||||
}
|
||||
|
||||
public async Task<PipelineAction?> ReadGroupAsync()
|
||||
public async Task<PipelineAction?> ReadGroupAsync(CancellationToken token)
|
||||
{
|
||||
if (!this.semaphoreSlim.Wait(0))
|
||||
{
|
||||
|
@ -44,7 +44,7 @@ namespace BililiveRecorder.Flv.Grouping
|
|||
{
|
||||
var tags = new List<Tag>();
|
||||
|
||||
var firstTag = await this.TagReader.ReadTagAsync().ConfigureAwait(false);
|
||||
var firstTag = await this.TagReader.ReadTagAsync(token).ConfigureAwait(false);
|
||||
|
||||
// 数据已经全部读完
|
||||
if (firstTag is null)
|
||||
|
@ -57,13 +57,13 @@ namespace BililiveRecorder.Flv.Grouping
|
|||
|
||||
tags.Add(firstTag);
|
||||
|
||||
while (true)
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var tag = await this.TagReader.PeekTagAsync().ConfigureAwait(false);
|
||||
var tag = await this.TagReader.PeekTagAsync(token).ConfigureAwait(false);
|
||||
|
||||
if (tag != null && rule.AppendWith(tag))
|
||||
if (tag != null && rule.AppendWith(tag, tags))
|
||||
{
|
||||
await this.TagReader.ReadTagAsync().ConfigureAwait(false);
|
||||
await this.TagReader.ReadTagAsync(token).ConfigureAwait(false);
|
||||
tags.Add(tag);
|
||||
}
|
||||
else
|
||||
|
@ -93,13 +93,13 @@ namespace BililiveRecorder.Flv.Grouping
|
|||
this.TagReader.Dispose();
|
||||
}
|
||||
|
||||
// TODO: free unmanaged resources (unmanaged objects) and override finalizer
|
||||
// TODO: set large fields to null
|
||||
// free unmanaged resources (unmanaged objects) and override finalizer
|
||||
// set large fields to null
|
||||
this.disposedValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
// // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
|
||||
// override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
|
||||
// ~TagGroupReader()
|
||||
// {
|
||||
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
|
||||
|
|
|
@ -1,11 +1,17 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Flv.Amf;
|
||||
using BililiveRecorder.Flv.Pipeline;
|
||||
|
||||
namespace BililiveRecorder.Flv
|
||||
{
|
||||
public interface IFlvProcessingContextWriter : IDisposable
|
||||
{
|
||||
Action<ScriptTagBody>? BeforeScriptTagWrite { get; set; }
|
||||
Action<ScriptTagBody>? BeforeScriptTagRewrite { get; set; }
|
||||
|
||||
event EventHandler<FileClosedEventArgs> FileClosed;
|
||||
|
||||
Task WriteAsync(FlvProcessingContext context);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,12 +12,12 @@ namespace BililiveRecorder.Flv
|
|||
/// Returns the next available Flv Tag but does not consume it.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
Task<Tag?> PeekTagAsync();
|
||||
Task<Tag?> PeekTagAsync(System.Threading.CancellationToken token);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the next Flv Tag.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
Task<Tag?> ReadTagAsync();
|
||||
Task<Tag?> ReadTagAsync(System.Threading.CancellationToken token);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace BililiveRecorder.Flv
|
||||
{
|
||||
public interface IFlvWriterTargetProvider
|
||||
{
|
||||
Stream CreateOutputStream();
|
||||
(Stream stream, object state) CreateOutputStream();
|
||||
|
||||
Stream CreateAlternativeHeaderStream();
|
||||
|
||||
bool ShouldCreateNewFile(Stream outputStream, IList<Tag> tags);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ namespace BililiveRecorder.Flv
|
|||
public interface IGroupingRule
|
||||
{
|
||||
bool StartWith(Tag tag);
|
||||
bool AppendWith(Tag tag);
|
||||
bool AppendWith(Tag tag, List<Tag> tags);
|
||||
PipelineAction CreatePipelineAction(List<Tag> tags);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BililiveRecorder.Flv.Pipeline;
|
||||
|
||||
|
@ -6,6 +7,6 @@ namespace BililiveRecorder.Flv
|
|||
{
|
||||
public interface ITagGroupReader : IDisposable
|
||||
{
|
||||
Task<PipelineAction?> ReadGroupAsync();
|
||||
Task<PipelineAction?> ReadGroupAsync(CancellationToken token);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ using System.IO;
|
|||
using System.IO.Pipelines;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
|
||||
namespace BililiveRecorder.Flv.Parser
|
||||
{
|
||||
|
@ -16,7 +17,7 @@ namespace BililiveRecorder.Flv.Parser
|
|||
public class FlvTagPipeReader : IFlvTagReader, IDisposable
|
||||
{
|
||||
private static int memoryCreateCounter = 0;
|
||||
|
||||
private readonly ILogger? logger;
|
||||
private readonly IMemoryStreamProvider memoryStreamProvider;
|
||||
private readonly bool skipData;
|
||||
private readonly bool leaveOpen;
|
||||
|
@ -29,12 +30,13 @@ namespace BililiveRecorder.Flv.Parser
|
|||
|
||||
public PipeReader Reader { get; }
|
||||
|
||||
public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider) : this(reader, memoryStreamProvider, false) { }
|
||||
public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, ILogger? logger = null) : this(reader, memoryStreamProvider, false, logger) { }
|
||||
|
||||
public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, bool skipData = false) : this(reader, memoryStreamProvider, skipData, false) { }
|
||||
public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, bool skipData = false, ILogger? logger = null) : this(reader, memoryStreamProvider, skipData, false, logger) { }
|
||||
|
||||
public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, bool skipData = false, bool leaveOpen = false)
|
||||
public FlvTagPipeReader(PipeReader reader, IMemoryStreamProvider memoryStreamProvider, bool skipData = false, bool leaveOpen = false, ILogger? logger = null)
|
||||
{
|
||||
this.logger = logger?.ForContext<FlvTagPipeReader>();
|
||||
this.Reader = reader ?? throw new ArgumentNullException(nameof(reader));
|
||||
|
||||
this.memoryStreamProvider = memoryStreamProvider ?? throw new ArgumentNullException(nameof(memoryStreamProvider));
|
||||
|
@ -269,9 +271,16 @@ namespace BililiveRecorder.Flv.Parser
|
|||
tagBodyStream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
if (tag.Type == TagType.Script)
|
||||
{
|
||||
try
|
||||
{
|
||||
tag.ScriptData = Amf.ScriptTagBody.Parse(tagBodyStream);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger?.Debug(ex, "Error parsing script tag body");
|
||||
}
|
||||
}
|
||||
else if (tag.Type == TagType.Video && !tag.Flag.HasFlag(TagFlag.Header))
|
||||
{
|
||||
if (H264Nalu.TryParseNalu(tagBodyStream, out var nalus))
|
||||
|
@ -288,7 +297,7 @@ namespace BililiveRecorder.Flv.Parser
|
|||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Tag?> PeekTagAsync()
|
||||
public async Task<Tag?> PeekTagAsync(CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -300,7 +309,7 @@ namespace BililiveRecorder.Flv.Parser
|
|||
}
|
||||
else
|
||||
{
|
||||
this.peekTag = await this.ReadNextTagAsync();
|
||||
this.peekTag = await this.ReadNextTagAsync(token);
|
||||
this.peek = true;
|
||||
return this.peekTag;
|
||||
}
|
||||
|
@ -312,7 +321,7 @@ namespace BililiveRecorder.Flv.Parser
|
|||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Tag?> ReadTagAsync()
|
||||
public async Task<Tag?> ReadTagAsync(CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -326,7 +335,7 @@ namespace BililiveRecorder.Flv.Parser
|
|||
}
|
||||
else
|
||||
{
|
||||
return await this.ReadNextTagAsync();
|
||||
return await this.ReadNextTagAsync(token);
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
|
|
@ -24,6 +24,7 @@ namespace BililiveRecorder.Flv.Pipeline
|
|||
|
||||
public static IProcessingPipelineBuilder AddDefault(this IProcessingPipelineBuilder builder) =>
|
||||
builder
|
||||
.Add<HandleDelayedAudioHeaderRule>()
|
||||
.Add<CheckMissingKeyframeRule>()
|
||||
.Add<CheckDiscontinuityRule>()
|
||||
.Add<UpdateTimestampRule>()
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user