Merge pull request #126 from Bililive/dev

Release 1.1.20
This commit is contained in:
Genteure 2020-11-24 21:14:48 +08:00 committed by GitHub
commit f6ad6b8593
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 3945 additions and 202 deletions

View File

@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
<StartupObject>BililiveRecorder.Cli.Program</StartupObject>
</PropertyGroup>
<ItemGroup>
<None Remove="config.json" />
<None Remove="NLog.config" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\TempBuildInfo\BuildInfo.Cli.cs" />
</ItemGroup>
<ItemGroup>
<Content Include="config.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="NLog.config">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Autofac" Version="4.9.4" />
<PackageReference Include="CommandLineParser" Version="2.4.3" />
<PackageReference Include="NLog" Version="4.5.10" />
<PackageReference Include="NLog.Config" Version="4.5.10" />
<PackageReference Include="NLog.Schema" Version="4.5.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BililiveRecorder.Core\BililiveRecorder.Core.csproj" />
<ProjectReference Include="..\BililiveRecorder.FlvProcessor\BililiveRecorder.FlvProcessor.csproj" />
</ItemGroup>
<Target Name="PreBuild" BeforeTargets="PreBuildEvent">
<Exec Command="cd $(SolutionDir)&#xD;&#xA;powershell -ExecutionPolicy Bypass -File .\CI\patch_buildinfo.ps1 Cli" />
</Target>
</Project>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.nlog-project.org/schemas/NLog.xsd NLog.xsd"
autoReload="true"
throwExceptions="false"
internalLogLevel="Off" internalLogFile="c:\temp\nlog-internal.log"
>
<targets>
<target name="console" xsi:type="Console" encoding="utf-8"
layout="${longdate} ${level:upperCase=true} ${processid} ${logger} ${event-properties:item=roomid} ${message} ${exception:format=Type} ${exception:format=Message} ${exception:format=ToString}"
/>
</targets>
<rules>
<logger name="*" minlevel="Info" writeTo="console"/>
</rules>
</nlog>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,112 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Autofac;
using BililiveRecorder.Core;
using BililiveRecorder.Core.Config;
using BililiveRecorder.FlvProcessor;
using CommandLine;
using NLog;
namespace BililiveRecorder.Cli
{
class Program
{
private static IContainer Container { get; set; }
private static ILifetimeScope RootScope { get; set; }
private static IRecorder Recorder { get; set; }
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
static void Main(string[] _)
{
var builder = new ContainerBuilder();
builder.RegisterModule<FlvProcessorModule>();
builder.RegisterModule<CoreModule>();
builder.RegisterType<CommandConfigV1>().As<ConfigV1>().InstancePerMatchingLifetimeScope("recorder_root");
Container = builder.Build();
RootScope = Container.BeginLifetimeScope("recorder_root");
Recorder = RootScope.Resolve<IRecorder>();
if (!Recorder.Initialize(System.IO.Directory.GetCurrentDirectory()))
{
Console.WriteLine("Initialize Error");
return;
}
Parser.Default
.ParseArguments<CommandConfigV1>(() => (CommandConfigV1)Recorder.Config, Environment.GetCommandLineArgs())
.WithParsed(Run);
}
private static void Run(ConfigV1 option)
{
foreach (var room in option.RoomList)
{
if (Recorder.Where(r => r.RoomId == room.Roomid).Count() == 0)
{
Recorder.AddRoom(room.Roomid);
}
}
logger.Info("开始录播");
Task.WhenAll(Recorder.Select(x => Task.Run(() => x.Start()))).Wait();
Console.CancelKeyPress += (sender, e) =>
{
Task.WhenAll(Recorder.Select(x => Task.Run(() => x.StopRecord()))).Wait();
logger.Info("停止录播");
};
while (true)
{
Thread.Sleep(TimeSpan.FromSeconds(10));
}
}
}
class ConfigV1Metadata
{
[Option('o', "dir", Default = ".", HelpText = "Output directory", Required = false)]
[Utils.DoNotCopyProperty]
public object WorkDirectory { get; set; }
[Option("cookie", HelpText = "Provide custom cookies", Required = false)]
public object Cookie { get; set; }
[Option("avoidtxy", HelpText = "Avoid Tencent Cloud server", Required = false)]
public object AvoidTxy { get; set; }
[Option("live_api_host", HelpText = "Use custom api host", Required = false)]
public object LiveApiHost { get; set; }
[Option("record_filename_format", HelpText = "Recording name format", Required = false)]
public object RecordFilenameFormat { get; set; }
}
[MetadataType(typeof(ConfigV1Metadata))]
class CommandConfigV1 : ConfigV1
{
[Option('i', "id", HelpText = "room id", Required = true)]
[Utils.DoNotCopyProperty]
public string _RoomList
{
set
{
var roomids = value.Split(',');
RoomList.Clear();
foreach (var roomid in roomids)
{
var room = new RoomV1();
room.Roomid = Int32.Parse(roomid);
room.Enabled = false;
RoomList.Add(room);
}
}
}
}
}

View File

@ -0,0 +1 @@
{"version":1,"data":"{\"roomlist\":[],\"feature\":0,\"clip_length_future\":10,\"clip_length_past\":20,\"cutting_mode\":0,\"cutting_number\":10,\"timing_stream_retry\":6000,\"timing_stream_connect\":3000,\"timing_danmaku_retry\":2000,\"timing_check_interval\":300,\"timing_watchdog_timeout\":10000,\"cookie\":\"\",\"avoidtxy\":true}"}

View File

@ -0,0 +1,5 @@
{
"configProperties": {
"System.Globalization.Invariant": true
}
}

View File

@ -0,0 +1,195 @@
using BililiveRecorder.Core.Config;
using System;
using System.IO;
using System.Text;
using System.Threading;
using System.Xml;
namespace BililiveRecorder.Core
{
public class BasicDanmakuWriter : IBasicDanmakuWriter
{
private static readonly XmlWriterSettings xmlWriterSettings = new XmlWriterSettings
{
Indent = true,
Encoding = Encoding.UTF8,
CloseOutput = true,
WriteEndDocumentOnClose = true
};
private XmlWriter xmlWriter = null;
private DateTimeOffset offset = DateTimeOffset.UtcNow;
private readonly ConfigV1 config;
public BasicDanmakuWriter(ConfigV1 config)
{
this.config = config ?? throw new ArgumentNullException(nameof(config));
}
private readonly SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1, 1);
public void EnableWithPath(string path)
{
if (disposedValue) return;
semaphoreSlim.Wait();
try
{
if (xmlWriter != null)
{
xmlWriter.Close();
xmlWriter.Dispose();
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);
offset = DateTimeOffset.UtcNow;
}
finally
{
semaphoreSlim.Release();
}
}
public void Disable()
{
if (disposedValue) return;
semaphoreSlim.Wait();
try
{
if (xmlWriter != null)
{
xmlWriter.Close();
xmlWriter.Dispose();
xmlWriter = null;
}
}
finally
{
semaphoreSlim.Release();
}
}
public void Write(DanmakuModel danmakuModel)
{
if (disposedValue) return;
semaphoreSlim.Wait();
try
{
if (xmlWriter != null)
{
// TimeSpan diff = DateTimeOffset.UtcNow - offset;
switch (danmakuModel.MsgType)
{
case MsgTypeEnum.Comment:
{
var type = danmakuModel.RawObj?["info"]?[0]?[1]?.ToObject<int>() ?? 1;
var size = danmakuModel.RawObj?["info"]?[0]?[2]?.ToObject<int>() ?? 25;
var color = danmakuModel.RawObj?["info"]?[0]?[3]?.ToObject<int>() ?? 0XFFFFFF;
long st = danmakuModel.RawObj?["info"]?[0]?[4]?.ToObject<long>() ?? 0L;
var ts = Math.Max((DateTimeOffset.FromUnixTimeMilliseconds(st) - offset).TotalSeconds, 0d);
xmlWriter.WriteStartElement("d");
xmlWriter.WriteAttributeString("p", $"{ts},{type},{size},{color},{st},0,{danmakuModel.UserID},0");
xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["info"]?.ToString(Newtonsoft.Json.Formatting.None));
xmlWriter.WriteValue(danmakuModel.CommentText);
xmlWriter.WriteEndElement();
}
break;
case MsgTypeEnum.SuperChat:
if (config.RecordDanmakuSuperChat)
{
xmlWriter.WriteStartElement("sc");
xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
xmlWriter.WriteAttributeString("price", danmakuModel.Price.ToString());
xmlWriter.WriteAttributeString("time", danmakuModel.SCKeepTime.ToString());
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
xmlWriter.WriteValue(danmakuModel.CommentText);
xmlWriter.WriteEndElement();
}
break;
case MsgTypeEnum.GiftSend:
if (config.RecordDanmakuGift)
{
xmlWriter.WriteStartElement("gift");
xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
xmlWriter.WriteAttributeString("giftname", danmakuModel.GiftName);
xmlWriter.WriteAttributeString("giftcount", danmakuModel.GiftCount.ToString());
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
xmlWriter.WriteEndElement();
}
break;
case MsgTypeEnum.GuardBuy:
if (config.RecordDanmakuGuard)
{
xmlWriter.WriteStartElement("guard");
xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
xmlWriter.WriteAttributeString("level", danmakuModel.UserGuardLevel.ToString()); ;
xmlWriter.WriteAttributeString("count", danmakuModel.GiftCount.ToString());
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
xmlWriter.WriteEndElement();
}
break;
default:
break;
}
}
}
finally
{
semaphoreSlim.Release();
}
}
private void WriteStartDocument(XmlWriter writer)
{
writer.WriteStartDocument();
writer.WriteStartElement("i");
writer.WriteAttributeString("BililiveRecorder", "B站录播姬拓展版弹幕文件");
writer.WriteComment("\nB站录播姬 " + BuildInfo.Version + " " + BuildInfo.HeadSha1 + "\n本文件在B站主站视频弹幕XML格式的基础上进行了拓展\n\nsc为SuperChat\ngift为礼物\nguard为上船\n\nattribute \"raw\" 为原始数据\n");
writer.WriteElementString("chatserver", "chat.bilibili.com");
writer.WriteElementString("chatid", "0");
writer.WriteElementString("mission", "0");
writer.WriteElementString("maxlimit", "1000");
writer.WriteElementString("state", "0");
writer.WriteElementString("real_name", "0");
writer.WriteElementString("source", "0");
writer.Flush();
}
private bool disposedValue;
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
// dispose managed state (managed objects)
semaphoreSlim.Dispose();
xmlWriter?.Close();
xmlWriter?.Dispose();
}
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
disposedValue = true;
}
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -111,7 +111,7 @@ namespace BililiveRecorder.Core
/// <exception cref="Exception"/>
public static async Task<string> GetPlayUrlAsync(int roomid)
{
string url = $@"https://api.live.bilibili.com/room/v1/Room/playUrl?cid={roomid}&quality=4&platform=web";
string url = $@"{Config.LiveApiHost}/room/v1/Room/playUrl?cid={roomid}&quality=4&platform=web";
if (Config.AvoidTxy)
{
// 尽量避开腾讯云

View File

@ -1,11 +1,14 @@
using Newtonsoft.Json;
using System;
using System.IO;
using NLog;
namespace BililiveRecorder.Core.Config
{
public static class ConfigParser
{
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
public static bool Load(string directory, ConfigV1 config = null)
{
if (!Directory.Exists(directory))
@ -42,9 +45,9 @@ namespace BililiveRecorder.Core.Config
return false;
}
}
catch (Exception)
catch (Exception ex)
{
// TODO: Log Exception
logger.Error(ex, "Failed to parse config!");
return false;
}
}

View File

@ -92,12 +92,42 @@ namespace BililiveRecorder.Core.Config
[JsonProperty("cookie")]
public string Cookie { get => _cookie; set => SetField(ref _cookie, value); }
/// <summary>
/// 是否同时录制弹幕
/// </summary>
[JsonProperty("record_danmaku")]
public bool RecordDanmaku { get => _recordDanmaku; set => SetField(ref _recordDanmaku, value); }
/// <summary>
/// 是否同时录制 SuperChat
/// </summary>
[JsonProperty("record_danmaku_sc")]
public bool RecordDanmakuSuperChat { get => _recordDanmakuSuperChat; set => SetField(ref _recordDanmakuSuperChat, value); }
/// <summary>
/// 是否同时录制 礼物
/// </summary>
[JsonProperty("record_danmaku_gift")]
public bool RecordDanmakuGift { get => _recordDanmakuGift; set => SetField(ref _recordDanmakuGift, value); }
/// <summary>
/// 是否同时录制 上船
/// </summary>
[JsonProperty("record_danmaku_guard")]
public bool RecordDanmakuGuard { get => _recordDanmakuGuard; set => SetField(ref _recordDanmakuGuard, value); }
/// <summary>
/// 尽量避开腾讯云服务器,可有效提升录制文件能正常播放的概率。(垃圾腾讯云直播服务)
/// </summary>
[JsonProperty("avoidtxy")]
public bool AvoidTxy { get => _avoidTxy; set => SetField(ref _avoidTxy, value); }
/// <summary>
/// 替换api.live.bilibili.com服务器为其他反代可以支持在云服务器上录制
/// </summary>
[JsonProperty("live_api_host")]
public string LiveApiHost { get => _liveApiHost; set => SetField(ref _liveApiHost, value); }
[JsonProperty("record_filename_format")]
public string RecordFilenameFormat
{
@ -118,7 +148,7 @@ namespace BililiveRecorder.Core.Config
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
{
if (EqualityComparer<T>.Default.Equals(field, value)) { return false; }
logger.Debug("设置 [{0}] 的值已从 [{1}] 修改到 [{2}]", propertyName, field, value);
logger.Trace("设置 [{0}] 的值已从 [{1}] 修改到 [{2}]", propertyName, field, value);
field = value; OnPropertyChanged(propertyName); return true;
}
#endregion
@ -126,7 +156,7 @@ namespace BililiveRecorder.Core.Config
private uint _clipLengthPast = 20;
private uint _clipLengthFuture = 10;
private uint _cuttingNumber = 10;
private EnabledFeature _enabledFeature = EnabledFeature.Both;
private EnabledFeature _enabledFeature = EnabledFeature.RecordOnly;
private AutoCuttingMode _cuttingMode = AutoCuttingMode.Disabled;
private string _workDirectory;
@ -141,6 +171,13 @@ namespace BililiveRecorder.Core.Config
private string _record_filename_format = @"{roomid}-{name}/录制-{roomid}-{date}-{time}-{title}.flv";
private string _clip_filename_format = @"{roomid}-{name}/剪辑片段-{roomid}-{date}-{time}-{title}.flv";
private bool _recordDanmaku = false;
private bool _recordDanmakuSuperChat = false;
private bool _recordDanmakuGift = false;
private bool _recordDanmakuGuard = false;
private bool _avoidTxy = false;
private string _liveApiHost = "https://api.live.bilibili.com";
}
}

View File

@ -17,6 +17,7 @@ namespace BililiveRecorder.Core
builder.RegisterType<TcpClient>().AsSelf().ExternallyOwned();
builder.RegisterType<StreamMonitor>().As<IStreamMonitor>().ExternallyOwned();
builder.RegisterType<RecordedRoom>().As<IRecordedRoom>().ExternallyOwned();
builder.RegisterType<BasicDanmakuWriter>().As<IBasicDanmakuWriter>().ExternallyOwned();
builder.RegisterType<Recorder>().As<IRecorder>().InstancePerMatchingLifetimeScope("recorder_root");
}
}

View File

@ -40,8 +40,11 @@ namespace BililiveRecorder.Core
/// <summary>
/// 购买船票(上船)
/// </summary>
GuardBuy
GuardBuy,
/// <summary>
/// SuperChat
/// </summary>
SuperChat
}
public class DanmakuModel
@ -81,6 +84,16 @@ namespace BililiveRecorder.Core
/// </summary>
public string UserName { get; set; }
/// <summary>
/// SC 价格
/// </summary>
public double Price { get; set; }
/// <summary>
/// SC 保持时间
/// </summary>
public int SCKeepTime { get; set; }
/// <summary>
/// 消息触发者用户ID
/// <para>此项有值的消息类型:<list type="bullet">
@ -170,6 +183,11 @@ namespace BililiveRecorder.Core
/// </summary>
public string RawData { get; set; }
/// <summary>
/// 原始数据, 高级开发用
/// </summary>
public JObject RawObj { get; set; }
/// <summary>
/// 内部用, JSON数据版本号 通常应该是2
/// </summary>
@ -184,6 +202,7 @@ namespace BililiveRecorder.Core
JSON_Version = 2;
var obj = JObject.Parse(JSON);
RawObj = obj;
string cmd = obj["cmd"]?.ToObject<string>();
switch (cmd)
{
@ -211,24 +230,6 @@ namespace BililiveRecorder.Core
UserID = obj["data"]["uid"].ToObject<int>();
GiftCount = obj["data"]["num"].ToObject<int>();
break;
case "WELCOME":
{
MsgType = MsgTypeEnum.Welcome;
UserName = obj["data"]["uname"].ToObject<string>();
UserID = obj["data"]["uid"].ToObject<int>();
IsVIP = true;
IsAdmin = obj["data"]?["is_admin"]?.ToObject<bool>() ?? obj["data"]?["isadmin"]?.ToObject<string>() == "1";
break;
}
case "WELCOME_GUARD":
{
MsgType = MsgTypeEnum.WelcomeGuard;
UserName = obj["data"]["username"].ToObject<string>();
UserID = obj["data"]["uid"].ToObject<int>();
UserGuardLevel = obj["data"]["guard_level"].ToObject<int>();
break;
}
case "GUARD_BUY":
{
MsgType = MsgTypeEnum.GuardBuy;
@ -239,6 +240,35 @@ namespace BililiveRecorder.Core
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>();
break;
}
/*
case "WELCOME":
{
MsgType = MsgTypeEnum.Welcome;
UserName = obj["data"]["uname"].ToObject<string>();
UserID = obj["data"]["uid"].ToObject<int>();
IsVIP = true;
IsAdmin = obj["data"]?["is_admin"]?.ToObject<bool>() ?? obj["data"]?["isadmin"]?.ToObject<string>() == "1";
break;
}
case "WELCOME_GUARD":
{
MsgType = MsgTypeEnum.WelcomeGuard;
UserName = obj["data"]["username"].ToObject<string>();
UserID = obj["data"]["uid"].ToObject<int>();
UserGuardLevel = obj["data"]["guard_level"].ToObject<int>();
break;
}
*/
default:
{
MsgType = MsgTypeEnum.Unknown;

View File

@ -0,0 +1,11 @@
using System;
namespace BililiveRecorder.Core
{
public interface IBasicDanmakuWriter : IDisposable
{
void Disable();
void EnableWithPath(string path);
void Write(DanmakuModel danmakuModel);
}
}

View File

@ -18,6 +18,7 @@ namespace BililiveRecorder.Core
{
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private static readonly Random random = new Random();
private static readonly Version VERSION_1_0 = new Version(1, 0);
private int _roomid;
private int _realRoomid;
@ -69,6 +70,7 @@ namespace BililiveRecorder.Core
public bool IsMonitoring => StreamMonitor.IsMonitoring;
public bool IsRecording => !(StreamDownloadTask?.IsCompleted ?? true);
private readonly IBasicDanmakuWriter basicDanmakuWriter;
private readonly Func<IFlvStreamProcessor> newIFlvStreamProcessor;
private IFlvStreamProcessor _processor;
public IFlvStreamProcessor Processor
@ -110,6 +112,7 @@ namespace BililiveRecorder.Core
}
public RecordedRoom(ConfigV1 config,
IBasicDanmakuWriter basicDanmakuWriter,
Func<int, IStreamMonitor> newIStreamMonitor,
Func<IFlvStreamProcessor> newIFlvStreamProcessor,
int roomid)
@ -118,16 +121,24 @@ namespace BililiveRecorder.Core
_config = config;
this.basicDanmakuWriter = basicDanmakuWriter;
RoomId = roomid;
StreamerName = "...";
StreamerName = "获取中...";
StreamMonitor = newIStreamMonitor(RoomId);
StreamMonitor.RoomInfoUpdated += StreamMonitor_RoomInfoUpdated;
StreamMonitor.StreamStarted += StreamMonitor_StreamStarted;
StreamMonitor.ReceivedDanmaku += StreamMonitor_ReceivedDanmaku;
StreamMonitor.FetchRoomInfoAsync();
}
private void StreamMonitor_ReceivedDanmaku(object sender, ReceivedDanmakuArgs e)
{
basicDanmakuWriter.Write(e.Danmaku);
}
private void StreamMonitor_RoomInfoUpdated(object sender, RoomInfoUpdatedArgs e)
{
RoomId = e.RoomInfo.RoomId;
@ -286,6 +297,7 @@ namespace BililiveRecorder.Core
Processor.ClipLengthFuture = _config.ClipLengthFuture;
Processor.ClipLengthPast = _config.ClipLengthPast;
Processor.CuttingNumber = _config.CuttingNumber;
Processor.StreamFinalized += (sender, e) => { basicDanmakuWriter.Disable(); };
Processor.OnMetaData += (sender, e) =>
{
e.Metadata["BililiveRecorder"] = new Dictionary<string, object>()
@ -310,7 +322,13 @@ namespace BililiveRecorder.Core
};
_stream = await _response.Content.ReadAsStreamAsync();
_stream.ReadTimeout = 3 * 1000;
try
{
if (_response.Headers.ConnectionClose == false || (_response.Headers.ConnectionClose is null && _response.Version != VERSION_1_0))
_stream.ReadTimeout = 3 * 1000;
}
catch (InvalidOperationException) { }
StreamDownloadTask = Task.Run(_ReadStreamLoop);
TriggerPropertyChanged(nameof(IsRecording));
@ -427,7 +445,19 @@ namespace BililiveRecorder.Core
Dispose(true);
}
private string GetStreamFilePath() => FormatFilename(_config.RecordFilenameFormat);
private string GetStreamFilePath()
{
string path = FormatFilename(_config.RecordFilenameFormat);
// 有点脏的写法,不过凑合吧
if (_config.RecordDanmaku)
{
var xmlpath = Path.ChangeExtension(path, "xml");
basicDanmakuWriter.EnableWithPath(xmlpath);
}
return path;
}
private string GetClipFilePath() => FormatFilename(_config.ClipFilenameFormat);
@ -505,11 +535,13 @@ namespace BililiveRecorder.Core
{
Stop();
StopRecord();
Processor?.FinallizeFile();
Processor?.Dispose();
StreamMonitor?.Dispose();
_response?.Dispose();
_stream?.Dispose();
cancellationTokenSource?.Dispose();
basicDanmakuWriter?.Dispose();
}
Processor = null;

View File

@ -48,7 +48,7 @@ namespace BililiveRecorder.Core
Rooms.CollectionChanged += (sender, e) =>
{
logger.Debug($"Rooms.CollectionChanged;{e.Action};" +
logger.Trace($"Rooms.CollectionChanged;{e.Action};" +
$"O:{e.OldItems?.Cast<IRecordedRoom>()?.Select(rr => rr.RoomId.ToString())?.Aggregate((current, next) => current + "," + next)};" +
$"N:{e.NewItems?.Cast<IRecordedRoom>()?.Select(rr => rr.RoomId.ToString())?.Aggregate((current, next) => current + "," + next)}");
};
@ -62,11 +62,11 @@ namespace BililiveRecorder.Core
{
try
{
logger.Debug("设置 Cookie 等待...");
logger.Trace("设置 Cookie 等待...");
await Task.Delay(100);
logger.Debug("设置 Cookie 信息...");
logger.Trace("设置 Cookie 信息...");
await BililiveAPI.ApplyCookieSettings(Config.Cookie);
logger.Debug("设置成功");
logger.Debug("设置 Cookie 成功");
}
finally
{

View File

@ -41,6 +41,7 @@ namespace BililiveRecorder.Core
private NetworkStream dmNetStream;
private Thread dmReceiveMessageLoopThread;
private CancellationTokenSource dmTokenSource = null;
private bool dmConnectionTriggered = false;
private readonly Timer httpTimer;
public int Roomid { get; private set; } = 0;
@ -57,6 +58,7 @@ namespace BililiveRecorder.Core
Roomid = roomid;
ReceivedDanmaku += Receiver_ReceivedDanmaku;
RoomInfoUpdated += StreamMonitor_RoomInfoUpdated;
dmTokenSource = new CancellationTokenSource();
Repeat.Interval(TimeSpan.FromSeconds(30), () =>
@ -97,8 +99,16 @@ namespace BililiveRecorder.Core
httpTimer.Interval = config.TimingCheckInterval * 1000;
}
};
}
Task.Run(() => ConnectWithRetryAsync());
private void StreamMonitor_RoomInfoUpdated(object sender, RoomInfoUpdatedArgs e)
{
Roomid = e.RoomInfo.RoomId;
if (!dmConnectionTriggered)
{
dmConnectionTriggered = true;
Task.Run(() => ConnectWithRetryAsync());
}
}
private void Receiver_ReceivedDanmaku(object sender, ReceivedDanmakuArgs e)

View File

@ -204,7 +204,8 @@ namespace BililiveRecorder.FlvProcessor
_readHead += text_size;
}
object value = DecodeScriptDataValue(buff, ref _readHead);
d.Add(key, value);
// d.Add(key, value);
d[key] = value; // fix duplicates
}
_readHead += 3;
return d;

View File

@ -121,7 +121,7 @@ namespace BililiveRecorder.FlvProcessor
{
lock (_writelock)
{
if (_finalized) { throw new InvalidOperationException("Processor Already Closed"); }
if (_finalized) { return; /*throw new InvalidOperationException("Processor Already Closed");*/ }
if (_leftover != null)
{
byte[] c = new byte[_leftover.Length + data.Length];
@ -302,7 +302,7 @@ namespace BililiveRecorder.FlvProcessor
_tagVideoCount++;
if (_tagVideoCount < 2)
{
logger.Trace("第一个 Video Tag 时间戳 {0} ms", tag.TimeStamp);
logger.Debug("第一个 Video Tag 时间戳 {0} ms", tag.TimeStamp);
_headerTags.Add(tag);
}
else
@ -310,7 +310,7 @@ namespace BililiveRecorder.FlvProcessor
_baseTimeStamp = tag.TimeStamp;
_hasOffset = true;
StartDateTime = DateTime.Now;
logger.Trace("重设时间戳 {0} 毫秒", _baseTimeStamp);
logger.Debug("重设时间戳 {0} 毫秒", _baseTimeStamp);
}
}
else if (tag.TagType == TagType.AUDIO)
@ -318,7 +318,7 @@ namespace BililiveRecorder.FlvProcessor
_tagAudioCount++;
if (_tagAudioCount < 2)
{
logger.Trace("第一个 Audio Tag 时间戳 {0} ms", tag.TimeStamp);
logger.Debug("第一个 Audio Tag 时间戳 {0} ms", tag.TimeStamp);
_headerTags.Add(tag);
}
else
@ -326,7 +326,7 @@ namespace BililiveRecorder.FlvProcessor
_baseTimeStamp = tag.TimeStamp;
_hasOffset = true;
StartDateTime = DateTime.Now;
logger.Trace("重设时间戳 {0} 毫秒", _baseTimeStamp);
logger.Debug("重设时间戳 {0} 毫秒", _baseTimeStamp);
}
}
}
@ -356,7 +356,11 @@ namespace BililiveRecorder.FlvProcessor
if (!EnabledFeature.IsClipEnabled()) { return null; }
lock (_writelock)
{
if (_finalized) { throw new InvalidOperationException("Processor Already Closed"); }
if (_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);

View File

@ -74,6 +74,8 @@ namespace BililiveRecorder.WPF
{
logger.Error(ex, "检查更新时出错,如持续出错请联系开发者 rec@danmuji.org");
}
_ = Task.Run(async () => { await Task.Delay(TimeSpan.FromDays(1)); await RunCheckUpdate(); });
}
}
}

View File

@ -4,7 +4,9 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tb="http://www.hardcodet.net/taskbar"
xmlns:core="clr-namespace:BililiveRecorder.Core;assembly=BililiveRecorder.Core"
xmlns:local="clr-namespace:BililiveRecorder.WPF"
d:DataContext="{d:DesignInstance Type=core:Recorder}"
mc:Ignorable="d"
MinHeight="400" MinWidth="650"
Title="录播姬" Height="450" Width="850"
@ -16,6 +18,7 @@
<TextBlock Text="{Binding}" TextWrapping="Wrap" MouseRightButtonUp="TextBlock_MouseRightButtonUp"/>
</DataTemplate>
<local:RecordStatusConverter x:Key="RSC"/>
<local:ClipButtonVisibilityConverter x:Key="ClipButtonVisibilityConverter"/>
<local:BoolToStringConverter x:Key="RecordStatusConverter" TrueValue="录制中" FalseValue="闲置"/>
<local:BoolToStringConverter x:Key="MonitorStatusConverter" TrueValue="自动录制" FalseValue="非自动"/>
</ResourceDictionary>
@ -44,7 +47,7 @@
</TextBlock.Text>
</TextBlock>
<TextBlock Text="{Binding DownloadSpeedPersentage,StringFormat=0.##%,Mode=OneWay}" Margin="5,0,0,0"/>
<TextBlock Margin="5,0" Text="{Binding RealRoomid,Mode=OneWay}"/>
<TextBlock Margin="5,0" Text="{Binding RoomId,Mode=OneWay}"/>
<TextBlock Text="{Binding StreamerName,Mode=OneWay}"/>
</StackPanel>
</DataTemplate>
@ -56,16 +59,14 @@
<tb:TaskbarIcon.ContextMenu>
<ContextMenu DataContext="{Binding Path=PlacementTarget.Tag, RelativeSource={RelativeSource Self}}">
<!--<MenuItem IsCheckable="True">不弹出提醒</MenuItem>-->
<MenuItem Header="退出">
<MenuItem Header="确认退出" Click="Taskbar_Quit_Click"/>
</MenuItem>
<MenuItem Header="退出" Click="Taskbar_Quit_Click"/>
</ContextMenu>
</tb:TaskbarIcon.ContextMenu>
</tb:TaskbarIcon>
<Grid Grid.Row="1">
<Grid.RowDefinitions>
<RowDefinition Height="7*"/>
<RowDefinition Height="4*"/>
<RowDefinition Height="8*"/>
<RowDefinition Height="2*"/>
<RowDefinition Height="4*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
@ -82,11 +83,14 @@
<MenuItem Header="删除" Click="RemoveRecRoom"/>
</ContextMenu>
</DataGrid.ContextMenu>
<DataGrid.Resources>
<local:BindingProxy x:Key="proxy" Data="{Binding Recorder}" />
</DataGrid.Resources>
<DataGrid.Columns>
<DataGridTemplateColumn Header="回放剪辑">
<DataGridTemplateColumn Visibility="{Binding Data.Config.EnabledFeature,Converter={StaticResource ClipButtonVisibilityConverter},Source={StaticResource proxy}}" Header="回放剪辑">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Click="Clip_Click">剪辑</Button>
<Button Click="Clip_Click" Content="{Binding Processor.Clips.Count,Mode=OneWay,FallbackValue=0}" ContentStringFormat="剪辑 ({0})"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@ -98,7 +102,6 @@
<DataGridTextColumn Binding="{Binding IsMonitoring,Converter={StaticResource MonitorStatusConverter},Mode=OneWay}" Header="是否自动录制"/>
<DataGridTextColumn Binding="{Binding DownloadSpeedMegaBitps,StringFormat=0.## Mbps,Mode=OneWay}" Header="实时下载速度"/>
<DataGridTextColumn Binding="{Binding DownloadSpeedPersentage,StringFormat=0.## %,Mode=OneWay}" Header="录制速度比"/>
<DataGridTextColumn Binding="{Binding Processor.Clips.Count,Mode=OneWay}" Header="剪辑数量"/>
</DataGrid.Columns>
</DataGrid>
<ItemsControl Grid.Row="1" Grid.RowSpan="2" x:Name="Log" ItemsSource="{Binding Logs}" ItemTemplate="{StaticResource LogTemplate}" ToolTip="右键点击可以复制单行日志">

View File

@ -25,7 +25,7 @@ namespace BililiveRecorder.WPF
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private static readonly Regex UrlToRoomidRegex = new Regex(@"^https?:\/\/live\.bilibili\.com\/(?<roomid>\d+)(?:[#\?].*)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
private const int MAX_LOG_ROW = 25;
private const int MAX_LOG_ROW = 30;
private const string LAST_WORK_DIR_FILE = "lastworkdir";
private IContainer Container { get; set; }
@ -42,6 +42,7 @@ namespace BililiveRecorder.WPF
"QQ群 689636812",
"",
"删除直播间按钮在列表右键菜单里",
"新增了弹幕录制功能,默认关闭,设置里可开启",
"",
"录制速度比 在 100% 左右说明跟上了主播直播的速度",
"小于 100% 说明录播电脑的下载带宽不够,跟不上录制直播"

View File

@ -37,6 +37,6 @@
</targets>
<rules>
<logger name="*" minlevel="Info" writeTo="WPFLogger"/>
<logger name="*" minlevel="Trace" writeTo="file"/>
<logger name="*" minlevel="Debug" writeTo="file"/>
</rules>
</nlog>

View File

@ -5,10 +5,12 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:BililiveRecorder.WPF"
xmlns:core="clr-namespace:BililiveRecorder.Core;assembly=BililiveRecorder.Core"
xmlns:config="clr-namespace:BililiveRecorder.Core.Config;assembly=BililiveRecorder.Core"
xmlns:flv="clr-namespace:BililiveRecorder.FlvProcessor;assembly=BililiveRecorder.FlvProcessor"
d:DataContext="{d:DesignInstance Type=config:ConfigV1}"
mc:Ignorable="d"
ShowInTaskbar="False" ResizeMode="NoResize"
Title="设置 - 录播姬" Height="550" Width="300">
Title="设置 - 录播姬" Height="630" Width="300">
<Window.Resources>
<local:ValueConverterGroup x:Key="EnumToInvertBooleanConverter">
<local:EnumToBooleanConverter/>
@ -36,6 +38,10 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="1*"/>
</Grid.RowDefinitions>
<Grid.Resources>
@ -48,7 +54,159 @@
</Style>
</ResourceDictionary>
</Grid.Resources>
<Grid Grid.Row="0">
<StackPanel Grid.Row="0">
<StackPanel.Resources>
<Style TargetType="CheckBox">
<Setter Property="Margin" Value="10,1"/>
</Style>
</StackPanel.Resources>
<CheckBox IsChecked="{Binding RecordDanmaku}">
录制弹幕
</CheckBox>
<CheckBox IsEnabled="{Binding RecordDanmaku}" IsChecked="{Binding RecordDanmakuSuperChat}">
同时保存 SuperChat
</CheckBox>
<CheckBox IsEnabled="{Binding RecordDanmaku}" IsChecked="{Binding RecordDanmakuGift}">
同时保存 送礼信息
</CheckBox>
<CheckBox IsEnabled="{Binding RecordDanmaku}" IsChecked="{Binding RecordDanmakuGuard}">
同时保存 舰长购买
</CheckBox>
</StackPanel>
<Separator Grid.Row="1"/>
<Grid Grid.Row="2">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="3*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Row="0" Grid.ColumnSpan="2" Margin="10,0">
<RadioButton GroupName="EnabledFeature" IsChecked="{Binding Path=EnabledFeature, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:EnabledFeature.RecordOnly}}" ToolTipService.InitialShowDelay="0"
ToolTip="占内存更少,但不能剪辑回放">只使用录制功能</RadioButton>
<RadioButton GroupName="EnabledFeature" IsChecked="{Binding Path=EnabledFeature, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:EnabledFeature.ClipOnly}}" ToolTipService.InitialShowDelay="0"
ToolTip="不保存所有直播数据到硬盘">只使用即时回放剪辑功能</RadioButton>
<RadioButton GroupName="EnabledFeature" IsChecked="{Binding Path=EnabledFeature, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:EnabledFeature.Both}}">同时启用两个功能</RadioButton>
</StackPanel>
<TextBlock Grid.Row="1" Grid.Column="0">剪辑过去时长:</TextBlock>
<Grid Grid.Row="1" Grid.Column="1" VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<local:ClickSelectTextBox Grid.Column="0" Text="{Binding ClipLengthPast,Delay=500,UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding Path=EnabledFeature, Converter={StaticResource EnumToInvertBooleanConverter},
ConverterParameter={x:Static flv:EnabledFeature.RecordOnly}}"/>
<TextBlock Grid.Column="1" Margin="5,0,10,0">秒</TextBlock>
</Grid>
<TextBlock Grid.Row="2" Grid.Column="0">剪辑将来时长:</TextBlock>
<Grid Grid.Row="2" Grid.Column="1" VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<local:ClickSelectTextBox Grid.Column="0" Text="{Binding ClipLengthFuture,Delay=500,UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding Path=EnabledFeature, Converter={StaticResource EnumToInvertBooleanConverter},
ConverterParameter={x:Static flv:EnabledFeature.RecordOnly}}"/>
<TextBlock Grid.Column="1" Margin="5,0,10,0">秒</TextBlock>
</Grid>
</Grid>
<Separator Grid.Row="3"/>
<Grid Grid.Row="4">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="3*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0">自动切割单位:</TextBlock>
<Grid Grid.Row="0" Grid.Column="1" VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<local:ClickSelectTextBox Grid.Column="0" Text="{Binding CuttingNumber,Delay=500,UpdateSourceTrigger=PropertyChanged}"
ToolTipService.InitialShowDelay="0" ToolTipService.ShowDuration="20000"
IsEnabled="{Binding Path=CuttingMode, Converter={StaticResource EnumToInvertBooleanConverter},
ConverterParameter={x:Static flv:AutoCuttingMode.Disabled}}">
<local:ClickSelectTextBox.ToolTip>
<ToolTip>
<StackPanel>
<StackPanel.Resources>
<Style TargetType="TextBlock">
<Setter Property="TextAlignment" Value="Left"/>
</Style>
</StackPanel.Resources>
<TextBlock FontWeight="Bold">注:</TextBlock>
<TextBlock>实际切割出来的视频文件会比设定的要大一点</TextBlock>
<TextBlock>根据实际情况不同,可能会 大不到1MiB 或 长几秒钟</TextBlock>
<TextBlock>请根据实际需求调整</TextBlock>
</StackPanel>
</ToolTip>
</local:ClickSelectTextBox.ToolTip>
</local:ClickSelectTextBox>
<TextBlock Grid.Column="1" Margin="5,0,10,0">分 或 MiB</TextBlock>
</Grid>
<StackPanel Grid.Row="1" Grid.ColumnSpan="2" Margin="10,0">
<RadioButton GroupName="CuttingMode" IsChecked="{Binding Path=CuttingMode, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:AutoCuttingMode.Disabled}}">不切割录制的视频文件</RadioButton>
<RadioButton GroupName="CuttingMode" IsChecked="{Binding Path=CuttingMode, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:AutoCuttingMode.ByTime}}">根据视频时间(分)切割</RadioButton>
<RadioButton GroupName="CuttingMode" IsChecked="{Binding Path=CuttingMode, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:AutoCuttingMode.BySize}}">根据文件大小(MiB)切割</RadioButton>
</StackPanel>
</Grid>
<Separator Grid.Row="5"/>
<Grid Grid.Row="6" Margin="10,0">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock HorizontalAlignment="Left">录制保存文件名格式: 只支持FLV</TextBlock>
<local:ClickSelectTextBox Grid.Row="1" Text="{Binding RecordFilenameFormat,Delay=500,UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
<Grid Grid.Row="7" Margin="10,0">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock HorizontalAlignment="Left">剪辑保存文件名格式: 只支持FLV</TextBlock>
<local:ClickSelectTextBox Grid.Row="1" Text="{Binding ClipFilenameFormat,Delay=500,UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
<Grid Grid.Row="8" Margin="10,0">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock HorizontalAlignment="Left">文件名变量说明:</TextBlock>
<TextBlock Grid.Row="1" HorizontalAlignment="Left">日期: {date} 时间: {time} 房间号: {roomid}</TextBlock>
<TextBlock Grid.Row="2" HorizontalAlignment="Left">标题: {title} 主播名: {name} 随机数: {random}</TextBlock>
</Grid>
<Separator Grid.Row="9"/>
<TextBlock Grid.Row="10" FontSize="16" FontWeight="Bold" TextAlignment="Center" HorizontalAlignment="Center">
以下设置项谨慎修改,建议保留默认
</TextBlock>
<Separator Grid.Row="11"/>
<Grid Grid.Row="12" Margin="10,0">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock HorizontalAlignment="Left">请求 API 时使用 Cookie: (可选)</TextBlock>
<local:ClickSelectTextBox Grid.Row="1" Text="{Binding Cookie,Delay=500,UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
<Separator Grid.Row="13"/>
<Grid Grid.Row="14">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
@ -186,145 +344,7 @@
<TextBlock Grid.Column="1" Margin="5,0,10,0">秒</TextBlock>
</Grid>
</Grid>
<Separator Grid.Row="1"/>
<Grid Grid.Row="2">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="3*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="1" Grid.Column="0">剪辑过去时长:</TextBlock>
<Grid Grid.Row="1" Grid.Column="1" VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<local:ClickSelectTextBox Grid.Column="0" Text="{Binding ClipLengthPast,Delay=500,UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding Path=EnabledFeature, Converter={StaticResource EnumToInvertBooleanConverter},
ConverterParameter={x:Static flv:EnabledFeature.RecordOnly}}"/>
<TextBlock Grid.Column="1" Margin="5,0,10,0">秒</TextBlock>
</Grid>
<TextBlock Grid.Row="2" Grid.Column="0">剪辑将来时长:</TextBlock>
<Grid Grid.Row="2" Grid.Column="1" VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<local:ClickSelectTextBox Grid.Column="0" Text="{Binding ClipLengthFuture,Delay=500,UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding Path=EnabledFeature, Converter={StaticResource EnumToInvertBooleanConverter},
ConverterParameter={x:Static flv:EnabledFeature.RecordOnly}}"/>
<TextBlock Grid.Column="1" Margin="5,0,10,0">秒</TextBlock>
</Grid>
<StackPanel Grid.Row="3" Grid.ColumnSpan="2" Margin="10,0">
<RadioButton GroupName="EnabledFeature" IsChecked="{Binding Path=EnabledFeature, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:EnabledFeature.ClipOnly}}" ToolTipService.InitialShowDelay="0"
ToolTip="不保存所有直播数据到硬盘">只使用即时回放剪辑功能</RadioButton>
<RadioButton GroupName="EnabledFeature" IsChecked="{Binding Path=EnabledFeature, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:EnabledFeature.RecordOnly}}" ToolTipService.InitialShowDelay="0"
ToolTip="占内存更少,但不能剪辑回放">只使用录制功能</RadioButton>
<RadioButton GroupName="EnabledFeature" IsChecked="{Binding Path=EnabledFeature, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:EnabledFeature.Both}}">同时启用两个功能</RadioButton>
</StackPanel>
</Grid>
<Separator Grid.Row="3"/>
<Grid Grid.Row="4">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="3*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0">自动切割单位:</TextBlock>
<Grid Grid.Row="0" Grid.Column="1" VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<local:ClickSelectTextBox Grid.Column="0" Text="{Binding CuttingNumber,Delay=500,UpdateSourceTrigger=PropertyChanged}"
ToolTipService.InitialShowDelay="0" ToolTipService.ShowDuration="20000"
IsEnabled="{Binding Path=CuttingMode, Converter={StaticResource EnumToInvertBooleanConverter},
ConverterParameter={x:Static flv:AutoCuttingMode.Disabled}}">
<local:ClickSelectTextBox.ToolTip>
<ToolTip>
<StackPanel>
<StackPanel.Resources>
<Style TargetType="TextBlock">
<Setter Property="TextAlignment" Value="Left"/>
</Style>
</StackPanel.Resources>
<TextBlock FontWeight="Bold">注:</TextBlock>
<TextBlock>实际切割出来的视频文件会比设定的要大一点</TextBlock>
<TextBlock>根据实际情况不同,可能会 大不到1MiB 或 长几秒钟</TextBlock>
<TextBlock>请根据实际需求调整</TextBlock>
</StackPanel>
</ToolTip>
</local:ClickSelectTextBox.ToolTip>
</local:ClickSelectTextBox>
<TextBlock Grid.Column="1" Margin="5,0,10,0">分 或 MiB</TextBlock>
</Grid>
<StackPanel Grid.Row="1" Grid.ColumnSpan="2" Margin="10,0">
<RadioButton GroupName="CuttingMode" IsChecked="{Binding Path=CuttingMode, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:AutoCuttingMode.Disabled}}">不切割录制的视频文件</RadioButton>
<RadioButton GroupName="CuttingMode" IsChecked="{Binding Path=CuttingMode, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:AutoCuttingMode.ByTime}}">根据视频时间(分)切割</RadioButton>
<RadioButton GroupName="CuttingMode" IsChecked="{Binding Path=CuttingMode, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:AutoCuttingMode.BySize}}">根据文件大小(MiB)切割</RadioButton>
</StackPanel>
</Grid>
<Separator Grid.Row="5"/>
<Grid Grid.Row="6" Margin="10,0">
<CheckBox IsChecked="{Binding AvoidTxy}">
<TextBlock>
尽量不使用腾讯云服务器
<LineBreak/> 注:目前这个选项勾选与否,没有什么区别。
</TextBlock>
</CheckBox>
</Grid>
<Separator Grid.Row="7"/>
<Grid Grid.Row="8" Margin="10,0">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock HorizontalAlignment="Left">请求 API 时使用 Cookie: (可选)</TextBlock>
<local:ClickSelectTextBox Grid.Row="1" Text="{Binding Cookie,Delay=500,UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
<Separator Grid.Row="9"/>
<Grid Grid.Row="10" Margin="10,0">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock HorizontalAlignment="Left">录制保存文件名格式:</TextBlock>
<local:ClickSelectTextBox Grid.Row="1" Text="{Binding RecordFilenameFormat,Delay=500,UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
<Grid Grid.Row="11" Margin="10,0">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock HorizontalAlignment="Left">剪辑保存文件名格式:</TextBlock>
<local:ClickSelectTextBox Grid.Row="1" Text="{Binding ClipFilenameFormat,Delay=500,UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
<Grid Grid.Row="12" Margin="10,0">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock HorizontalAlignment="Left">文件名变量说明:</TextBlock>
<TextBlock Grid.Row="1" HorizontalAlignment="Left">日期: {date} 时间: {time} 房间号: {roomid}</TextBlock>
<TextBlock Grid.Row="2" HorizontalAlignment="Left">标题: {title} 主播名: {name} 随机数: {random}</TextBlock>
</Grid>
<!--
<TextBlock Grid.Row="4" Grid.Column="0"></TextBlock>
<StackPanel Grid.Row="4" Grid.Column="1">

View File

@ -1,11 +1,44 @@
using System;
using BililiveRecorder.FlvProcessor;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Windows;
using System.Windows.Data;
namespace BililiveRecorder.WPF
{
internal class ClipButtonVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is null) return Visibility.Visible;
return EnabledFeature.RecordOnly == ((EnabledFeature)value) ? Visibility.Collapsed : Visibility.Visible;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
internal class BindingProxy : Freezable
{
protected override Freezable CreateInstanceCore()
{
return new BindingProxy();
}
public object Data
{
get { return (object)GetValue(DataProperty); }
set { SetValue(DataProperty, value); }
}
// Using a DependencyProperty as the backing store for Data. This enables animation, styling, binding, etc...
public static readonly DependencyProperty DataProperty = DependencyProperty.Register("Data", typeof(object), typeof(BindingProxy), new UIPropertyMetadata(null));
}
internal class ValueConverterGroup : List<IValueConverter>, IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)

View File

@ -1,16 +1,18 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.27428.1
# Visual Studio Version 16
VisualStudioVersion = 16.0.29924.181
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BililiveRecorder.WPF", "BililiveRecorder.WPF\BililiveRecorder.WPF.csproj", "{0C7D4236-BF43-4944-81FE-E07E05A3F31D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BililiveRecorder.Core", "BililiveRecorder.Core\BililiveRecorder.Core.csproj", "{CB9F2D58-181D-49F7-9560-D35A9B9C1D8C}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.Core", "BililiveRecorder.Core\BililiveRecorder.Core.csproj", "{CB9F2D58-181D-49F7-9560-D35A9B9C1D8C}"
ProjectSection(ProjectDependencies) = postProject
{51748048-1949-4218-8DED-94014ABE7633} = {51748048-1949-4218-8DED-94014ABE7633}
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BililiveRecorder.FlvProcessor", "BililiveRecorder.FlvProcessor\BililiveRecorder.FlvProcessor.csproj", "{51748048-1949-4218-8DED-94014ABE7633}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.FlvProcessor", "BililiveRecorder.FlvProcessor\BililiveRecorder.FlvProcessor.csproj", "{51748048-1949-4218-8DED-94014ABE7633}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.Cli", "BililiveRecorder.Cli\BililiveRecorder.Cli.csproj", "{1B626335-283F-4313-9045-B5B96FAAB2DF}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -18,10 +20,6 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{51748048-1949-4218-8DED-94014ABE7633}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{51748048-1949-4218-8DED-94014ABE7633}.Debug|Any CPU.Build.0 = Debug|Any CPU
{51748048-1949-4218-8DED-94014ABE7633}.Release|Any CPU.ActiveCfg = Release|Any CPU
{51748048-1949-4218-8DED-94014ABE7633}.Release|Any CPU.Build.0 = Release|Any CPU
{0C7D4236-BF43-4944-81FE-E07E05A3F31D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0C7D4236-BF43-4944-81FE-E07E05A3F31D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0C7D4236-BF43-4944-81FE-E07E05A3F31D}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -30,6 +28,14 @@ Global
{CB9F2D58-181D-49F7-9560-D35A9B9C1D8C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CB9F2D58-181D-49F7-9560-D35A9B9C1D8C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CB9F2D58-181D-49F7-9560-D35A9B9C1D8C}.Release|Any CPU.Build.0 = Release|Any CPU
{51748048-1949-4218-8DED-94014ABE7633}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{51748048-1949-4218-8DED-94014ABE7633}.Debug|Any CPU.Build.0 = Debug|Any CPU
{51748048-1949-4218-8DED-94014ABE7633}.Release|Any CPU.ActiveCfg = Release|Any CPU
{51748048-1949-4218-8DED-94014ABE7633}.Release|Any CPU.Build.0 = Release|Any CPU
{1B626335-283F-4313-9045-B5B96FAAB2DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -1 +1 @@
1.1.18
1.1.20

View File

@ -1,4 +1,4 @@
image: Visual Studio 2017
image: Visual Studio 2019
version: 0.0.0.{build}
platform: Any CPU
skip_tags: true

6
global.json Normal file
View File

@ -0,0 +1,6 @@
{
"sdk": {
"version": "3.1.102",
"rollForward": "latestFeature"
}
}