Add config v2

This commit is contained in:
Genteure 2021-01-01 14:46:27 +08:00
parent d37a3c2922
commit cce7d1c690
69 changed files with 2199 additions and 1088 deletions

View File

@ -5,7 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using Autofac;
using BililiveRecorder.Core;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config.V1;
using BililiveRecorder.FlvProcessor;
using CommandLine;
using Newtonsoft.Json;

View File

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

View File

@ -1,11 +1,12 @@
using BililiveRecorder.Core.Config;
using System;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Xml;
using BililiveRecorder.Core.Config.V2;
#nullable enable
namespace BililiveRecorder.Core
{
public class BasicDanmakuWriter : IBasicDanmakuWriter
@ -21,12 +22,12 @@ namespace BililiveRecorder.Core
private static readonly Regex invalidXMLChars = new Regex(@"(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\uFEFF\uFFFE\uFFFF]", RegexOptions.Compiled);
private static string RemoveInvalidXMLChars(string text) => string.IsNullOrEmpty(text) ? string.Empty : invalidXMLChars.Replace(text, string.Empty);
private XmlWriter xmlWriter = null;
private XmlWriter? xmlWriter = null;
private DateTimeOffset offset = DateTimeOffset.UtcNow;
private uint writeCount = 0;
private readonly ConfigV1 config;
private readonly RoomConfig config;
public BasicDanmakuWriter(ConfigV1 config)
public BasicDanmakuWriter(RoomConfig config)
{
this.config = config ?? throw new ArgumentNullException(nameof(config));
}
@ -35,62 +36,63 @@ namespace BililiveRecorder.Core
public void EnableWithPath(string path, IRecordedRoom recordedRoom)
{
if (disposedValue) return;
if (this.disposedValue) return;
semaphoreSlim.Wait();
this.semaphoreSlim.Wait();
try
{
if (xmlWriter != null)
if (this.xmlWriter != null)
{
xmlWriter.Close();
xmlWriter.Dispose();
xmlWriter = null;
this.xmlWriter.Close();
this.xmlWriter.Dispose();
this.xmlWriter = null;
}
try { Directory.CreateDirectory(Path.GetDirectoryName(path)); } catch (Exception) { }
var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.Read);
xmlWriter = XmlWriter.Create(stream, xmlWriterSettings);
WriteStartDocument(xmlWriter, recordedRoom);
offset = DateTimeOffset.UtcNow;
writeCount = 0;
this.xmlWriter = XmlWriter.Create(stream, xmlWriterSettings);
this.WriteStartDocument(this.xmlWriter, recordedRoom);
this.offset = DateTimeOffset.UtcNow;
this.writeCount = 0;
}
finally
{
semaphoreSlim.Release();
this.semaphoreSlim.Release();
}
}
public void Disable()
{
if (disposedValue) return;
if (this.disposedValue) return;
semaphoreSlim.Wait();
this.semaphoreSlim.Wait();
try
{
if (xmlWriter != null)
if (this.xmlWriter != null)
{
xmlWriter.Close();
xmlWriter.Dispose();
xmlWriter = null;
this.xmlWriter.Close();
this.xmlWriter.Dispose();
this.xmlWriter = null;
}
}
finally
{
semaphoreSlim.Release();
this.semaphoreSlim.Release();
}
}
public void Write(DanmakuModel danmakuModel)
{
if (disposedValue) return;
if (this.disposedValue) return;
semaphoreSlim.Wait();
this.semaphoreSlim.Wait();
try
{
if (xmlWriter != null)
if (this.xmlWriter != null)
{
var write = true;
var recordDanmakuRaw = this.config.RecordDanmakuRaw;
switch (danmakuModel.MsgType)
{
case MsgTypeEnum.Comment:
@ -99,58 +101,58 @@ namespace BililiveRecorder.Core
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 ts = Math.Max((DateTimeOffset.FromUnixTimeMilliseconds(st) - offset).TotalSeconds, 0d);
var ts = Math.Max((DateTimeOffset.FromUnixTimeMilliseconds(st) - this.offset).TotalSeconds, 0d);
xmlWriter.WriteStartElement("d");
xmlWriter.WriteAttributeString("p", $"{ts},{type},{size},{color},{st},0,{danmakuModel.UserID},0");
xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
if (config.RecordDanmakuRaw)
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["info"]?.ToString(Newtonsoft.Json.Formatting.None));
xmlWriter.WriteValue(RemoveInvalidXMLChars(danmakuModel.CommentText));
xmlWriter.WriteEndElement();
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.WriteValue(RemoveInvalidXMLChars(danmakuModel.CommentText));
this.xmlWriter.WriteEndElement();
}
break;
case MsgTypeEnum.SuperChat:
if (config.RecordDanmakuSuperChat)
if (this.config.RecordDanmakuSuperChat)
{
xmlWriter.WriteStartElement("sc");
var ts = Math.Max((DateTimeOffset.UtcNow - offset).TotalSeconds, 0d);
xmlWriter.WriteAttributeString("ts", ts.ToString());
xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
xmlWriter.WriteAttributeString("price", danmakuModel.Price.ToString());
xmlWriter.WriteAttributeString("time", danmakuModel.SCKeepTime.ToString());
if (config.RecordDanmakuRaw)
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
xmlWriter.WriteValue(RemoveInvalidXMLChars(danmakuModel.CommentText));
xmlWriter.WriteEndElement();
this.xmlWriter.WriteStartElement("sc");
var ts = Math.Max((DateTimeOffset.UtcNow - this.offset).TotalSeconds, 0d);
this.xmlWriter.WriteAttributeString("ts", ts.ToString());
this.xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
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.WriteValue(RemoveInvalidXMLChars(danmakuModel.CommentText));
this.xmlWriter.WriteEndElement();
}
break;
case MsgTypeEnum.GiftSend:
if (config.RecordDanmakuGift)
if (this.config.RecordDanmakuGift)
{
xmlWriter.WriteStartElement("gift");
var ts = Math.Max((DateTimeOffset.UtcNow - offset).TotalSeconds, 0d);
xmlWriter.WriteAttributeString("ts", ts.ToString());
xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
xmlWriter.WriteAttributeString("giftname", danmakuModel.GiftName);
xmlWriter.WriteAttributeString("giftcount", danmakuModel.GiftCount.ToString());
if (config.RecordDanmakuRaw)
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
xmlWriter.WriteEndElement();
this.xmlWriter.WriteStartElement("gift");
var ts = Math.Max((DateTimeOffset.UtcNow - this.offset).TotalSeconds, 0d);
this.xmlWriter.WriteAttributeString("ts", ts.ToString());
this.xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
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.WriteEndElement();
}
break;
case MsgTypeEnum.GuardBuy:
if (config.RecordDanmakuGuard)
if (this.config.RecordDanmakuGuard)
{
xmlWriter.WriteStartElement("guard");
var ts = Math.Max((DateTimeOffset.UtcNow - offset).TotalSeconds, 0d);
xmlWriter.WriteAttributeString("ts", ts.ToString());
xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
xmlWriter.WriteAttributeString("level", danmakuModel.UserGuardLevel.ToString()); ;
xmlWriter.WriteAttributeString("count", danmakuModel.GiftCount.ToString());
if (config.RecordDanmakuRaw)
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
xmlWriter.WriteEndElement();
this.xmlWriter.WriteStartElement("guard");
var ts = Math.Max((DateTimeOffset.UtcNow - this.offset).TotalSeconds, 0d);
this.xmlWriter.WriteAttributeString("ts", ts.ToString());
this.xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
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.WriteEndElement();
}
break;
default:
@ -158,16 +160,16 @@ namespace BililiveRecorder.Core
break;
}
if (write && writeCount++ >= config.RecordDanmakuFlushInterval)
if (write && this.writeCount++ >= this.config.RecordDanmakuFlushInterval)
{
xmlWriter.Flush();
writeCount = 0;
this.xmlWriter.Flush();
this.writeCount = 0;
}
}
}
finally
{
semaphoreSlim.Release();
this.semaphoreSlim.Release();
}
}
@ -202,26 +204,26 @@ namespace BililiveRecorder.Core
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
if (!this.disposedValue)
{
if (disposing)
{
// dispose managed state (managed objects)
semaphoreSlim.Dispose();
xmlWriter?.Close();
xmlWriter?.Dispose();
this.semaphoreSlim.Dispose();
this.xmlWriter?.Close();
this.xmlWriter?.Dispose();
}
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
disposedValue = true;
this.disposedValue = true;
}
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
this.Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}

View File

@ -4,7 +4,7 @@ using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config.V2;
using Newtonsoft.Json.Linq;
using NLog;
@ -20,26 +20,24 @@ namespace BililiveRecorder.Core
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private static readonly Random random = new Random();
private readonly ConfigV1 Config;
private readonly GlobalConfig globalConfig;
private readonly HttpClient danmakuhttpclient;
private HttpClient httpclient;
public BililiveAPI(ConfigV1 config)
public BililiveAPI(GlobalConfig globalConfig)
{
Config = config;
Config.PropertyChanged += (sender, e) =>
this.globalConfig = globalConfig;
this.globalConfig.PropertyChanged += (sender, e) =>
{
if (e.PropertyName == nameof(Config.Cookie))
{
ApplyCookieSettings(Config.Cookie);
}
if (e.PropertyName == nameof(this.globalConfig.Cookie))
this.ApplyCookieSettings(this.globalConfig.Cookie);
};
ApplyCookieSettings(Config.Cookie);
this.ApplyCookieSettings(this.globalConfig.Cookie);
danmakuhttpclient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
danmakuhttpclient.DefaultRequestHeaders.Add("Accept", HTTP_HEADER_ACCEPT);
danmakuhttpclient.DefaultRequestHeaders.Add("Referer", HTTP_HEADER_REFERER);
danmakuhttpclient.DefaultRequestHeaders.Add("User-Agent", Utils.UserAgent);
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)
@ -61,7 +59,7 @@ namespace BililiveRecorder.Core
pclient.DefaultRequestHeaders.Add("Referer", HTTP_HEADER_REFERER);
pclient.DefaultRequestHeaders.Add("User-Agent", Utils.UserAgent);
pclient.DefaultRequestHeaders.Add("Cookie", cookie_string);
httpclient = pclient;
this.httpclient = pclient;
}
else
{
@ -69,7 +67,7 @@ namespace BililiveRecorder.Core
cleanclient.DefaultRequestHeaders.Add("Accept", HTTP_HEADER_ACCEPT);
cleanclient.DefaultRequestHeaders.Add("Referer", HTTP_HEADER_REFERER);
cleanclient.DefaultRequestHeaders.Add("User-Agent", Utils.UserAgent);
httpclient = cleanclient;
this.httpclient = cleanclient;
}
logger.Debug("设置 Cookie 成功");
}
@ -109,9 +107,9 @@ namespace BililiveRecorder.Core
/// <exception cref="Exception"/>
public async Task<string> GetPlayUrlAsync(int roomid)
{
var url = $@"{Config.LiveApiHost}/room/v1/Room/playUrl?cid={roomid}&quality=4&platform=web";
var url = $@"{this.globalConfig.LiveApiHost}/room/v1/Room/playUrl?cid={roomid}&quality=4&platform=web";
// 随机选择一个 url
if ((await HttpGetJsonAsync(httpclient, url))?["data"]?["durl"] is JArray array)
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();
@ -134,14 +132,14 @@ namespace BililiveRecorder.Core
{
try
{
var room = await HttpGetJsonAsync(httpclient, $@"https://api.live.bilibili.com/room/v1/Room/get_info?id={roomid}");
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 HttpGetJsonAsync(httpclient, $@"https://api.live.bilibili.com/live_user/v1/UserInfo/get_anchor_in_room?roomid={roomid}");
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>() ?? "网络超时");
@ -174,7 +172,7 @@ namespace BililiveRecorder.Core
{
try
{
var result = await HttpGetJsonAsync(danmakuhttpclient, $@"https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id={roomid}&platform=pc&player=web");
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)
{

View File

@ -20,6 +20,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Autofac" Version="4.9.4" />
<PackageReference Include="JsonSubTypes" Version="1.8.0" />
<PackageReference Include="HierarchicalPropertyDefault" Version="0.1.1-beta-g721d36b97c" />
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
<PackageReference Include="NLog" Version="4.7.6" />
</ItemGroup>

View File

@ -3,7 +3,7 @@ using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config.V2;
using Newtonsoft.Json;
using NLog;
@ -15,7 +15,7 @@ namespace BililiveRecorder.Core.Callback
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private static readonly HttpClient client;
private readonly ConfigV1 Config;
private readonly ConfigV2 Config;
static BasicWebhook()
{
@ -23,21 +23,21 @@ namespace BililiveRecorder.Core.Callback
client.DefaultRequestHeaders.Add("User-Agent", $"BililiveRecorder/{typeof(BasicWebhook).Assembly.GetName().Version}-{BuildInfo.HeadShaShort}");
}
public BasicWebhook(ConfigV1 config)
public BasicWebhook(ConfigV2 config)
{
this.Config = config ?? throw new ArgumentNullException(nameof(config));
}
public async void Send(RecordEndData data)
{
var urls = this.Config.WebHookUrls;
var urls = this.Config.Global.WebHookUrls;
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
var tasks = urls!
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x))

View File

@ -0,0 +1,15 @@
using JsonSubTypes;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace BililiveRecorder.Core.Config
{
[JsonConverter(typeof(JsonSubtypes), nameof(Version))]
[JsonSubtypes.KnownSubType(typeof(V1.ConfigV1Wrapper), 1)]
[JsonSubtypes.KnownSubType(typeof(V2.ConfigV2), 2)]
public abstract class ConfigBase
{
[JsonProperty("version")]
public virtual int Version { get; internal protected set; }
}
}

View File

@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using BililiveRecorder.FlvProcessor;
#nullable enable
#pragma warning disable CS0612 // obsolete
namespace BililiveRecorder.Core.Config
{
internal static class ConfigMapper
{
public static V2.ConfigV2 Map1To2(V1.ConfigV1 v1)
{
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);
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.TimingCheckInterval, x => x.TimingCheckInterval);
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.TimingWatchdogTimeout, x => x.TimingWatchdogTimeout);
AddMap<V1.ConfigV1, V2.GlobalConfig, uint>(map, x => x.RecordDanmakuFlushInterval, x => x.RecordDanmakuFlushInterval);
AddMap<V1.ConfigV1, V2.GlobalConfig, string?>(map, x => x.Cookie, x => x.Cookie);
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, 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);
AddMap<V1.ConfigV1, V2.GlobalConfig, bool>(map, x => x.RecordDanmakuSuperChat, x => x.RecordDanmakuSuperChat);
AddMap<V1.ConfigV1, V2.GlobalConfig, bool>(map, x => x.RecordDanmakuGift, x => x.RecordDanmakuGift);
AddMap<V1.ConfigV1, V2.GlobalConfig, bool>(map, x => x.RecordDanmakuGuard, x => x.RecordDanmakuGuard);
var def = new V1.ConfigV1(); // old default
var v2 = new V2.ConfigV2();
foreach (var item in map)
{
var data = item.Key.GetValue(v1);
if (!(data?.Equals(item.Key.GetValue(def)) ?? true))
item.Value.SetValue(v2.Global, data);
}
v2.Rooms = v1.RoomList.Select(x => new V2.RoomConfig { RoomId = x.Roomid, AutoRecord = x.Enabled }).ToList();
return v2;
}
private static void AddMap<T1, T2, T3>(Dictionary<PropertyInfo, PropertyInfo> map, Expression<Func<T1, T3>> keyExpr, Expression<Func<T2, T3>> valueExpr)
{
var key = GetProperty(keyExpr);
var value = GetProperty(valueExpr);
if ((key is null) || (value is null))
return;
map.Add(key, value);
}
private static PropertyInfo? GetProperty<TType, TValue>(Expression<Func<TType, TValue>> expression)
=> (expression.Body as MemberExpression)?.Member as PropertyInfo;
}
}

View File

@ -1,88 +1,111 @@
using Newtonsoft.Json;
using System;
using System.IO;
using System.Text;
using Newtonsoft.Json;
using NLog;
#nullable enable
namespace BililiveRecorder.Core.Config
{
public static class ConfigParser
{
private const string CONFIG_FILE_NAME = "config.json";
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
public static bool Load(string directory, ConfigV1 config = null)
{
if (!Directory.Exists(directory))
{
return false;
}
var filepath = Path.Combine(directory, "config.json");
if (File.Exists(filepath))
public static V2.ConfigV2? LoadFrom(string directory)
{
try
{
var cw = JsonConvert.DeserializeObject<ConfigWrapper>(File.ReadAllText(filepath));
switch (cw.Version)
if (!Directory.Exists(directory))
return null;
var filepath = Path.Combine(directory, CONFIG_FILE_NAME);
if (!File.Exists(filepath))
{
case 1:
{
var v1 = JsonConvert.DeserializeObject<ConfigV1>(cw.Data);
v1.CopyPropertiesTo(config);
return true;
// (v1.ToV2()).CopyPropertiesTo(config);
logger.Debug("Config file does not exist. \"{path}\"", filepath);
return new V2.ConfigV2();
}
/**
* case 2:
* {
* var v2 = JsonConvert.DeserializeObject<ConfigV2>(cw.Data);
* v2.CopyPropertiesTo(config);
* return true;
* }
* */
logger.Debug("Loading config from path \"{path}\".", filepath);
var json = File.ReadAllText(filepath, Encoding.UTF8);
return LoadJson(json);
}
catch (Exception ex)
{
logger.Error(ex, "从文件加载设置时出错");
return null;
}
}
public static V2.ConfigV2? LoadJson(string json)
{
try
{
logger.Debug("Config json: {config}", json);
var configBase = JsonConvert.DeserializeObject<ConfigBase>(json);
switch (configBase)
{
case V1.ConfigV1Wrapper v1:
{
logger.Debug("读取到 config v1");
#pragma warning disable CS0612
var v1Data = JsonConvert.DeserializeObject<V1.ConfigV1>(v1.Data);
#pragma warning restore CS0612
var newConfig = ConfigMapper.Map1To2(v1Data);
return newConfig;
}
case V2.ConfigV2 v2:
logger.Debug("读取到 config v2");
return v2;
default:
// version not supported
// TODO: return status enum
return false;
logger.Error("读取到不支持的设置版本");
return null;
}
}
catch (Exception ex)
{
logger.Error(ex, "Failed to parse config!");
return false;
}
}
else
{
new ConfigV1().CopyPropertiesTo(config);
return true;
logger.Error(ex, "解析设置时出错");
return null;
}
}
public static bool Save(string directory, ConfigV1 config = null)
public static bool SaveTo(string directory, V2.ConfigV2 config)
{
if (config == null) { config = new ConfigV1(); }
if (!Directory.Exists(directory))
{
// User should create the directory
// TODO: return enum
return false;
}
var filepath = Path.Combine(directory, "config.json");
var json = SaveJson(config);
try
{
var data = JsonConvert.SerializeObject(config);
var cw = JsonConvert.SerializeObject(new ConfigWrapper()
{
Version = 1,
Data = data
});
File.WriteAllText(filepath, cw);
if (!Directory.Exists(directory))
return false;
var filepath = Path.Combine(directory, CONFIG_FILE_NAME);
if (json is not null)
File.WriteAllText(filepath, json, Encoding.UTF8);
return true;
}
catch (Exception)
catch (Exception ex)
{
logger.Error(ex, "保存设置时出错(写入文件)");
return false;
// TODO: Log Exception
}
}
public static string? SaveJson(V2.ConfigV2 config)
{
try
{
var json = JsonConvert.SerializeObject(config);
return json;
}
catch (Exception ex)
{
logger.Error(ex, "保存设置时出错(序列化)");
return null;
}
}
}

View File

@ -1,20 +0,0 @@
using Newtonsoft.Json;
namespace BililiveRecorder.Core.Config
{
[JsonObject(MemberSerialization = MemberSerialization.OptIn)]
internal class ConfigWrapper
{
/// <summary>
/// Config Version
/// </summary>
[JsonProperty("version")]
public int Version { get; set; }
/// <summary>
/// Config Data String
/// </summary>
[JsonProperty("data")]
public string Data { get; set; }
}
}

View File

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
@ -5,8 +6,9 @@ using BililiveRecorder.FlvProcessor;
using Newtonsoft.Json;
using NLog;
namespace BililiveRecorder.Core.Config
namespace BililiveRecorder.Core.Config.V1
{
[Obsolete]
[JsonObject(memberSerialization: MemberSerialization.OptIn)]
public class ConfigV1 : INotifyPropertyChanged
{
@ -17,7 +19,7 @@ namespace BililiveRecorder.Core.Config
/// </summary>
[JsonIgnore]
[Utils.DoNotCopyProperty]
public string WorkDirectory { get => _workDirectory; set => SetField(ref _workDirectory, value); }
public string WorkDirectory { get => this._workDirectory; set => this.SetField(ref this._workDirectory, value); }
/// <summary>
@ -30,122 +32,122 @@ namespace BililiveRecorder.Core.Config
/// 启用的功能
/// </summary>
[JsonProperty("feature")]
public EnabledFeature EnabledFeature { get => _enabledFeature; set => SetField(ref _enabledFeature, value); }
public EnabledFeature EnabledFeature { get => this._enabledFeature; set => this.SetField(ref this._enabledFeature, value); }
/// <summary>
/// 剪辑-过去的时长(秒)
/// </summary>
[JsonProperty("clip_length_future")]
public uint ClipLengthFuture { get => _clipLengthFuture; set => SetField(ref _clipLengthFuture, value); }
public uint ClipLengthFuture { get => this._clipLengthFuture; set => this.SetField(ref this._clipLengthFuture, value); }
/// <summary>
/// 剪辑-将来的时长(秒)
/// </summary>
[JsonProperty("clip_length_past")]
public uint ClipLengthPast { get => _clipLengthPast; set => SetField(ref _clipLengthPast, value); }
public uint ClipLengthPast { get => this._clipLengthPast; set => this.SetField(ref this._clipLengthPast, value); }
/// <summary>
/// 自动切割模式
/// </summary>
[JsonProperty("cutting_mode")]
public AutoCuttingMode CuttingMode { get => _cuttingMode; set => SetField(ref _cuttingMode, value); }
public AutoCuttingMode CuttingMode { get => this._cuttingMode; set => this.SetField(ref this._cuttingMode, value); }
/// <summary>
/// 自动切割数值(分钟/MiB
/// </summary>
[JsonProperty("cutting_number")]
public uint CuttingNumber { get => _cuttingNumber; set => SetField(ref _cuttingNumber, value); }
public uint CuttingNumber { get => this._cuttingNumber; set => this.SetField(ref this._cuttingNumber, value); }
/// <summary>
/// 录制断开重连时间间隔 毫秒
/// </summary>
[JsonProperty("timing_stream_retry")]
public uint TimingStreamRetry { get => _timingStreamRetry; set => SetField(ref _timingStreamRetry, value); }
public uint TimingStreamRetry { get => this._timingStreamRetry; set => this.SetField(ref this._timingStreamRetry, value); }
/// <summary>
/// 连接直播服务器超时时间 毫秒
/// </summary>
[JsonProperty("timing_stream_connect")]
public uint TimingStreamConnect { get => _timingStreamConnect; set => SetField(ref _timingStreamConnect, value); }
public uint TimingStreamConnect { get => this._timingStreamConnect; set => this.SetField(ref this._timingStreamConnect, value); }
/// <summary>
/// 弹幕服务器重连时间间隔 毫秒
/// </summary>
[JsonProperty("timing_danmaku_retry")]
public uint TimingDanmakuRetry { get => _timingDanmakuRetry; set => SetField(ref _timingDanmakuRetry, value); }
public uint TimingDanmakuRetry { get => this._timingDanmakuRetry; set => this.SetField(ref this._timingDanmakuRetry, value); }
/// <summary>
/// HTTP API 检查时间间隔 秒
/// </summary>
[JsonProperty("timing_check_interval")]
public uint TimingCheckInterval { get => _timingCheckInterval; set => SetField(ref _timingCheckInterval, value); }
public uint TimingCheckInterval { get => this._timingCheckInterval; set => this.SetField(ref this._timingCheckInterval, value); }
/// <summary>
/// 最大未收到新直播数据时间 毫秒
/// </summary>
[JsonProperty("timing_watchdog_timeout")]
public uint TimingWatchdogTimeout { get => _timingWatchdogTimeout; set => SetField(ref _timingWatchdogTimeout, value); }
public uint TimingWatchdogTimeout { get => this._timingWatchdogTimeout; set => this.SetField(ref this._timingWatchdogTimeout, value); }
/// <summary>
/// 请求 API 时使用的 Cookie
/// </summary>
[JsonProperty("cookie")]
public string Cookie { get => _cookie; set => SetField(ref _cookie, value); }
public string Cookie { get => this._cookie; set => this.SetField(ref this._cookie, value); }
/// <summary>
/// 是否同时录制弹幕
/// </summary>
[JsonProperty("record_danmaku")]
public bool RecordDanmaku { get => _recordDanmaku; set => SetField(ref _recordDanmaku, value); }
public bool RecordDanmaku { get => this._recordDanmaku; set => this.SetField(ref this._recordDanmaku, value); }
/// <summary>
/// 是否记录弹幕原始数据
/// </summary>
[JsonProperty("record_danmaku_raw")]
public bool RecordDanmakuRaw { get => _recordDanmakuRaw; set => SetField(ref _recordDanmakuRaw, value); }
public bool RecordDanmakuRaw { get => this._recordDanmakuRaw; set => this.SetField(ref this._recordDanmakuRaw, value); }
/// <summary>
/// 是否同时录制 SuperChat
/// </summary>
[JsonProperty("record_danmaku_sc")]
public bool RecordDanmakuSuperChat { get => _recordDanmakuSuperChat; set => SetField(ref _recordDanmakuSuperChat, value); }
public bool RecordDanmakuSuperChat { get => this._recordDanmakuSuperChat; set => this.SetField(ref this._recordDanmakuSuperChat, value); }
/// <summary>
/// 是否同时录制 礼物
/// </summary>
[JsonProperty("record_danmaku_gift")]
public bool RecordDanmakuGift { get => _recordDanmakuGift; set => SetField(ref _recordDanmakuGift, value); }
public bool RecordDanmakuGift { get => this._recordDanmakuGift; set => this.SetField(ref this._recordDanmakuGift, value); }
/// <summary>
/// 是否同时录制 上船
/// </summary>
[JsonProperty("record_danmaku_guard")]
public bool RecordDanmakuGuard { get => _recordDanmakuGuard; set => SetField(ref _recordDanmakuGuard, value); }
public bool RecordDanmakuGuard { get => this._recordDanmakuGuard; set => this.SetField(ref this._recordDanmakuGuard, value); }
/// <summary>
/// 触发 <see cref="System.Xml.XmlWriter.Flush"/> 的弹幕个数
/// </summary>
[JsonProperty("record_danmaku_flush_interval")]
public uint RecordDanmakuFlushInterval { get => _recordDanmakuFlushInterval; set => SetField(ref _recordDanmakuFlushInterval, value); }
public uint RecordDanmakuFlushInterval { get => this._recordDanmakuFlushInterval; set => this.SetField(ref this._recordDanmakuFlushInterval, value); }
/// <summary>
/// 替换api.live.bilibili.com服务器为其他反代可以支持在云服务器上录制
/// </summary>
[JsonProperty("live_api_host")]
public string LiveApiHost { get => _liveApiHost; set => SetField(ref _liveApiHost, value); }
public string LiveApiHost { get => this._liveApiHost; set => this.SetField(ref this._liveApiHost, value); }
[JsonProperty("record_filename_format")]
public string RecordFilenameFormat
{
get => _record_filename_format;
set => SetField(ref _record_filename_format, value);
get => this._record_filename_format;
set => this.SetField(ref this._record_filename_format, value);
}
[JsonProperty("clip_filename_format")]
public string ClipFilenameFormat
{
get => _clip_filename_format;
set => SetField(ref _clip_filename_format, value);
get => this._clip_filename_format;
set => this.SetField(ref this._clip_filename_format, value);
}
/// <summary>
@ -154,8 +156,8 @@ namespace BililiveRecorder.Core.Config
[JsonProperty("webhook_urls")]
public string WebHookUrls
{
get => _webhook_urls;
set => SetField(ref _webhook_urls, value);
get => this._webhook_urls;
set => this.SetField(ref this._webhook_urls, value);
}
#region INotifyPropertyChanged
@ -163,9 +165,8 @@ namespace BililiveRecorder.Core.Config
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);
field = value; OnPropertyChanged(propertyName); return true;
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

View File

@ -0,0 +1,13 @@
using Newtonsoft.Json;
namespace BililiveRecorder.Core.Config.V1
{
internal sealed class ConfigV1Wrapper : ConfigBase
{
/// <summary>
/// Config Data String
/// </summary>
[JsonProperty("data")]
public string Data { get; set; }
}
}

View File

@ -1,6 +1,6 @@
using Newtonsoft.Json;
using Newtonsoft.Json;
namespace BililiveRecorder.Core.Config
namespace BililiveRecorder.Core.Config.V1
{
[JsonObject(MemberSerialization = MemberSerialization.OptIn)]
public class RoomV1

View File

@ -0,0 +1,382 @@
// ******************************
// GENERATED CODE, DO NOT EDIT.
// RUN FORMATTER AFTER GENERATE
// ******************************
using System.ComponentModel;
using BililiveRecorder.FlvProcessor;
using HierarchicalPropertyDefault;
using Newtonsoft.Json;
#nullable enable
namespace BililiveRecorder.Core.Config.V2
{
[JsonObject(MemberSerialization.OptIn)]
public sealed partial class RoomConfig : HierarchicalObject<GlobalConfig, RoomConfig>
{
/// <summary>
/// 房间号
/// </summary>
public int RoomId { get => this.GetPropertyValue<int>(); set => this.SetPropertyValue(value); }
public bool HasRoomId { get => this.GetPropertyHasValue(nameof(this.RoomId)); set => this.SetPropertyHasValue<int>(value, nameof(this.RoomId)); }
[JsonProperty(nameof(RoomId)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<int> OptionalRoomId { get => this.GetPropertyValueOptional<int>(nameof(this.RoomId)); set => this.SetPropertyValueOptional(value, nameof(this.RoomId)); }
/// <summary>
/// 是否启用自动录制
/// </summary>
public bool AutoRecord { get => this.GetPropertyValue<bool>(); set => this.SetPropertyValue(value); }
public bool HasAutoRecord { get => this.GetPropertyHasValue(nameof(this.AutoRecord)); set => this.SetPropertyHasValue<bool>(value, nameof(this.AutoRecord)); }
[JsonProperty(nameof(AutoRecord)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<bool> OptionalAutoRecord { get => this.GetPropertyValueOptional<bool>(nameof(this.AutoRecord)); set => this.SetPropertyValueOptional(value, nameof(this.AutoRecord)); }
/// <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)); }
[JsonProperty(nameof(CuttingMode)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<AutoCuttingMode> OptionalCuttingMode { get => this.GetPropertyValueOptional<AutoCuttingMode>(nameof(this.CuttingMode)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingMode)); }
/// <summary>
/// 录制文件自动切割数值(分钟/MiB
/// </summary>
public uint CuttingNumber { get => this.GetPropertyValue<uint>(); set => this.SetPropertyValue(value); }
public bool HasCuttingNumber { get => this.GetPropertyHasValue(nameof(this.CuttingNumber)); set => this.SetPropertyHasValue<uint>(value, nameof(this.CuttingNumber)); }
[JsonProperty(nameof(CuttingNumber)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<uint> OptionalCuttingNumber { get => this.GetPropertyValueOptional<uint>(nameof(this.CuttingNumber)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingNumber)); }
/// <summary>
/// 是否同时录制弹幕
/// </summary>
public bool RecordDanmaku { get => this.GetPropertyValue<bool>(); set => this.SetPropertyValue(value); }
public bool HasRecordDanmaku { get => this.GetPropertyHasValue(nameof(this.RecordDanmaku)); set => this.SetPropertyHasValue<bool>(value, nameof(this.RecordDanmaku)); }
[JsonProperty(nameof(RecordDanmaku)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<bool> OptionalRecordDanmaku { get => this.GetPropertyValueOptional<bool>(nameof(this.RecordDanmaku)); set => this.SetPropertyValueOptional(value, nameof(this.RecordDanmaku)); }
/// <summary>
/// 是否记录弹幕原始数据
/// </summary>
public bool RecordDanmakuRaw { get => this.GetPropertyValue<bool>(); set => this.SetPropertyValue(value); }
public bool HasRecordDanmakuRaw { get => this.GetPropertyHasValue(nameof(this.RecordDanmakuRaw)); set => this.SetPropertyHasValue<bool>(value, nameof(this.RecordDanmakuRaw)); }
[JsonProperty(nameof(RecordDanmakuRaw)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<bool> OptionalRecordDanmakuRaw { get => this.GetPropertyValueOptional<bool>(nameof(this.RecordDanmakuRaw)); set => this.SetPropertyValueOptional(value, nameof(this.RecordDanmakuRaw)); }
/// <summary>
/// 是否同时录制 SuperChat
/// </summary>
public bool RecordDanmakuSuperChat { get => this.GetPropertyValue<bool>(); set => this.SetPropertyValue(value); }
public bool HasRecordDanmakuSuperChat { get => this.GetPropertyHasValue(nameof(this.RecordDanmakuSuperChat)); set => this.SetPropertyHasValue<bool>(value, nameof(this.RecordDanmakuSuperChat)); }
[JsonProperty(nameof(RecordDanmakuSuperChat)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<bool> OptionalRecordDanmakuSuperChat { get => this.GetPropertyValueOptional<bool>(nameof(this.RecordDanmakuSuperChat)); set => this.SetPropertyValueOptional(value, nameof(this.RecordDanmakuSuperChat)); }
/// <summary>
/// 是否同时录制 礼物
/// </summary>
public bool RecordDanmakuGift { get => this.GetPropertyValue<bool>(); set => this.SetPropertyValue(value); }
public bool HasRecordDanmakuGift { get => this.GetPropertyHasValue(nameof(this.RecordDanmakuGift)); set => this.SetPropertyHasValue<bool>(value, nameof(this.RecordDanmakuGift)); }
[JsonProperty(nameof(RecordDanmakuGift)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<bool> OptionalRecordDanmakuGift { get => this.GetPropertyValueOptional<bool>(nameof(this.RecordDanmakuGift)); set => this.SetPropertyValueOptional(value, nameof(this.RecordDanmakuGift)); }
/// <summary>
/// 是否同时录制 上船
/// </summary>
public bool RecordDanmakuGuard { get => this.GetPropertyValue<bool>(); set => this.SetPropertyValue(value); }
public bool HasRecordDanmakuGuard { get => this.GetPropertyHasValue(nameof(this.RecordDanmakuGuard)); set => this.SetPropertyHasValue<bool>(value, nameof(this.RecordDanmakuGuard)); }
[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>
public uint TimingStreamRetry => this.GetPropertyValue<uint>();
/// <summary>
/// 连接直播服务器超时时间 毫秒
/// </summary>
public uint TimingStreamConnect => this.GetPropertyValue<uint>();
/// <summary>
/// 弹幕服务器重连时间间隔 毫秒
/// </summary>
public uint TimingDanmakuRetry => this.GetPropertyValue<uint>();
/// <summary>
/// HTTP API 检查时间间隔 秒
/// </summary>
public uint TimingCheckInterval => this.GetPropertyValue<uint>();
/// <summary>
/// 最大未收到新直播数据时间 毫秒
/// </summary>
public uint TimingWatchdogTimeout => this.GetPropertyValue<uint>();
/// <summary>
/// 触发 <see cref="System.Xml.XmlWriter.Flush"/> 的弹幕个数
/// </summary>
public uint RecordDanmakuFlushInterval => this.GetPropertyValue<uint>();
/// <summary>
/// 请求 API 时使用的 Cookie
/// </summary>
public string? Cookie => this.GetPropertyValue<string>();
/// <summary>
/// 录制文件写入结束 Webhook 地址 每行一个
/// </summary>
public string? WebHookUrls => this.GetPropertyValue<string>();
/// <summary>
/// 替换 api.live.bilibili.com 服务器为其他反代,可以支持在云服务器上录制
/// </summary>
public string? LiveApiHost => this.GetPropertyValue<string>();
/// <summary>
/// 录制文件名模板
/// </summary>
public string? RecordFilenameFormat => this.GetPropertyValue<string>();
/// <summary>
/// 剪辑文件名模板
/// </summary>
public string? ClipFilenameFormat => this.GetPropertyValue<string>();
}
[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>
public uint TimingStreamRetry { get => this.GetPropertyValue<uint>(); set => this.SetPropertyValue(value); }
public bool HasTimingStreamRetry { get => this.GetPropertyHasValue(nameof(this.TimingStreamRetry)); set => this.SetPropertyHasValue<uint>(value, nameof(this.TimingStreamRetry)); }
[JsonProperty(nameof(TimingStreamRetry)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<uint> OptionalTimingStreamRetry { get => this.GetPropertyValueOptional<uint>(nameof(this.TimingStreamRetry)); set => this.SetPropertyValueOptional(value, nameof(this.TimingStreamRetry)); }
/// <summary>
/// 连接直播服务器超时时间 毫秒
/// </summary>
public uint TimingStreamConnect { get => this.GetPropertyValue<uint>(); set => this.SetPropertyValue(value); }
public bool HasTimingStreamConnect { get => this.GetPropertyHasValue(nameof(this.TimingStreamConnect)); set => this.SetPropertyHasValue<uint>(value, nameof(this.TimingStreamConnect)); }
[JsonProperty(nameof(TimingStreamConnect)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<uint> OptionalTimingStreamConnect { get => this.GetPropertyValueOptional<uint>(nameof(this.TimingStreamConnect)); set => this.SetPropertyValueOptional(value, nameof(this.TimingStreamConnect)); }
/// <summary>
/// 弹幕服务器重连时间间隔 毫秒
/// </summary>
public uint TimingDanmakuRetry { get => this.GetPropertyValue<uint>(); set => this.SetPropertyValue(value); }
public bool HasTimingDanmakuRetry { get => this.GetPropertyHasValue(nameof(this.TimingDanmakuRetry)); set => this.SetPropertyHasValue<uint>(value, nameof(this.TimingDanmakuRetry)); }
[JsonProperty(nameof(TimingDanmakuRetry)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<uint> OptionalTimingDanmakuRetry { get => this.GetPropertyValueOptional<uint>(nameof(this.TimingDanmakuRetry)); set => this.SetPropertyValueOptional(value, nameof(this.TimingDanmakuRetry)); }
/// <summary>
/// HTTP API 检查时间间隔 秒
/// </summary>
public uint TimingCheckInterval { get => this.GetPropertyValue<uint>(); set => this.SetPropertyValue(value); }
public bool HasTimingCheckInterval { get => this.GetPropertyHasValue(nameof(this.TimingCheckInterval)); set => this.SetPropertyHasValue<uint>(value, nameof(this.TimingCheckInterval)); }
[JsonProperty(nameof(TimingCheckInterval)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<uint> OptionalTimingCheckInterval { get => this.GetPropertyValueOptional<uint>(nameof(this.TimingCheckInterval)); set => this.SetPropertyValueOptional(value, nameof(this.TimingCheckInterval)); }
/// <summary>
/// 最大未收到新直播数据时间 毫秒
/// </summary>
public uint TimingWatchdogTimeout { get => this.GetPropertyValue<uint>(); set => this.SetPropertyValue(value); }
public bool HasTimingWatchdogTimeout { get => this.GetPropertyHasValue(nameof(this.TimingWatchdogTimeout)); set => this.SetPropertyHasValue<uint>(value, nameof(this.TimingWatchdogTimeout)); }
[JsonProperty(nameof(TimingWatchdogTimeout)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<uint> OptionalTimingWatchdogTimeout { get => this.GetPropertyValueOptional<uint>(nameof(this.TimingWatchdogTimeout)); set => this.SetPropertyValueOptional(value, nameof(this.TimingWatchdogTimeout)); }
/// <summary>
/// 触发 <see cref="System.Xml.XmlWriter.Flush"/> 的弹幕个数
/// </summary>
public uint RecordDanmakuFlushInterval { get => this.GetPropertyValue<uint>(); set => this.SetPropertyValue(value); }
public bool HasRecordDanmakuFlushInterval { get => this.GetPropertyHasValue(nameof(this.RecordDanmakuFlushInterval)); set => this.SetPropertyHasValue<uint>(value, nameof(this.RecordDanmakuFlushInterval)); }
[JsonProperty(nameof(RecordDanmakuFlushInterval)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<uint> OptionalRecordDanmakuFlushInterval { get => this.GetPropertyValueOptional<uint>(nameof(this.RecordDanmakuFlushInterval)); set => this.SetPropertyValueOptional(value, nameof(this.RecordDanmakuFlushInterval)); }
/// <summary>
/// 请求 API 时使用的 Cookie
/// </summary>
public string? Cookie { get => this.GetPropertyValue<string>(); set => this.SetPropertyValue(value); }
public bool HasCookie { get => this.GetPropertyHasValue(nameof(this.Cookie)); set => this.SetPropertyHasValue<string>(value, nameof(this.Cookie)); }
[JsonProperty(nameof(Cookie)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<string?> OptionalCookie { get => this.GetPropertyValueOptional<string>(nameof(this.Cookie)); set => this.SetPropertyValueOptional(value, nameof(this.Cookie)); }
/// <summary>
/// 录制文件写入结束 Webhook 地址 每行一个
/// </summary>
public string? WebHookUrls { get => this.GetPropertyValue<string>(); set => this.SetPropertyValue(value); }
public bool HasWebHookUrls { get => this.GetPropertyHasValue(nameof(this.WebHookUrls)); set => this.SetPropertyHasValue<string>(value, nameof(this.WebHookUrls)); }
[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>
/// 替换 api.live.bilibili.com 服务器为其他反代,可以支持在云服务器上录制
/// </summary>
public string? LiveApiHost { get => this.GetPropertyValue<string>(); set => this.SetPropertyValue(value); }
public bool HasLiveApiHost { get => this.GetPropertyHasValue(nameof(this.LiveApiHost)); set => this.SetPropertyHasValue<string>(value, nameof(this.LiveApiHost)); }
[JsonProperty(nameof(LiveApiHost)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<string?> OptionalLiveApiHost { get => this.GetPropertyValueOptional<string>(nameof(this.LiveApiHost)); set => this.SetPropertyValueOptional(value, nameof(this.LiveApiHost)); }
/// <summary>
/// 录制文件名模板
/// </summary>
public string? RecordFilenameFormat { get => this.GetPropertyValue<string>(); set => this.SetPropertyValue(value); }
public bool HasRecordFilenameFormat { get => this.GetPropertyHasValue(nameof(this.RecordFilenameFormat)); set => this.SetPropertyHasValue<string>(value, nameof(this.RecordFilenameFormat)); }
[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>
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)); }
[JsonProperty(nameof(CuttingMode)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<AutoCuttingMode> OptionalCuttingMode { get => this.GetPropertyValueOptional<AutoCuttingMode>(nameof(this.CuttingMode)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingMode)); }
/// <summary>
/// 录制文件自动切割数值(分钟/MiB
/// </summary>
public uint CuttingNumber { get => this.GetPropertyValue<uint>(); set => this.SetPropertyValue(value); }
public bool HasCuttingNumber { get => this.GetPropertyHasValue(nameof(this.CuttingNumber)); set => this.SetPropertyHasValue<uint>(value, nameof(this.CuttingNumber)); }
[JsonProperty(nameof(CuttingNumber)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<uint> OptionalCuttingNumber { get => this.GetPropertyValueOptional<uint>(nameof(this.CuttingNumber)); set => this.SetPropertyValueOptional(value, nameof(this.CuttingNumber)); }
/// <summary>
/// 是否同时录制弹幕
/// </summary>
public bool RecordDanmaku { get => this.GetPropertyValue<bool>(); set => this.SetPropertyValue(value); }
public bool HasRecordDanmaku { get => this.GetPropertyHasValue(nameof(this.RecordDanmaku)); set => this.SetPropertyHasValue<bool>(value, nameof(this.RecordDanmaku)); }
[JsonProperty(nameof(RecordDanmaku)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<bool> OptionalRecordDanmaku { get => this.GetPropertyValueOptional<bool>(nameof(this.RecordDanmaku)); set => this.SetPropertyValueOptional(value, nameof(this.RecordDanmaku)); }
/// <summary>
/// 是否记录弹幕原始数据
/// </summary>
public bool RecordDanmakuRaw { get => this.GetPropertyValue<bool>(); set => this.SetPropertyValue(value); }
public bool HasRecordDanmakuRaw { get => this.GetPropertyHasValue(nameof(this.RecordDanmakuRaw)); set => this.SetPropertyHasValue<bool>(value, nameof(this.RecordDanmakuRaw)); }
[JsonProperty(nameof(RecordDanmakuRaw)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<bool> OptionalRecordDanmakuRaw { get => this.GetPropertyValueOptional<bool>(nameof(this.RecordDanmakuRaw)); set => this.SetPropertyValueOptional(value, nameof(this.RecordDanmakuRaw)); }
/// <summary>
/// 是否同时录制 SuperChat
/// </summary>
public bool RecordDanmakuSuperChat { get => this.GetPropertyValue<bool>(); set => this.SetPropertyValue(value); }
public bool HasRecordDanmakuSuperChat { get => this.GetPropertyHasValue(nameof(this.RecordDanmakuSuperChat)); set => this.SetPropertyHasValue<bool>(value, nameof(this.RecordDanmakuSuperChat)); }
[JsonProperty(nameof(RecordDanmakuSuperChat)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<bool> OptionalRecordDanmakuSuperChat { get => this.GetPropertyValueOptional<bool>(nameof(this.RecordDanmakuSuperChat)); set => this.SetPropertyValueOptional(value, nameof(this.RecordDanmakuSuperChat)); }
/// <summary>
/// 是否同时录制 礼物
/// </summary>
public bool RecordDanmakuGift { get => this.GetPropertyValue<bool>(); set => this.SetPropertyValue(value); }
public bool HasRecordDanmakuGift { get => this.GetPropertyHasValue(nameof(this.RecordDanmakuGift)); set => this.SetPropertyHasValue<bool>(value, nameof(this.RecordDanmakuGift)); }
[JsonProperty(nameof(RecordDanmakuGift)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<bool> OptionalRecordDanmakuGift { get => this.GetPropertyValueOptional<bool>(nameof(this.RecordDanmakuGift)); set => this.SetPropertyValueOptional(value, nameof(this.RecordDanmakuGift)); }
/// <summary>
/// 是否同时录制 上船
/// </summary>
public bool RecordDanmakuGuard { get => this.GetPropertyValue<bool>(); set => this.SetPropertyValue(value); }
public bool HasRecordDanmakuGuard { get => this.GetPropertyHasValue(nameof(this.RecordDanmakuGuard)); set => this.SetPropertyHasValue<bool>(value, nameof(this.RecordDanmakuGuard)); }
[JsonProperty(nameof(RecordDanmakuGuard)), EditorBrowsable(EditorBrowsableState.Never)]
public Optional<bool> OptionalRecordDanmakuGuard { get => this.GetPropertyValueOptional<bool>(nameof(this.RecordDanmakuGuard)); set => this.SetPropertyValueOptional(value, nameof(this.RecordDanmakuGuard)); }
}
public sealed partial class DefaultConfig
{
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;
public uint TimingDanmakuRetry => 15 * 1000;
public uint TimingCheckInterval => 5 * 60;
public uint TimingWatchdogTimeout => 10 * 1000;
public uint RecordDanmakuFlushInterval => 20;
public string Cookie => string.Empty;
public string WebHookUrls => 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 AutoCuttingMode CuttingMode => AutoCuttingMode.Disabled;
public uint CuttingNumber => 100;
public bool RecordDanmaku => false;
public bool RecordDanmakuRaw => false;
public bool RecordDanmakuSuperChat => true;
public bool RecordDanmakuGift => false;
public bool RecordDanmakuGuard => true;
}
}

View File

@ -0,0 +1,44 @@
using System.Collections.Generic;
using Newtonsoft.Json;
#nullable enable
namespace BililiveRecorder.Core.Config.V2
{
public class ConfigV2 : ConfigBase
{
public override int Version => 2;
[JsonProperty("global")]
public GlobalConfig Global { get; set; } = new GlobalConfig();
[JsonProperty("rooms")]
public List<RoomConfig> Rooms { get; set; } = new List<RoomConfig>();
}
public partial class RoomConfig
{
public RoomConfig() : base(x => x.AutoMap(p => new[] { "Has" + p.Name }))
{ }
internal void SetParent(GlobalConfig? config) => this.Parent = config;
public string? WorkDirectory => this.GetPropertyValue<string>();
}
public partial class GlobalConfig
{
public GlobalConfig() : base(x => x.AutoMap(p => new[] { "Has" + p.Name }))
{
this.Parent = DefaultConfig.Instance;
}
/// <summary>
/// 当前工作目录
/// </summary>
public string? WorkDirectory
{
get => this.GetPropertyValue<string>();
set => this.SetPropertyValue(value);
}
}
}

View File

@ -0,0 +1,126 @@
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": "录制断开重连时间间隔 毫秒",
"default": "6 * 1000"
}, {
"name": "TimingStreamConnect",
"type": "uint",
"desc": "连接直播服务器超时时间 毫秒",
"default": "5 * 1000"
}, {
"name": "TimingDanmakuRetry",
"type": "uint",
"desc": "弹幕服务器重连时间间隔 毫秒",
"default": "15 * 1000"
}, {
"name": "TimingCheckInterval",
"type": "uint",
"desc": "HTTP API 检查时间间隔 秒",
"default": "5 * 60"
}, {
"name": "TimingWatchdogTimeout",
"type": "uint",
"desc": "最大未收到新直播数据时间 毫秒",
"default": "10 * 1000"
}, {
"name": "RecordDanmakuFlushInterval",
"type": "uint",
"desc": "触发 <see cref=\"System.Xml.XmlWriter.Flush\"/> 的弹幕个数",
"default": "20"
}, {
"name": "Cookie",
"type": "string",
"desc": "请求 API 时使用的 Cookie",
"default": "string.Empty",
"nullable": true
}, {
"name": "WebHookUrls",
"type": "string",
"desc": "录制文件写入结束 Webhook 地址 每行一个",
"default": "string.Empty",
"nullable": true
}, {
"name": "LiveApiHost",
"type": "string",
"desc": "替换 api.live.bilibili.com 服务器为其他反代,可以支持在云服务器上录制",
"default": "\"https://api.live.bilibili.com\"",
"nullable": true
}, {
"name": "RecordFilenameFormat",
"type": "string",
"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
}, ],
"room": [{
"name": "RoomId",
"type": "int",
"desc": "房间号",
"default": "default",
"without_global": true
}, {
"name": "AutoRecord",
"type": "bool",
"desc": "是否启用自动录制",
"default": "default",
"without_global": true
}, {
"name": "CuttingMode",
"type": "AutoCuttingMode",
"desc": "录制文件自动切割模式",
"default": "AutoCuttingMode.Disabled"
}, {
"name": "CuttingNumber",
"type": "uint",
"desc": "录制文件自动切割数值(分钟/MiB",
"default": "100"
}, {
"name": "RecordDanmaku",
"type": "bool",
"desc": "是否同时录制弹幕",
"default": "false"
}, {
"name": "RecordDanmakuRaw",
"type": "bool",
"desc": "是否记录弹幕原始数据",
"default": "false"
}, {
"name": "RecordDanmakuSuperChat",
"type": "bool",
"desc": "是否同时录制 SuperChat",
"default": "true"
}, {
"name": "RecordDanmakuGift",
"type": "bool",
"desc": "是否同时录制 礼物",
"default": "false"
}, {
"name": "RecordDanmakuGuard",
"type": "bool",
"desc": "是否同时录制 上船",
"default": "true"
}, ]
}

View File

@ -0,0 +1,79 @@
"use strict";
const fs = require("fs");
const data = require("./build_config.data.js");
const CODE_HEADER =
`// ******************************
// GENERATED CODE, DO NOT EDIT.
// RUN FORMATTER AFTER GENERATE
// ******************************
using System.ComponentModel;
using BililiveRecorder.FlvProcessor;
using HierarchicalPropertyDefault;
using Newtonsoft.Json;
#nullable enable
namespace BililiveRecorder.Core.Config.V2
{
`;
const CODE_FOOTER = `}\n`;
let result = CODE_HEADER;
function write_property(r) {
result += `/// <summary>\n/// ${r.desc}\n/// </summary>\n`
result += `public ${r.type}${!!r.nullable ? "?" : ""} ${r.name} { get => this.GetPropertyValue<${r.type}>(); set => this.SetPropertyValue(value); }\n`
result += `public bool Has${r.name} { get => this.GetPropertyHasValue(nameof(this.${r.name})); set => this.SetPropertyHasValue<${r.type}>(value, nameof(this.${r.name})); }\n`
result += `[JsonProperty(nameof(${r.name})), EditorBrowsable(EditorBrowsableState.Never)]\n`
result += `public Optional<${r.type}${!!r.nullable ? "?" : ""}> Optional${r.name} { get => this.GetPropertyValueOptional<${r.type}>(nameof(this.${r.name})); set => this.SetPropertyValueOptional(value, nameof(this.${r.name})); }\n\n`
}
function write_readonly_property(r) {
result += `/// <summary>\n/// ${r.desc}\n/// </summary>\n`
result += `public ${r.type}${!!r.nullable ? "?" : ""} ${r.name} => this.GetPropertyValue<${r.type}>();\n\n`
}
{
result += "[JsonObject(MemberSerialization.OptIn)]\n"
result += "public sealed partial class RoomConfig : HierarchicalObject<GlobalConfig, RoomConfig>\n"
result += "{\n";
data.room.forEach(r => write_property(r))
data.global.forEach(r => write_readonly_property(r))
result += "}\n\n"
}
{
result += "[JsonObject(MemberSerialization.OptIn)]\n"
result += "public sealed partial class GlobalConfig : HierarchicalObject<DefaultConfig, GlobalConfig>\n"
result += "{\n";
data.global
.concat(data.room.filter(x => !x.without_global))
.forEach(r => write_property(r))
result += "}\n\n"
}
{
result += `public sealed partial class DefaultConfig
{
internal static readonly DefaultConfig Instance = new DefaultConfig();
private DefaultConfig() {}\n\n`;
data.global
.concat(data.room.filter(x => !x.without_global))
.forEach(r => {
result += `public ${r.type} ${r.name} => ${r.default};\n\n`
})
result += "}\n\n"
}
result += CODE_FOOTER;
fs.writeFileSync("./Config.gen.cs", result, {
encoding: "utf8"
});

View File

@ -1,22 +1,21 @@
using System.Net.Sockets;
using Autofac;
using BililiveRecorder.Core.Callback;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config.V2;
#nullable enable
namespace BililiveRecorder.Core
{
public class CoreModule : Module
{
public CoreModule()
{
}
protected override void Load(ContainerBuilder builder)
{
builder.RegisterType<ConfigV1>().AsSelf().InstancePerMatchingLifetimeScope("recorder_root");
builder.Register(x => x.Resolve<IRecorder>().Config).As<ConfigV2>();
builder.Register(x => x.Resolve<ConfigV2>().Global).As<GlobalConfig>();
builder.RegisterType<BililiveAPI>().AsSelf().InstancePerMatchingLifetimeScope("recorder_root");
builder.RegisterType<BasicWebhook>().AsSelf().InstancePerMatchingLifetimeScope("recorder_root");
builder.RegisterType<TcpClient>().AsSelf().ExternallyOwned();
builder.RegisterType<StreamMonitor>().As<IStreamMonitor>().ExternallyOwned();
builder.RegisterType<RecordedRoom>().As<IRecordedRoom>().ExternallyOwned();

View File

@ -1,5 +1,5 @@
using Newtonsoft.Json.Linq;
using System;
using System;
using Newtonsoft.Json.Linq;
namespace BililiveRecorder.Core
{
@ -68,8 +68,8 @@ namespace BililiveRecorder.Core
[Obsolete("请使用 UserName")]
public string CommentUser
{
get { return UserName; }
set { UserName = value; }
get { return this.UserName; }
set { this.UserName = value; }
}
/// <summary>
@ -123,8 +123,8 @@ namespace BililiveRecorder.Core
[Obsolete("请使用 UserName")]
public string GiftUser
{
get { return UserName; }
set { UserName = value; }
get { return this.UserName; }
set { this.UserName = value; }
}
/// <summary>
@ -136,7 +136,7 @@ namespace BililiveRecorder.Core
/// 禮物數量
/// </summary>
[Obsolete("请使用 GiftCount")]
public string GiftNum { get { return GiftCount.ToString(); } }
public string GiftNum { get { return this.GiftCount.ToString(); } }
/// <summary>
/// 礼物数量
@ -198,56 +198,56 @@ namespace BililiveRecorder.Core
public DanmakuModel(string JSON)
{
RawData = JSON;
JSON_Version = 2;
this.RawData = JSON;
this.JSON_Version = 2;
var obj = JObject.Parse(JSON);
RawObj = obj;
this.RawObj = obj;
string cmd = obj["cmd"]?.ToObject<string>();
switch (cmd)
{
case "LIVE":
MsgType = MsgTypeEnum.LiveStart;
RoomID = obj["roomid"].ToObject<string>();
this.MsgType = MsgTypeEnum.LiveStart;
this.RoomID = obj["roomid"].ToObject<string>();
break;
case "PREPARING":
MsgType = MsgTypeEnum.LiveEnd;
RoomID = obj["roomid"].ToObject<string>();
this.MsgType = MsgTypeEnum.LiveEnd;
this.RoomID = obj["roomid"].ToObject<string>();
break;
case "DANMU_MSG":
MsgType = MsgTypeEnum.Comment;
CommentText = obj["info"][1].ToObject<string>();
UserID = obj["info"][2][0].ToObject<int>();
UserName = obj["info"][2][1].ToObject<string>();
IsAdmin = obj["info"][2][2].ToObject<string>() == "1";
IsVIP = obj["info"][2][3].ToObject<string>() == "1";
UserGuardLevel = obj["info"][7].ToObject<int>();
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":
MsgType = MsgTypeEnum.GiftSend;
GiftName = obj["data"]["giftName"].ToObject<string>();
UserName = obj["data"]["uname"].ToObject<string>();
UserID = obj["data"]["uid"].ToObject<int>();
GiftCount = obj["data"]["num"].ToObject<int>();
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":
{
MsgType = MsgTypeEnum.GuardBuy;
UserID = obj["data"]["uid"].ToObject<int>();
UserName = obj["data"]["username"].ToObject<string>();
UserGuardLevel = obj["data"]["guard_level"].ToObject<int>();
GiftName = UserGuardLevel == 3 ? "舰长" : UserGuardLevel == 2 ? "提督" : UserGuardLevel == 1 ? "总督" : "";
GiftCount = obj["data"]["num"].ToObject<int>();
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":
{
MsgType = MsgTypeEnum.SuperChat;
CommentText = obj["data"]["message"]?.ToString();
UserID = obj["data"]["uid"].ToObject<int>();
UserName = obj["data"]["user_info"]["uname"].ToString();
Price = obj["data"]["price"].ToObject<double>();
SCKeepTime = obj["data"]["time"].ToObject<int>();
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;
}
/*
@ -271,7 +271,7 @@ namespace BililiveRecorder.Core
*/
default:
{
MsgType = MsgTypeEnum.Unknown;
this.MsgType = MsgTypeEnum.Unknown;
break;
}
}

View File

@ -1,6 +1,7 @@
using System;
using System.ComponentModel;
using BililiveRecorder.Core.Callback;
using BililiveRecorder.Core.Config.V2;
using BililiveRecorder.FlvProcessor;
#nullable enable
@ -10,6 +11,8 @@ namespace BililiveRecorder.Core
{
Guid Guid { get; }
RoomConfig RoomConfig { get; }
int ShortRoomId { get; }
int RoomId { get; }
string StreamerName { get; }

View File

@ -2,13 +2,14 @@ using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config.V2;
#nullable enable
namespace BililiveRecorder.Core
{
public interface IRecorder : INotifyPropertyChanged, INotifyCollectionChanged, IEnumerable<IRecordedRoom>, ICollection<IRecordedRoom>, IDisposable
{
ConfigV1 Config { get; }
ConfigV2? Config { get; }
bool Initialize(string workdir);

View File

@ -6,7 +6,6 @@ namespace BililiveRecorder.Core
{
public interface IStreamMonitor : IDisposable, INotifyPropertyChanged
{
int Roomid { get; }
bool IsMonitoring { get; }
bool IsDanmakuConnected { get; }
event RoomInfoUpdatedEvent RoomInfoUpdated;

View File

@ -1,7 +1,3 @@
using BililiveRecorder.Core.Callback;
using BililiveRecorder.Core.Config;
using BililiveRecorder.FlvProcessor;
using NLog;
using System;
using System.Collections.Generic;
using System.ComponentModel;
@ -12,6 +8,10 @@ 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
{
@ -21,67 +21,68 @@ namespace BililiveRecorder.Core
private static readonly Random random = new Random();
private static readonly Version VERSION_1_0 = new Version(1, 0);
private int _roomid;
private int _realRoomid;
private int _shortRoomid;
private string _streamerName;
private string _title;
private bool _isStreaming;
public int ShortRoomId
{
get => _roomid;
get => this._shortRoomid;
private set
{
if (value == _roomid) { return; }
_roomid = value;
TriggerPropertyChanged(nameof(ShortRoomId));
if (value == this._shortRoomid) { return; }
this._shortRoomid = value;
this.TriggerPropertyChanged(nameof(this.ShortRoomId));
}
}
public int RoomId
{
get => _realRoomid;
get => this.RoomConfig.RoomId;
private set
{
if (value == _realRoomid) { return; }
_realRoomid = value;
TriggerPropertyChanged(nameof(RoomId));
if (value == this.RoomConfig.RoomId) { return; }
this.RoomConfig.RoomId = value;
this.TriggerPropertyChanged(nameof(this.RoomId));
}
}
public string StreamerName
{
get => _streamerName;
get => this._streamerName;
private set
{
if (value == _streamerName) { return; }
_streamerName = value;
TriggerPropertyChanged(nameof(StreamerName));
if (value == this._streamerName) { return; }
this._streamerName = value;
this.TriggerPropertyChanged(nameof(this.StreamerName));
}
}
public string Title
{
get => _title;
get => this._title;
private set
{
if (value == _title) { return; }
_title = value;
TriggerPropertyChanged(nameof(Title));
if (value == this._title) { return; }
this._title = value;
this.TriggerPropertyChanged(nameof(this.Title));
}
}
public bool IsMonitoring => StreamMonitor.IsMonitoring;
public bool IsRecording => !(StreamDownloadTask?.IsCompleted ?? true);
public bool IsDanmakuConnected => StreamMonitor.IsDanmakuConnected;
public bool IsMonitoring => this.StreamMonitor.IsMonitoring;
public bool IsRecording => !(this.StreamDownloadTask?.IsCompleted ?? true);
public bool IsDanmakuConnected => this.StreamMonitor.IsDanmakuConnected;
public bool IsStreaming
{
get => _isStreaming;
get => this._isStreaming;
private set
{
if (value == _isStreaming) { return; }
_isStreaming = value;
TriggerPropertyChanged(nameof(IsStreaming));
if (value == this._isStreaming) { return; }
this._isStreaming = value;
this.TriggerPropertyChanged(nameof(this.IsStreaming));
}
}
public RoomConfig RoomConfig { get; }
private RecordEndData recordEndData;
public event EventHandler<RecordEndData> RecordEnded;
@ -90,16 +91,15 @@ namespace BililiveRecorder.Core
private IFlvStreamProcessor _processor;
public IFlvStreamProcessor Processor
{
get => _processor;
get => this._processor;
private set
{
if (value == _processor) { return; }
_processor = value;
TriggerPropertyChanged(nameof(Processor));
if (value == this._processor) { return; }
this._processor = value;
this.TriggerPropertyChanged(nameof(this.Processor));
}
}
private ConfigV1 _config { get; }
private BililiveAPI BililiveAPI { get; }
public IStreamMonitor StreamMonitor { get; }
@ -118,41 +118,57 @@ namespace BililiveRecorder.Core
public DateTime LastUpdateDateTime { get; private set; } = DateTime.Now;
public double DownloadSpeedPersentage
{
get { return _DownloadSpeedPersentage; }
private set { if (value != _DownloadSpeedPersentage) { _DownloadSpeedPersentage = value; TriggerPropertyChanged(nameof(DownloadSpeedPersentage)); } }
get { return this._DownloadSpeedPersentage; }
private set { if (value != this._DownloadSpeedPersentage) { this._DownloadSpeedPersentage = value; this.TriggerPropertyChanged(nameof(this.DownloadSpeedPersentage)); } }
}
public double DownloadSpeedMegaBitps
{
get { return _DownloadSpeedMegaBitps; }
private set { if (value != _DownloadSpeedMegaBitps) { _DownloadSpeedMegaBitps = value; TriggerPropertyChanged(nameof(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();
public RecordedRoom(ConfigV1 config,
IBasicDanmakuWriter basicDanmakuWriter,
Func<int, IStreamMonitor> newIStreamMonitor,
// TODO: 重构 DI
public RecordedRoom(Func<RoomConfig, IBasicDanmakuWriter> newBasicDanmakuWriter,
Func<RoomConfig, IStreamMonitor> newIStreamMonitor,
Func<IFlvStreamProcessor> newIFlvStreamProcessor,
BililiveAPI bililiveAPI,
int roomid)
RoomConfig roomConfig)
{
this.RoomConfig = roomConfig;
this.StreamerName = "获取中...";
this.BililiveAPI = bililiveAPI;
this.newIFlvStreamProcessor = newIFlvStreamProcessor;
_config = config;
BililiveAPI = bililiveAPI;
this.basicDanmakuWriter = newBasicDanmakuWriter(this.RoomConfig);
this.basicDanmakuWriter = basicDanmakuWriter;
this.StreamMonitor = newIStreamMonitor(this.RoomConfig);
this.StreamMonitor.RoomInfoUpdated += this.StreamMonitor_RoomInfoUpdated;
this.StreamMonitor.StreamStarted += this.StreamMonitor_StreamStarted;
this.StreamMonitor.ReceivedDanmaku += this.StreamMonitor_ReceivedDanmaku;
this.StreamMonitor.PropertyChanged += this.StreamMonitor_PropertyChanged;
RoomId = roomid;
StreamerName = "获取中...";
this.PropertyChanged += this.RecordedRoom_PropertyChanged;
StreamMonitor = newIStreamMonitor(RoomId);
StreamMonitor.RoomInfoUpdated += StreamMonitor_RoomInfoUpdated;
StreamMonitor.StreamStarted += StreamMonitor_StreamStarted;
StreamMonitor.ReceivedDanmaku += StreamMonitor_ReceivedDanmaku;
StreamMonitor.PropertyChanged += StreamMonitor_PropertyChanged;
this.StreamMonitor.FetchRoomInfoAsync();
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)
@ -160,7 +176,7 @@ namespace BililiveRecorder.Core
switch (e.PropertyName)
{
case nameof(IStreamMonitor.IsDanmakuConnected):
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsDanmakuConnected)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected)));
break;
default:
break;
@ -172,112 +188,116 @@ namespace BililiveRecorder.Core
switch (e.Danmaku.MsgType)
{
case MsgTypeEnum.LiveStart:
IsStreaming = true;
this.IsStreaming = true;
break;
case MsgTypeEnum.LiveEnd:
IsStreaming = false;
this.IsStreaming = false;
break;
default:
break;
}
basicDanmakuWriter.Write(e.Danmaku);
this.basicDanmakuWriter.Write(e.Danmaku);
}
private void StreamMonitor_RoomInfoUpdated(object sender, RoomInfoUpdatedArgs e)
{
RoomId = e.RoomInfo.RoomId;
ShortRoomId = e.RoomInfo.ShortRoomId;
StreamerName = e.RoomInfo.UserName;
Title = e.RoomInfo.Title;
IsStreaming = e.RoomInfo.IsStreaming;
// TODO: StreamMonitor 里的 RoomInfoUpdated Handler 也会设置一次 RoomId
// 暂时保持不变,此处的 RoomId 需要触发 PropertyChanged 事件
this.RoomId = e.RoomInfo.RoomId;
this.ShortRoomId = e.RoomInfo.ShortRoomId;
this.StreamerName = e.RoomInfo.UserName;
this.Title = e.RoomInfo.Title;
this.IsStreaming = e.RoomInfo.IsStreaming;
}
public bool Start()
{
if (disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
// TODO: 重构: 删除 Start() Stop() 通过 RoomConfig.AutoRecord 控制监控状态和逻辑
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
var r = StreamMonitor.Start();
TriggerPropertyChanged(nameof(IsMonitoring));
var r = this.StreamMonitor.Start();
this.TriggerPropertyChanged(nameof(this.IsMonitoring));
return r;
}
public void Stop()
{
if (disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
// TODO: 见 Start()
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
StreamMonitor.Stop();
TriggerPropertyChanged(nameof(IsMonitoring));
this.StreamMonitor.Stop();
this.TriggerPropertyChanged(nameof(this.IsMonitoring));
}
public void RefreshRoomInfo()
{
if (disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
StreamMonitor.FetchRoomInfoAsync();
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
this.StreamMonitor.FetchRoomInfoAsync();
}
private void StreamMonitor_StreamStarted(object sender, StreamStartedArgs e)
{
lock (StartupTaskLock)
if (!IsRecording && (StartupTask?.IsCompleted ?? true))
StartupTask = _StartRecordAsync();
lock (this.StartupTaskLock)
if (!this.IsRecording && (this.StartupTask?.IsCompleted ?? true))
this.StartupTask = this._StartRecordAsync();
}
public void StartRecord()
{
if (disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
StreamMonitor.Check(TriggerType.Manual);
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
this.StreamMonitor.Check(TriggerType.Manual);
}
public void StopRecord()
{
if (disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
if (this.disposedValue) throw new ObjectDisposedException(nameof(RecordedRoom));
_retry = false;
this._retry = false;
try
{
if (cancellationTokenSource != null)
if (this.cancellationTokenSource != null)
{
cancellationTokenSource.Cancel();
if (!(StreamDownloadTask?.Wait(TimeSpan.FromSeconds(2)) ?? true))
this.cancellationTokenSource.Cancel();
if (!(this.StreamDownloadTask?.Wait(TimeSpan.FromSeconds(2)) ?? true))
{
logger.Log(RoomId, LogLevel.Warn, "停止录制超时,尝试强制关闭连接,请检查网络连接是否稳定");
logger.Log(this.RoomId, LogLevel.Warn, "停止录制超时,尝试强制关闭连接,请检查网络连接是否稳定");
_stream?.Close();
_stream?.Dispose();
_response?.Dispose();
StreamDownloadTask?.Wait();
this._stream?.Close();
this._stream?.Dispose();
this._response?.Dispose();
this.StreamDownloadTask?.Wait();
}
}
}
catch (Exception ex)
{
logger.Log(RoomId, LogLevel.Warn, "在尝试停止录制时发生错误,请检查网络连接是否稳定", ex);
logger.Log(this.RoomId, LogLevel.Warn, "在尝试停止录制时发生错误,请检查网络连接是否稳定", ex);
}
finally
{
_retry = true;
this._retry = true;
}
}
private async Task _StartRecordAsync()
{
if (IsRecording)
if (this.IsRecording)
{
// TODO: 这里逻辑可能有问题StartupTask 会变成当前这个已经结束的
logger.Log(RoomId, LogLevel.Warn, "已经在录制中了");
logger.Log(this.RoomId, LogLevel.Warn, "已经在录制中了");
return;
}
cancellationTokenSource = new CancellationTokenSource();
var token = cancellationTokenSource.Token;
this.cancellationTokenSource = new CancellationTokenSource();
var token = this.cancellationTokenSource.Token;
try
{
var flv_path = await BililiveAPI.GetPlayUrlAsync(RoomId);
var flv_path = await this.BililiveAPI.GetPlayUrlAsync(this.RoomId);
if (string.IsNullOrWhiteSpace(flv_path))
{
if (_retry)
if (this._retry)
{
StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)_config.TimingStreamRetry);
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
}
return;
}
@ -291,7 +311,7 @@ namespace BililiveRecorder.Core
}))
{
client.Timeout = TimeSpan.FromMilliseconds(_config.TimingStreamConnect);
client.Timeout = TimeSpan.FromMilliseconds(this.RoomConfig.TimingStreamConnect);
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*"));
client.DefaultRequestHeaders.UserAgent.Clear();
@ -300,46 +320,46 @@ namespace BililiveRecorder.Core
client.DefaultRequestHeaders.Add("Origin", "https://live.bilibili.com");
logger.Log(RoomId, LogLevel.Info, "连接直播服务器 " + new Uri(flv_path).Host);
logger.Log(RoomId, LogLevel.Debug, "直播流地址: " + flv_path);
logger.Log(this.RoomId, LogLevel.Info, "连接直播服务器 " + new Uri(flv_path).Host);
logger.Log(this.RoomId, LogLevel.Debug, "直播流地址: " + flv_path);
_response = await client.GetAsync(flv_path, HttpCompletionOption.ResponseHeadersRead);
this._response = await client.GetAsync(flv_path, HttpCompletionOption.ResponseHeadersRead);
}
if (_response.StatusCode == HttpStatusCode.Redirect || _response.StatusCode == HttpStatusCode.Moved)
if (this._response.StatusCode == HttpStatusCode.Redirect || this._response.StatusCode == HttpStatusCode.Moved)
{
// workaround for missing Referrer
flv_path = _response.Headers.Location.OriginalString;
_response.Dispose();
flv_path = this._response.Headers.Location.OriginalString;
this._response.Dispose();
goto unwrap_redir;
}
else if (_response.StatusCode != HttpStatusCode.OK)
else if (this._response.StatusCode != HttpStatusCode.OK)
{
logger.Log(RoomId, LogLevel.Info, string.Format("尝试下载直播流时服务器返回了 ({0}){1}", _response.StatusCode, _response.ReasonPhrase));
logger.Log(this.RoomId, LogLevel.Info, string.Format("尝试下载直播流时服务器返回了 ({0}){1}", this._response.StatusCode, this._response.ReasonPhrase));
StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)_config.TimingStreamRetry);
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
_CleanupFlvRequest();
return;
}
else
{
Processor = newIFlvStreamProcessor().Initialize(GetStreamFilePath, GetClipFilePath, _config.EnabledFeature, _config.CuttingMode);
Processor.ClipLengthFuture = _config.ClipLengthFuture;
Processor.ClipLengthPast = _config.ClipLengthPast;
Processor.CuttingNumber = _config.CuttingNumber;
Processor.StreamFinalized += (sender, e) => { basicDanmakuWriter.Disable(); };
Processor.FileFinalized += (sender, size) =>
this.Processor = this.newIFlvStreamProcessor().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 (recordEndData is null) return;
var data = recordEndData;
recordEndData = null;
if (this.recordEndData is null) return;
var data = this.recordEndData;
this.recordEndData = null;
data.EndRecordTime = DateTimeOffset.Now;
data.FileSize = size;
RecordEnded?.Invoke(this, data);
};
Processor.OnMetaData += (sender, e) =>
this.Processor.OnMetaData += (sender, e) =>
{
e.Metadata["BililiveRecorder"] = new Dictionary<string, object>()
{
@ -353,26 +373,26 @@ namespace BililiveRecorder.Core
},
{
"roomid",
RoomId.ToString()
this.RoomId.ToString()
},
{
"streamername",
StreamerName
this.StreamerName
},
};
};
_stream = await _response.Content.ReadAsStreamAsync();
this._stream = await this._response.Content.ReadAsStreamAsync();
try
{
if (_response.Headers.ConnectionClose == false || (_response.Headers.ConnectionClose is null && _response.Version != VERSION_1_0))
_stream.ReadTimeout = 3 * 1000;
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) { }
StreamDownloadTask = Task.Run(_ReadStreamLoop);
TriggerPropertyChanged(nameof(IsRecording));
this.StreamDownloadTask = Task.Run(_ReadStreamLoop);
this.TriggerPropertyChanged(nameof(this.IsRecording));
}
}
catch (TaskCanceledException)
@ -381,16 +401,16 @@ namespace BililiveRecorder.Core
// useless exception message :/
_CleanupFlvRequest();
logger.Log(RoomId, LogLevel.Warn, "连接直播服务器超时。");
StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)_config.TimingStreamRetry);
logger.Log(this.RoomId, LogLevel.Warn, "连接直播服务器超时。");
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
}
catch (Exception ex)
{
_CleanupFlvRequest();
logger.Log(RoomId, LogLevel.Error, "启动直播流下载出错。" + (_retry ? "将重试启动。" : ""), ex);
if (_retry)
logger.Log(this.RoomId, LogLevel.Error, "启动直播流下载出错。" + (this._retry ? "将重试启动。" : ""), ex);
if (this._retry)
{
StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)_config.TimingStreamRetry);
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
}
}
return;
@ -403,17 +423,17 @@ namespace BililiveRecorder.Core
byte[] buffer = new byte[BUF_SIZE];
while (!token.IsCancellationRequested)
{
int bytesRead = await _stream.ReadAsync(buffer, 0, BUF_SIZE, token);
int bytesRead = await this._stream.ReadAsync(buffer, 0, BUF_SIZE, token);
_UpdateDownloadSpeed(bytesRead);
if (bytesRead != 0)
{
if (bytesRead != BUF_SIZE)
{
Processor.AddBytes(buffer.Take(bytesRead).ToArray());
this.Processor.AddBytes(buffer.Take(bytesRead).ToArray());
}
else
{
Processor.AddBytes(buffer);
this.Processor.AddBytes(buffer);
}
}
else
@ -422,19 +442,19 @@ namespace BililiveRecorder.Core
}
}
logger.Log(RoomId, LogLevel.Info,
logger.Log(this.RoomId, LogLevel.Info,
(token.IsCancellationRequested ? "本地操作结束当前录制。" : "服务器关闭直播流,可能是直播已结束。")
+ (_retry ? "将重试启动。" : ""));
if (_retry)
+ (this._retry ? "将重试启动。" : ""));
if (this._retry)
{
StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)_config.TimingStreamRetry);
this.StreamMonitor.Check(TriggerType.HttpApiRecheck, (int)this.RoomConfig.TimingStreamRetry);
}
}
catch (Exception e)
{
if (e is ObjectDisposedException && token.IsCancellationRequested) { return; }
logger.Log(RoomId, LogLevel.Warn, "录播发生错误", e);
logger.Log(this.RoomId, LogLevel.Warn, "录播发生错误", e);
}
finally
{
@ -443,55 +463,55 @@ namespace BililiveRecorder.Core
}
void _CleanupFlvRequest()
{
if (Processor != null)
if (this.Processor != null)
{
Processor.FinallizeFile();
Processor.Dispose();
Processor = null;
this.Processor.FinallizeFile();
this.Processor.Dispose();
this.Processor = null;
}
_stream?.Dispose();
_stream = null;
_response?.Dispose();
_response = null;
this._stream?.Dispose();
this._stream = null;
this._response?.Dispose();
this._response = null;
_lastUpdateTimestamp = 0;
DownloadSpeedMegaBitps = 0d;
DownloadSpeedPersentage = 0d;
TriggerPropertyChanged(nameof(IsRecording));
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 - LastUpdateDateTime).TotalSeconds;
_lastUpdateSize += bytesRead;
double passedSeconds = (now - this.LastUpdateDateTime).TotalSeconds;
this._lastUpdateSize += bytesRead;
if (passedSeconds > 1.5)
{
DownloadSpeedMegaBitps = _lastUpdateSize / passedSeconds * 8d / 1_000_000d; // mega bit per second
DownloadSpeedPersentage = (DownloadSpeedPersentage / 2) + ((Processor.TotalMaxTimestamp - _lastUpdateTimestamp) / passedSeconds / 1000 / 2); // ((RecordedTime/1000) / RealTime)%
_lastUpdateTimestamp = Processor.TotalMaxTimestamp;
_lastUpdateSize = 0;
LastUpdateDateTime = now;
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() => Processor?.Clip();
public void Clip() => this.Processor?.Clip();
public void Shutdown() => Dispose(true);
public void Shutdown() => this.Dispose(true);
private (string fullPath, string relativePath) GetStreamFilePath()
{
var path = FormatFilename(_config.RecordFilenameFormat);
var path = this.FormatFilename(this.RoomConfig.RecordFilenameFormat);
// 有点脏的写法,不过凑合吧
if (_config.RecordDanmaku)
if (this.RoomConfig.RecordDanmaku)
{
var xmlpath = Path.ChangeExtension(path.fullPath, "xml");
basicDanmakuWriter.EnableWithPath(xmlpath, this);
this.basicDanmakuWriter.EnableWithPath(xmlpath, this);
}
recordEndData = new RecordEndData
this.recordEndData = new RecordEndData
{
RoomId = RoomId,
Title = Title,
@ -503,7 +523,7 @@ namespace BililiveRecorder.Core
return path;
}
private string GetClipFilePath() => FormatFilename(_config.ClipFilenameFormat).fullPath;
private string GetClipFilePath() => this.FormatFilename(this.RoomConfig.ClipFilenameFormat).fullPath;
private (string fullPath, string relativePath) FormatFilename(string formatString)
{
@ -516,29 +536,30 @@ namespace BililiveRecorder.Core
.Replace(@"{date}", date)
.Replace(@"{time}", time)
.Replace(@"{random}", randomStr)
.Replace(@"{roomid}", RoomId.ToString())
.Replace(@"{title}", Title.RemoveInvalidFileName())
.Replace(@"{name}", StreamerName.RemoveInvalidFileName());
.Replace(@"{roomid}", this.RoomId.ToString())
.Replace(@"{title}", this.Title.RemoveInvalidFileName())
.Replace(@"{name}", this.StreamerName.RemoveInvalidFileName());
if (!relativePath.EndsWith(".flv", StringComparison.OrdinalIgnoreCase))
relativePath += ".flv";
relativePath = relativePath.RemoveInvalidFileName(ignore_slash: true);
var fullPath = Path.Combine(_config.WorkDirectory, relativePath);
var workDirectory = this.RoomConfig.WorkDirectory;
var fullPath = Path.Combine(workDirectory, relativePath);
fullPath = Path.GetFullPath(fullPath);
if (!CheckPath(_config.WorkDirectory, Path.GetDirectoryName(fullPath)))
if (!CheckPath(workDirectory, Path.GetDirectoryName(fullPath)))
{
logger.Log(RoomId, LogLevel.Warn, "录制文件位置超出允许范围,请检查设置。将写入到默认路径。");
relativePath = Path.Combine(RoomId.ToString(), $"{RoomId}-{date}-{time}-{randomStr}.flv");
fullPath = Path.Combine(_config.WorkDirectory, relativePath);
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(RoomId, LogLevel.Warn, "录制文件名冲突,请检查设置。将写入到默认路径。");
relativePath = Path.Combine(RoomId.ToString(), $"{RoomId}-{date}-{time}-{randomStr}.flv");
fullPath = Path.Combine(_config.WorkDirectory, relativePath);
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);
@ -575,34 +596,34 @@ namespace BililiveRecorder.Core
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
if (!this.disposedValue)
{
if (disposing)
{
Stop();
StopRecord();
Processor?.FinallizeFile();
Processor?.Dispose();
StreamMonitor?.Dispose();
_response?.Dispose();
_stream?.Dispose();
cancellationTokenSource?.Dispose();
basicDanmakuWriter?.Dispose();
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();
}
Processor = null;
_response = null;
_stream = null;
cancellationTokenSource = null;
this.Processor = null;
this._response = null;
this._stream = null;
this.cancellationTokenSource = null;
disposedValue = true;
this.disposedValue = true;
}
}
public void Dispose()
{
// 请勿更改此代码。将清理代码放入以上 Dispose(bool disposing) 中。
Dispose(true);
this.Dispose(true);
}
#endregion
}

View File

@ -6,18 +6,19 @@ using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BililiveRecorder.Core.Callback;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config.V2;
using NLog;
#nullable enable
namespace BililiveRecorder.Core
{
public class Recorder : IRecorder
{
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private readonly Func<int, IRecordedRoom> newIRecordedRoom;
private readonly Func<RoomConfig, IRecordedRoom> newIRecordedRoom;
private readonly CancellationTokenSource tokenSource;
private bool _valid = false;
@ -25,24 +26,22 @@ namespace BililiveRecorder.Core
private ObservableCollection<IRecordedRoom> Rooms { get; } = new ObservableCollection<IRecordedRoom>();
public ConfigV1 Config { get; }
public ConfigV2? Config { get; private set; }
private BasicWebhook Webhook { get; }
private BasicWebhook? Webhook { get; set; }
public int Count => Rooms.Count;
public int Count => this.Rooms.Count;
public bool IsReadOnly => true;
public IRecordedRoom this[int index] => Rooms[index];
public IRecordedRoom this[int index] => this.Rooms[index];
public Recorder(ConfigV1 config, BasicWebhook webhook, Func<int, IRecordedRoom> iRecordedRoom)
public Recorder(Func<RoomConfig, IRecordedRoom> iRecordedRoom)
{
newIRecordedRoom = iRecordedRoom ?? throw new ArgumentNullException(nameof(iRecordedRoom));
Config = config ?? throw new ArgumentNullException(nameof(config));
Webhook = webhook ?? throw new ArgumentNullException(nameof(webhook));
this.newIRecordedRoom = iRecordedRoom ?? throw new ArgumentNullException(nameof(iRecordedRoom));
tokenSource = new CancellationTokenSource();
Repeat.Interval(TimeSpan.FromSeconds(3), DownloadWatchdog, tokenSource.Token);
this.tokenSource = new CancellationTokenSource();
Repeat.Interval(TimeSpan.FromSeconds(3), this.DownloadWatchdog, this.tokenSource.Token);
Rooms.CollectionChanged += (sender, e) =>
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)};" +
@ -53,15 +52,15 @@ namespace BililiveRecorder.Core
public bool Initialize(string workdir)
{
logger.Debug("Initialize: " + workdir);
if (ConfigParser.Load(directory: workdir, config: Config))
var config = ConfigParser.LoadFrom(directory: workdir);
if (config is not null)
{
_valid = true;
Config.WorkDirectory = workdir;
if ((Config.RoomList?.Count ?? 0) > 0)
{
Config.RoomList.ForEach((r) => AddRoom(r.Roomid, r.Enabled));
}
ConfigParser.Save(Config.WorkDirectory, Config);
this.Config = config;
this.Config.Global.WorkDirectory = workdir;
this.Webhook = new BasicWebhook(this.Config);
this._valid = true;
this.Config.Rooms.ForEach(r => this.AddRoom(r));
ConfigParser.SaveTo(this.Config.Global.WorkDirectory, this.Config);
return true;
}
else
@ -75,7 +74,7 @@ namespace BililiveRecorder.Core
/// </summary>
/// <param name="roomid">房间号(支持短号)</param>
/// <exception cref="ArgumentOutOfRangeException"/>
public void AddRoom(int roomid) => AddRoom(roomid, true);
public void AddRoom(int roomid) => this.AddRoom(roomid, true);
/// <summary>
/// 添加直播间到录播姬
@ -87,21 +86,19 @@ namespace BililiveRecorder.Core
{
try
{
if (!_valid) { throw new InvalidOperationException("Not Initialized"); }
if (!this._valid) { throw new InvalidOperationException("Not Initialized"); }
if (roomid <= 0)
{
throw new ArgumentOutOfRangeException(nameof(roomid), "房间号需要大于0");
}
var rr = newIRecordedRoom(roomid);
if (enabled)
var config = new RoomConfig
{
Task.Run(() => rr.Start());
}
RoomId = roomid,
AutoRecord = enabled,
};
logger.Debug("AddRoom 添加了 {roomid} 直播间 ", rr.RoomId);
rr.RecordEnded += this.RecordedRoom_RecordEnded;
Rooms.Add(rr);
this.AddRoom(config);
}
catch (Exception ex)
{
@ -109,6 +106,29 @@ namespace BililiveRecorder.Core
}
}
/// <summary>
/// 添加直播间到录播姬
/// </summary>
/// <param name="roomConfig">房间设置</param>
public void AddRoom(RoomConfig roomConfig)
{
try
{
if (!this._valid) { throw new InvalidOperationException("Not Initialized"); }
roomConfig.SetParent(this.Config?.Global);
var rr = this.newIRecordedRoom(roomConfig);
logger.Debug("AddRoom 添加了 {roomid} 直播间 ", rr.RoomId);
rr.RecordEnded += this.RecordedRoom_RecordEnded;
this.Rooms.Add(rr);
}
catch (Exception ex)
{
logger.Debug(ex, "AddRoom 添加 {roomid} 直播间错误 ", roomConfig.RoomId);
}
}
/// <summary>
/// 从录播姬移除直播间
/// </summary>
@ -116,56 +136,49 @@ namespace BililiveRecorder.Core
public void RemoveRoom(IRecordedRoom rr)
{
if (rr is null) return;
if (!_valid) { throw new InvalidOperationException("Not Initialized"); }
if (!this._valid) { throw new InvalidOperationException("Not Initialized"); }
rr.Shutdown();
rr.RecordEnded -= RecordedRoom_RecordEnded;
rr.RecordEnded -= this.RecordedRoom_RecordEnded;
logger.Debug("RemoveRoom 移除了直播间 {roomid}", rr.RoomId);
Rooms.Remove(rr);
this.Rooms.Remove(rr);
}
private void Shutdown()
{
if (!_valid) { return; }
if (!this._valid) { return; }
logger.Debug("Shutdown called.");
tokenSource.Cancel();
this.tokenSource.Cancel();
SaveConfigToFile();
this.SaveConfigToFile();
Rooms.ToList().ForEach(rr =>
this.Rooms.ToList().ForEach(rr =>
{
rr.Shutdown();
});
Rooms.Clear();
this.Rooms.Clear();
}
private void RecordedRoom_RecordEnded(object sender, RecordEndData e) => Webhook.Send(e);
private void RecordedRoom_RecordEnded(object sender, RecordEndData e) => this.Webhook?.Send(e);
public void SaveConfigToFile()
{
Config.RoomList = new List<RoomV1>();
Rooms.ToList().ForEach(rr =>
{
Config.RoomList.Add(new RoomV1()
{
Roomid = rr.RoomId,
Enabled = rr.IsMonitoring,
});
});
if (this.Config is null) return;
ConfigParser.Save(Config.WorkDirectory, Config);
this.Config.Rooms = this.Rooms.Select(x => x.RoomConfig).ToList();
ConfigParser.SaveTo(this.Config.Global.WorkDirectory!, this.Config);
}
private void DownloadWatchdog()
{
if (!_valid) { return; }
if (!this._valid) { return; }
try
{
Rooms.ToList().ForEach(room =>
this.Rooms.ToList().ForEach(room =>
{
if (room.IsRecording)
{
if (DateTime.Now - room.LastUpdateDateTime > TimeSpan.FromMilliseconds(Config.TimingWatchdogTimeout))
if (DateTime.Now - room.LastUpdateDateTime > TimeSpan.FromMilliseconds(this.Config!.Global.TimingWatchdogTimeout))
{
logger.Warn("服务器未断开连接但停止提供 [{roomid}] 直播间的直播数据,通常是录制侧网络不稳定导致,将会断开重连", room.RoomId);
room.StopRecord();
@ -183,37 +196,37 @@ namespace BililiveRecorder.Core
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) => Rooms.Contains(item);
void ICollection<IRecordedRoom>.CopyTo(IRecordedRoom[] array, int arrayIndex) => Rooms.CopyTo(array, arrayIndex);
public IEnumerator<IRecordedRoom> GetEnumerator() => Rooms.GetEnumerator();
IEnumerator<IRecordedRoom> IEnumerable<IRecordedRoom>.GetEnumerator() => Rooms.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => Rooms.GetEnumerator();
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
{
add => (Rooms as INotifyPropertyChanged).PropertyChanged += value;
remove => (Rooms as INotifyPropertyChanged).PropertyChanged -= value;
add => (this.Rooms as INotifyPropertyChanged).PropertyChanged += value;
remove => (this.Rooms as INotifyPropertyChanged).PropertyChanged -= value;
}
public event NotifyCollectionChangedEventHandler CollectionChanged
{
add => (Rooms as INotifyCollectionChanged).CollectionChanged += value;
remove => (Rooms as INotifyCollectionChanged).CollectionChanged -= value;
add => (this.Rooms as INotifyCollectionChanged).CollectionChanged += value;
remove => (this.Rooms as INotifyCollectionChanged).CollectionChanged -= value;
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
if (!this.disposedValue)
{
if (disposing)
{
// dispose managed state (managed objects)
Shutdown();
this.Shutdown();
}
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
disposedValue = true;
this.disposedValue = true;
}
}
@ -227,7 +240,7 @@ namespace BililiveRecorder.Core
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
this.Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}

View File

@ -30,7 +30,7 @@ namespace BililiveRecorder.Core
}
}
static class CancellationTokenExtensions
internal static class CancellationTokenExtensions
{
public static bool WaitCancellationRequested(
this CancellationToken token,

View File

@ -7,7 +7,7 @@ using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config.V2;
using Newtonsoft.Json;
using NLog;
using Timer = System.Timers.Timer;
@ -31,7 +31,7 @@ namespace BililiveRecorder.Core
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private readonly Func<TcpClient> funcTcpClient;
private readonly ConfigV1 config;
private readonly RoomConfig roomConfig;
private readonly BililiveAPI bililiveAPI;
private Exception dmError = null;
@ -42,73 +42,74 @@ namespace BililiveRecorder.Core
private bool dmConnectionTriggered = false;
private readonly Timer httpTimer;
public int Roomid { get; private set; } = 0;
private int RoomId { get => this.roomConfig.RoomId; set => this.roomConfig.RoomId = value; }
public bool IsMonitoring { get; private set; } = false;
public bool IsDanmakuConnected => dmClient?.Connected ?? 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(int roomid, Func<TcpClient> funcTcpClient, ConfigV1 config, BililiveAPI bililiveAPI)
public StreamMonitor(RoomConfig roomConfig, Func<TcpClient> funcTcpClient, BililiveAPI bililiveAPI)
{
this.funcTcpClient = funcTcpClient;
this.config = config;
this.roomConfig = roomConfig;
this.bililiveAPI = bililiveAPI;
Roomid = roomid;
ReceivedDanmaku += this.Receiver_ReceivedDanmaku;
RoomInfoUpdated += this.StreamMonitor_RoomInfoUpdated;
ReceivedDanmaku += Receiver_ReceivedDanmaku;
RoomInfoUpdated += StreamMonitor_RoomInfoUpdated;
dmTokenSource = new CancellationTokenSource();
this.dmTokenSource = new CancellationTokenSource();
Repeat.Interval(TimeSpan.FromSeconds(30), () =>
{
if (dmNetStream != null && dmNetStream.CanWrite)
if (this.dmNetStream != null && this.dmNetStream.CanWrite)
{
try
{
SendSocketData(2);
this.SendSocketData(2);
}
catch (Exception) { }
}
}, dmTokenSource.Token);
}, this.dmTokenSource.Token);
httpTimer = new Timer(config.TimingCheckInterval * 1000)
this.httpTimer = new Timer(roomConfig.TimingCheckInterval * 1000)
{
Enabled = false,
AutoReset = true,
SynchronizingObject = null,
Site = null
};
httpTimer.Elapsed += (sender, e) =>
this.httpTimer.Elapsed += (sender, e) =>
{
try
{
Check(TriggerType.HttpApi);
this.Check(TriggerType.HttpApi);
}
catch (Exception ex)
{
logger.Log(Roomid, LogLevel.Warn, "获取直播间开播状态出错", ex);
logger.Log(this.RoomId, LogLevel.Warn, "获取直播间开播状态出错", ex);
}
};
config.PropertyChanged += (sender, e) =>
roomConfig.PropertyChanged += (sender, e) =>
{
if (e.PropertyName.Equals(nameof(config.TimingCheckInterval)))
if (e.PropertyName.Equals(nameof(roomConfig.TimingCheckInterval)))
{
httpTimer.Interval = config.TimingCheckInterval * 1000;
this.httpTimer.Interval = roomConfig.TimingCheckInterval * 1000;
}
};
}
private void StreamMonitor_RoomInfoUpdated(object sender, RoomInfoUpdatedArgs e)
{
Roomid = e.RoomInfo.RoomId;
if (!dmConnectionTriggered)
this.RoomId = e.RoomInfo.RoomId;
// TODO: RecordedRoom 里的 RoomInfoUpdated Handler 也会设置一次 RoomId
// 暂时保持不变,此处需要使用请求返回的房间号连接弹幕服务器
if (!this.dmConnectionTriggered)
{
dmConnectionTriggered = true;
Task.Run(() => ConnectWithRetryAsync());
this.dmConnectionTriggered = true;
Task.Run(() => this.ConnectWithRetryAsync());
}
}
@ -117,7 +118,7 @@ namespace BililiveRecorder.Core
switch (e.Danmaku.MsgType)
{
case MsgTypeEnum.LiveStart:
if (IsMonitoring)
if (this.IsMonitoring)
{
Task.Run(() => StreamStarted?.Invoke(this, new StreamStartedArgs() { type = TriggerType.Danmaku }));
}
@ -133,31 +134,31 @@ namespace BililiveRecorder.Core
public bool Start()
{
if (disposedValue)
if (this.disposedValue)
{
throw new ObjectDisposedException(nameof(StreamMonitor));
}
IsMonitoring = true;
httpTimer.Start();
Check(TriggerType.HttpApi);
this.IsMonitoring = true;
this.httpTimer.Start();
this.Check(TriggerType.HttpApi);
return true;
}
public void Stop()
{
if (disposedValue)
if (this.disposedValue)
{
throw new ObjectDisposedException(nameof(StreamMonitor));
}
IsMonitoring = false;
httpTimer.Stop();
this.IsMonitoring = false;
this.httpTimer.Stop();
}
public void Check(TriggerType type, int millisecondsDelay = 0)
{
if (disposedValue)
if (this.disposedValue)
{
throw new ObjectDisposedException(nameof(StreamMonitor));
}
@ -170,7 +171,7 @@ namespace BililiveRecorder.Core
Task.Run(async () =>
{
await Task.Delay(millisecondsDelay).ConfigureAwait(false);
if ((await FetchRoomInfoAsync().ConfigureAwait(false)).IsStreaming)
if ((await this.FetchRoomInfoAsync().ConfigureAwait(false)).IsStreaming)
{
StreamStarted?.Invoke(this, new StreamStartedArgs() { type = type });
}
@ -179,7 +180,7 @@ namespace BililiveRecorder.Core
public async Task<RoomInfo> FetchRoomInfoAsync()
{
RoomInfo roomInfo = await bililiveAPI.GetRoomInfoAsync(Roomid).ConfigureAwait(false);
RoomInfo roomInfo = await this.bililiveAPI.GetRoomInfoAsync(this.RoomId).ConfigureAwait(false);
if (roomInfo != null)
RoomInfoUpdated?.Invoke(this, new RoomInfoUpdatedArgs { RoomInfo = roomInfo });
return roomInfo;
@ -191,46 +192,46 @@ namespace BililiveRecorder.Core
private async Task ConnectWithRetryAsync()
{
bool connect_result = false;
while (!IsDanmakuConnected && !dmTokenSource.Token.IsCancellationRequested)
while (!this.IsDanmakuConnected && !this.dmTokenSource.Token.IsCancellationRequested)
{
logger.Log(Roomid, LogLevel.Info, "连接弹幕服务器...");
connect_result = await ConnectAsync().ConfigureAwait(false);
logger.Log(this.RoomId, LogLevel.Info, "连接弹幕服务器...");
connect_result = await this.ConnectAsync().ConfigureAwait(false);
if (!connect_result)
await Task.Delay((int)Math.Max(config.TimingDanmakuRetry, 0));
await Task.Delay((int)Math.Max(this.roomConfig.TimingDanmakuRetry, 0));
}
if (connect_result)
{
logger.Log(Roomid, LogLevel.Info, "弹幕服务器连接成功");
logger.Log(this.RoomId, LogLevel.Info, "弹幕服务器连接成功");
}
}
private async Task<bool> ConnectAsync()
{
if (IsDanmakuConnected) { return true; }
if (this.IsDanmakuConnected) { return true; }
try
{
var (token, host, port) = await bililiveAPI.GetDanmuConf(Roomid);
var (token, host, port) = await this.bililiveAPI.GetDanmuConf(this.RoomId);
logger.Log(Roomid, LogLevel.Debug, $"连接弹幕服务器 {host}:{port} {(string.IsNullOrWhiteSpace(token) ? "" : "")} token");
logger.Log(this.RoomId, LogLevel.Debug, $"连接弹幕服务器 {host}:{port} {(string.IsNullOrWhiteSpace(token) ? "" : "")} token");
dmClient = funcTcpClient();
await dmClient.ConnectAsync(host, port).ConfigureAwait(false);
dmNetStream = dmClient.GetStream();
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsDanmakuConnected)));
this.dmClient = this.funcTcpClient();
await this.dmClient.ConnectAsync(host, port).ConfigureAwait(false);
this.dmNetStream = this.dmClient.GetStream();
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected)));
dmReceiveMessageLoopThread = new Thread(ReceiveMessageLoop)
this.dmReceiveMessageLoopThread = new Thread(this.ReceiveMessageLoop)
{
Name = "ReceiveMessageLoop " + Roomid,
Name = "ReceiveMessageLoop " + this.RoomId,
IsBackground = true
};
dmReceiveMessageLoopThread.Start();
this.dmReceiveMessageLoopThread.Start();
var hello = JsonConvert.SerializeObject(new
{
uid = 0,
roomid = Roomid,
roomid = this.RoomId,
protover = 2,
platform = "web",
clientver = "1.11.0",
@ -238,30 +239,30 @@ namespace BililiveRecorder.Core
key = token,
}, Formatting.None);
SendSocketData(7, hello);
SendSocketData(2);
this.SendSocketData(7, hello);
this.SendSocketData(2);
return true;
}
catch (Exception ex)
{
dmError = ex;
logger.Log(Roomid, LogLevel.Warn, "连接弹幕服务器错误", ex);
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsDanmakuConnected)));
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(Roomid, LogLevel.Trace, "ReceiveMessageLoop Started");
logger.Log(this.RoomId, LogLevel.Trace, "ReceiveMessageLoop Started");
try
{
var stableBuffer = new byte[16];
var buffer = new byte[4096];
while (IsDanmakuConnected)
while (this.IsDanmakuConnected)
{
dmNetStream.ReadB(stableBuffer, 0, 16);
this.dmNetStream.ReadB(stableBuffer, 0, 16);
Parse2Protocol(stableBuffer, out DanmakuProtocol protocol);
if (protocol.PacketLength < 16)
@ -280,7 +281,7 @@ namespace BililiveRecorder.Core
buffer = new byte[payloadlength];
}
dmNetStream.ReadB(buffer, 0, payloadlength);
this.dmNetStream.ReadB(buffer, 0, payloadlength);
if (protocol.Version == 2 && protocol.Action == 5) // 处理deflate消息
{
@ -324,7 +325,7 @@ namespace BililiveRecorder.Core
}
catch (Exception ex)
{
logger.Log(Roomid, LogLevel.Warn, "", ex);
logger.Log(this.RoomId, LogLevel.Warn, "", ex);
}
break;
default:
@ -335,25 +336,25 @@ namespace BililiveRecorder.Core
}
catch (Exception ex)
{
dmError = ex;
this.dmError = ex;
// logger.Error(ex);
logger.Log(Roomid, LogLevel.Debug, "Disconnected");
dmClient?.Close();
dmNetStream = null;
if (!(dmTokenSource?.IsCancellationRequested ?? true))
logger.Log(this.RoomId, LogLevel.Debug, "Disconnected");
this.dmClient?.Close();
this.dmNetStream = null;
if (!(this.dmTokenSource?.IsCancellationRequested ?? true))
{
logger.Log(Roomid, LogLevel.Warn, "弹幕连接被断开,将尝试重连", ex);
logger.Log(this.RoomId, LogLevel.Warn, "弹幕连接被断开,将尝试重连", ex);
Task.Run(async () =>
{
await Task.Delay((int)Math.Max(config.TimingDanmakuRetry, 0));
await ConnectWithRetryAsync();
await Task.Delay((int)Math.Max(this.roomConfig.TimingDanmakuRetry, 0));
await this.ConnectWithRetryAsync();
});
}
}
finally
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsDanmakuConnected)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsDanmakuConnected)));
}
}
@ -382,8 +383,8 @@ namespace BililiveRecorder.Core
{
ms.Write(playload, 0, playload.Length);
}
dmNetStream.Write(buffer, 0, buffer.Length);
dmNetStream.Flush();
this.dmNetStream.Write(buffer, 0, buffer.Length);
this.dmNetStream.Flush();
}
}
@ -423,11 +424,11 @@ namespace BililiveRecorder.Core
/// </summary>
public void ChangeEndian()
{
PacketLength = IPAddress.HostToNetworkOrder(PacketLength);
HeaderLength = IPAddress.HostToNetworkOrder(HeaderLength);
Version = IPAddress.HostToNetworkOrder(Version);
Action = IPAddress.HostToNetworkOrder(Action);
Parameter = IPAddress.HostToNetworkOrder(Parameter);
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);
}
}
@ -439,25 +440,25 @@ namespace BililiveRecorder.Core
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
if (!this.disposedValue)
{
if (disposing)
{
dmTokenSource?.Cancel();
dmTokenSource?.Dispose();
httpTimer?.Dispose();
dmClient?.Close();
this.dmTokenSource?.Cancel();
this.dmTokenSource?.Dispose();
this.httpTimer?.Dispose();
this.dmClient?.Close();
}
dmNetStream = null;
disposedValue = true;
this.dmNetStream = null;
this.disposedValue = true;
}
}
public void Dispose()
{
// 请勿更改此代码。将清理代码放入以上 Dispose(bool disposing) 中。
Dispose(true);
this.Dispose(true);
}
#endregion
}

View File

@ -1,8 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace BililiveRecorder.Core
namespace BililiveRecorder.Core
{
public enum TriggerType
{

View File

@ -1,7 +1,7 @@
using NLog;
using System;
using System.Collections.Generic;
using System.IO;
using NLog;
namespace BililiveRecorder.FlvProcessor
{
@ -25,22 +25,22 @@ namespace BililiveRecorder.FlvProcessor
public IFlvClipProcessor Initialize(string path, IFlvMetadata metadata, List<IFlvTag> head, List<IFlvTag> data, uint seconds)
{
this.path = path;
Header = metadata; // TODO: Copy a copy, do not share
HTags = head;
Tags = data;
target = Tags[Tags.Count - 1].TimeStamp + (int)(seconds * FlvStreamProcessor.SEC_TO_MS);
this.Header = metadata; // TODO: Copy a copy, do not share
this.HTags = head;
this.Tags = data;
this.target = this.Tags[this.Tags.Count - 1].TimeStamp + (int)(seconds * FlvStreamProcessor.SEC_TO_MS);
logger.Debug("Clip 创建 Tags.Count={0} Tags[0].TimeStamp={1} Tags[Tags.Count-1].TimeStamp={2} Tags里秒数={3}",
Tags.Count, Tags[0].TimeStamp, Tags[Tags.Count - 1].TimeStamp, (Tags[Tags.Count - 1].TimeStamp - Tags[0].TimeStamp) / 1000d);
this.Tags.Count, this.Tags[0].TimeStamp, this.Tags[this.Tags.Count - 1].TimeStamp, (this.Tags[this.Tags.Count - 1].TimeStamp - this.Tags[0].TimeStamp) / 1000d);
return this;
}
public void AddTag(IFlvTag tag)
{
Tags.Add(tag);
if (tag.TimeStamp >= target)
this.Tags.Add(tag);
if (tag.TimeStamp >= this.target)
{
FinallizeFile();
this.FinallizeFile();
}
}
@ -48,40 +48,40 @@ namespace BililiveRecorder.FlvProcessor
{
try
{
if (!Directory.Exists(Path.GetDirectoryName(path)))
if (!Directory.Exists(Path.GetDirectoryName(this.path)))
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
Directory.CreateDirectory(Path.GetDirectoryName(this.path));
}
using (var fs = new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite))
using (var fs = new FileStream(this.path, FileMode.CreateNew, FileAccess.ReadWrite))
{
fs.Write(FlvStreamProcessor.FLV_HEADER_BYTES, 0, FlvStreamProcessor.FLV_HEADER_BYTES.Length);
fs.Write(new byte[] { 0, 0, 0, 0, }, 0, 4);
double clipDuration = (Tags[Tags.Count - 1].TimeStamp - Tags[0].TimeStamp) / 1000d;
Header["duration"] = clipDuration;
Header["lasttimestamp"] = (double)(Tags[Tags.Count - 1].TimeStamp - Tags[0].TimeStamp);
double clipDuration = (this.Tags[this.Tags.Count - 1].TimeStamp - this.Tags[0].TimeStamp) / 1000d;
this.Header["duration"] = clipDuration;
this.Header["lasttimestamp"] = (double)(this.Tags[this.Tags.Count - 1].TimeStamp - this.Tags[0].TimeStamp);
var t = funcFlvTag();
var t = this.funcFlvTag();
t.TagType = TagType.DATA;
if (Header.ContainsKey("BililiveRecorder"))
if (this.Header.ContainsKey("BililiveRecorder"))
{
// TODO: 更好的写法
(Header["BililiveRecorder"] as Dictionary<string, object>)["starttime"] = DateTime.UtcNow - TimeSpan.FromSeconds(clipDuration);
(this.Header["BililiveRecorder"] as Dictionary<string, object>)["starttime"] = DateTime.UtcNow - TimeSpan.FromSeconds(clipDuration);
}
t.Data = Header.ToBytes();
t.Data = this.Header.ToBytes();
t.WriteTo(fs);
int offset = Tags[0].TimeStamp;
int offset = this.Tags[0].TimeStamp;
HTags.ForEach(tag => tag.WriteTo(fs));
Tags.ForEach(tag => tag.WriteTo(fs, offset));
this.HTags.ForEach(tag => tag.WriteTo(fs));
this.Tags.ForEach(tag => tag.WriteTo(fs, offset));
logger.Info("剪辑已保存:{0}", Path.GetFileName(path));
logger.Info("剪辑已保存:{0}", Path.GetFileName(this.path));
fs.Close();
}
Tags.Clear();
this.Tags.Clear();
}
catch (IOException ex)
{

View File

@ -11,20 +11,20 @@ namespace BililiveRecorder.FlvProcessor
{
private IDictionary<string, object> Meta { get; set; } = new Dictionary<string, object>();
public ICollection<string> Keys => Meta.Keys;
public ICollection<string> Keys => this.Meta.Keys;
public ICollection<object> Values => Meta.Values;
public ICollection<object> Values => this.Meta.Values;
public int Count => Meta.Count;
public int Count => this.Meta.Count;
public bool IsReadOnly => false;
public object this[string key] { get => Meta[key]; set => Meta[key] = value; }
public object this[string key] { get => this.Meta[key]; set => this.Meta[key] = value; }
public FlvMetadata()
{
Meta["duration"] = 0.0;
Meta["lasttimestamp"] = 0.0;
this.Meta["duration"] = 0.0;
this.Meta["lasttimestamp"] = 0.0;
}
public FlvMetadata(byte[] data)
@ -37,24 +37,24 @@ namespace BililiveRecorder.FlvProcessor
{
throw new Exception("Isn't onMetadata");
}
Meta = DecodeScriptDataValue(data, ref readHead) as Dictionary<string, object>;
this.Meta = DecodeScriptDataValue(data, ref readHead) as Dictionary<string, object>;
if (!Meta.ContainsKey("duration"))
if (!this.Meta.ContainsKey("duration"))
{
Meta["duration"] = 0d;
this.Meta["duration"] = 0d;
}
if (!Meta.ContainsKey("lasttimestamp"))
if (!this.Meta.ContainsKey("lasttimestamp"))
{
Meta["lasttimestamp"] = 0d;
this.Meta["lasttimestamp"] = 0d;
}
Meta.Remove("");
foreach (var item in Meta.ToArray())
this.Meta.Remove("");
foreach (var item in this.Meta.ToArray())
{
if (item.Value is string text)
{
Meta[item.Key] = text.Replace("\0", "");
this.Meta[item.Key] = text.Replace("\0", "");
}
}
}
@ -64,7 +64,7 @@ namespace BililiveRecorder.FlvProcessor
using (var ms = new MemoryStream())
{
EncodeScriptDataValue(ms, "onMetaData");
EncodeScriptDataValue(ms, Meta);
EncodeScriptDataValue(ms, this.Meta);
return ms.ToArray();
}
}
@ -278,57 +278,57 @@ namespace BililiveRecorder.FlvProcessor
public void Add(string key, object value)
{
Meta.Add(key, value);
this.Meta.Add(key, value);
}
public bool ContainsKey(string key)
{
return Meta.ContainsKey(key);
return this.Meta.ContainsKey(key);
}
public bool Remove(string key)
{
return Meta.Remove(key);
return this.Meta.Remove(key);
}
public bool TryGetValue(string key, out object value)
{
return Meta.TryGetValue(key, out value);
return this.Meta.TryGetValue(key, out value);
}
public void Add(KeyValuePair<string, object> item)
{
Meta.Add(item);
this.Meta.Add(item);
}
public void Clear()
{
Meta.Clear();
this.Meta.Clear();
}
public bool Contains(KeyValuePair<string, object> item)
{
return Meta.Contains(item);
return this.Meta.Contains(item);
}
public void CopyTo(KeyValuePair<string, object>[] array, int arrayIndex)
{
Meta.CopyTo(array, arrayIndex);
this.Meta.CopyTo(array, arrayIndex);
}
public bool Remove(KeyValuePair<string, object> item)
{
return Meta.Remove(item);
return this.Meta.Remove(item);
}
public IEnumerator<KeyValuePair<string, object>> GetEnumerator()
{
return Meta.GetEnumerator();
return this.Meta.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return Meta.GetEnumerator();
return this.Meta.GetEnumerator();
}

View File

@ -49,7 +49,7 @@ namespace BililiveRecorder.FlvProcessor
private int _tagAudioCount = 0;
public int TotalMaxTimestamp { get; private set; } = 0;
public int CurrentMaxTimestamp { get => TotalMaxTimestamp - _writeTimeStamp; }
public int CurrentMaxTimestamp { get => this.TotalMaxTimestamp - this._writeTimeStamp; }
private readonly Func<IFlvClipProcessor> funcFlvClipProcessor;
private readonly Func<byte[], IFlvMetadata> funcFlvMetadata;
@ -83,56 +83,56 @@ namespace BililiveRecorder.FlvProcessor
public IFlvStreamProcessor Initialize(Func<(string fullPath, string relativePath)> getStreamFileName, Func<string> getClipFileName, EnabledFeature enabledFeature, AutoCuttingMode autoCuttingMode)
{
GetStreamFileName = getStreamFileName;
GetClipFileName = getClipFileName;
EnabledFeature = enabledFeature;
CuttingMode = autoCuttingMode;
this.GetStreamFileName = getStreamFileName;
this.GetClipFileName = getClipFileName;
this.EnabledFeature = enabledFeature;
this.CuttingMode = autoCuttingMode;
return this;
}
private void OpenNewRecordFile()
{
var (fullPath, relativePath) = GetStreamFileName();
var (fullPath, relativePath) = this.GetStreamFileName();
logger.Debug("打开新录制文件: " + fullPath);
try { Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); } catch (Exception) { }
_targetFile = new FileStream(fullPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read | FileShare.Delete);
this._targetFile = new FileStream(fullPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read | FileShare.Delete);
if (_headerParsed)
if (this._headerParsed)
{
_targetFile.Write(FlvStreamProcessor.FLV_HEADER_BYTES, 0, FlvStreamProcessor.FLV_HEADER_BYTES.Length);
_targetFile.Write(new byte[] { 0, 0, 0, 0, }, 0, 4);
this._targetFile.Write(FlvStreamProcessor.FLV_HEADER_BYTES, 0, FlvStreamProcessor.FLV_HEADER_BYTES.Length);
this._targetFile.Write(new byte[] { 0, 0, 0, 0, }, 0, 4);
var script_tag = funcFlvTag();
var script_tag = this.funcFlvTag();
script_tag.TagType = TagType.DATA;
if (Metadata.ContainsKey("BililiveRecorder"))
if (this.Metadata.ContainsKey("BililiveRecorder"))
{
// TODO: 更好的写法
(Metadata["BililiveRecorder"] as Dictionary<string, object>)["starttime"] = DateTime.UtcNow;
(this.Metadata["BililiveRecorder"] as Dictionary<string, object>)["starttime"] = DateTime.UtcNow;
}
script_tag.Data = Metadata.ToBytes();
script_tag.WriteTo(_targetFile);
script_tag.Data = this.Metadata.ToBytes();
script_tag.WriteTo(this._targetFile);
_headerTags.ForEach(tag => tag.WriteTo(_targetFile));
this._headerTags.ForEach(tag => tag.WriteTo(this._targetFile));
}
}
public void AddBytes(byte[] data)
{
lock (_writelock)
lock (this._writelock)
{
if (_finalized) { return; /*throw new InvalidOperationException("Processor Already Closed");*/ }
if (_leftover != null)
if (this._finalized) { return; /*throw new InvalidOperationException("Processor Already Closed");*/ }
if (this._leftover != null)
{
byte[] c = new byte[_leftover.Length + data.Length];
_leftover.CopyTo(c, 0);
data.CopyTo(c, _leftover.Length);
_leftover = null;
ParseBytes(c);
byte[] c = new byte[this._leftover.Length + data.Length];
this._leftover.CopyTo(c, 0);
data.CopyTo(c, this._leftover.Length);
this._leftover = null;
this.ParseBytes(c);
}
else
{
ParseBytes(data);
this.ParseBytes(data);
}
}
}
@ -140,14 +140,14 @@ namespace BililiveRecorder.FlvProcessor
private void ParseBytes(byte[] data)
{
int position = 0;
if (!_headerParsed) { ReadFlvHeader(); }
if (!this._headerParsed) { ReadFlvHeader(); }
while (position < data.Length)
{
if (_currentTag == null)
if (this._currentTag == null)
{
if (!ParseTagHead())
{
_leftover = data.Skip(position).ToArray();
this._leftover = data.Skip(position).ToArray();
break;
}
}
@ -159,7 +159,7 @@ namespace BililiveRecorder.FlvProcessor
if (data.Length - position < 15) { return false; }
byte[] b = new byte[4];
IFlvTag tag = funcFlvTag();
IFlvTag tag = this.funcFlvTag();
// Previous Tag Size UI24
position += 4;
@ -186,21 +186,21 @@ namespace BililiveRecorder.FlvProcessor
tag.StreamId[1] = data[position++];
tag.StreamId[2] = data[position++];
_currentTag = tag;
this._currentTag = tag;
return true;
}
void FillTagData()
{
int toRead = Math.Min(data.Length - position, _currentTag.TagSize - (int)_data.Position);
_data.Write(buffer: data, offset: position, count: toRead);
int toRead = Math.Min(data.Length - position, this._currentTag.TagSize - (int)this._data.Position);
this._data.Write(buffer: data, offset: position, count: toRead);
position += toRead;
if ((int)_data.Position == _currentTag.TagSize)
if ((int)this._data.Position == this._currentTag.TagSize)
{
_currentTag.Data = _data.ToArray();
_data.SetLength(0); // reset data buffer
TagCreated(_currentTag);
_currentTag = null;
this._currentTag.Data = this._data.ToArray();
this._data.SetLength(0); // reset data buffer
this.TagCreated(this._currentTag);
this._currentTag = null;
}
}
void ReadFlvHeader()
@ -223,108 +223,108 @@ namespace BililiveRecorder.FlvProcessor
throw new NotSupportedException("Not FLV Stream or Not Supported"); // TODO: custom Exception.
}
_headerParsed = true;
this._headerParsed = true;
position += FLV_HEADER_BYTES.Length;
}
}
private void TagCreated(IFlvTag tag)
{
if (Metadata == null)
if (this.Metadata == null)
{
ParseMetadata();
}
else
{
if (!_hasOffset) { ParseTimestampOffset(); }
if (!this._hasOffset) { ParseTimestampOffset(); }
SetTimestamp();
if (EnabledFeature.IsRecordEnabled()) { ProcessRecordLogic(); }
if (EnabledFeature.IsClipEnabled()) { ProcessClipLogic(); }
if (this.EnabledFeature.IsRecordEnabled()) { ProcessRecordLogic(); }
if (this.EnabledFeature.IsClipEnabled()) { ProcessClipLogic(); }
TagProcessed?.Invoke(this, new TagProcessedArgs() { Tag = tag });
}
return;
void SetTimestamp()
{
if (_hasOffset)
if (this._hasOffset)
{
tag.SetTimeStamp(tag.TimeStamp - _baseTimeStamp);
TotalMaxTimestamp = Math.Max(TotalMaxTimestamp, tag.TimeStamp);
tag.SetTimeStamp(tag.TimeStamp - this._baseTimeStamp);
this.TotalMaxTimestamp = Math.Max(this.TotalMaxTimestamp, tag.TimeStamp);
}
else
{ tag.SetTimeStamp(0); }
}
void ProcessRecordLogic()
{
if (CuttingMode != AutoCuttingMode.Disabled && tag.IsVideoKeyframe)
if (this.CuttingMode != AutoCuttingMode.Disabled && tag.IsVideoKeyframe)
{
bool byTime = (CuttingMode == AutoCuttingMode.ByTime) && (CurrentMaxTimestamp / 1000 >= CuttingNumber * 60);
bool bySize = (CuttingMode == AutoCuttingMode.BySize) && ((_targetFile.Length / 1024 / 1024) >= CuttingNumber);
bool byTime = (this.CuttingMode == AutoCuttingMode.ByTime) && (this.CurrentMaxTimestamp / 1000 >= this.CuttingNumber * 60);
bool bySize = (this.CuttingMode == AutoCuttingMode.BySize) && ((this._targetFile.Length / 1024 / 1024) >= this.CuttingNumber);
if (byTime || bySize)
{
FinallizeCurrentFile();
OpenNewRecordFile();
_writeTimeStamp = TotalMaxTimestamp;
this.FinallizeCurrentFile();
this.OpenNewRecordFile();
this._writeTimeStamp = this.TotalMaxTimestamp;
}
}
if (!(_targetFile?.CanWrite ?? false))
if (!(this._targetFile?.CanWrite ?? false))
{
OpenNewRecordFile();
this.OpenNewRecordFile();
}
tag.WriteTo(_targetFile, _writeTimeStamp);
tag.WriteTo(this._targetFile, this._writeTimeStamp);
}
void ProcessClipLogic()
{
_tags.Add(tag);
this._tags.Add(tag);
// 移除过旧的数据
if (TotalMaxTimestamp - _lasttimeRemovedTimestamp > 800)
if (this.TotalMaxTimestamp - this._lasttimeRemovedTimestamp > 800)
{
_lasttimeRemovedTimestamp = TotalMaxTimestamp;
int max_remove_index = _tags.FindLastIndex(x => x.IsVideoKeyframe && ((TotalMaxTimestamp - x.TimeStamp) > (ClipLengthPast * SEC_TO_MS)));
this._lasttimeRemovedTimestamp = this.TotalMaxTimestamp;
int max_remove_index = this._tags.FindLastIndex(x => x.IsVideoKeyframe && ((this.TotalMaxTimestamp - x.TimeStamp) > (this.ClipLengthPast * SEC_TO_MS)));
if (max_remove_index > 0)
{
_tags.RemoveRange(0, max_remove_index);
this._tags.RemoveRange(0, max_remove_index);
}
// Tags.RemoveRange(0, max_remove_index + 1 - 1);
// 给将来的备注:这里是故意 + 1 - 1 的,因为要保留选中的那个关键帧, + 1 就把关键帧删除了
}
Clips.ToList().ForEach(fcp => fcp.AddTag(tag));
this.Clips.ToList().ForEach(fcp => fcp.AddTag(tag));
}
void ParseTimestampOffset()
{
if (tag.TagType == TagType.VIDEO)
{
_tagVideoCount++;
if (_tagVideoCount < 2)
this._tagVideoCount++;
if (this._tagVideoCount < 2)
{
logger.Debug("第一个 Video Tag 时间戳 {0} ms", tag.TimeStamp);
_headerTags.Add(tag);
this._headerTags.Add(tag);
}
else
{
_baseTimeStamp = tag.TimeStamp;
_hasOffset = true;
logger.Debug("重设时间戳 {0} 毫秒", _baseTimeStamp);
this._baseTimeStamp = tag.TimeStamp;
this._hasOffset = true;
logger.Debug("重设时间戳 {0} 毫秒", this._baseTimeStamp);
}
}
else if (tag.TagType == TagType.AUDIO)
{
_tagAudioCount++;
if (_tagAudioCount < 2)
this._tagAudioCount++;
if (this._tagAudioCount < 2)
{
logger.Debug("第一个 Audio Tag 时间戳 {0} ms", tag.TimeStamp);
_headerTags.Add(tag);
this._headerTags.Add(tag);
}
else
{
_baseTimeStamp = tag.TimeStamp;
_hasOffset = true;
logger.Debug("重设时间戳 {0} 毫秒", _baseTimeStamp);
this._baseTimeStamp = tag.TimeStamp;
this._hasOffset = true;
logger.Debug("重设时间戳 {0} 毫秒", this._baseTimeStamp);
}
}
}
@ -332,15 +332,15 @@ namespace BililiveRecorder.FlvProcessor
{
if (tag.TagType == TagType.DATA)
{
_targetFile?.Write(FLV_HEADER_BYTES, 0, FLV_HEADER_BYTES.Length);
_targetFile?.Write(new byte[] { 0, 0, 0, 0, }, 0, 4);
this._targetFile?.Write(FLV_HEADER_BYTES, 0, FLV_HEADER_BYTES.Length);
this._targetFile?.Write(new byte[] { 0, 0, 0, 0, }, 0, 4);
Metadata = funcFlvMetadata(tag.Data);
this.Metadata = this.funcFlvMetadata(tag.Data);
OnMetaData?.Invoke(this, new FlvMetadataArgs() { Metadata = Metadata });
tag.Data = Metadata.ToBytes();
tag.WriteTo(_targetFile);
tag.Data = this.Metadata.ToBytes();
tag.WriteTo(this._targetFile);
}
else
{
@ -351,19 +351,19 @@ namespace BililiveRecorder.FlvProcessor
public IFlvClipProcessor Clip()
{
if (!EnabledFeature.IsClipEnabled()) { return null; }
lock (_writelock)
if (!this.EnabledFeature.IsClipEnabled()) { return null; }
lock (this._writelock)
{
if (_finalized)
if (this._finalized)
{
return null;
// throw new InvalidOperationException("Processor Already Closed");
}
logger.Info("剪辑处理中,将会保存过去 {0} 秒和将来 {1} 秒的直播流", (_tags[_tags.Count - 1].TimeStamp - _tags[0].TimeStamp) / 1000d, ClipLengthFuture);
IFlvClipProcessor clip = funcFlvClipProcessor().Initialize(GetClipFileName(), Metadata, _headerTags, new List<IFlvTag>(_tags.ToArray()), ClipLengthFuture);
clip.ClipFinalized += (sender, e) => { Clips.Remove(e.ClipProcessor); };
Clips.Add(clip);
logger.Info("剪辑处理中,将会保存过去 {0} 秒和将来 {1} 秒的直播流", (this._tags[this._tags.Count - 1].TimeStamp - this._tags[0].TimeStamp) / 1000d, this.ClipLengthFuture);
IFlvClipProcessor clip = this.funcFlvClipProcessor().Initialize(this.GetClipFileName(), this.Metadata, this._headerTags, new List<IFlvTag>(this._tags.ToArray()), this.ClipLengthFuture);
clip.ClipFinalized += (sender, e) => { this.Clips.Remove(e.ClipProcessor); };
this.Clips.Add(clip);
return clip;
}
}
@ -372,16 +372,16 @@ namespace BililiveRecorder.FlvProcessor
{
try
{
var fileSize = _targetFile?.Length ?? -1;
logger.Debug("正在关闭当前录制文件: " + _targetFile?.Name);
Metadata["duration"] = CurrentMaxTimestamp / 1000.0;
Metadata["lasttimestamp"] = (double)CurrentMaxTimestamp;
byte[] metadata = Metadata.ToBytes();
var fileSize = this._targetFile?.Length ?? -1;
logger.Debug("正在关闭当前录制文件: " + this._targetFile?.Name);
this.Metadata["duration"] = this.CurrentMaxTimestamp / 1000.0;
this.Metadata["lasttimestamp"] = (double)this.CurrentMaxTimestamp;
byte[] metadata = this.Metadata.ToBytes();
// 13 for FLV header & "0th" tag size
// 11 for 1st tag header
_targetFile?.Seek(13 + 11, SeekOrigin.Begin);
_targetFile?.Write(metadata, 0, metadata.Length);
this._targetFile?.Seek(13 + 11, SeekOrigin.Begin);
this._targetFile?.Write(metadata, 0, metadata.Length);
if (fileSize > 0)
FileFinalized?.Invoke(this, fileSize);
@ -396,30 +396,30 @@ namespace BililiveRecorder.FlvProcessor
}
finally
{
_targetFile?.Close();
_targetFile = null;
this._targetFile?.Close();
this._targetFile = null;
}
}
public void FinallizeFile()
{
if (!_finalized)
if (!this._finalized)
{
lock (_writelock)
lock (this._writelock)
{
try
{
FinallizeCurrentFile();
this.FinallizeCurrentFile();
}
finally
{
_targetFile?.Close();
_data.Close();
_tags.Clear();
this._targetFile?.Close();
this._data.Close();
this._tags.Clear();
_finalized = true;
this._finalized = true;
Clips.ToList().ForEach(fcp => fcp.FinallizeFile()); // TODO: check
this.Clips.ToList().ForEach(fcp => fcp.FinallizeFile()); // TODO: check
StreamFinalized?.Invoke(this, new StreamFinalizedArgs() { StreamProcessor = this });
}
}
@ -431,24 +431,24 @@ namespace BililiveRecorder.FlvProcessor
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
if (!this.disposedValue)
{
if (disposing)
{
_data.Dispose();
_targetFile?.Dispose();
this._data.Dispose();
this._targetFile?.Dispose();
OnMetaData = null;
StreamFinalized = null;
TagProcessed = null;
}
_tags.Clear();
disposedValue = true;
this._tags.Clear();
this.disposedValue = true;
}
}
public void Dispose()
{
Dispose(true);
this.Dispose(true);
}
#endregion
}

View File

@ -15,10 +15,10 @@ namespace BililiveRecorder.FlvProcessor
public int Profile { get; private set; } = -1;
public int Level { get; private set; } = -1;
public byte[] Data { get => _data; set { _data = value; ParseInfo(); } }
public byte[] Data { get => this._data; set { this._data = value; this.ParseInfo(); } }
private byte[] _data = null;
public void SetTimeStamp(int timestamp) => TimeStamp = timestamp;
public void SetTimeStamp(int timestamp) => this.TimeStamp = timestamp;
private void ParseInfo()
{
@ -46,42 +46,42 @@ namespace BililiveRecorder.FlvProcessor
* AVCLevelIndication
* */
IsVideoKeyframe = false;
Profile = -1;
Level = -1;
this.IsVideoKeyframe = false;
this.Profile = -1;
this.Level = -1;
if (TagType != TagType.VIDEO) { return; }
if (Data.Length < 9) { return; }
if (this.TagType != TagType.VIDEO) { return; }
if (this.Data.Length < 9) { return; }
// Not AVC Keyframe
if (Data[0] != 0x17) { return; }
if (this.Data[0] != 0x17) { return; }
IsVideoKeyframe = true;
this.IsVideoKeyframe = true;
// Isn't AVCDecoderConfigurationRecord
if (Data[1] != 0x00) { return; }
if (this.Data[1] != 0x00) { return; }
// version is not 1
if (Data[5] != 0x01) { return; }
if (this.Data[5] != 0x01) { return; }
Profile = Data[6];
Level = Data[8];
this.Profile = this.Data[6];
this.Level = this.Data[8];
#if DEBUG
Debug.WriteLine("Video Profile: " + Profile + ", Level: " + Level);
Debug.WriteLine("Video Profile: " + this.Profile + ", Level: " + this.Level);
#endif
}
public byte[] ToBytes(bool useDataSize, int offset = 0)
{
var tag = new byte[11];
tag[0] = (byte)TagType;
var size = BitConverter.GetBytes(useDataSize ? Data.Length : TagSize).ToBE();
tag[0] = (byte)this.TagType;
var size = BitConverter.GetBytes(useDataSize ? this.Data.Length : this.TagSize).ToBE();
Buffer.BlockCopy(size, 1, tag, 1, 3);
byte[] timing = BitConverter.GetBytes(TimeStamp - offset).ToBE();
byte[] timing = BitConverter.GetBytes(this.TimeStamp - offset).ToBE();
Buffer.BlockCopy(timing, 1, tag, 4, 3);
Buffer.BlockCopy(timing, 0, tag, 7, 1);
Buffer.BlockCopy(StreamId, 0, tag, 8, 3);
Buffer.BlockCopy(this.StreamId, 0, tag, 8, 3);
return tag;
}
@ -90,22 +90,22 @@ namespace BililiveRecorder.FlvProcessor
{
if (stream != null)
{
var vs = ToBytes(true, offset);
var vs = this.ToBytes(true, offset);
stream.Write(vs, 0, vs.Length);
stream.Write(Data, 0, Data.Length);
stream.Write(BitConverter.GetBytes(Data.Length + vs.Length).ToBE(), 0, 4);
stream.Write(this.Data, 0, this.Data.Length);
stream.Write(BitConverter.GetBytes(this.Data.Length + vs.Length).ToBE(), 0, 4);
}
}
private int _ParseIsVideoKeyframe()
{
if (TagType != TagType.VIDEO) { return 0; }
if (Data.Length < 1) { return -1; }
if (this.TagType != TagType.VIDEO) { return 0; }
if (this.Data.Length < 1) { return -1; }
const byte mask = 0b00001111;
const byte compare = 0b00011111;
return (Data[0] | mask) == compare ? 1 : 0;
return (this.Data[0] | mask) == compare ? 1 : 0;
}
}
}

View File

@ -82,7 +82,7 @@ namespace BililiveRecorder.WPF
logger.Warn(ex, "检查更新时出错,如持续出错请联系开发者 rec@danmuji.org");
}
_ = Task.Run(async () => { await Task.Delay(TimeSpan.FromDays(1)); await RunCheckUpdate(); });
_ = Task.Run(async () => { await Task.Delay(TimeSpan.FromDays(1)); await this.RunCheckUpdate(); });
}
private void Application_SessionEnding(object sender, SessionEndingCancelEventArgs e)

View File

@ -96,9 +96,15 @@
<Compile Include="Controls\DeleteRoomConfirmDialog.xaml.cs">
<DependentUpon>DeleteRoomConfirmDialog.xaml</DependentUpon>
</Compile>
<Compile Include="Controls\PerRoomSettingsDialog.xaml.cs">
<DependentUpon>PerRoomSettingsDialog.xaml</DependentUpon>
</Compile>
<Compile Include="Controls\RoomCard.xaml.cs">
<DependentUpon>RoomCard.xaml</DependentUpon>
</Compile>
<Compile Include="Controls\SettingWithDefault.xaml.cs">
<DependentUpon>SettingWithDefault.xaml</DependentUpon>
</Compile>
<Compile Include="Controls\TaskbarIconControl.xaml.cs">
<DependentUpon>TaskbarIconControl.xaml</DependentUpon>
</Compile>
@ -145,10 +151,18 @@
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Controls\PerRoomSettingsDialog.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Controls\RoomCard.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Controls\SettingWithDefault.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Controls\TaskbarIconControl.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>

View File

@ -14,26 +14,26 @@ namespace BililiveRecorder.WPF.Controls
public AddRoomCard()
{
InitializeComponent();
this.InitializeComponent();
}
private void AddRoom()
{
AddRoomRequested?.Invoke(this, InputTextBox.Text);
InputTextBox.Text = string.Empty;
InputTextBox.Focus();
AddRoomRequested?.Invoke(this, this.InputTextBox.Text);
this.InputTextBox.Text = string.Empty;
this.InputTextBox.Focus();
}
private void Button_Click(object sender, RoutedEventArgs e)
{
AddRoom();
this.AddRoom();
}
private void InputTextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
AddRoom();
this.AddRoom();
}
}
}

View File

@ -7,7 +7,7 @@ namespace BililiveRecorder.WPF.Controls
{
public AddRoomFailedDialog()
{
InitializeComponent();
this.InitializeComponent();
}
}
}

View File

@ -7,7 +7,7 @@ namespace BililiveRecorder.WPF.Controls
{
public CloseWindowConfirmDialog()
{
InitializeComponent();
this.InitializeComponent();
}
}
}

View File

@ -7,7 +7,7 @@ namespace BililiveRecorder.WPF.Controls
{
public DeleteRoomConfirmDialog()
{
InitializeComponent();
this.InitializeComponent();
}
}
}

View File

@ -0,0 +1,87 @@
<ui:ContentDialog
x:Class="BililiveRecorder.WPF.Controls.PerRoomSettingsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Controls"
xmlns:mock="clr-namespace:BililiveRecorder.WPF.MockData"
xmlns:flv="clr-namespace:BililiveRecorder.FlvProcessor;assembly=BililiveRecorder.FlvProcessor"
DefaultButton="Close"
CloseButtonText="关闭"
d:DataContext="{d:DesignInstance Type=mock:MockRecordedRoom,IsDesignTimeCreatable=True}"
mc:Ignorable="d"
d:DesignHeight="1000" d:DesignWidth="500">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel>
<TextBlock VerticalAlignment="Center" Style="{DynamicResource SubtitleTextBlockStyle}" FontFamily="Microsoft Yahei" TextWrapping="NoWrap" HorizontalAlignment="Center"
TextAlignment="Center" TextTrimming="CharacterEllipsis" Text="{Binding StreamerName,Mode=OneWay}" ToolTip="{Binding StreamerName,Mode=OneWay}"/>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Orientation="Horizontal" Grid.Row="1" Grid.ColumnSpan="2" Margin="5,0,0,0">
<ui:PathIcon Height="10" Style="{StaticResource PathIconDataUpperCaseIdentifier}" />
<TextBlock Text="{Binding RoomId, StringFormat=\{0\},Mode=OneWay}" Margin="4,0"/>
<ui:PathIcon Height="10" Style="{StaticResource PathIconDataLowerCaseIdentifier}" Margin="3,0"
Visibility="{Binding ShortRoomId,Converter={StaticResource ShortRoomIdToVisibilityConverter}}"/>
<TextBlock Text="{Binding ShortRoomId, StringFormat=\{0\},Mode=OneWay}"
Visibility="{Binding ShortRoomId,Converter={StaticResource ShortRoomIdToVisibilityConverter}}"/>
</StackPanel>
<Separator/>
</StackPanel>
<ScrollViewer Grid.Row="1">
<ui:SimpleStackPanel Orientation="Vertical" Spacing="5" DataContext="{Binding RoomConfig}">
<GroupBox Header="弹幕录制">
<StackPanel>
<local:SettingWithDefault IsSettingNotUsingDefault="{Binding HasRecordDanmaku}">
<ui:ToggleSwitch IsOn="{Binding RecordDanmaku}"
OnContent="保存弹幕&#160;&#160;(当前为保存)" OffContent="不保存弹幕(当前为不保存)"/>
</local:SettingWithDefault>
<local:SettingWithDefault IsSettingNotUsingDefault="{Binding HasRecordDanmakuSuperChat}">
<ui:ToggleSwitch IsOn="{Binding RecordDanmakuSuperChat}"
OnContent="同时保存 SuperChat" OffContent="不保存 SuperChat"/>
</local:SettingWithDefault>
<local:SettingWithDefault IsSettingNotUsingDefault="{Binding HasRecordDanmakuGuard}">
<ui:ToggleSwitch IsOn="{Binding RecordDanmakuGuard}"
OnContent="同时保存 舰长购买" OffContent="不保存 舰长购买"/>
</local:SettingWithDefault>
<local:SettingWithDefault IsSettingNotUsingDefault="{Binding HasRecordDanmakuGift}">
<ui:ToggleSwitch IsOn="{Binding RecordDanmakuGift}"
OnContent="同时保存 送礼信息" OffContent="不保存 送礼信息"/>
</local:SettingWithDefault>
<local:SettingWithDefault IsSettingNotUsingDefault="{Binding HasRecordDanmakuRaw}">
<ui:ToggleSwitch IsOn="{Binding RecordDanmakuRaw}"
OnContent="同时保存 弹幕原始数据" OffContent="不保存 弹幕原始数据"/>
</local:SettingWithDefault>
</StackPanel>
</GroupBox>
<GroupBox Header="自动分段">
<StackPanel>
<local:SettingWithDefault IsSettingNotUsingDefault="{Binding HasCuttingMode}">
<StackPanel>
<RadioButton GroupName="自动分段" Content="不自动分段"
IsChecked="{Binding Path=CuttingMode, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:AutoCuttingMode.Disabled}}" />
<RadioButton GroupName="自动分段" Content="根据文件大小自动分段"
IsChecked="{Binding Path=CuttingMode, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:AutoCuttingMode.BySize}}" />
<RadioButton GroupName="自动分段" Content="根据视频时间自动分段"
IsChecked="{Binding Path=CuttingMode, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:AutoCuttingMode.ByTime}}" />
</StackPanel>
</local:SettingWithDefault>
<local:SettingWithDefault IsSettingNotUsingDefault="{Binding HasCuttingNumber}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5,0,0">
<TextBlock VerticalAlignment="Center" Text="每"/>
<TextBox Margin="5,0" Width="100" Text="{Binding CuttingNumber}" ui:TextBoxHelper.IsDeleteButtonVisible="False"/>
<TextBlock VerticalAlignment="Center" Text="MiB/分 保存为一个文件"/>
</StackPanel>
</local:SettingWithDefault>
</StackPanel>
</GroupBox>
</ui:SimpleStackPanel>
</ScrollViewer>
</Grid>
</ui:ContentDialog>

View File

@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace BililiveRecorder.WPF.Controls
{
/// <summary>
/// Interaction logic for PerRoomSettingsDialog.xaml
/// </summary>
public partial class PerRoomSettingsDialog
{
public PerRoomSettingsDialog()
{
this.InitializeComponent();
}
}
}

View File

@ -63,6 +63,12 @@
</MenuItem.Icon>
</MenuItem>
<Separator/>
<MenuItem Header="房间设置" Click="MenuItem_ShowSettings_Click">
<MenuItem.Icon>
<ui:PathIcon Style="{StaticResource PathIconDataCogOutline}"/>
</MenuItem.Icon>
</MenuItem>
<Separator/>
<ui:RadioMenuItem Header="自动录制" GroupName="自动录制" IsChecked="{Binding IsMonitoring,Mode=OneWay}" Click="MenuItem_StartMonitor_Click">
<ui:RadioMenuItem.Icon>
<ui:PathIcon Foreground="Orange" Style="{StaticResource PathIconDataCctv}"/>
@ -123,7 +129,7 @@
<MultiBinding Converter="{StaticResource MultiBoolToVisibilityCollapsedConverter}" Mode="OneWay">
<Binding Path="IsRecording" Mode="OneWay"/>
<Binding RelativeSource="{RelativeSource Mode=FindAncestor,AncestorType=pages:RootPage}"
Path="DataContext.Recorder.Config.EnabledFeature" Mode="OneWay"
Path="DataContext.Recorder.Config.Global.EnabledFeature" Mode="OneWay"
Converter="{StaticResource ClipEnabledToBooleanConverter}"/>
</MultiBinding>
</Button.Visibility>

View File

@ -13,49 +13,56 @@ namespace BililiveRecorder.WPF.Controls
{
public RoomCard()
{
InitializeComponent();
this.InitializeComponent();
}
public event EventHandler DeleteRequested;
public event EventHandler ShowSettingsRequested;
private void MenuItem_StartRecording_Click(object sender, RoutedEventArgs e)
{
(DataContext as IRecordedRoom)?.StartRecord();
(this.DataContext as IRecordedRoom)?.StartRecord();
}
private void MenuItem_StopRecording_Click(object sender, RoutedEventArgs e)
{
(DataContext as IRecordedRoom)?.StopRecord();
(this.DataContext as IRecordedRoom)?.StopRecord();
}
private void MenuItem_RefreshInfo_Click(object sender, RoutedEventArgs e)
{
(DataContext as IRecordedRoom)?.RefreshRoomInfo();
(this.DataContext as IRecordedRoom)?.RefreshRoomInfo();
}
private void MenuItem_StartMonitor_Click(object sender, RoutedEventArgs e)
{
(DataContext as IRecordedRoom)?.Start();
(this.DataContext as IRecordedRoom)?.Start();
}
private void MenuItem_StopMonitor_Click(object sender, RoutedEventArgs e)
{
(DataContext as IRecordedRoom)?.Stop();
(this.DataContext as IRecordedRoom)?.Stop();
}
private void MenuItem_DeleteRoom_Click(object sender, RoutedEventArgs e)
{
DeleteRequested?.Invoke(DataContext, EventArgs.Empty);
DeleteRequested?.Invoke(this.DataContext, EventArgs.Empty);
}
private void MenuItem_ShowSettings_Click(object sender, RoutedEventArgs e)
{
ShowSettingsRequested?.Invoke(this.DataContext, EventArgs.Empty);
}
private void Button_Clip_Click(object sender, RoutedEventArgs e)
{
(DataContext as IRecordedRoom)?.Clip();
(this.DataContext as IRecordedRoom)?.Clip();
}
private void MenuItem_OpenInBrowser_Click(object sender, RoutedEventArgs e)
{
if (DataContext is IRecordedRoom r && r is not null)
if (this.DataContext is IRecordedRoom r && r is not null)
{
try
{

View File

@ -0,0 +1,28 @@
<UserControl
Name="SettingWithDefaultUserControl"
x:Class="BililiveRecorder.WPF.Controls.SettingWithDefault"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Controls"
mc:Ignorable="d"
d:DesignHeight="50" d:DesignWidth="200">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="80"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="{Binding Header,ElementName=SettingWithDefaultUserControl}" Visibility="{Binding Header.Length, ElementName=SettingWithDefaultUserControl,Converter={StaticResource ShortRoomIdToVisibilityConverter}}"/>
<ContentPresenter Grid.Row="1"
Content="{Binding InnerContent,ElementName=SettingWithDefaultUserControl}"
IsEnabled="{Binding IsSettingNotUsingDefault,ElementName=SettingWithDefaultUserControl}"/>
<CheckBox Content="默认" Margin="10,0,0,0" Grid.Row="1" Grid.Column="1" VerticalAlignment="Top" HorizontalAlignment="Left"
IsChecked="{Binding IsSettingNotUsingDefault,ElementName=SettingWithDefaultUserControl,Converter={StaticResource BooleanInverterConverter}}"/>
</Grid>
</UserControl>

View File

@ -0,0 +1,49 @@
using System.Windows;
using System.Windows.Markup;
namespace BililiveRecorder.WPF.Controls
{
/// <summary>
/// Interaction logic for SettingWithDefault.xaml
/// </summary>
[ContentProperty("InnerContent")]
public partial class SettingWithDefault
{
public SettingWithDefault()
{
this.InitializeComponent();
}
public static readonly DependencyProperty HeaderProperty
= DependencyProperty.Register("Header",
typeof(string),
typeof(SettingWithDefault),
new FrameworkPropertyMetadata(string.Empty));
public string Header
{
get => (string)this.GetValue(HeaderProperty);
set => this.SetValue(HeaderProperty, value);
}
public static readonly DependencyProperty IsSettingNotUsingDefaultProperty
= DependencyProperty.Register("IsSettingNotUsingDefault",
typeof(bool),
typeof(SettingWithDefault),
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));
public bool IsSettingNotUsingDefault
{
get => (bool)this.GetValue(IsSettingNotUsingDefaultProperty);
set => this.SetValue(IsSettingNotUsingDefaultProperty, value);
}
public static readonly DependencyProperty InnerContentProperty = DependencyProperty.Register("InnerContent", typeof(object), typeof(SettingWithDefault));
public object InnerContent
{
get => this.GetValue(InnerContentProperty);
set => this.SetValue(InnerContentProperty, value);
}
}
}

View File

@ -35,7 +35,7 @@
</Grid.RowDefinitions>
<StackPanel Margin="10">
<TextBlock Style="{DynamicResource SubtitleTextBlockStyle}" Text="B站录播姬" FontFamily="Microsoft Yahei"/>
<TextBlock Text="{Binding Recorder.Config.WorkDirectory,Mode=OneWay}" TextWrapping="NoWrap" FontFamily="Microsoft Yahei"/>
<TextBlock Text="{Binding Recorder.Config.Global.WorkDirectory,Mode=OneWay}" TextWrapping="NoWrap" FontFamily="Microsoft Yahei"/>
</StackPanel>
<Separator Grid.Row="1" Grid.ColumnSpan="2" />
<ItemsControl Grid.Row="2" Grid.ColumnSpan="2" Margin="10,0,10,10" ItemsSource="{Binding Recorder, Mode=OneWay}">

View File

@ -1,6 +1,5 @@
using System.Windows;
using System.Windows.Controls;
using Hardcodet.Wpf.TaskbarNotification;
namespace BililiveRecorder.WPF.Controls
{
@ -11,14 +10,14 @@ namespace BililiveRecorder.WPF.Controls
{
public TaskbarIconControl()
{
InitializeComponent();
this.InitializeComponent();
// AddHandler(NewMainWindow.ShowBalloonTipEvent, (RoutedEventHandler)UserControl_ShowBalloonTip);
if (Application.Current.MainWindow is NewMainWindow nmw)
{
nmw.ShowBalloonTipCallback = (title, msg, sym) =>
{
TaskbarIcon.ShowBalloonTip(title, msg, sym);
this.TaskbarIcon.ShowBalloonTip(title, msg, sym);
};
}
}

View File

@ -13,14 +13,14 @@ namespace BililiveRecorder.WPF.Controls
private string error = string.Empty;
private string path = string.Empty;
public string Error { get => error; set => SetField(ref error, value); }
public string Error { get => this.error; set => this.SetField(ref this.error, value); }
public string Path { get => path; set => SetField(ref path, value); }
public string Path { get => this.path; set => this.SetField(ref this.path, value); }
public WorkDirectorySelectorDialog()
{
DataContext = this;
InitializeComponent();
this.DataContext = this;
this.InitializeComponent();
}
public event PropertyChangedEventHandler PropertyChanged;
@ -28,7 +28,7 @@ namespace BililiveRecorder.WPF.Controls
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
{
if (EqualityComparer<T>.Default.Equals(field, value)) { return false; }
field = value; OnPropertyChanged(propertyName); return true;
field = value; this.OnPropertyChanged(propertyName); return true;
}
private void Button_Click(object sender, System.Windows.RoutedEventArgs e)
@ -45,7 +45,7 @@ namespace BililiveRecorder.WPF.Controls
};
if (fileDialog.ShowDialog() == CommonFileDialogResult.Ok)
{
Path = fileDialog.FileName;
this.Path = fileDialog.FileName;
}
}
}

View File

@ -10,24 +10,24 @@ namespace BililiveRecorder.WPF.Converters
public static readonly DependencyProperty TrueValueProperty = DependencyProperty.Register(nameof(TrueValue), typeof(object), typeof(BoolToValueConverter), new PropertyMetadata(null));
public static readonly DependencyProperty FalseValueProperty = DependencyProperty.Register(nameof(FalseValue), typeof(object), typeof(BoolToValueConverter), new PropertyMetadata(null));
public object TrueValue { get => GetValue(TrueValueProperty); set => SetValue(TrueValueProperty, value); }
public object FalseValue { get => GetValue(FalseValueProperty); set => SetValue(FalseValueProperty, value); }
public object TrueValue { get => this.GetValue(TrueValueProperty); set => this.SetValue(TrueValueProperty, value); }
public object FalseValue { get => this.GetValue(FalseValueProperty); set => this.SetValue(FalseValueProperty, value); }
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value == null)
{
return FalseValue;
return this.FalseValue;
}
else
{
return (bool)value ? TrueValue : FalseValue;
return (bool)value ? this.TrueValue : this.FalseValue;
}
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return value != null ? value.Equals(TrueValue) : false;
return value != null ? value.Equals(this.TrueValue) : false;
}
}
}

View File

@ -15,10 +15,10 @@ namespace BililiveRecorder.WPF.Converters
{
if ((value is bool boolean) && boolean == false)
{
return FalseValue;
return this.FalseValue;
}
}
return TrueValue;
return this.TrueValue;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)

View File

@ -8,6 +8,6 @@ namespace BililiveRecorder.WPF.Converters
public DataTemplate Normal { get; set; }
public DataTemplate Null { get; set; }
public override DataTemplate SelectTemplate(object item, DependencyObject container) => item is null ? Null : Normal;
public override DataTemplate SelectTemplate(object item, DependencyObject container) => item is null ? this.Null : this.Normal;
}
}

View File

@ -4,7 +4,6 @@ using System.Collections.Specialized;
using System.Globalization;
using System.Windows.Data;
using BililiveRecorder.Core;
using BililiveRecorder.Core.Config;
namespace BililiveRecorder.WPF.Converters
{
@ -27,7 +26,7 @@ namespace BililiveRecorder.WPF.Converters
public RecorderWrapper(IRecorder recorder) : base(recorder)
{
this.recorder = recorder;
Add(null);
this.Add(null);
recorder.CollectionChanged += (sender, e) =>
{
@ -35,19 +34,19 @@ namespace BililiveRecorder.WPF.Converters
{
case NotifyCollectionChangedAction.Add:
if (e.NewItems.Count != 1) throw new NotImplementedException("Wrapper Add Item Count != 1");
InsertItem(e.NewStartingIndex, e.NewItems[0] as IRecordedRoom);
this.InsertItem(e.NewStartingIndex, e.NewItems[0] as IRecordedRoom);
break;
case NotifyCollectionChangedAction.Remove:
if (e.OldItems.Count != 1) throw new NotImplementedException("Wrapper Remove Item Count != 1");
if (!Remove(e.OldItems[0] as IRecordedRoom)) throw new NotImplementedException("Wrapper Remove Item Sync Fail");
if (!this.Remove(e.OldItems[0] as IRecordedRoom)) throw new NotImplementedException("Wrapper Remove Item Sync Fail");
break;
case NotifyCollectionChangedAction.Replace:
throw new NotImplementedException("Wrapper Replace Item");
case NotifyCollectionChangedAction.Move:
throw new NotImplementedException("Wrapper Move Item");
case NotifyCollectionChangedAction.Reset:
ClearItems();
Add(null);
this.ClearItems();
this.Add(null);
break;
default:
break;

View File

@ -2,6 +2,7 @@ using System;
using System.ComponentModel;
using BililiveRecorder.Core;
using BililiveRecorder.Core.Callback;
using BililiveRecorder.Core.Config.V2;
using BililiveRecorder.FlvProcessor;
#nullable enable
@ -14,14 +15,14 @@ namespace BililiveRecorder.WPF.MockData
public MockRecordedRoom()
{
RoomId = 123456789;
ShortRoomId = 1234;
StreamerName = "Mock主播名Mock主播名Mock主播名Mock主播名";
IsMonitoring = false;
IsRecording = true;
IsStreaming = true;
DownloadSpeedPersentage = 100d;
DownloadSpeedMegaBitps = 2.45d;
this.RoomId = 123456789;
this.ShortRoomId = 1234;
this.StreamerName = "Mock主播名Mock主播名Mock主播名Mock主播名";
this.IsMonitoring = false;
this.IsRecording = true;
this.IsStreaming = true;
this.DownloadSpeedPersentage = 100d;
this.DownloadSpeedMegaBitps = 2.45d;
}
public int ShortRoomId { get; set; }
@ -52,6 +53,8 @@ namespace BililiveRecorder.WPF.MockData
public Guid Guid { get; } = Guid.NewGuid();
public RoomConfig RoomConfig => new RoomConfig();
public event PropertyChangedEventHandler? PropertyChanged;
public event EventHandler<RecordEndData>? RecordEnded;
@ -70,32 +73,32 @@ namespace BililiveRecorder.WPF.MockData
public bool Start()
{
IsMonitoring = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsMonitoring)));
this.IsMonitoring = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsMonitoring)));
return true;
}
public void StartRecord()
{
IsRecording = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsRecording)));
this.IsRecording = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsRecording)));
}
public void Stop()
{
IsMonitoring = false;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsMonitoring)));
this.IsMonitoring = false;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsMonitoring)));
}
public void StopRecord()
{
IsRecording = false;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsRecording)));
this.IsRecording = false;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.IsRecording)));
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
if (!this.disposedValue)
{
if (disposing)
{
@ -104,7 +107,7 @@ namespace BililiveRecorder.WPF.MockData
// TODO: free unmanaged resources (unmanaged objects) and override finalizer
// TODO: set large fields to null
disposedValue = true;
this.disposedValue = true;
}
}
@ -118,7 +121,7 @@ namespace BililiveRecorder.WPF.MockData
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
this.Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}

View File

@ -5,7 +5,7 @@ using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using BililiveRecorder.Core;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config.V2;
namespace BililiveRecorder.WPF.MockData
{
@ -16,47 +16,47 @@ namespace BililiveRecorder.WPF.MockData
public MockRecorder()
{
Rooms.Add(new MockRecordedRoom
this.Rooms.Add(new MockRecordedRoom
{
IsMonitoring = false,
IsRecording = false
});
Rooms.Add(new MockRecordedRoom
this.Rooms.Add(new MockRecordedRoom
{
IsMonitoring = true,
IsRecording = false
});
Rooms.Add(new MockRecordedRoom
this.Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 100,
DownloadSpeedMegaBitps = 12.45
});
Rooms.Add(new MockRecordedRoom
this.Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 95,
DownloadSpeedMegaBitps = 789.45
});
Rooms.Add(new MockRecordedRoom
this.Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 90
});
Rooms.Add(new MockRecordedRoom
this.Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 85
});
Rooms.Add(new MockRecordedRoom
this.Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 80
});
Rooms.Add(new MockRecordedRoom
this.Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 75
});
Rooms.Add(new MockRecordedRoom
this.Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 70
});
Rooms.Add(new MockRecordedRoom
this.Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 109
});
@ -64,28 +64,28 @@ namespace BililiveRecorder.WPF.MockData
private ObservableCollection<IRecordedRoom> Rooms { get; } = new ObservableCollection<IRecordedRoom>();
public ConfigV1 Config { get; } = new ConfigV1();
public ConfigV2 Config { get; } = new ConfigV2();
public int Count => Rooms.Count;
public int Count => this.Rooms.Count;
public bool IsReadOnly => true;
int ICollection<IRecordedRoom>.Count => Rooms.Count;
int ICollection<IRecordedRoom>.Count => this.Rooms.Count;
bool ICollection<IRecordedRoom>.IsReadOnly => true;
public IRecordedRoom this[int index] => Rooms[index];
public IRecordedRoom this[int index] => this.Rooms[index];
public event PropertyChangedEventHandler PropertyChanged
{
add => (Rooms as INotifyPropertyChanged).PropertyChanged += value;
remove => (Rooms as INotifyPropertyChanged).PropertyChanged -= value;
add => (this.Rooms as INotifyPropertyChanged).PropertyChanged += value;
remove => (this.Rooms as INotifyPropertyChanged).PropertyChanged -= value;
}
public event NotifyCollectionChangedEventHandler CollectionChanged
{
add => (Rooms as INotifyCollectionChanged).CollectionChanged += value;
remove => (Rooms as INotifyCollectionChanged).CollectionChanged -= value;
add => (this.Rooms as INotifyCollectionChanged).CollectionChanged += value;
remove => (this.Rooms as INotifyCollectionChanged).CollectionChanged -= value;
}
void ICollection<IRecordedRoom>.Add(IRecordedRoom item) => throw new NotSupportedException("Collection is readonly");
@ -94,35 +94,35 @@ namespace BililiveRecorder.WPF.MockData
bool ICollection<IRecordedRoom>.Remove(IRecordedRoom item) => throw new NotSupportedException("Collection is readonly");
bool ICollection<IRecordedRoom>.Contains(IRecordedRoom item) => Rooms.Contains(item);
bool ICollection<IRecordedRoom>.Contains(IRecordedRoom item) => this.Rooms.Contains(item);
void ICollection<IRecordedRoom>.CopyTo(IRecordedRoom[] array, int arrayIndex) => Rooms.CopyTo(array, arrayIndex);
void ICollection<IRecordedRoom>.CopyTo(IRecordedRoom[] array, int arrayIndex) => this.Rooms.CopyTo(array, arrayIndex);
public IEnumerator<IRecordedRoom> GetEnumerator() => Rooms.GetEnumerator();
public IEnumerator<IRecordedRoom> GetEnumerator() => this.Rooms.GetEnumerator();
IEnumerator<IRecordedRoom> IEnumerable<IRecordedRoom>.GetEnumerator() => Rooms.GetEnumerator();
IEnumerator<IRecordedRoom> IEnumerable<IRecordedRoom>.GetEnumerator() => this.Rooms.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => Rooms.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => this.Rooms.GetEnumerator();
public bool Initialize(string workdir)
{
Config.WorkDirectory = workdir;
this.Config.Global.WorkDirectory = workdir;
return true;
}
public void AddRoom(int roomid)
{
AddRoom(roomid, false);
this.AddRoom(roomid, false);
}
public void AddRoom(int roomid, bool enabled)
{
Rooms.Add(new MockRecordedRoom { RoomId = roomid, IsMonitoring = enabled });
this.Rooms.Add(new MockRecordedRoom { RoomId = roomid, IsMonitoring = enabled });
}
public void RemoveRoom(IRecordedRoom rr)
{
Rooms.Remove(rr);
this.Rooms.Remove(rr);
}
public void SaveConfigToFile()
@ -132,17 +132,17 @@ namespace BililiveRecorder.WPF.MockData
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
if (!this.disposedValue)
{
if (disposing)
{
// dispose managed state (managed objects)
Rooms.Clear();
this.Rooms.Clear();
}
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
disposedValue = true;
this.disposedValue = true;
}
}
@ -156,7 +156,7 @@ namespace BililiveRecorder.WPF.MockData
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
this.Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}

View File

@ -32,12 +32,12 @@ namespace BililiveRecorder.WPF.Models
#region ICommand Members
public void Execute(object parameter) => ExecuteDelegate?.Invoke(parameter);
public void Execute(object parameter) => this.ExecuteDelegate?.Invoke(parameter);
public bool CanExecute(object parameter) => CanExecuteDelegate switch
public bool CanExecute(object parameter) => this.CanExecuteDelegate switch
{
null => true,
_ => CanExecuteDelegate(parameter),
_ => this.CanExecuteDelegate(parameter),
};
public event EventHandler CanExecuteChanged

View File

@ -16,37 +16,37 @@ namespace BililiveRecorder.WPF.Models
public LogModel() : base(new[] { "" })
{
LogReceived += LogModel_LogReceived;
LogReceived += this.LogModel_LogReceived;
}
private void LogModel_LogReceived(object sender, string e)
{
_ = Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.DataBind, (Action<string>)AddLogToCollection, e);
_ = Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.DataBind, (Action<string>)this.AddLogToCollection, e);
}
private void AddLogToCollection(string e)
{
Add(e);
while (Count > MAX_LINE)
this.Add(e);
while (this.Count > MAX_LINE)
{
RemoveItem(0);
this.RemoveItem(0);
}
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
if (!this.disposedValue)
{
if (disposing)
{
// dispose managed state (managed objects)
LogReceived -= LogModel_LogReceived;
ClearItems();
LogReceived -= this.LogModel_LogReceived;
this.ClearItems();
}
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
disposedValue = true;
this.disposedValue = true;
}
}
@ -60,7 +60,7 @@ namespace BililiveRecorder.WPF.Models
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
this.Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}

View File

@ -15,7 +15,7 @@ namespace BililiveRecorder.WPF.Models
public LogModel Logs { get; } = new LogModel();
public IRecorder Recorder { get => recorder; internal set => SetField(ref recorder, value); }
public IRecorder Recorder { get => this.recorder; internal set => this.SetField(ref this.recorder, value); }
public RootModel()
{
@ -26,23 +26,23 @@ namespace BililiveRecorder.WPF.Models
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
{
if (EqualityComparer<T>.Default.Equals(field, value)) { return false; }
field = value; OnPropertyChanged(propertyName); return true;
field = value; this.OnPropertyChanged(propertyName); return true;
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
if (!this.disposedValue)
{
if (disposing)
{
// dispose managed state (managed objects)
Recorder?.Dispose();
Logs.Dispose();
this.Recorder?.Dispose();
this.Logs.Dispose();
}
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
disposedValue = true;
this.disposedValue = true;
}
}
@ -56,7 +56,7 @@ namespace BililiveRecorder.WPF.Models
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
this.Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}

View File

@ -14,14 +14,14 @@ namespace BililiveRecorder.WPF
{
public NewMainWindow()
{
InitializeComponent();
this.InitializeComponent();
Title = "B站录播姬 " + BuildInfo.Version + " " + BuildInfo.HeadShaShort;
this.Title = "B站录播姬 " + BuildInfo.Version + " " + BuildInfo.HeadShaShort;
SingleInstance.NotificationReceived += SingleInstance_NotificationReceived;
SingleInstance.NotificationReceived += this.SingleInstance_NotificationReceived;
}
private void SingleInstance_NotificationReceived(object sender, EventArgs e) => SuperActivateAction();
private void SingleInstance_NotificationReceived(object sender, EventArgs e) => this.SuperActivateAction();
public event EventHandler NativeBeforeWindowClose;
@ -29,20 +29,20 @@ namespace BililiveRecorder.WPF
internal void CloseWithoutConfirmAction()
{
CloseConfirmed = true;
Close();
this.CloseConfirmed = true;
this.Close();
}
internal void SuperActivateAction()
{
try
{
Show();
WindowState = WindowState.Normal;
Topmost = true;
Activate();
Topmost = false;
Focus();
this.Show();
this.WindowState = WindowState.Normal;
this.Topmost = true;
this.Activate();
this.Topmost = false;
this.Focus();
}
catch (Exception)
{ }
@ -50,10 +50,10 @@ namespace BililiveRecorder.WPF
private void Window_StateChanged(object sender, EventArgs e)
{
if (WindowState == WindowState.Minimized)
if (this.WindowState == WindowState.Minimized)
{
Hide();
ShowBalloonTipCallback?.Invoke("B站录播姬", "录播姬已最小化到托盘,左键单击图标恢复界面", BalloonIcon.Info);
this.Hide();
this.ShowBalloonTipCallback?.Invoke("B站录播姬", "录播姬已最小化到托盘,左键单击图标恢复界面", BalloonIcon.Info);
}
}
@ -67,28 +67,28 @@ namespace BililiveRecorder.WPF
private async void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
if (PromptCloseConfirm && !CloseConfirmed)
if (this.PromptCloseConfirm && !this.CloseConfirmed)
{
e.Cancel = true;
if (CloseWindowSemaphoreSlim.Wait(0))
if (this.CloseWindowSemaphoreSlim.Wait(0))
{
try
{
if (await new CloseWindowConfirmDialog().ShowAsync() == ContentDialogResult.Primary)
{
CloseConfirmed = true;
CloseWindowSemaphoreSlim.Release();
Close();
this.CloseConfirmed = true;
this.CloseWindowSemaphoreSlim.Release();
this.Close();
return;
}
}
catch (Exception) { }
CloseWindowSemaphoreSlim.Release();
this.CloseWindowSemaphoreSlim.Release();
}
}
else
{
SingleInstance.NotificationReceived -= SingleInstance_NotificationReceived;
SingleInstance.NotificationReceived -= this.SingleInstance_NotificationReceived;
NativeBeforeWindowClose?.Invoke(this, EventArgs.Empty);
return;
}

View File

@ -5,78 +5,87 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:c="clr-namespace:BililiveRecorder.WPF.Controls"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Pages"
xmlns:config="clr-namespace:BililiveRecorder.Core.Config;assembly=BililiveRecorder.Core"
xmlns:config="clr-namespace:BililiveRecorder.Core.Config.V2;assembly=BililiveRecorder.Core"
mc:Ignorable="d"
d:DesignHeight="1500" d:DesignWidth="500"
DataContext="{Binding RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=local:RootPage},Path=DataContext.Recorder.Config}"
DataContext="{Binding RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=local:RootPage},Path=DataContext.Recorder.Config.Global}"
Title="SettingsPage">
<ui:Page.Resources>
<Style TargetType="TextBlock">
<Setter Property="FontSize" Value="14"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style TargetType="ui:NumberBox">
<Setter Property="Width" Value="250"/>
<Style TargetType="c:SettingWithDefault">
<Setter Property="HorizontalAlignment" Value="Left"/>
</Style>
<Style TargetType="ui:NumberBox">
<Setter Property="Width" Value="220"/>
<Setter Property="ValidationMode" Value="InvalidInputOverwritten"/>
<Setter Property="SpinButtonPlacementMode" Value="Inline"/>
</Style>
</ui:Page.Resources>
<ScrollViewer d:DataContext="{d:DesignInstance Type=config:ConfigV1}">
<ScrollViewer d:DataContext="{d:DesignInstance Type=config:GlobalConfig}">
<ui:SimpleStackPanel Orientation="Vertical" Spacing="5" Margin="20">
<TextBlock Text="高级设置" Style="{StaticResource TitleTextBlockStyle}" Margin="0,10"/>
<TextBlock Text="高级设置" Style="{StaticResource TitleTextBlockStyle}" FontFamily="Microsoft YaHei" Margin="0,10"/>
<GroupBox Header="Cookie">
<StackPanel>
<TextBlock Text="请求API时使用此 Cookie"/>
<TextBox Text="{Binding Cookie,UpdateSourceTrigger=PropertyChanged,Delay=1000}" Width="250" HorizontalAlignment="Left"/>
</StackPanel>
<c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasCookie}" Header="请求API时使用此 Cookie">
<TextBox Text="{Binding Cookie,UpdateSourceTrigger=PropertyChanged,Delay=1000}" Width="220" HorizontalAlignment="Left"/>
</c:SettingWithDefault>
</GroupBox>
<GroupBox Header="弹幕录制">
<ui:NumberBox Minimum="0" Header="触发写硬盘所需弹幕个数" Description="单位: 个" SmallChange="1"
<c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasRecordDanmakuFlushInterval}" Header="触发写硬盘所需弹幕个数">
<ui:NumberBox Minimum="0" Description="单位: 个" SmallChange="1"
Text="{Binding RecordDanmakuFlushInterval,UpdateSourceTrigger=PropertyChanged}"/>
</c:SettingWithDefault>
</GroupBox>
<GroupBox Header="Timing">
<ui:SimpleStackPanel Spacing="10">
<ui:NumberBox Minimum="1000" Header="录制重试间隔" Description="单位: 毫秒" SmallChange="100" Text="{Binding TimingStreamRetry,Delay=500}">
<ui:NumberBox.ToolTip>
<c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasTimingStreamRetry}" Header="录制重试间隔">
<c:SettingWithDefault.ToolTip>
<TextBlock>录制断开后等待多长时间再尝试开始录制</TextBlock>
</ui:NumberBox.ToolTip>
</ui:NumberBox>
<ui:NumberBox Minimum="1000" Header="录制连接超时" Description="单位: 毫秒" SmallChange="100" Text="{Binding TimingStreamConnect,Delay=500}">
<ui:NumberBox.ToolTip>
</c:SettingWithDefault.ToolTip>
<ui:NumberBox Minimum="1000" Description="单位: 毫秒" SmallChange="100" Text="{Binding TimingStreamRetry,Delay=500}"/>
</c:SettingWithDefault>
<c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasTimingStreamConnect}" Header="录制连接超时">
<c:SettingWithDefault.ToolTip>
<TextBlock>
发出连接直播服务器的请求后等待多长时间<LineBreak/>
防止直播服务器长时间不返回数据导致卡住
</TextBlock>
</ui:NumberBox.ToolTip>
</ui:NumberBox>
<ui:NumberBox Minimum="1000" Header="弹幕重连间隔" Description="单位: 毫秒" SmallChange="100" Text="{Binding TimingDanmakuRetry,Delay=500}">
<ui:NumberBox.ToolTip>
</c:SettingWithDefault.ToolTip>
<ui:NumberBox Minimum="1000" Description="单位: 毫秒" SmallChange="100" Text="{Binding TimingStreamConnect,Delay=500}"/>
</c:SettingWithDefault>
<c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasTimingDanmakuRetry}" Header="弹幕重连间隔">
<c:SettingWithDefault.ToolTip>
<TextBlock>
弹幕服务器被断开后等待多长时间再尝试连接<LineBreak/>
监控开播的主要途径是通过弹幕服务器发送的信息
</TextBlock>
</ui:NumberBox.ToolTip>
</ui:NumberBox>
<ui:NumberBox Minimum="1000" Header="接收数据超时" Description="单位: 毫秒" SmallChange="100" Text="{Binding TimingWatchdogTimeout,Delay=500}">
<ui:NumberBox.ToolTip>
</c:SettingWithDefault.ToolTip>
<ui:NumberBox Minimum="1000" Description="单位: 毫秒" SmallChange="100" Text="{Binding TimingDanmakuRetry,Delay=500}"/>
</c:SettingWithDefault>
<c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasTimingWatchdogTimeout}" Header="接收数据超时">
<c:SettingWithDefault.ToolTip>
<TextBlock>
在一定时间没有收到直播服务器发送的数据后断开重连<LineBreak/>
用于防止因为玄学网络问题导致卡住
</TextBlock>
</ui:NumberBox.ToolTip>
</ui:NumberBox>
<ui:NumberBox Minimum="60" Header="开播检查间隔" Description="单位: 秒" SmallChange="10" Text="{Binding TimingCheckInterval,Delay=500}">
<ui:NumberBox.ToolTip>
</c:SettingWithDefault.ToolTip>
<ui:NumberBox Minimum="1000" Description="单位: 毫秒" SmallChange="100" Text="{Binding TimingWatchdogTimeout,Delay=500}"/>
</c:SettingWithDefault>
<c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasTimingCheckInterval}" Header="开播检查间隔">
<c:SettingWithDefault.ToolTip>
<TextBlock>
此项影响的时间间隔是定时请求HTTP接口的间隔
主要目的是防止没有从弹幕服务器收到开播消息,
所以此项不需要设置太短。<LineBreak/>
时间间隔设置太短会被B站服务器屏蔽导致无法录制。
</TextBlock>
</ui:NumberBox.ToolTip>
</ui:NumberBox>
</c:SettingWithDefault.ToolTip>
<ui:NumberBox Minimum="60" Description="单位: 秒" SmallChange="10" Text="{Binding TimingCheckInterval,Delay=500}"/>
</c:SettingWithDefault>
</ui:SimpleStackPanel>
</GroupBox>
<GroupBox Header="手动崩溃">

View File

@ -11,7 +11,7 @@ namespace BililiveRecorder.WPF.Pages
{
public AdvancedSettingsPage()
{
InitializeComponent();
this.InitializeComponent();
}
private void Crash_Click(object sender, RoutedEventArgs e)

View File

@ -28,20 +28,20 @@ namespace BililiveRecorder.WPF.Pages
public AnnouncementPage()
{
InitializeComponent();
Dispatcher.BeginInvoke(DispatcherPriority.Normal, new Action(async () => await LoadAnnouncementAsync(ignore_cache: false, show_error: false)));
this.InitializeComponent();
this.Dispatcher.BeginInvoke(DispatcherPriority.Normal, new Action(async () => await this.LoadAnnouncementAsync(ignore_cache: false, show_error: false)));
}
private async void Button_Click(object sender, RoutedEventArgs e) => await LoadAnnouncementAsync(ignore_cache: true, show_error: Keyboard.Modifiers.HasFlag(ModifierKeys.Control));
private async void Button_Click(object sender, RoutedEventArgs e) => await this.LoadAnnouncementAsync(ignore_cache: true, show_error: Keyboard.Modifiers.HasFlag(ModifierKeys.Control));
private async Task LoadAnnouncementAsync(bool ignore_cache, bool show_error)
{
MemoryStream data;
bool success;
Container.Child = null;
Error.Visibility = Visibility.Collapsed;
Loading.Visibility = Visibility.Visible;
this.Container.Child = null;
this.Error.Visibility = Visibility.Collapsed;
this.Loading.Visibility = Visibility.Visible;
if (AnnouncementCache is not null && !ignore_cache)
{
@ -83,7 +83,7 @@ namespace BililiveRecorder.WPF.Pages
using var reader = new XamlXmlReader(stream, System.Windows.Markup.XamlReader.GetWpfSchemaContext());
var obj = System.Windows.Markup.XamlReader.Load(reader);
if (obj is UIElement elem)
Container.Child = elem;
this.Container.Child = elem;
}
catch (Exception ex)
{
@ -93,16 +93,16 @@ namespace BililiveRecorder.WPF.Pages
}
}
Loading.Visibility = Visibility.Collapsed;
this.Loading.Visibility = Visibility.Collapsed;
if (success)
{
RefreshButton.ToolTip = "当前公告获取时间: " + AnnouncementCacheTime.ToString("F");
this.RefreshButton.ToolTip = "当前公告获取时间: " + AnnouncementCacheTime.ToString("F");
AnnouncementCache = data;
}
else
{
RefreshButton.ToolTip = null;
Error.Visibility = Visibility.Visible;
this.RefreshButton.ToolTip = null;
this.Error.Visibility = Visibility.Visible;
}
}
@ -135,7 +135,7 @@ namespace BililiveRecorder.WPF.Pages
{
MessageBox.Show(ex.ToString(), "加载发生错误");
}
await LoadAnnouncementAsync(ignore_cache: false, show_error: true);
await this.LoadAnnouncementAsync(ignore_cache: false, show_error: true);
}
}
}

View File

@ -12,8 +12,8 @@ namespace BililiveRecorder.WPF.Pages
{
public LogPage()
{
InitializeComponent();
VersionTextBlock.Text = BuildInfo.Version + " " + BuildInfo.HeadShaShort;
this.InitializeComponent();
this.VersionTextBlock.Text = BuildInfo.Version + " " + BuildInfo.HeadShaShort;
}
private void TextBlock_MouseRightButtonUp(object sender, MouseButtonEventArgs e)

View File

@ -7,7 +7,7 @@
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Pages"
xmlns:mock="clr-namespace:BililiveRecorder.WPF.MockData"
xmlns:controls="clr-namespace:BililiveRecorder.WPF.Controls"
xmlns:c="clr-namespace:BililiveRecorder.WPF.Controls"
xmlns:converters="clr-namespace:BililiveRecorder.WPF.Converters"
mc:Ignorable="d"
d:DesignHeight="1000" d:DesignWidth="960"
@ -17,12 +17,12 @@
<ui:Page.Resources>
<DataTemplate x:Key="NormalRoomCardTemplate">
<ui:ThemeShadowChrome IsShadowEnabled="True" Depth="10">
<controls:RoomCard DeleteRequested="RoomCard_DeleteRequested" />
<c:RoomCard DeleteRequested="RoomCard_DeleteRequested" ShowSettingsRequested="RoomCard_ShowSettingsRequested" />
</ui:ThemeShadowChrome>
</DataTemplate>
<DataTemplate x:Key="AddRoomCardTemplate">
<ui:ThemeShadowChrome IsShadowEnabled="True" Depth="10">
<controls:AddRoomCard AddRoomRequested="AddRoomCard_AddRoomRequested"/>
<c:AddRoomCard AddRoomRequested="AddRoomCard_AddRoomRequested"/>
</ui:ThemeShadowChrome>
</DataTemplate>
<converters:NullValueTemplateSelector

View File

@ -29,15 +29,15 @@ namespace BililiveRecorder.WPF.Pages
public RoomListPage()
{
InitializeComponent();
this.InitializeComponent();
SortedRoomList = new SortedItemsSourceView(DataContext);
DataContextChanged += RoomListPage_DataContextChanged;
this.SortedRoomList = new SortedItemsSourceView(this.DataContext);
DataContextChanged += this.RoomListPage_DataContextChanged;
}
private void RoomListPage_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
(SortedRoomList as SortedItemsSourceView).Data = e.NewValue as ICollection<IRecordedRoom>;
(this.SortedRoomList as SortedItemsSourceView).Data = e.NewValue as ICollection<IRecordedRoom>;
}
public static readonly DependencyProperty SortedRoomListProperty =
@ -49,8 +49,8 @@ namespace BililiveRecorder.WPF.Pages
public object SortedRoomList
{
get => GetValue(SortedRoomListProperty);
set => SetValue(SortedRoomListProperty, value);
get => this.GetValue(SortedRoomListProperty);
set => this.SetValue(SortedRoomListProperty, value);
}
private static void OnPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
@ -60,7 +60,7 @@ namespace BililiveRecorder.WPF.Pages
private async void RoomCard_DeleteRequested(object sender, EventArgs e)
{
if (DataContext is IRecorder rec && sender is IRecordedRoom room)
if (this.DataContext is IRecorder rec && sender is IRecordedRoom room)
{
var dialog = new DeleteRoomConfirmDialog
{
@ -77,10 +77,19 @@ namespace BililiveRecorder.WPF.Pages
}
}
private async void RoomCard_ShowSettingsRequested(object sender, EventArgs e)
{
try
{
await new PerRoomSettingsDialog { DataContext = sender }.ShowAsync();
}
catch (Exception) { }
}
private async void AddRoomCard_AddRoomRequested(object sender, string e)
{
var input = e.Trim();
if (string.IsNullOrWhiteSpace(input) || DataContext is not IRecorder rec) return;
if (string.IsNullOrWhiteSpace(input) || this.DataContext is not IRecorder rec) return;
if (!int.TryParse(input, out var roomid))
{
@ -119,7 +128,7 @@ namespace BililiveRecorder.WPF.Pages
private async void MenuItem_EnableAutoRecAll_Click(object sender, RoutedEventArgs e)
{
if (!(DataContext is IRecorder rec)) return;
if (!(this.DataContext is IRecorder rec)) return;
await Task.WhenAll(rec.ToList().Select(rr => Task.Run(() => rr.Start())));
rec.SaveConfigToFile();
@ -127,7 +136,7 @@ namespace BililiveRecorder.WPF.Pages
private async void MenuItem_DisableAutoRecAll_Click(object sender, RoutedEventArgs e)
{
if (!(DataContext is IRecorder rec)) return;
if (!(this.DataContext is IRecorder rec)) return;
await Task.WhenAll(rec.ToList().Select(rr => Task.Run(() => rr.Stop())));
rec.SaveConfigToFile();
@ -135,23 +144,23 @@ namespace BililiveRecorder.WPF.Pages
private void MenuItem_SortBy_Click(object sender, RoutedEventArgs e)
{
(SortedRoomList as SortedItemsSourceView).SortedBy = (SortedBy)((MenuItem)sender).Tag;
(this.SortedRoomList as SortedItemsSourceView).SortedBy = (SortedBy)((MenuItem)sender).Tag;
}
private void MenuItem_ShowLog_Click(object sender, RoutedEventArgs e)
{
Splitter.Visibility = Visibility.Visible;
LogElement.Visibility = Visibility.Visible;
RoomListRowDefinition.Height = new GridLength(1, GridUnitType.Star);
LogRowDefinition.Height = new GridLength(1, GridUnitType.Star);
this.Splitter.Visibility = Visibility.Visible;
this.LogElement.Visibility = Visibility.Visible;
this.RoomListRowDefinition.Height = new GridLength(1, GridUnitType.Star);
this.LogRowDefinition.Height = new GridLength(1, GridUnitType.Star);
}
private void MenuItem_HideLog_Click(object sender, RoutedEventArgs e)
{
Splitter.Visibility = Visibility.Collapsed;
LogElement.Visibility = Visibility.Collapsed;
RoomListRowDefinition.Height = new GridLength(1, GridUnitType.Star);
LogRowDefinition.Height = new GridLength(0);
this.Splitter.Visibility = Visibility.Collapsed;
this.LogElement.Visibility = Visibility.Collapsed;
this.RoomListRowDefinition.Height = new GridLength(1, GridUnitType.Star);
this.LogRowDefinition.Height = new GridLength(0);
}
private void Log_ScrollViewer_Loaded(object sender, RoutedEventArgs e)
@ -177,8 +186,8 @@ namespace BililiveRecorder.WPF.Pages
{
try
{
if (DataContext is IRecorder rec)
Process.Start("explorer.exe", rec.Config.WorkDirectory);
if (this.DataContext is IRecorder rec)
Process.Start("explorer.exe", rec.Config.Global.WorkDirectory);
}
catch (Exception)
{
@ -195,7 +204,7 @@ namespace BililiveRecorder.WPF.Pages
internal class SortedItemsSourceView : IList, IReadOnlyList<IRecordedRoom>, IKeyIndexMapping, INotifyCollectionChanged
{
private static Logger logger = LogManager.GetCurrentClassLogger();
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private ICollection<IRecordedRoom> _data;
private SortedBy sortedBy;
@ -210,73 +219,73 @@ namespace BililiveRecorder.WPF.Pages
{
if (data is IList<IRecordedRoom> list)
{
if (list is INotifyCollectionChanged n) n.CollectionChanged += Data_CollectionChanged;
_data = list;
if (list is INotifyCollectionChanged n) n.CollectionChanged += this.Data_CollectionChanged;
this._data = list;
}
else
{
throw new ArgumentException("Type not supported.", nameof(data));
}
}
Sort();
this.Sort();
}
private void Data_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) => Sort();
private void Data_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e) => this.Sort();
public ICollection<IRecordedRoom> Data
{
get => _data;
get => this._data;
set
{
if (_data is INotifyCollectionChanged n1) n1.CollectionChanged -= Data_CollectionChanged;
if (value is INotifyCollectionChanged n2) n2.CollectionChanged += Data_CollectionChanged;
_data = value;
Sort();
if (this._data is INotifyCollectionChanged n1) n1.CollectionChanged -= this.Data_CollectionChanged;
if (value is INotifyCollectionChanged n2) n2.CollectionChanged += this.Data_CollectionChanged;
this._data = value;
this.Sort();
}
}
public SortedBy SortedBy { get => sortedBy; set { sortedBy = value; Sort(); } }
public SortedBy SortedBy { get => this.sortedBy; set { this.sortedBy = value; this.Sort(); } }
public List<IRecordedRoom> Sorted { get; private set; }
private int sortSeboucneCount = int.MinValue;
private SemaphoreSlim sortSemaphoreSlim = new SemaphoreSlim(1, 1);
private readonly SemaphoreSlim sortSemaphoreSlim = new SemaphoreSlim(1, 1);
private async void Sort()
{
// debounce && lock
logger.Debug("Sort called.");
var callCount = Interlocked.Increment(ref sortSeboucneCount);
var callCount = Interlocked.Increment(ref this.sortSeboucneCount);
await Task.Delay(200);
if (sortSeboucneCount != callCount)
if (this.sortSeboucneCount != callCount)
{
logger.Debug("Sort cancelled by debounce.");
return;
}
await sortSemaphoreSlim.WaitAsync();
try { SortImpl(); }
finally { sortSemaphoreSlim.Release(); }
await this.sortSemaphoreSlim.WaitAsync();
try { this.SortImpl(); }
finally { this.sortSemaphoreSlim.Release(); }
}
private void SortImpl()
{
logger.Debug("SortImpl called with {sortedBy} and {count} rooms.", SortedBy, Data?.Count ?? -1);
logger.Debug("SortImpl called with {sortedBy} and {count} rooms.", this.SortedBy, this.Data?.Count ?? -1);
if (Data is null)
if (this.Data is null)
{
Sorted = NullRoom.ToList();
this.Sorted = this.NullRoom.ToList();
logger.Debug("SortImpl returned NullRoom.");
}
else
{
IEnumerable<IRecordedRoom> orderedData = SortedBy switch
IEnumerable<IRecordedRoom> orderedData = this.SortedBy switch
{
SortedBy.RoomId => Data.OrderBy(x => x.ShortRoomId == 0 ? x.RoomId : x.ShortRoomId),
SortedBy.Status => Data.OrderByDescending(x => x.IsRecording).ThenByDescending(x => x.IsMonitoring),
_ => Data,
SortedBy.RoomId => this.Data.OrderBy(x => x.ShortRoomId == 0 ? x.RoomId : x.ShortRoomId),
SortedBy.Status => this.Data.OrderByDescending(x => x.IsRecording).ThenByDescending(x => x.IsMonitoring),
_ => this.Data,
};
var result = orderedData.Concat(NullRoom).ToList();
var result = orderedData.Concat(this.NullRoom).ToList();
logger.Debug("SortImpl sorted with {count} items.", result.Count);
{ // 崩溃问题信息收集。。虽然不觉得是这里的问题
@ -310,7 +319,7 @@ namespace BililiveRecorder.WPF.Pages
}
}
Sorted = result;
this.Sorted = result;
}
// Instead of tossing out existing elements and re-creating them,
@ -319,21 +328,21 @@ namespace BililiveRecorder.WPF.Pages
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public IRecordedRoom this[int index] => Sorted != null ? Sorted[index] : throw new IndexOutOfRangeException();
public int Count => Sorted != null ? Sorted.Count : 0;
public IRecordedRoom this[int index] => this.Sorted != null ? this.Sorted[index] : throw new IndexOutOfRangeException();
public int Count => this.Sorted != null ? this.Sorted.Count : 0;
public bool IsReadOnly => ((IList)Sorted).IsReadOnly;
public bool IsReadOnly => ((IList)this.Sorted).IsReadOnly;
public bool IsFixedSize => ((IList)Sorted).IsFixedSize;
public bool IsFixedSize => ((IList)this.Sorted).IsFixedSize;
public object SyncRoot => ((ICollection)Sorted).SyncRoot;
public object SyncRoot => ((ICollection)this.Sorted).SyncRoot;
public bool IsSynchronized => ((ICollection)Sorted).IsSynchronized;
public bool IsSynchronized => ((ICollection)this.Sorted).IsSynchronized;
object IList.this[int index] { get => ((IList)Sorted)[index]; set => ((IList)Sorted)[index] = value; }
object IList.this[int index] { get => ((IList)this.Sorted)[index]; set => ((IList)this.Sorted)[index] = value; }
public IEnumerator<IRecordedRoom> GetEnumerator() => Sorted.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public IEnumerator<IRecordedRoom> GetEnumerator() => this.Sorted.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
#region IKeyIndexMapping
@ -353,15 +362,15 @@ namespace BililiveRecorder.WPF.Pages
{
// We'll try to increase our odds of finding a match sooner by starting from the
// position that we know was last requested and search forward.
var start = lastRequestedIndex;
for (var i = start; i < Count; i++)
var start = this.lastRequestedIndex;
for (var i = start; i < this.Count; i++)
{
if ((this[i]?.Guid ?? Guid.Empty).Equals(uniqueId))
return i;
}
// Then try searching backward.
start = Math.Min(Count - 1, lastRequestedIndex);
start = Math.Min(this.Count - 1, this.lastRequestedIndex);
for (var i = start; i >= 0; i--)
{
if ((this[i]?.Guid ?? Guid.Empty).Equals(uniqueId))
@ -374,48 +383,48 @@ namespace BililiveRecorder.WPF.Pages
public string KeyFromIndex(int index)
{
var key = this[index]?.Guid ?? Guid.Empty;
lastRequestedIndex = index;
this.lastRequestedIndex = index;
return key.ToString();
}
public int Add(object value)
{
return ((IList)Sorted).Add(value);
return ((IList)this.Sorted).Add(value);
}
public bool Contains(object value)
{
return ((IList)Sorted).Contains(value);
return ((IList)this.Sorted).Contains(value);
}
public void Clear()
{
((IList)Sorted).Clear();
((IList)this.Sorted).Clear();
}
public int IndexOf(object value)
{
return ((IList)Sorted).IndexOf(value);
return ((IList)this.Sorted).IndexOf(value);
}
public void Insert(int index, object value)
{
((IList)Sorted).Insert(index, value);
((IList)this.Sorted).Insert(index, value);
}
public void Remove(object value)
{
((IList)Sorted).Remove(value);
((IList)this.Sorted).Remove(value);
}
public void RemoveAt(int index)
{
((IList)Sorted).RemoveAt(index);
((IList)this.Sorted).RemoveAt(index);
}
public void CopyTo(Array array, int index)
{
((ICollection)Sorted).CopyTo(array, index);
((ICollection)this.Sorted).CopyTo(array, index);
}
#endregion

View File

@ -40,38 +40,38 @@ namespace BililiveRecorder.WPF.Pages
public RootPage()
{
void AddType(Type t) => PageMap.Add(t.Name, t);
void AddType(Type t) => this.PageMap.Add(t.Name, t);
AddType(typeof(RoomListPage));
AddType(typeof(LogPage));
AddType(typeof(SettingsPage));
AddType(typeof(AdvancedSettingsPage));
AddType(typeof(AnnouncementPage));
Model = new RootModel();
DataContext = Model;
this.Model = new RootModel();
this.DataContext = this.Model;
var builder = new ContainerBuilder();
builder.RegisterModule<FlvProcessorModule>();
builder.RegisterModule<CoreModule>();
Container = builder.Build();
RootScope = Container.BeginLifetimeScope("recorder_root");
this.Container = builder.Build();
this.RootScope = this.Container.BeginLifetimeScope("recorder_root");
InitializeComponent();
AdvancedSettingsPageItem.Visibility = Visibility.Hidden;
this.InitializeComponent();
this.AdvancedSettingsPageItem.Visibility = Visibility.Hidden;
(Application.Current.MainWindow as NewMainWindow).NativeBeforeWindowClose += RootPage_NativeBeforeWindowClose;
Loaded += RootPage_Loaded;
(Application.Current.MainWindow as NewMainWindow).NativeBeforeWindowClose += this.RootPage_NativeBeforeWindowClose;
Loaded += this.RootPage_Loaded;
}
private void RootPage_NativeBeforeWindowClose(object sender, EventArgs e)
{
Model.Dispose();
this.Model.Dispose();
SingleInstance.Cleanup();
}
private async void RootPage_Loaded(object sender, RoutedEventArgs e)
{
var recorder = RootScope.Resolve<IRecorder>();
var recorder = this.RootScope.Resolve<IRecorder>();
var first_time = true;
var error = string.Empty;
string path;
@ -113,8 +113,8 @@ namespace BililiveRecorder.WPF.Pages
var lastdir = string.Empty;
try
{
if (File.Exists(lastdir_path))
lastdir = File.ReadAllText(lastdir_path).Replace("\r", "").Replace("\n", "").Trim();
if (File.Exists(this.lastdir_path))
lastdir = File.ReadAllText(this.lastdir_path).Replace("\r", "").Replace("\n", "").Trim();
}
catch (Exception) { }
@ -161,7 +161,7 @@ namespace BililiveRecorder.WPF.Pages
// 如果不是从命令行参数传入的路径,写入 lastdir_path 记录
try
{ if (string.IsNullOrWhiteSpace(commandLineOption?.WorkDirectory)) File.WriteAllText(lastdir_path, path); }
{ if (string.IsNullOrWhiteSpace(commandLineOption?.WorkDirectory)) File.WriteAllText(this.lastdir_path, path); }
catch (Exception) { }
// 检查已经在同目录运行的其他进程
@ -170,14 +170,14 @@ namespace BililiveRecorder.WPF.Pages
// 无已经在同目录运行的进程
if (recorder.Initialize(path))
{
Model.Recorder = recorder;
this.Model.Recorder = recorder;
_ = Task.Run(async () =>
{
await Task.Delay(100);
_ = Dispatcher.BeginInvoke(DispatcherPriority.Normal, method: new Action(() =>
_ = this.Dispatcher.BeginInvoke(DispatcherPriority.Normal, method: new Action(() =>
{
RoomListPageNavigationViewItem.IsSelected = true;
this.RoomListPageNavigationViewItem.IsSelected = true;
}));
});
break;
@ -206,10 +206,10 @@ namespace BililiveRecorder.WPF.Pages
private void NavigationView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
{
SettingsClickCount = 0;
this.SettingsClickCount = 0;
if (args.IsSettingsSelected)
{
MainFrame.Navigate(typeof(SettingsPage), null, transitionInfo);
this.MainFrame.Navigate(typeof(SettingsPage), null, this.transitionInfo);
}
else
{
@ -219,26 +219,26 @@ namespace BililiveRecorder.WPF.Pages
{
try
{
MainFrame.Navigate(new Uri(selectedItemTag), null, transitionInfo);
this.MainFrame.Navigate(new Uri(selectedItemTag), null, this.transitionInfo);
}
catch (Exception)
{
}
}
else if (PageMap.ContainsKey(selectedItemTag))
else if (this.PageMap.ContainsKey(selectedItemTag))
{
var pageType = PageMap[selectedItemTag];
MainFrame.Navigate(pageType, null, transitionInfo);
var pageType = this.PageMap[selectedItemTag];
this.MainFrame.Navigate(pageType, null, this.transitionInfo);
}
}
}
private void NavigationViewItem_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
if (++SettingsClickCount > 3)
if (++this.SettingsClickCount > 3)
{
SettingsClickCount = 0;
AdvancedSettingsPageItem.Visibility = AdvancedSettingsPageItem.Visibility != Visibility.Visible ? Visibility.Visible : Visibility.Hidden;
this.SettingsClickCount = 0;
this.AdvancedSettingsPageItem.Visibility = this.AdvancedSettingsPageItem.Visibility != Visibility.Visible ? Visibility.Visible : Visibility.Hidden;
}
}

View File

@ -5,12 +5,13 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:c="clr-namespace:BililiveRecorder.WPF.Controls"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Pages"
xmlns:config="clr-namespace:BililiveRecorder.Core.Config;assembly=BililiveRecorder.Core"
xmlns:config="clr-namespace:BililiveRecorder.Core.Config.V2;assembly=BililiveRecorder.Core"
xmlns:flv="clr-namespace:BililiveRecorder.FlvProcessor;assembly=BililiveRecorder.FlvProcessor"
mc:Ignorable="d"
d:DesignHeight="1500" d:DesignWidth="500"
DataContext="{Binding RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=local:RootPage},Path=DataContext.Recorder.Config}"
DataContext="{Binding RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=local:RootPage},Path=DataContext.Recorder.Config.Global}"
Title="SettingsPage">
<ui:Page.Resources>
<Style TargetType="TextBlock">
@ -18,7 +19,7 @@
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
</ui:Page.Resources>
<ScrollViewer d:DataContext="{d:DesignInstance Type=config:ConfigV1}">
<ScrollViewer d:DataContext="{d:DesignInstance Type=config:GlobalConfig}">
<ui:SimpleStackPanel Orientation="Vertical" Spacing="5" Margin="20">
<TextBlock Text="设置" Style="{StaticResource TitleTextBlockStyle}" FontFamily="Microsoft Yahei" Margin="0,10"/>
<GroupBox Header="弹幕录制">
@ -86,7 +87,7 @@
</StackPanel>
</GroupBox>
<GroupBox Header="文件名">
<StackPanel MaxWidth="400" HorizontalAlignment="Left">
<StackPanel MaxWidth="500" HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<StackPanel.ToolTip>
<TextBlock FontSize="13">
@ -106,11 +107,13 @@
<TextBlock Text="说明"/>
<ui:PathIcon Margin="2,0" VerticalAlignment="Center" Height="15" Style="{StaticResource PathIconDataInformationOutline}"/>
</StackPanel>
<TextBlock Text="录制文件名格式"/>
<c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasRecordFilenameFormat}" Header="录制文件名格式">
<TextBox Text="{Binding RecordFilenameFormat,Delay=500}" ui:TextBoxHelper.IsDeleteButtonVisible="False"/>
<TextBlock Text="剪辑文件名格式" Margin="0,5,0,0" Visibility="{Binding ElementName=EnabledFeatureRecordOnlyRadioButton,Path=IsChecked,Converter={StaticResource InvertBooleanToVisibilityCollapsedConverter}}"/>
<TextBox Text="{Binding ClipFilenameFormat,Delay=500}" ui:TextBoxHelper.IsDeleteButtonVisible="False"
Visibility="{Binding ElementName=EnabledFeatureRecordOnlyRadioButton,Path=IsChecked,Converter={StaticResource InvertBooleanToVisibilityCollapsedConverter}}"/>
</c:SettingWithDefault>
<c:SettingWithDefault IsSettingNotUsingDefault="{Binding HasClipFilenameFormat}" Header="剪辑文件名格式" Margin="0,5,0,0"
Visibility="{Binding ElementName=EnabledFeatureRecordOnlyRadioButton,Path=IsChecked,Converter={StaticResource InvertBooleanToVisibilityCollapsedConverter}}">
<TextBox Text="{Binding ClipFilenameFormat,Delay=500}" ui:TextBoxHelper.IsDeleteButtonVisible="False"/>
</c:SettingWithDefault>
</StackPanel>
</GroupBox>
<GroupBox Header="Webhook">

View File

@ -1,18 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace BililiveRecorder.WPF.Pages
{
/// <summary>
@ -22,7 +7,7 @@ namespace BililiveRecorder.WPF.Pages
{
public SettingsPage()
{
InitializeComponent();
this.InitializeComponent();
}
}
}

View File

@ -1,6 +1,4 @@
using System.Reflection;
using System.Resources;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Windows;

View File

@ -19,6 +19,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
.editorconfig = .editorconfig
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{623A2ACC-DAC6-4E6F-9242-B4B54381AAE1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BililiveRecorder.UnitTest.Core", "test\BililiveRecorder.UnitTest.Core\BililiveRecorder.UnitTest.Core.csproj", "{521EC763-5694-45A8-B87F-6E6B7F2A3BD4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -41,10 +45,17 @@ Global
{1B626335-283F-4313-9045-B5B96FAAB2DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1B626335-283F-4313-9045-B5B96FAAB2DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1B626335-283F-4313-9045-B5B96FAAB2DF}.Release|Any CPU.Build.0 = Release|Any CPU
{521EC763-5694-45A8-B87F-6E6B7F2A3BD4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{521EC763-5694-45A8-B87F-6E6B7F2A3BD4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{521EC763-5694-45A8-B87F-6E6B7F2A3BD4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{521EC763-5694-45A8-B87F-6E6B7F2A3BD4}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{521EC763-5694-45A8-B87F-6E6B7F2A3BD4} = {623A2ACC-DAC6-4E6F-9242-B4B54381AAE1}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F3CB8B14-077A-458F-BD8E-1747ED0F5170}
EndGlobalSection

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="1.3.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\BililiveRecorder.Core\BililiveRecorder.Core.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config.V1;
using BililiveRecorder.Core.Config.V2;
using Newtonsoft.Json;
using Xunit;
namespace BililiveRecorder.UnitTest.Core
{
public class ConfigTests
{
private const string V2TestString1 = "{\"version\":2,\"global\":{\"EnabledFeature\":{\"HasValue\":false,\"Value\":0},\"ClipLengthPast\":{\"HasValue\":false,\"Value\":0},\"ClipLengthFuture\":{\"HasValue\":false,\"Value\":0},\"TimingStreamRetry\":{\"HasValue\":false,\"Value\":0},\"TimingStreamConnect\":{\"HasValue\":false,\"Value\":0},\"TimingDanmakuRetry\":{\"HasValue\":false,\"Value\":0},\"TimingCheckInterval\":{\"HasValue\":false,\"Value\":0},\"TimingWatchdogTimeout\":{\"HasValue\":false,\"Value\":0},\"RecordDanmakuFlushInterval\":{\"HasValue\":false,\"Value\":0},\"Cookie\":{\"HasValue\":false,\"Value\":null},\"WebHookUrls\":{\"HasValue\":false,\"Value\":null},\"LiveApiHost\":{\"HasValue\":false,\"Value\":null},\"RecordFilenameFormat\":{\"HasValue\":false,\"Value\":null},\"ClipFilenameFormat\":{\"HasValue\":false,\"Value\":null},\"CuttingMode\":{\"HasValue\":false,\"Value\":0},\"CuttingNumber\":{\"HasValue\":false,\"Value\":0},\"RecordDanmaku\":{\"HasValue\":false,\"Value\":false},\"RecordDanmakuRaw\":{\"HasValue\":false,\"Value\":false},\"RecordDanmakuSuperChat\":{\"HasValue\":false,\"Value\":false},\"RecordDanmakuGift\":{\"HasValue\":false,\"Value\":false},\"RecordDanmakuGuard\":{\"HasValue\":false,\"Value\":false}},\"rooms\":[{\"RoomId\":{\"HasValue\":true,\"Value\":1},\"AutoRecord\":{\"HasValue\":false,\"Value\":false},\"CuttingMode\":{\"HasValue\":false,\"Value\":0},\"CuttingNumber\":{\"HasValue\":false,\"Value\":0},\"RecordDanmaku\":{\"HasValue\":false,\"Value\":false},\"RecordDanmakuRaw\":{\"HasValue\":false,\"Value\":false},\"RecordDanmakuSuperChat\":{\"HasValue\":false,\"Value\":false},\"RecordDanmakuGift\":{\"HasValue\":false,\"Value\":false},\"RecordDanmakuGuard\":{\"HasValue\":false,\"Value\":false}},{\"RoomId\":{\"HasValue\":true,\"Value\":2},\"AutoRecord\":{\"HasValue\":false,\"Value\":false},\"CuttingMode\":{\"HasValue\":false,\"Value\":0},\"CuttingNumber\":{\"HasValue\":false,\"Value\":0},\"RecordDanmaku\":{\"HasValue\":false,\"Value\":false},\"RecordDanmakuRaw\":{\"HasValue\":false,\"Value\":false},\"RecordDanmakuSuperChat\":{\"HasValue\":false,\"Value\":false},\"RecordDanmakuGift\":{\"HasValue\":false,\"Value\":false},\"RecordDanmakuGuard\":{\"HasValue\":false,\"Value\":false}}]}";
private static readonly List<object[]> V1 = new()
{
new object[] { "{\"version\":1}", 1 },
new object[] { "{\"version\":1,\"data\":\"\"}", 1 },
};
private static readonly List<object[]> V2 = new()
{
new object[] { "{\"version\":2}", 2 },
new object[] { "{\"version\":2,\"data\":{}}", 2 },
new object[] { V2TestString1, 2 },
};
public static IEnumerable<object[]> GetTestData(int version)
=> version switch
{
0 => V1.Concat(V2).AsEnumerable(),
1 => V1.AsEnumerable(),
2 => V2.AsEnumerable(),
_ => throw new ArgumentException()
};
[Theory, MemberData(nameof(GetTestData), 0)]
public void Parse(string data, int ver)
{
var result = JsonConvert.DeserializeObject<ConfigBase>(data);
var type = ver switch
{
1 => typeof(ConfigV1Wrapper),
2 => typeof(ConfigV2),
_ => throw new Exception("not supported")
};
Assert.Equal(type, result.GetType());
}
[Fact]
public void V2Test1()
{
var obj = JsonConvert.DeserializeObject<ConfigBase>(V2TestString1);
var v2 = Assert.IsType<ConfigV2>(obj);
Assert.Equal(2, v2.Rooms.Count);
Assert.Equal(1, v2.Rooms[0].RoomId);
Assert.Equal(2, v2.Rooms[1].RoomId);
}
[Fact]
public void Save()
{
ConfigBase config = new ConfigV2()
{
Rooms = new List<RoomConfig>
{
new RoomConfig { RoomId = 1 },
new RoomConfig { RoomId = 2 }
},
Global = new GlobalConfig()
};
var json = JsonConvert.SerializeObject(config);
}
}
}