2021-02-23 18:03:37 +08:00
|
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.ComponentModel;
|
|
|
|
|
using System.IO;
|
2022-08-27 18:07:09 +08:00
|
|
|
|
using System.Net.Http;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
using System.Runtime.CompilerServices;
|
|
|
|
|
using System.Threading;
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using BililiveRecorder.Core.Api;
|
2021-12-19 21:10:34 +08:00
|
|
|
|
using BililiveRecorder.Core.Config.V3;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
using BililiveRecorder.Core.Danmaku;
|
|
|
|
|
using BililiveRecorder.Core.Event;
|
|
|
|
|
using BililiveRecorder.Core.Recording;
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
2022-05-06 19:36:40 +08:00
|
|
|
|
using Newtonsoft.Json.Linq;
|
2021-03-01 21:38:13 +08:00
|
|
|
|
using Polly;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
using Serilog;
|
2021-03-01 21:38:13 +08:00
|
|
|
|
using Serilog.Events;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
using Timer = System.Timers.Timer;
|
|
|
|
|
|
|
|
|
|
namespace BililiveRecorder.Core
|
|
|
|
|
{
|
2022-05-16 23:28:31 +08:00
|
|
|
|
internal class Room : IRoom
|
2021-02-23 18:03:37 +08:00
|
|
|
|
{
|
2022-11-12 22:57:57 +08:00
|
|
|
|
private const int HR_ERROR_HANDLE_DISK_FULL = unchecked((int)0x80070027);
|
|
|
|
|
private const int HR_ERROR_DISK_FULL = unchecked((int)0x80070070);
|
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
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 danmakuConnected;
|
2021-04-21 23:18:23 +08:00
|
|
|
|
private bool streaming;
|
|
|
|
|
private bool autoRecordForThisSession = true;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
|
|
|
|
|
private IRecordTask? recordTask;
|
|
|
|
|
private DateTimeOffset recordTaskStartTime;
|
2022-04-30 14:04:57 +08:00
|
|
|
|
private DateTimeOffset danmakuClientConnectTime;
|
|
|
|
|
private static readonly TimeSpan danmakuClientReconnectNoDelay = TimeSpan.FromMinutes(1);
|
2021-02-23 18:03:37 +08:00
|
|
|
|
|
2022-09-03 22:14:53 +08:00
|
|
|
|
private static readonly HttpClient coverDownloadHttpClient = new HttpClient();
|
|
|
|
|
|
|
|
|
|
static Room()
|
|
|
|
|
{
|
|
|
|
|
coverDownloadHttpClient.Timeout = TimeSpan.FromSeconds(10);
|
|
|
|
|
coverDownloadHttpClient.DefaultRequestHeaders.UserAgent.Clear();
|
|
|
|
|
}
|
|
|
|
|
|
2022-05-17 15:14:45 +08:00
|
|
|
|
public Room(IServiceScope scope, RoomConfig roomConfig, int initDelayFactor, ILogger logger, IDanmakuClient danmakuClient, IApiClient apiClient, IBasicDanmakuWriter basicDanmakuWriter, IRecordTaskFactory recordTaskFactory)
|
2021-02-23 18:03:37 +08:00
|
|
|
|
{
|
|
|
|
|
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));
|
|
|
|
|
|
2022-06-30 21:37:00 +08:00
|
|
|
|
this.timer = new Timer(this.RoomConfig.TimingCheckInterval * 1000d);
|
2021-02-23 18:03:37 +08:00
|
|
|
|
this.cts = new CancellationTokenSource();
|
|
|
|
|
this.ct = this.cts.Token;
|
|
|
|
|
|
2021-04-21 23:18:23 +08:00
|
|
|
|
this.PropertyChanged += this.Room_PropertyChanged;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
this.RoomConfig.PropertyChanged += this.RoomConfig_PropertyChanged;
|
2021-04-21 23:18:23 +08:00
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
this.timer.Elapsed += this.Timer_Elapsed;
|
2021-04-21 23:18:23 +08:00
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
this.danmakuClient.StatusChanged += this.DanmakuClient_StatusChanged;
|
|
|
|
|
this.danmakuClient.DanmakuReceived += this.DanmakuClient_DanmakuReceived;
|
|
|
|
|
|
|
|
|
|
_ = Task.Run(async () =>
|
|
|
|
|
{
|
2021-04-30 19:35:15 +08:00
|
|
|
|
await Task.Delay(1500 + (initDelayFactor * 500));
|
|
|
|
|
this.timer.Start();
|
2021-02-23 18:03:37 +08:00
|
|
|
|
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); }
|
|
|
|
|
|
2022-05-06 19:36:40 +08:00
|
|
|
|
public JObject? RawBilibiliApiJsonData { get; private set; }
|
|
|
|
|
|
2021-04-21 23:18:23 +08:00
|
|
|
|
public bool Streaming { get => this.streaming; private set => this.SetField(ref this.streaming, value); }
|
|
|
|
|
|
|
|
|
|
public bool AutoRecordForThisSession { get => this.autoRecordForThisSession; private set => this.SetField(ref this.autoRecordForThisSession, value); }
|
2021-02-23 18:03:37 +08:00
|
|
|
|
|
|
|
|
|
public bool DanmakuConnected { get => this.danmakuConnected; private set => this.SetField(ref this.danmakuConnected, value); }
|
|
|
|
|
|
2021-04-21 23:18:23 +08:00
|
|
|
|
public bool Recording => this.recordTask != null;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
|
|
|
|
|
public RoomConfig RoomConfig { get; }
|
2022-04-09 16:43:05 +08:00
|
|
|
|
public RoomStats Stats { get; } = new RoomStats();
|
2021-02-23 18:03:37 +08:00
|
|
|
|
|
|
|
|
|
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;
|
2021-12-19 00:56:41 +08:00
|
|
|
|
public event EventHandler<IOStatsEventArgs>? IOStats;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
public event EventHandler<RecordingStatsEventArgs>? RecordingStats;
|
|
|
|
|
public event PropertyChangedEventHandler? PropertyChanged;
|
|
|
|
|
|
|
|
|
|
public void SplitOutput()
|
|
|
|
|
{
|
2021-04-30 19:35:15 +08:00
|
|
|
|
if (this.disposedValue)
|
|
|
|
|
return;
|
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
lock (this.recordStartLock)
|
|
|
|
|
{
|
|
|
|
|
this.recordTask?.SplitOutput();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void StartRecord()
|
|
|
|
|
{
|
2021-04-30 19:35:15 +08:00
|
|
|
|
if (this.disposedValue)
|
|
|
|
|
return;
|
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
lock (this.recordStartLock)
|
|
|
|
|
{
|
2021-04-21 23:18:23 +08:00
|
|
|
|
this.AutoRecordForThisSession = true;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
|
2021-04-21 23:18:23 +08:00
|
|
|
|
_ = Task.Run(() =>
|
2021-02-23 18:03:37 +08:00
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
2022-07-30 20:17:12 +08:00
|
|
|
|
// 手动触发录制,启动录制前再刷新一次房间信息
|
|
|
|
|
this.CreateAndStartNewRecordTask(skipFetchRoomInfo: false);
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
2021-04-23 19:15:20 +08:00
|
|
|
|
this.logger.Write(ex is ExecutionRejectedException ? LogEventLevel.Verbose : LogEventLevel.Warning, ex, "尝试开始录制时出错");
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void StopRecord()
|
|
|
|
|
{
|
2021-04-30 19:35:15 +08:00
|
|
|
|
if (this.disposedValue)
|
|
|
|
|
return;
|
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
lock (this.recordStartLock)
|
|
|
|
|
{
|
2021-04-21 23:18:23 +08:00
|
|
|
|
this.AutoRecordForThisSession = false;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
|
|
|
|
|
if (this.recordTask == null)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
this.recordTask.RequestStop();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task RefreshRoomInfoAsync()
|
|
|
|
|
{
|
2021-04-30 19:35:15 +08:00
|
|
|
|
if (this.disposedValue)
|
|
|
|
|
return;
|
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
try
|
|
|
|
|
{
|
2022-07-30 20:17:12 +08:00
|
|
|
|
// 如果直播状态从 false 改成 true,Room_PropertyChanged 会触发录制
|
2021-02-23 18:03:37 +08:00
|
|
|
|
await this.FetchRoomInfoAsync().ConfigureAwait(false);
|
2022-05-06 19:36:40 +08:00
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
this.StartDamakuConnection(delay: false);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
2021-04-23 19:15:20 +08:00
|
|
|
|
this.logger.Write(ex is ExecutionRejectedException ? LogEventLevel.Verbose : LogEventLevel.Warning, ex, "刷新房间信息时出错");
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#region Recording
|
|
|
|
|
|
|
|
|
|
/// <exception cref="Exception"/>
|
|
|
|
|
private async Task FetchRoomInfoAsync()
|
|
|
|
|
{
|
2021-04-30 19:35:15 +08:00
|
|
|
|
if (this.disposedValue)
|
|
|
|
|
return;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
var room = (await this.apiClient.GetRoomInfoAsync(this.RoomConfig.RoomId).ConfigureAwait(false)).Data;
|
|
|
|
|
if (room != null)
|
|
|
|
|
{
|
2022-05-06 19:36:40 +08:00
|
|
|
|
this.RoomConfig.RoomId = room.Room.RoomId;
|
|
|
|
|
this.ShortId = room.Room.ShortId;
|
|
|
|
|
this.Title = room.Room.Title;
|
|
|
|
|
this.AreaNameParent = room.Room.ParentAreaName;
|
|
|
|
|
this.AreaNameChild = room.Room.AreaName;
|
|
|
|
|
this.Streaming = room.Room.LiveStatus == 1;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
|
2022-05-06 19:36:40 +08:00
|
|
|
|
this.Name = room.User.BaseInfo.Name;
|
|
|
|
|
|
|
|
|
|
this.RawBilibiliApiJsonData = room.RawBilibiliApiJsonData;
|
|
|
|
|
}
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
///
|
2022-07-30 20:17:12 +08:00
|
|
|
|
private void CreateAndStartNewRecordTask(bool skipFetchRoomInfo = false)
|
2021-02-23 18:03:37 +08:00
|
|
|
|
{
|
|
|
|
|
lock (this.recordStartLock)
|
|
|
|
|
{
|
|
|
|
|
if (this.disposedValue)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
if (!this.Streaming)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
if (this.recordTask != null)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
var task = this.recordTaskFactory.CreateRecordTask(this);
|
2021-12-19 00:56:41 +08:00
|
|
|
|
task.IOStats += this.RecordTask_IOStats;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
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;
|
2021-04-21 19:48:28 +08:00
|
|
|
|
this.Stats.Reset();
|
2021-02-23 18:03:37 +08:00
|
|
|
|
this.OnPropertyChanged(nameof(this.Recording));
|
|
|
|
|
|
|
|
|
|
_ = Task.Run(async () =>
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
2022-07-30 20:17:12 +08:00
|
|
|
|
if (!skipFetchRoomInfo)
|
|
|
|
|
await this.FetchRoomInfoAsync();
|
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
await this.recordTask.StartAsync();
|
|
|
|
|
}
|
2021-11-20 14:34:35 +08:00
|
|
|
|
catch (NoMatchingQnValueException)
|
|
|
|
|
{
|
|
|
|
|
this.recordTask = null;
|
|
|
|
|
this.OnPropertyChanged(nameof(this.Recording));
|
|
|
|
|
|
|
|
|
|
// 无匹配的画质,重试录制之前等待更长时间
|
|
|
|
|
_ = Task.Run(() => this.RestartAfterRecordTaskFailedAsync(RestartRecordingReason.NoMatchingQnValue));
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
2021-02-23 18:03:37 +08:00
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
2021-04-23 19:15:20 +08:00
|
|
|
|
this.logger.Write(ex is ExecutionRejectedException ? LogEventLevel.Verbose : LogEventLevel.Warning, ex, "启动录制出错");
|
2021-03-01 21:38:13 +08:00
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
this.recordTask = null;
|
|
|
|
|
this.OnPropertyChanged(nameof(this.Recording));
|
2021-04-21 23:18:23 +08:00
|
|
|
|
|
2022-11-12 22:57:57 +08:00
|
|
|
|
if (ex is IOException ioex && (ioex.HResult == HR_ERROR_DISK_FULL || ioex.HResult == HR_ERROR_HANDLE_DISK_FULL))
|
|
|
|
|
{
|
|
|
|
|
this.logger.Warning("因为硬盘空间已满,本次不再自动重试启动录制。");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2022-12-10 07:59:54 +08:00
|
|
|
|
else if (ex is BilibiliApiResponseCodeNotZeroException notzero && notzero.Code == 19002005)
|
|
|
|
|
{
|
|
|
|
|
// 房间已加密
|
|
|
|
|
this.logger.Warning("房间已加密,无密码获取不到直播流,本次不再自动重试启动录制。");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2022-11-12 22:57:57 +08:00
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
// 请求直播流出错时的重试逻辑
|
|
|
|
|
_ = Task.Run(() => this.RestartAfterRecordTaskFailedAsync(RestartRecordingReason.GenericRetry));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
2021-11-20 14:34:35 +08:00
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
RecordSessionStarted?.Invoke(this, new RecordSessionStartedEventArgs(this)
|
|
|
|
|
{
|
|
|
|
|
SessionId = this.recordTask.SessionId
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
///
|
2021-11-20 14:34:35 +08:00
|
|
|
|
private async Task RestartAfterRecordTaskFailedAsync(RestartRecordingReason restartRecordingReason)
|
2021-02-23 18:03:37 +08:00
|
|
|
|
{
|
2021-04-30 19:35:15 +08:00
|
|
|
|
if (this.disposedValue)
|
|
|
|
|
return;
|
2021-04-21 23:18:23 +08:00
|
|
|
|
if (!this.Streaming || !this.AutoRecordForThisSession)
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
try
|
2021-02-23 18:03:37 +08:00
|
|
|
|
{
|
2021-04-21 23:18:23 +08:00
|
|
|
|
if (!await this.recordRetryDelaySemaphoreSlim.WaitAsync(0).ConfigureAwait(false))
|
|
|
|
|
return;
|
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
try
|
|
|
|
|
{
|
2021-11-20 14:34:35 +08:00
|
|
|
|
var delay = restartRecordingReason switch
|
|
|
|
|
{
|
|
|
|
|
RestartRecordingReason.GenericRetry => this.RoomConfig.TimingStreamRetry,
|
|
|
|
|
RestartRecordingReason.NoMatchingQnValue => this.RoomConfig.TimingStreamRetryNoQn * 1000,
|
|
|
|
|
_ => throw new InvalidOperationException()
|
|
|
|
|
};
|
|
|
|
|
await Task.Delay((int)delay, this.ct).ConfigureAwait(false);
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
2021-04-23 19:15:20 +08:00
|
|
|
|
catch (TaskCanceledException)
|
|
|
|
|
{
|
|
|
|
|
// 房间已经被删除
|
|
|
|
|
return;
|
|
|
|
|
}
|
2021-04-21 23:18:23 +08:00
|
|
|
|
finally
|
2021-02-23 18:03:37 +08:00
|
|
|
|
{
|
2022-11-23 23:33:42 +08:00
|
|
|
|
_ = this.recordRetryDelaySemaphoreSlim.Release();
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
2021-04-21 23:18:23 +08:00
|
|
|
|
|
2022-07-30 20:17:12 +08:00
|
|
|
|
// 如果状态是非直播中,跳过重试尝试。当状态切换到直播中时会开始新的录制任务。
|
2021-04-21 23:18:23 +08:00
|
|
|
|
if (!this.Streaming || !this.AutoRecordForThisSession)
|
|
|
|
|
return;
|
|
|
|
|
|
2022-07-30 20:17:12 +08:00
|
|
|
|
// 启动录制时更新房间信息
|
2021-04-21 23:18:23 +08:00
|
|
|
|
if (this.Streaming && this.AutoRecordForThisSession)
|
2022-07-30 20:17:12 +08:00
|
|
|
|
this.CreateAndStartNewRecordTask(skipFetchRoomInfo: false);
|
2021-04-21 23:18:23 +08:00
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
2021-04-23 19:15:20 +08:00
|
|
|
|
this.logger.Write(ex is ExecutionRejectedException ? LogEventLevel.Verbose : LogEventLevel.Warning, ex, "重试开始录制时出错");
|
2021-11-20 14:34:35 +08:00
|
|
|
|
_ = Task.Run(() => this.RestartAfterRecordTaskFailedAsync(restartRecordingReason));
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
///
|
|
|
|
|
private void StartDamakuConnection(bool delay = true) =>
|
|
|
|
|
Task.Run(async () =>
|
|
|
|
|
{
|
2021-04-30 19:35:15 +08:00
|
|
|
|
if (this.disposedValue)
|
|
|
|
|
return;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (delay)
|
2022-08-25 18:42:36 +08:00
|
|
|
|
{
|
2021-04-23 19:15:20 +08:00
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
await Task.Delay((int)this.RoomConfig.TimingDanmakuRetry, this.ct).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
catch (TaskCanceledException)
|
|
|
|
|
{
|
|
|
|
|
// 房间已被删除
|
|
|
|
|
return;
|
|
|
|
|
}
|
2022-08-25 18:42:36 +08:00
|
|
|
|
}
|
2021-02-23 18:03:37 +08:00
|
|
|
|
|
2022-08-25 18:42:36 +08:00
|
|
|
|
await this.danmakuClient.ConnectAsync(this.RoomConfig.RoomId, this.RoomConfig.DanmakuTransport, this.ct).ConfigureAwait(false);
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
2021-04-23 19:15:20 +08:00
|
|
|
|
this.logger.Write(ex is ExecutionRejectedException ? LogEventLevel.Verbose : LogEventLevel.Warning, ex, "连接弹幕服务器时出错");
|
2021-02-23 18:03:37 +08:00
|
|
|
|
|
|
|
|
|
if (!this.ct.IsCancellationRequested)
|
2022-08-25 18:42:36 +08:00
|
|
|
|
this.StartDamakuConnection(delay: true);
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Event Handlers
|
|
|
|
|
|
|
|
|
|
///
|
2021-12-19 00:56:41 +08:00
|
|
|
|
private void RecordTask_IOStats(object sender, IOStatsEventArgs e)
|
2021-02-23 18:03:37 +08:00
|
|
|
|
{
|
2021-12-19 00:56:41 +08:00
|
|
|
|
this.logger.Verbose("IO stats: {@stats}", e);
|
2021-02-23 18:03:37 +08:00
|
|
|
|
|
2022-06-29 19:46:58 +08:00
|
|
|
|
this.Stats.StreamHost = e.StreamHost;
|
2022-04-09 16:43:05 +08:00
|
|
|
|
this.Stats.StartTime = e.StartTime;
|
|
|
|
|
this.Stats.EndTime = e.EndTime;
|
|
|
|
|
this.Stats.Duration = e.Duration;
|
|
|
|
|
this.Stats.NetworkBytesDownloaded = e.NetworkBytesDownloaded;
|
2021-12-19 00:56:41 +08:00
|
|
|
|
this.Stats.NetworkMbps = e.NetworkMbps;
|
2022-04-09 16:43:05 +08:00
|
|
|
|
this.Stats.DiskWriteDuration = e.DiskWriteDuration;
|
|
|
|
|
this.Stats.DiskBytesWritten = e.DiskBytesWritten;
|
|
|
|
|
this.Stats.DiskMBps = e.DiskMBps;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
|
2021-12-19 00:56:41 +08:00
|
|
|
|
IOStats?.Invoke(this, e);
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
///
|
|
|
|
|
private void RecordTask_RecordingStats(object sender, RecordingStatsEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
this.logger.Verbose("Recording stats: {@stats}", e);
|
|
|
|
|
|
2022-04-09 16:43:05 +08:00
|
|
|
|
this.Stats.SessionDuration = TimeSpan.FromMilliseconds(e.SessionDuration);
|
|
|
|
|
this.Stats.TotalInputBytes = e.TotalInputBytes;
|
|
|
|
|
this.Stats.TotalOutputBytes = e.TotalOutputBytes;
|
|
|
|
|
this.Stats.CurrentFileSize = e.CurrentFileSize;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
this.Stats.SessionMaxTimestamp = TimeSpan.FromMilliseconds(e.SessionMaxTimestamp);
|
2022-04-09 16:43:05 +08:00
|
|
|
|
this.Stats.FileMaxTimestamp = TimeSpan.FromMilliseconds(e.FileMaxTimestamp);
|
|
|
|
|
this.Stats.AddedDuration = e.AddedDuration;
|
|
|
|
|
this.Stats.PassedTime = e.PassedTime;
|
2021-08-04 21:58:35 +08:00
|
|
|
|
this.Stats.DurationRatio = e.DurationRatio;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
|
2022-04-09 16:43:05 +08:00
|
|
|
|
this.Stats.InputVideoBytes = e.InputVideoBytes;
|
|
|
|
|
this.Stats.InputAudioBytes = e.InputAudioBytes;
|
|
|
|
|
|
|
|
|
|
this.Stats.OutputVideoFrames = e.OutputVideoFrames;
|
|
|
|
|
this.Stats.OutputAudioFrames = e.OutputAudioFrames;
|
|
|
|
|
this.Stats.OutputVideoBytes = e.OutputVideoBytes;
|
|
|
|
|
this.Stats.OutputAudioBytes = e.OutputAudioBytes;
|
|
|
|
|
|
|
|
|
|
this.Stats.TotalInputVideoBytes = e.TotalInputVideoBytes;
|
|
|
|
|
this.Stats.TotalInputAudioBytes = e.TotalInputAudioBytes;
|
|
|
|
|
|
|
|
|
|
this.Stats.TotalOutputVideoFrames = e.TotalOutputVideoFrames;
|
|
|
|
|
this.Stats.TotalOutputAudioFrames = e.TotalOutputAudioFrames;
|
|
|
|
|
this.Stats.TotalOutputVideoBytes = e.TotalOutputVideoBytes;
|
|
|
|
|
this.Stats.TotalOutputAudioBytes = e.TotalOutputAudioBytes;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
|
|
|
|
|
RecordingStats?.Invoke(this, e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
///
|
|
|
|
|
private void RecordTask_RecordFileClosed(object sender, RecordFileClosedEventArgs e)
|
|
|
|
|
{
|
2021-04-14 17:57:35 +08:00
|
|
|
|
this.basicDanmakuWriter.Disable();
|
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
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);
|
2021-03-18 22:28:10 +08:00
|
|
|
|
else
|
|
|
|
|
this.basicDanmakuWriter.Disable();
|
|
|
|
|
|
2022-08-27 18:07:09 +08:00
|
|
|
|
if (this.RoomConfig.SaveStreamCover)
|
|
|
|
|
{
|
2022-09-03 22:14:53 +08:00
|
|
|
|
_ = Task.Run(() => this.SaveStreamCoverAsync(e.FullPath));
|
|
|
|
|
}
|
2022-08-27 18:07:09 +08:00
|
|
|
|
|
2022-09-03 22:14:53 +08:00
|
|
|
|
RecordFileOpening?.Invoke(this, e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task SaveStreamCoverAsync(string flvFullPath)
|
|
|
|
|
{
|
|
|
|
|
const int MAX_ATTEMPT = 3;
|
|
|
|
|
var attempt = 0;
|
2022-11-23 23:33:42 +08:00
|
|
|
|
retry:
|
2022-09-03 22:14:53 +08:00
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var coverUrl = this.RawBilibiliApiJsonData?["room_info"]?["cover"]?.ToObject<string>();
|
2022-08-27 18:07:09 +08:00
|
|
|
|
|
2022-09-03 22:14:53 +08:00
|
|
|
|
if (string.IsNullOrWhiteSpace(coverUrl))
|
|
|
|
|
{
|
|
|
|
|
this.logger.Information("没有直播间封面信息");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2022-08-27 18:07:09 +08:00
|
|
|
|
|
2022-09-03 22:14:53 +08:00
|
|
|
|
var targetPath = Path.ChangeExtension(flvFullPath, "cover" + Path.GetExtension(coverUrl));
|
2022-08-27 18:07:09 +08:00
|
|
|
|
|
2022-09-03 22:14:53 +08:00
|
|
|
|
var stream = await coverDownloadHttpClient.GetStreamAsync(coverUrl).ConfigureAwait(false);
|
|
|
|
|
using var file = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
|
|
|
|
await stream.CopyToAsync(file).ConfigureAwait(false);
|
2022-08-27 18:07:09 +08:00
|
|
|
|
|
2022-09-03 22:14:53 +08:00
|
|
|
|
this.logger.Debug("直播间封面已成功从 {CoverUrl} 保存到 {FilePath}", coverUrl, targetPath);
|
2022-08-27 18:07:09 +08:00
|
|
|
|
}
|
2022-09-03 22:14:53 +08:00
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
if (attempt++ < MAX_ATTEMPT)
|
|
|
|
|
{
|
|
|
|
|
this.logger.Debug(ex, "保存直播间封面时出错, 次数 {Attempt}", attempt);
|
|
|
|
|
goto retry;
|
|
|
|
|
}
|
2022-08-27 18:07:09 +08:00
|
|
|
|
|
2022-09-03 22:14:53 +08:00
|
|
|
|
this.logger.Warning(ex, "保存直播间封面时出错");
|
|
|
|
|
}
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
///
|
|
|
|
|
private void RecordTask_RecordSessionEnded(object sender, EventArgs e)
|
|
|
|
|
{
|
|
|
|
|
Guid id;
|
|
|
|
|
lock (this.recordStartLock)
|
|
|
|
|
{
|
|
|
|
|
id = this.recordTask?.SessionId ?? default;
|
|
|
|
|
this.recordTask = null;
|
2021-04-21 23:18:23 +08:00
|
|
|
|
_ = Task.Run(async () =>
|
|
|
|
|
{
|
2022-07-30 20:17:12 +08:00
|
|
|
|
await Task.Yield();
|
|
|
|
|
|
2021-04-21 23:18:23 +08:00
|
|
|
|
// 录制结束退出后的重试逻辑
|
2021-04-23 19:15:20 +08:00
|
|
|
|
// 比 RestartAfterRecordTaskFailedAsync 少了等待时间
|
|
|
|
|
|
2022-07-30 20:17:12 +08:00
|
|
|
|
// 如果状态是非直播中,跳过重试尝试。当状态切换到直播中时会开始新的录制任务。
|
2021-04-21 23:18:23 +08:00
|
|
|
|
if (!this.Streaming || !this.AutoRecordForThisSession)
|
|
|
|
|
return;
|
|
|
|
|
|
2021-04-23 19:15:20 +08:00
|
|
|
|
try
|
|
|
|
|
{
|
2022-07-30 20:17:12 +08:00
|
|
|
|
// 开始录制前刷新房间信息
|
2021-04-23 19:15:20 +08:00
|
|
|
|
if (this.Streaming && this.AutoRecordForThisSession)
|
2022-07-30 20:17:12 +08:00
|
|
|
|
this.CreateAndStartNewRecordTask(skipFetchRoomInfo: false);
|
2021-04-23 19:15:20 +08:00
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
this.logger.Write(LogEventLevel.Warning, ex, "重试开始录制时出错");
|
2021-11-20 14:34:35 +08:00
|
|
|
|
_ = Task.Run(() => this.RestartAfterRecordTaskFailedAsync(RestartRecordingReason.GenericRetry));
|
2021-04-23 19:15:20 +08:00
|
|
|
|
}
|
2021-04-21 23:18:23 +08:00
|
|
|
|
});
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
2021-03-18 22:28:10 +08:00
|
|
|
|
this.basicDanmakuWriter.Disable();
|
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
this.OnPropertyChanged(nameof(this.Recording));
|
2021-04-21 19:48:28 +08:00
|
|
|
|
this.Stats.Reset();
|
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2022-05-18 00:23:01 +08:00
|
|
|
|
_ = Task.Run(async () => await this.basicDanmakuWriter.WriteAsync(d));
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void DanmakuClient_StatusChanged(object sender, Api.Danmaku.StatusChangedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
if (e.Connected)
|
|
|
|
|
{
|
|
|
|
|
this.DanmakuConnected = true;
|
2022-04-30 14:04:57 +08:00
|
|
|
|
this.danmakuClientConnectTime = DateTimeOffset.UtcNow;
|
2021-04-30 19:35:15 +08:00
|
|
|
|
this.logger.Information("弹幕服务器已连接");
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
this.DanmakuConnected = false;
|
2021-04-30 19:35:15 +08:00
|
|
|
|
this.logger.Information("与弹幕服务器的连接被断开");
|
2022-04-30 14:04:57 +08:00
|
|
|
|
|
|
|
|
|
// 如果连接弹幕服务器的时间在至少 1 分钟之前,重连时不需要等待
|
|
|
|
|
// 针对偶尔的网络波动的优化,如果偶尔断开了尽快重连,减少漏录的弹幕量
|
|
|
|
|
this.StartDamakuConnection(delay: !((DateTimeOffset.UtcNow - this.danmakuClientConnectTime) > danmakuClientReconnectNoDelay));
|
|
|
|
|
|
|
|
|
|
this.danmakuClientConnectTime = DateTimeOffset.MaxValue;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void Timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
|
|
|
|
|
{
|
2021-04-21 23:18:23 +08:00
|
|
|
|
this.StartDamakuConnection(delay: false);
|
|
|
|
|
|
2021-02-23 18:03:37 +08:00
|
|
|
|
if (this.RoomConfig.AutoRecord)
|
2021-04-21 23:18:23 +08:00
|
|
|
|
{
|
|
|
|
|
_ = Task.Run(async () =>
|
|
|
|
|
{
|
2021-05-02 21:02:33 +08:00
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// 定时主动检查不需要错误重试
|
|
|
|
|
await this.FetchRoomInfoAsync().ConfigureAwait(false);
|
2021-04-21 23:18:23 +08:00
|
|
|
|
|
2022-07-30 20:17:12 +08:00
|
|
|
|
// 刚更新了房间信息不需要再获取一次
|
2021-05-02 21:02:33 +08:00
|
|
|
|
if (this.Streaming && this.AutoRecordForThisSession && this.RoomConfig.AutoRecord)
|
2022-07-30 20:17:12 +08:00
|
|
|
|
this.CreateAndStartNewRecordTask(skipFetchRoomInfo: true);
|
2021-05-02 21:02:33 +08:00
|
|
|
|
}
|
|
|
|
|
catch (Exception) { }
|
2021-04-21 23:18:23 +08:00
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void Room_PropertyChanged(object sender, PropertyChangedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
switch (e.PropertyName)
|
|
|
|
|
{
|
|
|
|
|
case nameof(this.Streaming):
|
|
|
|
|
if (this.Streaming)
|
|
|
|
|
{
|
2022-07-30 20:17:12 +08:00
|
|
|
|
// 如果开播状态是通过广播消息获取的,本地的直播间信息就不是最新的,需要重新获取。
|
2021-04-21 23:18:23 +08:00
|
|
|
|
if (this.AutoRecordForThisSession && this.RoomConfig.AutoRecord)
|
2022-07-30 20:17:12 +08:00
|
|
|
|
this.CreateAndStartNewRecordTask(skipFetchRoomInfo: false);
|
2021-04-21 23:18:23 +08:00
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
this.AutoRecordForThisSession = true;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
2021-02-23 18:03:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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):
|
2022-06-30 21:37:00 +08:00
|
|
|
|
this.timer.Interval = this.RoomConfig.TimingCheckInterval * 1000d;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
break;
|
2021-03-01 21:38:13 +08:00
|
|
|
|
case nameof(this.RoomConfig.AutoRecord):
|
|
|
|
|
if (this.RoomConfig.AutoRecord)
|
2021-04-21 23:18:23 +08:00
|
|
|
|
{
|
|
|
|
|
this.AutoRecordForThisSession = true;
|
2022-07-30 20:17:12 +08:00
|
|
|
|
|
|
|
|
|
// 启动录制时更新一次房间信息
|
2021-04-21 23:18:23 +08:00
|
|
|
|
if (this.Streaming && this.AutoRecordForThisSession)
|
2022-07-30 20:17:12 +08:00
|
|
|
|
this.CreateAndStartNewRecordTask(skipFetchRoomInfo: false);
|
2021-04-21 23:18:23 +08:00
|
|
|
|
}
|
2021-03-01 21:38:13 +08:00
|
|
|
|
break;
|
2021-02-23 18:03:37 +08:00
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|