Add DanmakuReceiver

This commit is contained in:
Genteure 2018-03-13 14:54:15 +08:00
parent 3b33e9d65f
commit 98c4fc8da5
6 changed files with 2018 additions and 0 deletions

View File

@ -0,0 +1,34 @@
using System;
namespace BililiveRecorder.Core
{
public delegate void ConnectedEvt(object sender, ConnectedEvtArgs e);
public class ConnectedEvtArgs
{
public int roomid;
}
public delegate void DisconnectEvt(object sender, DisconnectEvtArgs e);
public class DisconnectEvtArgs
{
public Exception Error;
}
public delegate void ReceivedRoomCountEvt(object sender, ReceivedRoomCountArgs e);
public class ReceivedRoomCountArgs
{
public uint UserCount;
}
public delegate void ReceivedDanmakuEvt(object sender, ReceivedDanmakuArgs e);
public class ReceivedDanmakuArgs
{
public DanmakuModel Danmaku;
}
public delegate void LogMessageEvt(object sender, LogMessageArgs e);
public class LogMessageArgs
{
public string message = string.Empty;
}
}

View File

@ -0,0 +1,250 @@
using System;
namespace BililiveRecorder.Core
{
public enum MsgTypeEnum
{
/// <summary>
/// 彈幕
/// </summary>
Comment,
/// <summary>
/// 禮物
/// </summary>
GiftSend,
/// <summary>
/// 歡迎老爷
/// </summary>
Welcome,
/// <summary>
/// 直播開始
/// </summary>
LiveStart,
/// <summary>
/// 直播結束
/// </summary>
LiveEnd,
/// <summary>
/// 其他
/// </summary>
Unknown,
/// <summary>
/// 欢迎船员
/// </summary>
WelcomeGuard,
/// <summary>
/// 购买船票(上船)
/// </summary>
GuardBuy
}
public class DanmakuModel
{
/// <summary>
/// 消息類型
/// </summary>
public MsgTypeEnum MsgType { get; set; }
/// <summary>
/// 彈幕內容
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="MsgTypeEnum.Comment"/></item>
/// </list></para>
/// </summary>
public string CommentText { get; set; }
/// <summary>
/// 彈幕用戶
/// </summary>
[Obsolete("请使用 UserName")]
public string CommentUser
{
get { return UserName; }
set { UserName = value; }
}
/// <summary>
/// 消息触发者用户名
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="MsgTypeEnum.Comment"/></item>
/// <item><see cref="MsgTypeEnum.GiftSend"/></item>
/// <item><see cref="MsgTypeEnum.Welcome"/></item>
/// <item><see cref="MsgTypeEnum.WelcomeGuard"/></item>
/// <item><see cref="MsgTypeEnum.GuardBuy"/></item>
/// </list></para>
/// </summary>
public string UserName { get; set; }
/// <summary>
/// 消息触发者用户ID
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="MsgTypeEnum.Comment"/></item>
/// <item><see cref="MsgTypeEnum.GiftSend"/></item>
/// <item><see cref="MsgTypeEnum.Welcome"/></item>
/// <item><see cref="MsgTypeEnum.WelcomeGuard"/></item>
/// <item><see cref="MsgTypeEnum.GuardBuy"/></item>
/// </list></para>
/// </summary>
public int UserID { get; set; }
/// <summary>
/// 用户舰队等级
/// <para>0 为非船员 1 为总督 2 为提督 3 为舰长</para>
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="MsgTypeEnum.Comment"/></item>
/// <item><see cref="MsgTypeEnum.WelcomeGuard"/></item>
/// <item><see cref="MsgTypeEnum.GuardBuy"/></item>
/// </list></para>
/// </summary>
public int UserGuardLevel { get; set; }
/// <summary>
/// 禮物用戶
/// </summary>
[Obsolete("请使用 UserName")]
public string GiftUser
{
get { return UserName; }
set { UserName = value; }
}
/// <summary>
/// 禮物名稱
/// </summary>
public string GiftName { get; set; }
/// <summary>
/// 禮物數量
/// </summary>
[Obsolete("请使用 GiftCount")]
public string GiftNum { get { return GiftCount.ToString(); } }
/// <summary>
/// 礼物数量
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="MsgTypeEnum.GiftSend"/></item>
/// <item><see cref="MsgTypeEnum.GuardBuy"/></item>
/// </list></para>
/// <para>此字段也用于标识上船 <see cref="MsgTypeEnum.GuardBuy"/> 的数量(月数)</para>
/// </summary>
public int GiftCount { get; set; }
/// <summary>
/// 当前房间的礼物积分Room Cost
/// 因以前出现过不传递rcost的礼物并且用处不大所以弃用
/// </summary>
[Obsolete("如有需要请自行解析RawData", true)]
public string Giftrcost { get { return "0"; } set { } }
/// <summary>
/// 该用户是否为房管(包括主播)
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="MsgTypeEnum.Comment"/></item>
/// <item><see cref="MsgTypeEnum.GiftSend"/></item>
/// </list></para>
/// </summary>
public bool isAdmin { get; set; }
/// <summary>
/// 是否VIP用戶(老爺)
/// <para>此项有值的消息类型:<list type="bullet">
/// <item><see cref="MsgTypeEnum.Comment"/></item>
/// <item><see cref="MsgTypeEnum.Welcome"/></item>
/// </list></para>
/// </summary>
public bool isVIP { get; set; }
/// <summary>
/// <see cref="MsgTypeEnum.LiveStart"/>,<see cref="MsgTypeEnum.LiveEnd"/> 事件对应的房间号
/// </summary>
public string roomID { get; set; }
/// <summary>
/// 原始数据, 高级开发用
/// </summary>
public string RawData { get; set; }
/// <summary>
/// 内部用, JSON数据版本号 通常应该是2
/// </summary>
public int JSON_Version { get; set; }
public DanmakuModel()
{ }
public DanmakuModel(string JSON)
{
RawData = JSON;
JSON_Version = 2;
var obj = new JSONObject(JSON);
string cmd = obj["cmd"].str;
switch (cmd)
{
case "LIVE":
MsgType = MsgTypeEnum.LiveStart;
roomID = obj["roomid"].str;
break;
case "PREPARING":
MsgType = MsgTypeEnum.LiveEnd;
roomID = obj["roomid"].str;
break;
case "DANMU_MSG":
MsgType = MsgTypeEnum.Comment;
CommentText = obj["info"][1].str;
UserID = (int)obj["info"][2][0].i;
UserName = obj["info"][2][1].str;
isAdmin = obj["info"][2][2].str == "1";
isVIP = obj["info"][2][3].str == "1";
UserGuardLevel = (int)obj["info"][7].i;
break;
case "SEND_GIFT":
MsgType = MsgTypeEnum.GiftSend;
GiftName = obj["data"]["giftName"].str;
UserName = obj["data"]["uname"].str;
UserID = (int)obj["data"]["uid"].i;
// Giftrcost = obj["data"]["rcost"].ToString();
GiftCount = (int)obj["data"]["num"].i;
break;
case "WELCOME":
{
MsgType = MsgTypeEnum.Welcome;
UserName = obj["data"]["uname"].str;
UserID = (int)obj["data"]["uid"].i;
isVIP = true;
isAdmin = obj["data"]["isadmin"].str == "1";
break;
}
case "WELCOME_GUARD":
{
MsgType = MsgTypeEnum.WelcomeGuard;
UserName = obj["data"]["username"].str;
UserID = (int)obj["data"]["uid"].i;
UserGuardLevel = (int)obj["data"]["guard_level"].i;
break;
}
case "GUARD_BUY":
{
MsgType = MsgTypeEnum.GuardBuy;
UserID = (int)obj["data"]["uid"].i;
UserName = obj["data"]["username"].str;
UserGuardLevel = (int)obj["data"]["guard_level"].i;
GiftName = UserGuardLevel == 3 ? "舰长" : UserGuardLevel == 2 ? "提督" : UserGuardLevel == 1 ? "总督" : "";
GiftCount = (int)obj["data"]["num"].i;
break;
}
default:
{
MsgType = MsgTypeEnum.Unknown;
break;
}
}
}
}
}

View File

@ -0,0 +1,285 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Xml;
namespace BililiveRecorder.Core
{
public class DanmakuReceiver
{
private const string defaulthosts = "broadcastlv.chat.bilibili.com";
private string CIDInfoUrl = "http://live.bilibili.com/api/player?id=cid:";
private string ChatHost = defaulthosts;
private int ChatPort = 2243;
private TcpClient Client;
private NetworkStream NetStream;
private Thread ReceiveMessageLoopThread;
private Thread HeartbeatLoopThread;
private CancellationTokenSource HeartbeatLoopSource;
public bool isConnected
{
get => _isConnected;
private set
{
_isConnected = value; if (!value) HeartbeatLoopSource.Cancel();
}
}
private bool _isConnected = false;
public Exception Error { get; private set; }
public uint ViewerCount { get; private set; }
public event ConnectedEvt Connected;
public event DisconnectEvt Disconnected;
public event ReceivedRoomCountEvt ReceivedRoomCount;
public event ReceivedDanmakuEvt ReceivedDanmaku;
public event LogMessageEvt LogMessage;
public bool Connect(int roomId)
{
try
{
if (this.isConnected) throw new InvalidOperationException();
int channelId = roomId;
try
{
var request2 = WebRequest.Create(CIDInfoUrl + channelId);
request2.Timeout = 2000;
var response2 = request2.GetResponse();
using (var stream = response2.GetResponseStream())
{
using (var sr = new StreamReader(stream))
{
var text = sr.ReadToEnd();
var xml = "<root>" + text + "</root>";
XmlDocument doc = new XmlDocument();
doc.LoadXml(xml);
ChatHost = doc["root"]["dm_server"].InnerText;
ChatPort = int.Parse(doc["root"]["dm_port"].InnerText);
}
}
}
catch (WebException ex)
{
HttpWebResponse errorResponse = ex.Response as HttpWebResponse;
if (errorResponse.StatusCode == HttpStatusCode.NotFound)
{ // 直播间不存在HTTP 404
LogMessage?.Invoke(this, new LogMessageArgs() { message = "该直播间疑似不存在,弹幕姬只支持使用原房间号连接" });
}
else
{ // B站服务器响应错误
LogMessage?.Invoke(this, new LogMessageArgs() { message = "B站服务器响应弹幕服务器地址出错尝试使用常见地址连接" });
}
}
catch (Exception)
{ // 其他错误XML解析错误
LogMessage?.Invoke(this, new LogMessageArgs() { message = "获取弹幕服务器地址时出现未知错误,尝试使用常见地址连接" });
}
Client = new TcpClient();
Client.Connect(ChatHost, ChatPort);
if (!Client.Connected)
{
return false;
}
NetStream = Client.GetStream();
SendSocketData(7, "{\"roomid\":" + channelId + ",\"uid\":0}");
isConnected = true;
ReceiveMessageLoopThread = new Thread(this.ReceiveMessageLoop);
ReceiveMessageLoopThread.IsBackground = true;
ReceiveMessageLoopThread.Start();
HeartbeatLoopSource = new CancellationTokenSource();
Repeat.Interval(TimeSpan.FromSeconds(30), this.SendHeartbeat, HeartbeatLoopSource.Token);
// HeartbeatLoopThread = new Thread(this.HeartbeatLoop);
// HeartbeatLoopThread.IsBackground = true;
// HeartbeatLoopThread.Start();
return true;
}
catch (Exception ex)
{
this.Error = ex;
return false;
}
}
public void Disconnect()
{
isConnected = false;
try
{
Client.Close();
}
catch (Exception)
{ }
NetStream = null;
}
private void ReceiveMessageLoop()
{
Debug.WriteLine("ReceiveMessageLoop Started!");
try
{
var stableBuffer = new byte[Client.ReceiveBufferSize];
while (this.isConnected)
{
NetStream.ReadB(stableBuffer, 0, 4);
var packetlength = BitConverter.ToInt32(stableBuffer, 0);
packetlength = IPAddress.NetworkToHostOrder(packetlength);
if (packetlength < 16)
throw new NotSupportedException("协议失败: (L:" + packetlength + ")");
NetStream.ReadB(stableBuffer, 0, 2);//magic
NetStream.ReadB(stableBuffer, 0, 2);//protocol_version
NetStream.ReadB(stableBuffer, 0, 4);
var typeId = BitConverter.ToInt32(stableBuffer, 0);
typeId = IPAddress.NetworkToHostOrder(typeId);
Console.WriteLine(typeId);
NetStream.ReadB(stableBuffer, 0, 4);//magic, params?
var playloadlength = packetlength - 16;
if (playloadlength == 0)
continue;//没有内容了
typeId = typeId - 1;//和反编译的代码对应
var buffer = new byte[playloadlength];
NetStream.ReadB(buffer, 0, playloadlength);
switch (typeId)
{
case 0:
case 1:
case 2:
{
var viewer = BitConverter.ToUInt32(buffer.Take(4).Reverse().ToArray(), 0); //观众人数
ViewerCount = viewer;
ReceivedRoomCount?.Invoke(this, new ReceivedRoomCountArgs() { UserCount = viewer });
break;
}
case 3:
case 4://playerCommand
{
var json = Encoding.UTF8.GetString(buffer, 0, playloadlength);
try
{
ReceivedDanmaku?.Invoke(this, new ReceivedDanmakuArgs() { Danmaku = new DanmakuModel(json) });
}
catch (Exception)
{ } // ignored
break;
}
case 5://newScrollMessage
case 7:
case 16:
default:
break;
}
}
}
catch (Exception ex)
{
this.Error = ex;
_disconnect();
}
}
private void HeartbeatLoop()
{
Debug.WriteLine("HeartbeatLoop Started!");
try
{
while (this.isConnected)
{
this.SendHeartbeat();
for (int i = 0; i < 30; i++)
{
Thread.Sleep(1000);//1s
if (!isConnected)
{
Debug.WriteLine("HeartbeatLoop Break");
break;
}
}
}
}
catch (Exception ex)
{
this.Error = ex;
_disconnect();
}
}
private void _disconnect()
{
if (isConnected)
{
Debug.WriteLine("Disconnected");
isConnected = false;
Client.Close();
NetStream = null;
Disconnected?.Invoke(this, new DisconnectEvtArgs() { Error = Error });
}
}
private void SendHeartbeat()
{
SendSocketData(2);
Debug.WriteLine("Message Sent: Heartbeat");
}
private void SendSocketData(int action, string body = "")
{
SendSocketData(0, 16, /*protocolversion*/1, action, 1, body);
}
private void SendSocketData(int packetlength, short magic, short ver, int action, int param = 1, string body = "")
{
var playload = Encoding.UTF8.GetBytes(body);
if (packetlength == 0)
{
packetlength = playload.Length + 16;
}
var buffer = new byte[packetlength];
using (var ms = new MemoryStream(buffer))
{
var b = BitConverter.GetBytes(buffer.Length).ToBE();
ms.Write(b, 0, 4);
b = BitConverter.GetBytes(magic).ToBE();
ms.Write(b, 0, 2);
b = BitConverter.GetBytes(ver).ToBE();
ms.Write(b, 0, 2);
b = BitConverter.GetBytes(action).ToBE();
ms.Write(b, 0, 4);
b = BitConverter.GetBytes(param).ToBE();
ms.Write(b, 0, 4);
if (playload.Length > 0)
{
ms.Write(playload, 0, playload.Length);
}
NetStream.Write(buffer, 0, buffer.Length);
NetStream.Flush();
}
}
// Use this for initialization
void Awake()
{
isConnected = false;
ViewerCount = 0;
}
}
}

View File

@ -0,0 +1,31 @@
using System;
using System.Linq;
using System.Net.Sockets;
namespace BililiveRecorder.Core
{
internal static class DanmakuUtils
{
public static byte[] ToBE(this byte[] b)
{
if (BitConverter.IsLittleEndian) return b.Reverse().ToArray(); else return b;
}
public static void ReadB(this NetworkStream stream, byte[] buffer, int offset, int count)
{
if (offset + count > buffer.Length)
throw new ArgumentException();
int read = 0;
while (read < count)
{
var available = stream.Read(buffer, offset, count - read);
if (available == 0)
{
throw new ObjectDisposedException(null);
}
read += available;
offset += available;
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
/**
* Author: Roger Lipscombe
* Source: https://stackoverflow.com/a/7472334
* */
using System;
using System.Threading;
using System.Threading.Tasks;
namespace BililiveRecorder.Core
{
internal static class Repeat
{
public static Task Interval(
TimeSpan pollInterval,
Action action,
CancellationToken token)
{
// We don't use Observable.Interval:
// If we block, the values start bunching up behind each other.
return Task.Factory.StartNew(
() =>
{
for (; ; )
{
if (token.WaitCancellationRequested(pollInterval))
break;
action();
}
}, token, TaskCreationOptions.LongRunning, TaskScheduler.Default);
}
}
static class CancellationTokenExtensions
{
public static bool WaitCancellationRequested(
this CancellationToken token,
TimeSpan timeout)
{
return token.WaitHandle.WaitOne(timeout);
}
}
}