新增 Mjpegplayer 用来播放 Web 流
This commit is contained in:
75
SHH.MjpegPlayer/Server/CoreImagesService.cs
Normal file
75
SHH.MjpegPlayer/Server/CoreImagesService.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using Ayay.SerilogLogs;
|
||||
using Core.WcfProtocol;
|
||||
using Serilog;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// CoreImagesService 服务
|
||||
/// </summary>
|
||||
public class CoreImagesService : ICoreImagesService
|
||||
{
|
||||
private static readonly ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
|
||||
|
||||
#region Defines
|
||||
|
||||
/// <summary>
|
||||
/// 按秒统计
|
||||
/// </summary>
|
||||
public static SumByTime _sumBySecond = new SumByTime();
|
||||
|
||||
#endregion
|
||||
|
||||
#region UploadImage
|
||||
|
||||
/// <summary>
|
||||
/// 上传图片
|
||||
/// </summary>
|
||||
/// <param name="req"></param>
|
||||
/// <returns></returns>
|
||||
public UploadImageReply UploadImage(UploadImageRequest req)
|
||||
{
|
||||
var reply = new UploadImageReply();
|
||||
|
||||
try
|
||||
{
|
||||
// 日志准备
|
||||
_sumBySecond.Refresh("UploadImage");
|
||||
|
||||
PrismMsg<UploadImageRequest>.Publish(req);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Warning($"上传图片失败, {ex.Message} {ex.StackTrace}");
|
||||
reply.ReplyFalt(ex.Message, ex.Source);
|
||||
}
|
||||
|
||||
reply.ReplySuccess();
|
||||
return reply;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UploadImageOneWay
|
||||
|
||||
/// <summary>
|
||||
/// 上传图片
|
||||
/// </summary>
|
||||
/// <param name="req"></param>
|
||||
public void UploadImageOneWay(UploadImageRequest req)
|
||||
{
|
||||
try
|
||||
{
|
||||
_sumBySecond.Refresh("UploadImage");
|
||||
|
||||
PrismMsg<UploadImageRequest>.Publish(req);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Warning($"上传图片失败, {ex.Message} {ex.StackTrace}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
163
SHH.MjpegPlayer/Server/MjpegHttpCmd.cs
Normal file
163
SHH.MjpegPlayer/Server/MjpegHttpCmd.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using Newtonsoft.Json;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// MJPEG HTTP命令类
|
||||
/// </summary>
|
||||
public class MjpegHttpCmd
|
||||
{
|
||||
#region DoHttpCmd
|
||||
|
||||
public static bool DoHttpCmd(NetworkStream stream,
|
||||
SessionInfo Info, string Cmd)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (Cmd)
|
||||
{
|
||||
case "view":
|
||||
Info.Message = "执行 view 命令.";
|
||||
DoHttpCmdView(stream, Info);
|
||||
return true;
|
||||
|
||||
case "task":
|
||||
Info.Message = "执行 task 命令.";
|
||||
DoHttpCmdTask(stream, Info);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SendJson(stream, $"Command Failed: {ex.Message}", 400);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DoHttpCmdView
|
||||
|
||||
private static void DoHttpCmdView(NetworkStream stream, SessionInfo Info)
|
||||
{
|
||||
var sessions = new List<SessionInfo>();
|
||||
int iSessionCount = 0;
|
||||
|
||||
var allSessions = MjpegStatics.Sessions.GetAllSessionInfos();
|
||||
|
||||
foreach (var sessionInfo in allSessions)
|
||||
{
|
||||
if (sessionInfo == null) continue;
|
||||
|
||||
if (!string.IsNullOrEmpty(Info.DeviceId))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(sessionInfo.DeviceId)
|
||||
&& !sessionInfo.DeviceId.Equals(Info.DeviceId))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Info.TypeCode))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(sessionInfo.TypeCode)
|
||||
&& !sessionInfo.TypeCode.Equals(Info.TypeCode))
|
||||
continue;
|
||||
}
|
||||
|
||||
iSessionCount++;
|
||||
sessions.Add(sessionInfo);
|
||||
}
|
||||
|
||||
var chns = new List<ImageChannel>();
|
||||
var imgChns = MjpegStatics.ImageChannels;
|
||||
int iImgChanCount = 0;
|
||||
|
||||
foreach (var kvp in imgChns.Channels)
|
||||
{
|
||||
var imgChannel = kvp.Value;
|
||||
if (imgChannel == null) continue;
|
||||
|
||||
if (!string.IsNullOrEmpty(Info.DeviceId))
|
||||
{
|
||||
if (!imgChannel.DeviceId.ToString().Equals(Info.DeviceId))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(Info.TypeCode))
|
||||
{
|
||||
if (!imgChannel.Type.Equals(Info.TypeCode))
|
||||
continue;
|
||||
}
|
||||
|
||||
iImgChanCount++;
|
||||
chns.Add(imgChannel);
|
||||
}
|
||||
|
||||
var result = new
|
||||
{
|
||||
webAccessCount = iSessionCount,
|
||||
deviceChannelCount = iImgChanCount,
|
||||
webAccessItems = sessions,
|
||||
deviceChannels = chns
|
||||
};
|
||||
|
||||
SendJson(stream, JsonConvert.SerializeObject(result, Formatting.Indented));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DoHttpCmdTask
|
||||
|
||||
private static void DoHttpCmdTask(NetworkStream stream, SessionInfo Info)
|
||||
{
|
||||
// [Optimized]: 直接从 TaskManager 获取实时任务快照,避免遍历旧的静态字典
|
||||
var activeTasks = TaskManager.RunningTasks.Values.ToList();
|
||||
|
||||
int iTaskCount = activeTasks.Count;
|
||||
int iMjpegServerListenCount = activeTasks.Count(t => t.Name.Contains("MjpegServer-"));
|
||||
|
||||
var result = new
|
||||
{
|
||||
taskCount = iTaskCount,
|
||||
portListenCount = iMjpegServerListenCount,
|
||||
// 映射为前端需要的格式
|
||||
taskItems = activeTasks.Select(t => new { t.Name, t.Type })
|
||||
};
|
||||
|
||||
// 使用 Newtonsoft.Json 或 System.Text.Json 输出
|
||||
SendJson(stream, JsonConvert.SerializeObject(result, Formatting.Indented));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper
|
||||
|
||||
private static void SendJson(NetworkStream stream, string json, int code = 200)
|
||||
{
|
||||
try
|
||||
{
|
||||
string statusLine = code == 200 ? "200 OK" : "400 Bad Request";
|
||||
|
||||
// [修复] 添加 CORS 头,允许诊断页面跨域访问
|
||||
byte[] response = Encoding.UTF8.GetBytes(
|
||||
$"HTTP/1.1 {statusLine}\r\n" +
|
||||
"Access-Control-Allow-Origin: *\r\n" +
|
||||
"Access-Control-Allow-Methods: GET, POST\r\n" +
|
||||
"Content-Type: application/json; charset=utf-8\r\n" +
|
||||
$"Content-Length: {Encoding.UTF8.GetByteCount(json)}\r\n\r\n" +
|
||||
json
|
||||
);
|
||||
|
||||
stream.Write(response, 0, response.Length);
|
||||
stream.Flush();
|
||||
stream.Close();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
28
SHH.MjpegPlayer/Server/MjpegImagesService.cs
Normal file
28
SHH.MjpegPlayer/Server/MjpegImagesService.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Player.MJPEG;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// MjpegImagesService 服务
|
||||
/// </summary>
|
||||
public class MjpegImagesService : CoreImagesService, IMjpegImagesService
|
||||
{
|
||||
#region GetRtspRtcPlayInfo
|
||||
|
||||
/// <summary>
|
||||
/// 获取 RtspRtc 播放信息
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public MjpegPlayInfoReply GetRtspRtcPlayInfo()
|
||||
{
|
||||
var reply = new MjpegPlayInfoReply();
|
||||
|
||||
// 发送消息
|
||||
PrismMsg<MjpegPlayInfoReply>.Publish(reply);
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
98
SHH.MjpegPlayer/Server/MjpegServer.cs
Normal file
98
SHH.MjpegPlayer/Server/MjpegServer.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// Mjpeg 服务
|
||||
/// </summary>
|
||||
public class MjpegServer
|
||||
{
|
||||
// [修复] 静态列表管理监听器,支持优雅停止
|
||||
private static readonly List<TcpListener> _listeners = new List<TcpListener>();
|
||||
private static readonly object _lock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// 启动服务
|
||||
/// </summary>
|
||||
/// <param name="port"></param>
|
||||
public static void Start(int port)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 示例:在 MjpegServer 初始化循环中调用
|
||||
TaskManager.Run($"MjpegServer-{port}", "Network", async (token) =>
|
||||
{
|
||||
// [Modified]: 使用 TaskManager 托管,支持外部取消令牌 token
|
||||
try
|
||||
{
|
||||
var cfg = MjpegStatics.Cfg;
|
||||
IPAddress ipAddress = IPAddress.Any;
|
||||
|
||||
if (!string.IsNullOrEmpty(cfg.SvrMjpegIp) && IPAddress.TryParse(cfg.SvrMjpegIp, out var parsedIp))
|
||||
{
|
||||
ipAddress = parsedIp;
|
||||
}
|
||||
|
||||
var server = new TcpListener(ipAddress, port);
|
||||
lock (_lock) _listeners.Add(server);
|
||||
|
||||
server.Start();
|
||||
// Logs.LogInformation...
|
||||
|
||||
try
|
||||
{
|
||||
// [Modified]: 检查取消令牌和全局运行状态
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 使用 AcceptTcpClientAsync 的重载或在外部检查
|
||||
var client = await server.AcceptTcpClientAsync();
|
||||
if (client == null) continue;
|
||||
|
||||
var session = new MjpegSession();
|
||||
session.Create(client);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// [修复] 异常防暴
|
||||
await Task.Delay(1000, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// [修复] 任务退出清理
|
||||
try { server.Stop(); } catch { }
|
||||
lock (_lock) _listeners.Remove(server);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Logs.LogError... 捕获初始化异常
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Logs.LogError<MjpegServer>(ex.Message, ex.StackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止所有服务 (新增)
|
||||
/// </summary>
|
||||
public static void StopAll()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var server in _listeners)
|
||||
{
|
||||
try { server.Stop(); } catch { }
|
||||
}
|
||||
_listeners.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
341
SHH.MjpegPlayer/Server/MjpegSession.cs
Normal file
341
SHH.MjpegPlayer/Server/MjpegSession.cs
Normal file
@@ -0,0 +1,341 @@
|
||||
using Ayay.SerilogLogs;
|
||||
using Core.WcfProtocol;
|
||||
using Serilog;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// Mjpeg 会话工作单元
|
||||
/// </summary>
|
||||
public class MjpegSession : IDisposable
|
||||
{
|
||||
private static readonly ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
|
||||
|
||||
#region Counter
|
||||
|
||||
private SumByTime _sumBySecond = new SumByTime();
|
||||
/// <summary>
|
||||
/// 计数器
|
||||
/// </summary>
|
||||
public SumByTime Counter => _sumBySecond;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Info
|
||||
|
||||
/// <summary>
|
||||
/// 基础信息
|
||||
/// </summary>
|
||||
public SessionInfo Info { get; private set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cmd
|
||||
|
||||
/// <summary>
|
||||
/// 命令
|
||||
/// </summary>
|
||||
public string? Cmd { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
// [修复] 引入 Disposed 标志位
|
||||
private volatile bool _isDisposed = false;
|
||||
|
||||
#region Constructor
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
public MjpegSession()
|
||||
{
|
||||
Info = new SessionInfo
|
||||
{
|
||||
Counter = _sumBySecond
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DoHttpHeader
|
||||
|
||||
private bool DoHttpHeader(NetworkStream stream)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] buffer = new byte[4096];
|
||||
int totalBytesRead = 0;
|
||||
string httpRequest = string.Empty;
|
||||
|
||||
while (totalBytesRead < buffer.Length)
|
||||
{
|
||||
int bytesRead = stream.Read(buffer, totalBytesRead, buffer.Length - totalBytesRead);
|
||||
if (bytesRead == 0) return false;
|
||||
|
||||
totalBytesRead += bytesRead;
|
||||
httpRequest = Encoding.ASCII.GetString(buffer, 0, totalBytesRead);
|
||||
if (httpRequest.Contains("\r\n\r\n")) break;
|
||||
}
|
||||
|
||||
if (!httpRequest.Contains("\r\n\r\n")) return false;
|
||||
|
||||
if (!string.IsNullOrEmpty(httpRequest))
|
||||
{
|
||||
int queryStartIndex = httpRequest.IndexOf('?');
|
||||
if (queryStartIndex > -1)
|
||||
{
|
||||
int spaceIndex = httpRequest.IndexOf(' ', queryStartIndex);
|
||||
if (spaceIndex == -1) spaceIndex = httpRequest.Length;
|
||||
string queryString = httpRequest.Substring(queryStartIndex + 1, spaceIndex - queryStartIndex - 1);
|
||||
var queryParams = System.Web.HttpUtility.ParseQueryString(queryString);
|
||||
if (queryParams != null)
|
||||
{
|
||||
Info.DeviceId = queryParams["id"];
|
||||
Info.TypeCode = queryParams["typeCode"];
|
||||
Cmd = queryParams["cmd"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(Cmd))
|
||||
{
|
||||
if (string.IsNullOrEmpty(Info.DeviceId) || string.IsNullOrEmpty(Info.TypeCode))
|
||||
{
|
||||
SendErrorResponse(stream, "错误:缺少必要参数(id 或 typeCode)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (MjpegHttpCmd.DoHttpCmd(stream, Info, Cmd)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SendErrorResponse(stream, $"解析异常: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SendErrorResponse(NetworkStream stream, string msg)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] response = Encoding.UTF8.GetBytes(
|
||||
$"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{msg}");
|
||||
stream.Write(response, 0, response.Length);
|
||||
stream.Flush();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Create
|
||||
|
||||
/// <summary>
|
||||
/// 创建会话
|
||||
/// </summary>
|
||||
public void Create(TcpClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Info == null) return;
|
||||
Info.AcceptTime = DateTime.Now;
|
||||
// 初始化最近接收时间,避免刚连接就被判定为超时
|
||||
LastRecImgTime = DateTime.Now;
|
||||
|
||||
Task.Run(() => { DoWorkTask(client); });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Error($"MjpegSession Create Exception, 异常信息:{ex.Message}, {ex.StackTrace}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DoWorkTask
|
||||
|
||||
private void DoWorkTask(TcpClient client)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var stream = client.GetStream())
|
||||
{
|
||||
// 设置写入超时 3秒
|
||||
stream.WriteTimeout = 3000;
|
||||
|
||||
#region 等待连接, 处理地址栏参数
|
||||
|
||||
int iLoc = 0;
|
||||
while (!client.Connected)
|
||||
{
|
||||
Thread.Sleep(50);
|
||||
if (++iLoc > 60) return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (client.Client?.RemoteEndPoint is IPEndPoint endpoint)
|
||||
{
|
||||
Info.ClientIp = endpoint.Address.ToString();
|
||||
Info.ClientPort = endpoint.Port;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
if (!DoHttpHeader(stream)) return;
|
||||
|
||||
#endregion
|
||||
|
||||
MjpegStatics.Sessions.AddSession(this);
|
||||
|
||||
byte[] header = Encoding.ASCII.GetBytes(
|
||||
"HTTP/1.1 200 OK\r\n" +
|
||||
"Content-Type: multipart/x-mixed-replace; boundary=frame\r\n\r\n");
|
||||
stream.Write(header, 0, header.Length);
|
||||
|
||||
var frameInterval = MjpegStatics.Cfg.FrameInterval;
|
||||
if (frameInterval < 1 || frameInterval > 500) frameInterval = 125;
|
||||
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
UploadImageRequest? lastProcItem = null;
|
||||
|
||||
byte[] boundaryBytes = Encoding.ASCII.GetBytes("\r\n--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ");
|
||||
byte[] doubleNewLine = Encoding.ASCII.GetBytes("\r\n\r\n");
|
||||
|
||||
while (client.Connected && !_isDisposed)
|
||||
{
|
||||
try
|
||||
{
|
||||
// [新增] 僵尸连接熔断机制:如果源头超过 60 秒没有新图片,主动断开连接
|
||||
if ((DateTime.Now - LastRecImgTime).TotalSeconds > 60)
|
||||
{
|
||||
_sysLog.Warning($"会话超时断开 (源头无数据 > 60s): {Info.Key} - Client: {Info.ClientIp}");
|
||||
break;
|
||||
}
|
||||
|
||||
stopwatch.Restart();
|
||||
|
||||
if (_lastRecObj == null || _lastRecObj.ImageBytes == null)
|
||||
{
|
||||
Info.Message = "等待图片数据抵达";
|
||||
Thread.Sleep(40);
|
||||
continue;
|
||||
}
|
||||
|
||||
Info.Message = "视频流播放中";
|
||||
if (lastProcItem != null && lastProcItem != _lastRecObj)
|
||||
{
|
||||
_sumBySecond.Refresh("有效帧数");
|
||||
}
|
||||
lastProcItem = _lastRecObj;
|
||||
|
||||
// 如果图片太旧(超过3秒),认为是滞后数据,暂时不发
|
||||
if ((DateTime.Now - _lastRecObj.Time).TotalSeconds > 3)
|
||||
{
|
||||
Thread.Sleep(40);
|
||||
continue;
|
||||
}
|
||||
|
||||
byte[] imageData = _lastRecObj.ImageBytes;
|
||||
|
||||
stream.Write(boundaryBytes, 0, boundaryBytes.Length);
|
||||
byte[] lengthBytes = Encoding.ASCII.GetBytes(imageData.Length.ToString());
|
||||
stream.Write(lengthBytes, 0, lengthBytes.Length);
|
||||
stream.Write(doubleNewLine, 0, doubleNewLine.Length);
|
||||
stream.Write(imageData, 0, imageData.Length);
|
||||
|
||||
_sumBySecond.Refresh("播放帧数");
|
||||
|
||||
stopwatch.Stop();
|
||||
var needSleep = frameInterval - (int)stopwatch.ElapsedMilliseconds;
|
||||
if (needSleep > 0) Thread.Sleep(needSleep);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Warning($"播放连接断开, : {ex.Message}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Error($"异常信息:{ex.Message}, {ex.StackTrace}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Dispose();
|
||||
if (client != null)
|
||||
{
|
||||
try { client.Close(); client.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DoImageProc
|
||||
|
||||
private UInt64 _lastPlayImageOrder = 0;
|
||||
private UploadImageRequest? _lastRecObj = null;
|
||||
|
||||
/// <summary>
|
||||
/// 处置图片
|
||||
/// </summary>
|
||||
public void DoImageProc(UploadImageRequest req)
|
||||
{
|
||||
try
|
||||
{
|
||||
LastRecImgTime = DateTime.Now;
|
||||
_sumBySecond.Refresh("接收帧数");
|
||||
if (req == null || req.ImageBytes == null || req.ImageBytes.Length < 100) return;
|
||||
|
||||
// 防止死锁:序号重置检测
|
||||
if (req.Order < _lastPlayImageOrder)
|
||||
{
|
||||
if (_lastPlayImageOrder > 100 && req.Order < 100)
|
||||
{
|
||||
_lastPlayImageOrder = req.Order;
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_lastPlayImageOrder = req.Order;
|
||||
_lastRecObj = req;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Error($"异常信息:{ex.Message}, {ex.StackTrace}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 最近收到图片时间
|
||||
/// </summary>
|
||||
public DateTime LastRecImgTime { get; set; } = DateTime.MinValue;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dispose
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
MjpegStatics.Sessions.RemoveSession(this);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
116
SHH.MjpegPlayer/Server/MjpegSessions.cs
Normal file
116
SHH.MjpegPlayer/Server/MjpegSessions.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using Core.WcfProtocol;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace SHH.MjpegPlayer;
|
||||
|
||||
/// <summary>
|
||||
/// 服务器会话集合 (线程安全重构版)
|
||||
/// </summary>
|
||||
public class MjpegSessions
|
||||
{
|
||||
// 核心改变:使用字典建立索引,Key = DeviceId#TypeCode
|
||||
// 这样可以将查找特定摄像头的复杂度从 O(N) 降低到 O(1)
|
||||
private readonly ConcurrentDictionary<string, List<MjpegSession>> _sessionMap
|
||||
= new ConcurrentDictionary<string, List<MjpegSession>>();
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
public MjpegSessions()
|
||||
{
|
||||
PrismMsg<UploadImageRequest>.Subscribe(ProcUploadImageRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优化后的图片分发逻辑 (解决 O(N) 和 线程安全问题)
|
||||
/// </summary>
|
||||
/// <param name="req"></param>
|
||||
public void ProcUploadImageRequest(UploadImageRequest req)
|
||||
{
|
||||
try
|
||||
{
|
||||
string key = $"{req.Id}#{req.Type}";
|
||||
|
||||
// 1. 更新通道信息 (ImageChannels 现已是线程安全的)
|
||||
var chn = MjpegStatics.ImageChannels.Do(req, key);
|
||||
|
||||
// 2. O(1) 快速查找关注该流的会话列表
|
||||
if (_sessionMap.TryGetValue(key, out var targetSessions))
|
||||
{
|
||||
// 必须加锁,防止遍历时 List 被其他线程(如 AddSession/RemoveSession) 修改
|
||||
lock (targetSessions)
|
||||
{
|
||||
// 倒序遍历,方便在需要时移除失效会话
|
||||
for (var i = targetSessions.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var session = targetSessions[i];
|
||||
session.DoImageProc(req);
|
||||
}
|
||||
}
|
||||
|
||||
if (chn != null) chn.IsPlaying = targetSessions.Count > 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (chn != null) chn.IsPlaying = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Logs.LogWarning<MjpegSessions>(ex.Message, ex.StackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加会话
|
||||
/// </summary>
|
||||
/// <param name="session"></param>
|
||||
public void AddSession(MjpegSession session)
|
||||
{
|
||||
if (session?.Info?.Key == null) return;
|
||||
|
||||
// 使用 GetOrAdd 确保线程安全地获取或创建 List
|
||||
var list = _sessionMap.GetOrAdd(session.Info.Key, _ => new List<MjpegSession>());
|
||||
|
||||
lock (list)
|
||||
{
|
||||
list.Add(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除会话
|
||||
/// </summary>
|
||||
/// <param name="session"></param>
|
||||
public void RemoveSession(MjpegSession session)
|
||||
{
|
||||
if (session?.Info?.Key == null) return;
|
||||
|
||||
if (_sessionMap.TryGetValue(session.Info.Key, out var list))
|
||||
{
|
||||
lock (list)
|
||||
{
|
||||
list.Remove(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前所有会话信息的快照 (用于 HTTP API 统计与展示)
|
||||
/// [新增] 此方法替代旧版直接访问 Sessions 列表,防止 HTTP 线程与 MJPEG 线程发生冲突
|
||||
/// </summary>
|
||||
public List<SessionInfo> GetAllSessionInfos()
|
||||
{
|
||||
var result = new List<SessionInfo>();
|
||||
// 遍历字典,线程安全地收集所有 Info
|
||||
foreach (var kvp in _sessionMap)
|
||||
{
|
||||
// 对内部 List 加锁,确保复制过程不被打断
|
||||
lock (kvp.Value)
|
||||
{
|
||||
result.AddRange(kvp.Value.Select(s => s.Info));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
163
SHH.MjpegPlayer/Server/RtmpPushServer.cs
Normal file
163
SHH.MjpegPlayer/Server/RtmpPushServer.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
using Ayay.SerilogLogs;
|
||||
using Serilog;
|
||||
|
||||
namespace SHH.MjpegPlayer
|
||||
{
|
||||
/// <summary>
|
||||
/// RTMP 推流参数同步服务器
|
||||
/// 职责:定期将本地图片通道信息同步至流媒体服务器,并获取最新的 RTMP 推流地址。
|
||||
/// </summary>
|
||||
public class RtmpPushServer
|
||||
{
|
||||
private static ILogger _sysLog = Log.ForContext("SourceContext", LogModules.Core);
|
||||
|
||||
#region Instance
|
||||
|
||||
/// <summary>
|
||||
/// 获取 RTMP 推流处理器的全局单例实例
|
||||
/// </summary>
|
||||
public static RtmpPushServer Instance { get; } = new RtmpPushServer();
|
||||
|
||||
// 私有构造函数防止外部 new
|
||||
private RtmpPushServer() { }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Start
|
||||
|
||||
/// <summary>
|
||||
/// 启动 RTMP 推流任务 (对接新架构 TaskManager)
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
// Optimized: 使用 TaskManager.Run 替代旧的线程启动方式,实现任务可视化管理
|
||||
TaskManager.Run("RtmpPushSync", "Monitor", async (token) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_sysLog.Information("RTMP 推流同步任务正在启动...");
|
||||
|
||||
// 1. 初始化延迟:稍作延迟,等待系统其他组件初始化完成
|
||||
await Task.Delay(2000, token);
|
||||
|
||||
// 2. 配置校验
|
||||
if (!MjpegStatics.Cfg.UseRtmpServer)
|
||||
{
|
||||
_sysLog.Warning("配置未启用 RTMP 服务,推流任务已跳过");
|
||||
return;
|
||||
}
|
||||
|
||||
#region 启动连接检测
|
||||
|
||||
bool isConnected = false;
|
||||
while (!isConnected && !token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfg = MjpegStatics.Cfg;
|
||||
var testItems = new List<object> { new { deviceIp = "", deviceId = "", algCode = "" } };
|
||||
|
||||
var result = testItems.PostJson<CfgRtmpReply>(cfg.RtmpServerDjhUri);
|
||||
if (result != null && result.IsSuccess)
|
||||
{
|
||||
_sysLog.Information("流媒体服务接口检测通过: {Uri}", cfg.RtmpServerDjhUri);
|
||||
isConnected = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_sysLog.Warning("检测流媒体服务接口失败, 30秒后再试... Uri: {Uri}", cfg.RtmpServerDjhUri);
|
||||
await Task.Delay(30000, token);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Error(ex, "流媒体服务接口检测异常");
|
||||
await Task.Delay(5000, token);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 周期性同步任务 (代替 OnDoTaskAction)
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
// 执行业务同步逻辑
|
||||
await SyncRtmpParametersAsync(token);
|
||||
|
||||
// 每 50ms 执行一次 (对应原 Start 中的 50ms 频率)
|
||||
await Task.Delay(50, token);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_sysLog.Information("RTMP 推流同步任务已正常取消");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_sysLog.Fatal(ex, "RTMP 推流任务发生致命错误");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 核心业务逻辑
|
||||
|
||||
private async Task SyncRtmpParametersAsync(CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfg = MjpegStatics.Cfg;
|
||||
var imgChns = MjpegStatics.ImageChannels;
|
||||
var pushItems = new List<object>();
|
||||
|
||||
// 构建上报数据
|
||||
foreach (var kvp in imgChns.Channels)
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
|
||||
var imgChn = kvp.Value;
|
||||
if (imgChn == null || !imgChn.UseRtmp) continue;
|
||||
|
||||
pushItems.Add(new
|
||||
{
|
||||
deviceIp = string.IsNullOrEmpty(imgChn.IpAddress) ? "127.0.0.1" : imgChn.IpAddress,
|
||||
deviceId = imgChn.DeviceId.ToString(),
|
||||
algCode = imgChn.Type
|
||||
});
|
||||
}
|
||||
|
||||
if (pushItems.Count > 0)
|
||||
{
|
||||
// 使用 await 配合异步扩展,释放线程池线程
|
||||
// 如果你的 NetHttpExtension 已支持异步,则直接 await。
|
||||
// 否则使用 await Task.Run(() => pushItems.PostJson(...)) 暂时过渡。
|
||||
var result = await pushItems.PostJsonAsync<CfgRtmpReply>(cfg.RtmpServerDjhUri, 2000);
|
||||
|
||||
if (result?.rtmpVoList != null && result.IsSuccess)
|
||||
{
|
||||
foreach (var item in result.rtmpVoList)
|
||||
{
|
||||
var channel = imgChns.Get(item.deviceId, item.algCode);
|
||||
if (channel != null && channel.RtmpUri != item.rtmp)
|
||||
{
|
||||
// 假设后续我们会统计实例中的成功次数
|
||||
channel.RtmpUri = item.rtmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 使用统一的 Serilog 对象输出结构化日志
|
||||
_sysLog.Error(ex, "SyncRtmpParametersAsync 执行异常");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
48
SHH.MjpegPlayer/Server/TaskManager.cs
Normal file
48
SHH.MjpegPlayer/Server/TaskManager.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace SHH.MjpegPlayer;
|
||||
|
||||
/// <summary>
|
||||
/// 任务状态信息载荷
|
||||
/// </summary>
|
||||
public record TaskMetadata(string Name, string Type, DateTime StartTime);
|
||||
|
||||
/// <summary>
|
||||
/// 任务管理器:替代原 CoreTaskRun 功能
|
||||
/// 职责:记录运行中的异步任务,支持状态检索和统一取消
|
||||
/// </summary>
|
||||
public static class TaskManager
|
||||
{
|
||||
// 存储运行中的任务及其元数据
|
||||
public static readonly ConcurrentDictionary<string, TaskMetadata> RunningTasks = new();
|
||||
|
||||
// 存储取消令牌,用于停止特定任务
|
||||
private static readonly ConcurrentDictionary<string, CancellationTokenSource> _tokens = new();
|
||||
|
||||
/// <summary>
|
||||
/// 注册并运行一个受控任务
|
||||
/// </summary>
|
||||
public static void Run(string taskName, string taskType, Func<CancellationToken, Task> action)
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
_tokens[taskName] = cts;
|
||||
|
||||
var metadata = new TaskMetadata(taskName, taskType, DateTime.Now);
|
||||
RunningTasks[taskName] = metadata;
|
||||
|
||||
// 启动异步任务
|
||||
Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await action(cts.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Optimized: 任务结束(无论正常还是异常)必须清理资源
|
||||
RunningTasks.TryRemove(taskName, out _);
|
||||
_tokens.TryRemove(taskName, out _);
|
||||
}
|
||||
}, cts.Token);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user