using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml; using System.Xml.Linq; namespace BililiveRecorder.ToolBox.Tool.DanmakuMerger { public class DanmakuMergerHandler : ICommandHandler { private static readonly string[] DanmakuElementNames = new[] { "d", "gift", "sc", "guard" }; public string Name => "Merge Danmaku"; #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously public async Task> Handle(DanmakuMergerRequest request, CancellationToken cancellationToken, ProgressCallback? progress) #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously { var inputLength = request.Inputs.Length; if (inputLength < 2) return new CommandResponse { Status = ResponseStatus.Error, ErrorMessage = "At least 2 input files required" }; if (request.Offsets is not null) { if (request.Offsets.Length != inputLength) { return new CommandResponse { Status = ResponseStatus.Error, ErrorMessage = "The number of offsets should match the number of input files." }; } } var files = new FileStream[inputLength]; var readers = new XmlReader?[inputLength]; FileStream? outputFile = null; XmlWriter? writer = null; XElement recordInfo; TimeSpan[] timeDiff; try // finally { // 读取文件开头并计算时间差 try { DateTimeOffset baseTime; // 打开输入文件 for (var i = 0; i < inputLength; i++) { var file = File.Open(request.Inputs[i], FileMode.Open, FileAccess.Read, FileShare.Read); files[i] = file; readers[i] = XmlReader.Create(file, null); } // 读取XML文件开头 var startTimes = new (DateTimeOffset time, XElement element)[inputLength]; for (var i = 0; i < inputLength; i++) { var r = readers[i]!; r.ReadStartElement("i"); while (r.Name != "i") { if (r.Name == "BililiveRecorderRecordInfo") { var el = (XNode.ReadFrom(r) as XElement)!; var time = (DateTimeOffset)el.Attribute("start_time"); startTimes[i] = (time, el); break; } else { r.Skip(); } } } if (request.Offsets is not null) { // 使用传递进来的参数作为时间差 timeDiff = request.Offsets.Select(x => TimeSpan.FromSeconds(x)).ToArray(); var (time, element) = startTimes[Array.IndexOf(timeDiff, timeDiff.Min())]; recordInfo = element; baseTime = time; } else { // 使用文件内的开始时间作为时间差 var (time, element) = startTimes.OrderBy(x => x.time).First(); recordInfo = element; baseTime = time; timeDiff = startTimes.Select(x => x.time - baseTime).ToArray(); } } catch (Exception ex) { return new CommandResponse { Status = ResponseStatus.InputIOError, Exception = ex, ErrorMessage = ex.Message }; } try { // 打开输出文件 outputFile = File.Open(request.Output, FileMode.Create, FileAccess.ReadWrite, FileShare.None); writer = XmlWriter.Create(outputFile, new XmlWriterSettings { Indent = true, IndentChars = " ", Encoding = Encoding.UTF8, CloseOutput = true, WriteEndDocumentOnClose = true, }); // 写入文件开头 writer.WriteStartDocument(); writer.WriteProcessingInstruction("xml-stylesheet", "type=\"text/xsl\" href=\"#s\""); writer.WriteStartElement("i"); writer.WriteComment("\nmikufans录播姬 " + GitVersionInformation.InformationalVersion + " 使用工具箱合并\nhttps://rec.danmuji.org/user/danmaku/\n本文件的弹幕信息兼容mikufans主站视频弹幕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.WriteAttributeString("merged", "by toolbox"); writer.WriteEndElement(); // 写入直播间信息 recordInfo.WriteTo(writer); // see BililiveRecorder.Core\Danmaku\BasicDanmakuWriter.cs const string style = @"mikufans录播姬弹幕文件 - <z:value-of select=""/i/BililiveRecorderRecordInfo/@name""/>

mikufans录播姬弹幕XML文件

本文件不支持在 IE 浏览器里预览,请使用 Chrome Firefox Edge 等浏览器。

文件用法参考文档 https://rec.danmuji.org/user/danmaku/

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

弹幕

用户名出现时间用户ID弹幕参数

舰长购买

用户名用户ID舰长等级购买数量出现时间
https://space.bilibili.com/

SuperChat 醒目留言

用户名用户ID内容显示时长价格出现时间
https://space.bilibili.com/

礼物

用户名用户ID礼物名礼物数量出现时间
https://space.bilibili.com/
"; writer.WriteStartElement("BililiveRecorderXmlStyle"); writer.WriteRaw(style); writer.WriteEndElement(); } catch (Exception ex) { return new CommandResponse { Status = ResponseStatus.OutputIOError, Exception = ex, ErrorMessage = ex.Message }; } try { var els = new List<(TimeSpan time, XElement el, int reader)>(); // 取出所有文件里第一条数据 for (var i = 0; i < inputLength; i++) { var r = readers[i]!; XElement? el = null; try { el = ReadDanmakuElement(r); } catch (Exception readEx) { return new CommandResponse { Status = ResponseStatus.InputIOError, Exception = readEx, ErrorMessage = request.Inputs[i] + ": " + readEx.Message }; } if (el is null) { readers[i] = null; continue; } var time = UpdateTimestamp(el, timeDiff[i]); els.Add((time, el, i)); } // 排序 els.Sort((a, b) => a.time.CompareTo(b.time)); while (true) { // 写入时间最小的数据 // 所有数据写完就退出循环 if (els.Count == 0) break; (_, var el, var readerIndex) = els[0]; el.WriteTo(writer); els.RemoveAt(0); // 读取一个新的数据 var reader = readers[readerIndex]; // 检查这个文件是否还有更多数据 if (reader is not null) { readNextElementFromSameReader: XElement? newEl = null; try { newEl = ReadDanmakuElement(reader); } catch (Exception readEx) { return new CommandResponse { Status = ResponseStatus.InputIOError, Exception = readEx, ErrorMessage = request.Inputs[readerIndex] + ": " + readEx.Message }; } if (newEl is null) { // 文件已结束 reader.Dispose(); readers[readerIndex] = null; continue; } else { // 计算新的时间 var newTime = UpdateTimestamp(newEl, timeDiff[readerIndex]); if (els.Count < 1 || newTime < els[0].time) { // 如果这是最后一个文件,或当前数据的时间是所有数据里最小的 // 直接写入输出文件 newEl.WriteTo(writer); goto readNextElementFromSameReader; } else { // 如果其他数据比本数据的时间更小 // 添加到列表中,后面排序再来 els.Add((newTime, newEl, readerIndex)); els.Sort((a, b) => a.time.CompareTo(b.time)); } } } } } catch (Exception ex) { return new CommandResponse { Status = ResponseStatus.Error, Exception = ex, ErrorMessage = ex.Message }; } return new CommandResponse { Status = ResponseStatus.OK, Data = new DanmakuMergerResponse() }; } finally { try { writer?.Dispose(); outputFile?.Dispose(); } catch (Exception) { } for (var i = 0; i < inputLength; i++) { try { readers[i]?.Dispose(); files[i]?.Dispose(); } catch (Exception) { } } } } private static XElement? ReadDanmakuElement(XmlReader r) { while (r.Name != "i") { if (DanmakuElementNames.Contains(r.Name)) { var el = (XNode.ReadFrom(r) as XElement)!; return el; } else { r.Skip(); } } return null; } private static TimeSpan UpdateTimestamp(XElement element, TimeSpan offset) { switch (element.Name.LocalName) { case "d": { var p = element.Attribute("p"); var i = p.Value.IndexOf(','); var t = TimeSpan.FromSeconds(double.Parse(p.Value.Substring(0, i))); t += offset; p.Value = t.TotalSeconds.ToString("F3") + p.Value.Substring(i); return t; } case "gift": case "sc": case "guard": { var ts = TimeSpan.FromSeconds((double)element.Attribute("ts")); ts += offset; element.SetAttributeValue("ts", ts.TotalSeconds.ToString("F3")); return ts; } default: return default; } } } }