using Application.Abstractions.Data;
using Application.Abstractions.Hub;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Infrastructure.Hubs;
///
/// 후원 전용 Hub — OBS 위젯, 리모콘 전용
/// 채널 WidgetToken 기반 그룹으로 분리
///
public sealed class DonationHub(
IAppDbContext db,
ILogger logger,
IHubContext hubContext
) : Hub
{
public override async Task OnConnectedAsync()
{
logger.LogInformation("[DonationHub] Connected: {ConnectionId}", Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
logger.LogInformation("[DonationHub] Disconnected: {ConnectionId}", Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
/// 채널 그룹 참가 (OBS 위젯, 리모콘에서 호출) — WidgetToken 유효성 검증 후에만 참가 허용
public async Task JoinChannel(string widgetToken)
{
if (string.IsNullOrWhiteSpace(widgetToken))
{
logger.LogWarning("[DonationHub] Empty widgetToken rejected from {ConnectionId}", Context.ConnectionId);
Context.Abort();
return;
}
var channelID = await db.Channel.AsNoTracking().Where(c => c.WidgetToken == widgetToken && c.IsActive).Select(c => (int?)c.ID).FirstOrDefaultAsync(Context.ConnectionAborted);
if (channelID is null)
{
logger.LogWarning("[DonationHub] Invalid widgetToken rejected from {ConnectionId}", Context.ConnectionId);
Context.Abort();
return;
}
await Groups.AddToGroupAsync(Context.ConnectionId, widgetToken);
logger.LogInformation("[DonationHub] {ConnectionId} joined channel {ChannelID}", Context.ConnectionId, channelID);
// 새로 join한 connection에 현재 메타 상태(일시정지/수신/음성/영상) 즉시 전송
var meta = await db.DonationMeta.AsNoTracking()
.FirstOrDefaultAsync(m => m.ChannelID == channelID.Value, Context.ConnectionAborted);
await Clients.Caller.ReceiveState(new
{
isPaused = meta?.IsPaused ?? false,
isAccepting = meta?.IsAccepting ?? true,
isAudioOnly = meta?.IsAudioOnly ?? false,
isVideoOnly = meta?.IsVideoOnly ?? false
});
}
/// 채널 그룹 퇴장
public async Task LeaveChannel(string widgetToken)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, widgetToken);
}
/// OBS 위젯에서 재생 시작 보고 (큐에서 꺼내 재생 시작 시점)
/// displayDurationSec — 위젯이 매칭된 AlertConfig 의 노출 시간(초). 리모콘 타이머 ring 표시용.
///
public async Task AlertDelivered(int alertID, double? displayDurationSec = null)
{
var alert = await db.DonationAlert
.Include(a => a.Donation)
.FirstOrDefaultAsync(a => a.ID == alertID, Context.ConnectionAborted);
if (alert is null || alert.Donation is null)
{
logger.LogWarning("[DonationHub] AlertDelivered: alert {AlertID} not found", alertID);
return;
}
try
{
alert.MarkPlaying();
await db.SaveChangesAsync(Context.ConnectionAborted);
}
catch (Exception ex)
{
logger.LogWarning(ex, "[DonationHub] AlertDelivered: MarkPlaying failed for {AlertID}", alertID);
return;
}
var token = await db.Channel.AsNoTracking()
.Where(c => c.ID == alert.Donation.ChannelID)
.Select(c => c.WidgetToken)
.FirstOrDefaultAsync(Context.ConnectionAborted);
if (!string.IsNullOrEmpty(token))
{
await Clients.Group(token).ReceiveAlertStatusUpdate(new
{
alertID,
status = "playing",
displayDurationSec,
startedAt = DateTime.UtcNow
});
}
logger.LogInformation("[DonationHub] AlertDelivered → Playing: {AlertID} (duration={Duration}s)", alertID, displayDurationSec);
}
/// OBS 위젯에서 재생 완료 보고
public async Task AlertCompleted(int alertID)
{
var alert = await db.DonationAlert
.Include(a => a.Donation)
.FirstOrDefaultAsync(a => a.ID == alertID, Context.ConnectionAborted);
if (alert is null || alert.Donation is null)
{
logger.LogWarning("[DonationHub] AlertCompleted: alert {AlertID} not found", alertID);
return;
}
try
{
alert.MarkDelivered();
await db.SaveChangesAsync(Context.ConnectionAborted);
}
catch (Exception ex)
{
logger.LogWarning(ex, "[DonationHub] AlertCompleted: MarkDelivered failed for {AlertID}", alertID);
return;
}
var token = await db.Channel.AsNoTracking()
.Where(c => c.ID == alert.Donation.ChannelID)
.Select(c => c.WidgetToken)
.FirstOrDefaultAsync(Context.ConnectionAborted);
if (!string.IsNullOrEmpty(token))
{
await Clients.Group(token).ReceiveAlertStatusUpdate(new { alertID, status = "delivered" });
// ⭐ 알림 재생 종료 시점 — Goal/Rank/Crew 위젯에 통계 반영 broadcast
logger.LogInformation("[DonationHub] AlertCompleted broadcast trigger — alertID={AlertID}, channelID={ChannelID}, crewMemberID={CrewMemberID}, isTest={IsTest}",
alertID, alert.Donation.ChannelID, alert.Donation.CrewMemberID, alert.Donation.IsTest);
// 현재 후원 정보 (IsTest=true이면 helper가 영구 합산에 단일 추가, false면 이미 영구에 포함됨)
var currentDonation = new CurrentDonation(
Amount: alert.Donation.Amount,
NetAmount: alert.Donation.NetAmount,
SponsorMemberID: alert.Donation.SponsorMemberID,
SendName: alert.Donation.SendName,
CrewMemberID: alert.Donation.CrewMemberID,
IsTest: alert.Donation.IsTest
);
await DonationBroadcastHelper.BroadcastGoalAndRankAsync(
hubContext, db,
alert.Donation.ChannelID, token, alert.Donation.CrewMemberID,
currentDonation,
Context.ConnectionAborted,
logger
);
}
logger.LogInformation("[DonationHub] AlertCompleted → Delivered: {AlertID}", alertID);
}
/// OBS 위젯에서 알림 전달 실패 보고
public async Task AlertFailed(int alertID, string reason)
{
logger.LogWarning("[DonationHub] AlertFailed: {AlertID} — {Reason}", alertID, reason);
var alert = await db.DonationAlert
.Include(a => a.Donation)
.FirstOrDefaultAsync(a => a.ID == alertID, Context.ConnectionAborted);
if (alert is null || alert.Donation is null)
{
return;
}
try
{
alert.MarkFailed();
await db.SaveChangesAsync(Context.ConnectionAborted);
}
catch (Exception ex)
{
logger.LogWarning(ex, "[DonationHub] AlertFailed: MarkFailed save failed for {AlertID}", alertID);
return;
}
var token = await db.Channel.AsNoTracking()
.Where(c => c.ID == alert.Donation.ChannelID)
.Select(c => c.WidgetToken)
.FirstOrDefaultAsync(Context.ConnectionAborted);
if (!string.IsNullOrEmpty(token))
{
await Clients.Group(token).ReceiveAlertStatusUpdate(new { alertID, status = "failed" });
}
}
}