using System; using System.IO; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Xml; using BililiveRecorder.Core.Api.Danmaku; using BililiveRecorder.Core.Config.V3; using Serilog; #nullable enable namespace BililiveRecorder.Core.Danmaku { public class BasicDanmakuWriter : IBasicDanmakuWriter { private static readonly XmlWriterSettings xmlWriterSettings = new XmlWriterSettings { Async = true, Indent = true, IndentChars = " ", Encoding = Encoding.UTF8, CloseOutput = true, WriteEndDocumentOnClose = true, }; private static readonly Regex invalidXMLChars = new Regex(@"(? string.IsNullOrWhiteSpace(text) ? string.Empty : invalidXMLChars.Replace(text, string.Empty); private XmlWriter? xmlWriter = null; private DateTimeOffset offset = DateTimeOffset.UtcNow; private uint writeCount = 0; private RoomConfig? config; private readonly SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1, 1); private readonly ILogger logger; public BasicDanmakuWriter(ILogger logger) { this.logger = logger?.ForContext() ?? throw new ArgumentNullException(nameof(logger)); } public void EnableWithPath(string path, IRoom room) { if (this.disposedValue) return; this.semaphoreSlim.Wait(); try { if (this.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); this.config = room.RoomConfig; this.xmlWriter = XmlWriter.Create(stream, xmlWriterSettings); WriteStartDocument(this.xmlWriter, room); this.offset = DateTimeOffset.UtcNow; this.writeCount = 0; } finally { this.semaphoreSlim.Release(); } } public void Disable() { if (this.disposedValue) return; this.semaphoreSlim.Wait(); try { this.DisableCore(); } finally { this.semaphoreSlim.Release(); } } private void DisableCore() { try { if (this.xmlWriter != null) { this.xmlWriter.Close(); this.xmlWriter.Dispose(); this.xmlWriter = null; } } catch (Exception ex) { this.logger.Warning(ex, "关闭弹幕文件时发生错误"); this.xmlWriter = null; } } public async Task WriteAsync(DanmakuModel danmakuModel) { if (this.disposedValue) return; if (this.config is null) return; await this.semaphoreSlim.WaitAsync(); try { if (this.xmlWriter != null) { var write = true; var recordDanmakuRaw = this.config.RecordDanmakuRaw; switch (danmakuModel.MsgType) { case DanmakuMsgType.Comment: { var type = danmakuModel.RawObject?["info"]?[0]?[1]?.ToObject() ?? 1; var size = danmakuModel.RawObject?["info"]?[0]?[2]?.ToObject() ?? 25; var color = danmakuModel.RawObject?["info"]?[0]?[3]?.ToObject() ?? 0XFFFFFF; var st = danmakuModel.RawObject?["info"]?[0]?[4]?.ToObject() ?? 0L; var ts = Math.Max((DateTimeOffset.UtcNow - this.offset).TotalSeconds, 0d); await this.xmlWriter.WriteStartElementAsync(null, "d", null).ConfigureAwait(false); await this.xmlWriter.WriteAttributeStringAsync(null, "p", null, $"{ts:F3},{type},{size},{color},{st},0,{danmakuModel.UserID},0").ConfigureAwait(false); await this.xmlWriter.WriteAttributeStringAsync(null, "user", null, RemoveInvalidXMLChars(danmakuModel.UserName)).ConfigureAwait(false); if (recordDanmakuRaw) await this.xmlWriter.WriteAttributeStringAsync(null, "raw", null, danmakuModel.RawObject?["info"]?.ToString(Newtonsoft.Json.Formatting.None)).ConfigureAwait(false); this.xmlWriter.WriteValue(RemoveInvalidXMLChars(danmakuModel.CommentText)); await this.xmlWriter.WriteEndElementAsync().ConfigureAwait(false); } break; case DanmakuMsgType.SuperChat: if (this.config.RecordDanmakuSuperChat) { await this.xmlWriter.WriteStartElementAsync(null, "sc", null).ConfigureAwait(false); var ts = Math.Max((DateTimeOffset.UtcNow - this.offset).TotalSeconds, 0d); await this.xmlWriter.WriteAttributeStringAsync(null, "ts", null, ts.ToString("F3")).ConfigureAwait(false); await this.xmlWriter.WriteAttributeStringAsync(null, "user", null, RemoveInvalidXMLChars(danmakuModel.UserName)).ConfigureAwait(false); await this.xmlWriter.WriteAttributeStringAsync(null, "price", null, danmakuModel.Price.ToString()).ConfigureAwait(false); await this.xmlWriter.WriteAttributeStringAsync(null, "time", null, danmakuModel.SCKeepTime.ToString()).ConfigureAwait(false); if (recordDanmakuRaw) await this.xmlWriter.WriteAttributeStringAsync(null, "raw", null, danmakuModel.RawObject?["data"]?.ToString(Newtonsoft.Json.Formatting.None)).ConfigureAwait(false); this.xmlWriter.WriteValue(RemoveInvalidXMLChars(danmakuModel.CommentText)); await this.xmlWriter.WriteEndElementAsync().ConfigureAwait(false); } break; case DanmakuMsgType.GiftSend: if (this.config.RecordDanmakuGift) { await this.xmlWriter.WriteStartElementAsync(null, "gift", null).ConfigureAwait(false); var ts = Math.Max((DateTimeOffset.UtcNow - this.offset).TotalSeconds, 0d); await this.xmlWriter.WriteAttributeStringAsync(null, "ts", null, ts.ToString("F3")).ConfigureAwait(false); await this.xmlWriter.WriteAttributeStringAsync(null, "user", null, RemoveInvalidXMLChars(danmakuModel.UserName)).ConfigureAwait(false); await this.xmlWriter.WriteAttributeStringAsync(null, "giftname", null, RemoveInvalidXMLChars(danmakuModel.GiftName)).ConfigureAwait(false); await this.xmlWriter.WriteAttributeStringAsync(null, "giftcount", null, danmakuModel.GiftCount.ToString()).ConfigureAwait(false); if (recordDanmakuRaw) await this.xmlWriter.WriteAttributeStringAsync(null, "raw", null, danmakuModel.RawObject?["data"]?.ToString(Newtonsoft.Json.Formatting.None)).ConfigureAwait(false); await this.xmlWriter.WriteEndElementAsync().ConfigureAwait(false); } break; case DanmakuMsgType.GuardBuy: if (this.config.RecordDanmakuGuard) { await this.xmlWriter.WriteStartElementAsync(null, "guard", null).ConfigureAwait(false); var ts = Math.Max((DateTimeOffset.UtcNow - this.offset).TotalSeconds, 0d); await this.xmlWriter.WriteAttributeStringAsync(null, "ts", null, ts.ToString("F3")).ConfigureAwait(false); await this.xmlWriter.WriteAttributeStringAsync(null, "user", null, RemoveInvalidXMLChars(danmakuModel.UserName)).ConfigureAwait(false); await this.xmlWriter.WriteAttributeStringAsync(null, "level", null, danmakuModel.UserGuardLevel.ToString()).ConfigureAwait(false); ; await this.xmlWriter.WriteAttributeStringAsync(null, "count", null, danmakuModel.GiftCount.ToString()).ConfigureAwait(false); if (recordDanmakuRaw) await this.xmlWriter.WriteAttributeStringAsync(null, "raw", null, danmakuModel.RawObject?["data"]?.ToString(Newtonsoft.Json.Formatting.None)).ConfigureAwait(false); await this.xmlWriter.WriteEndElementAsync().ConfigureAwait(false); } break; default: write = false; break; } if (write && this.writeCount++ >= this.config.RecordDanmakuFlushInterval) { await this.xmlWriter.FlushAsync(); this.writeCount = 0; } } } catch (Exception ex) { this.logger.Warning(ex, "写入弹幕时发生错误"); this.DisableCore(); } finally { this.semaphoreSlim.Release(); } } private static void WriteStartDocument(XmlWriter writer, IRoom room) { writer.WriteStartDocument(); writer.WriteProcessingInstruction("xml-stylesheet", "type=\"text/xsl\" href=\"#s\""); writer.WriteStartElement("i"); writer.WriteComment("\nB站录播姬 " + GitVersionInformation.FullSemVer + "\n本文件的弹幕信息兼容B站主站视频弹幕XML格式\n本XML自带样式可以在浏览器里打开(推荐使用Chrome)\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.WriteStartElement("BililiveRecorder"); writer.WriteAttributeString("version", GitVersionInformation.FullSemVer); writer.WriteEndElement(); writer.WriteStartElement("BililiveRecorderRecordInfo"); writer.WriteAttributeString("roomid", room.RoomConfig.RoomId.ToString()); writer.WriteAttributeString("shortid", room.ShortId.ToString()); writer.WriteAttributeString("name", RemoveInvalidXMLChars(room.Name)); writer.WriteAttributeString("title", RemoveInvalidXMLChars(room.Title)); writer.WriteAttributeString("areanameparent", RemoveInvalidXMLChars(room.AreaNameParent)); writer.WriteAttributeString("areanamechild", RemoveInvalidXMLChars(room.AreaNameChild)); writer.WriteAttributeString("start_time", DateTimeOffset.Now.ToString("O")); writer.WriteEndElement(); // see BililiveRecorder.ToolBox\Tool\DanmakuMerger\DanmakuMergerHandler.cs const string style = @"B站录播姬弹幕文件 - <z:value-of select=""/i/BililiveRecorderRecordInfo/@name""/>

B站录播姬弹幕XML文件

本文件的弹幕信息兼容B站主站视频弹幕XML格式,可以使用现有的转换工具把文件中的弹幕转为ass字幕文件

录播姬版本
房间号
主播名
录制开始时间
弹幕 条记录
上船 条记录
SC 条记录
礼物 条记录

弹幕

用户名弹幕参数

舰长购买

用户名舰长等级购买数量出现时间

SuperChat 醒目留言

用户名内容显示时长价格出现时间

礼物

用户名礼物名礼物数量出现时间
"; writer.WriteStartElement("BililiveRecorderXmlStyle"); writer.WriteRaw(style); writer.WriteEndElement(); writer.Flush(); } private bool disposedValue; protected virtual void Dispose(bool disposing) { if (!this.disposedValue) { if (disposing) { // dispose managed state (managed objects) this.semaphoreSlim.Dispose(); this.xmlWriter?.Close(); this.xmlWriter?.Dispose(); this.xmlWriter = null; } // free unmanaged resources (unmanaged objects) and override finalizer // set large fields to null this.disposedValue = true; } } public void Dispose() { // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method this.Dispose(disposing: true); GC.SuppressFinalize(this); } } }