| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- @page
- @model Admin.Pages.Channel.YouTubePubSub.IndexModel
- @{
- ViewData["Title"] = "YouTube PubSub 구독";
- // 채널별 건강 정보 lookup (YouTubeChannelID → ChannelHealthItem)
- var healthByChannelId = Model.Health.Channels
- .GroupBy(h => h.YouTubeChannelID)
- .ToDictionary(g => g.Key, g => g.First());
- string FormatAgo(DateTime? utc)
- {
- if (utc is null) return "-";
- var diff = DateTime.UtcNow - utc.Value;
- if (diff < TimeSpan.Zero) return utc.Value.ToString("yyyy-MM-dd HH:mm:ss") + " UTC";
- if (diff.TotalMinutes < 1) return $"{(int)diff.TotalSeconds}초 전";
- if (diff.TotalHours < 1) return $"{(int)diff.TotalMinutes}분 전";
- if (diff.TotalDays < 1) return $"{(int)diff.TotalHours}시간 전";
- return $"{(int)diff.TotalDays}일 전";
- }
- string FormatUntil(DateTime? utc)
- {
- if (utc is null) return "-";
- var diff = utc.Value - DateTime.UtcNow;
- if (diff < TimeSpan.Zero) return "만료됨";
- if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}분 후";
- if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}시간 후";
- return $"{(int)diff.TotalDays}일 후";
- }
- }
- <div class="container">
- <h3>@ViewData["Title"]</h3>
- <hr />
- <partial name="_StatusMessage" />
- <div class="alert alert-info" role="alert">
- <strong>WebSub (PubSubHubbub)</strong> 기반 YouTube 채널 알림 구독 현황입니다.
- <br />
- 구독 만료 전(기본 10일) 자동 재구독 백그라운드 서비스가 7일마다 동작합니다. 누락된 채널은 아래 표에서 채널별로 <strong>구독</strong> 버튼을 눌러 동기화하세요.
- </div>
- <hr />
- <!-- 연결 상태 (API 키 / HMAC / 수집 현황) -->
- <details open>
- <summary class="fs-5 mb-3">연결 상태</summary>
- <div class="row g-3 mb-3">
- <div class="col-md-6 col-lg-3">
- <div class="card h-100">
- <div class="card-body py-3 px-3">
- <div class="text-muted small fw-semibold mb-2">API Key</div>
- @if (Model.Health.ApiKeyConfigured)
- {
- <div class="fs-6"><span class="badge bg-success">설정됨</span> <code class="small ms-1">@Model.Health.ApiKeyMaskedTail</code></div>
- }
- else
- {
- <div class="fs-6"><span class="badge bg-danger">없음</span></div>
- }
- </div>
- </div>
- </div>
- <div class="col-md-6 col-lg-3">
- <div class="card h-100">
- <div class="card-body py-3 px-3">
- <div class="text-muted small fw-semibold mb-2">HMAC Secret</div>
- @if (Model.Health.HmacSecretConfigured)
- {
- <div class="fs-6"><span class="badge bg-success">설정됨</span> <code class="small ms-1">@Model.Health.HmacSecretMaskedTail</code></div>
- }
- else
- {
- <div class="fs-6"><span class="badge bg-danger">없음</span></div>
- }
- </div>
- </div>
- </div>
- <div class="col-md-6 col-lg-3">
- <div class="card h-100">
- <div class="card-body py-3 px-3">
- <div class="text-muted small fw-semibold mb-2">Feed 폴링 주기</div>
- <div class="fs-4 fw-bold">@Model.Health.FeedPollingIntervalMinutes <span class="fs-6 fw-normal text-muted">분</span></div>
- </div>
- </div>
- </div>
- <div class="col-md-6 col-lg-3">
- <div class="card h-100">
- <div class="card-body py-3 px-3">
- <div class="text-muted small fw-semibold mb-2">라이브 채팅 수집기</div>
- <div class="fs-4 fw-bold">@Model.Health.ActiveLiveChatCollectors <span class="fs-6 fw-normal text-muted">개 활성</span></div>
- </div>
- </div>
- </div>
- </div>
- <div class="row g-3 mb-3">
- <div class="col-md-6 col-lg-3">
- <div class="card h-100">
- <div class="card-body py-3 px-3">
- <div class="text-muted small fw-semibold mb-2">활성 내부 채널</div>
- <div class="fs-4 fw-bold">@Model.Health.ActiveInternalChannelsCount</div>
- </div>
- </div>
- </div>
- <div class="col-md-6 col-lg-3">
- <div class="card h-100">
- <div class="card-body py-3 px-3">
- <div class="text-muted small fw-semibold mb-2">PubSub 구독 채널</div>
- <div class="fs-4 fw-bold">@Model.Health.SubscribedChannelsCount</div>
- </div>
- </div>
- </div>
- <div class="col-md-6 col-lg-3">
- <div class="card h-100">
- <div class="card-body py-3 px-3">
- <div class="text-muted small fw-semibold mb-2">현재 라이브</div>
- <div class="fs-4 fw-bold">@Model.Health.ActiveLiveVideosCount</div>
- </div>
- </div>
- </div>
- <div class="col-md-6 col-lg-3">
- <div class="card h-100">
- <div class="card-body py-3 px-3 d-flex flex-column">
- <div class="text-muted small fw-semibold mb-2">수동 API 키 테스트</div>
- <form method="post" asp-page-handler="TestApiKey" class="mt-auto">
- <button type="submit" class="btn btn-sm btn-outline-primary w-100" data-once>
- <i class="bi bi-plug"></i> API 키 테스트 (1 unit)
- </button>
- </form>
- <div class="form-text small mt-1">30초 쿨다운</div>
- </div>
- </div>
- </div>
- </div>
- </details>
- <hr />
- <!-- 현재 감지된 라이브 -->
- <details open>
- <summary class="fs-5 mb-3 d-flex justify-content-between align-items-center">
- <span>현재 감지된 라이브</span>
- <span class="badge bg-danger">@Model.LiveStatus.Items.Count</span>
- </summary>
- @if (Model.LiveStatus.Items.Count == 0)
- {
- <div class="p-3 text-muted text-center">현재 감지된 라이브 방송이 없습니다.</div>
- }
- else
- {
- <div class="table-responsive">
- <table class="table table-sm mb-0 align-middle">
- <thead>
- <tr>
- <th>채널명</th>
- <th>Video ID</th>
- <th>제목</th>
- <th style="width: 180px;">시작 시각(UTC)</th>
- </tr>
- </thead>
- <tbody>
- @foreach (var live in Model.LiveStatus.Items)
- {
- <tr>
- <td>@(live.ChannelName ?? "(미확인)")</td>
- <td><code class="small">@live.VideoID</code></td>
- <td>@live.Title</td>
- <td class="small">@(live.ActualStartTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-")</td>
- </tr>
- }
- </tbody>
- </table>
- </div>
- }
- </details>
- <hr />
- <!-- 수동 라이브 감지 -->
- <details>
- <summary class="fs-5 mb-3">수동 방송 감지 (긴급 복구용 - quota 사용)</summary>
- <div class="alert alert-warning small mb-3">
- 현재 라이브 중인데 PubSub/Feed 폴링으로 감지되지 않았을 때 수동 트리거합니다.
- <code>PubSubNotify.Command</code> 파이프라인 그대로 실행 →
- <code>videos.list</code> 1 unit → <code>actualStartTime</code> 있으면 BroadcastSession 생성 + LiveChat 시작.
- </div>
- <form method="post" asp-page-handler="ManualDetect">
- <div class="row mb-2">
- <label class="col-sm-3 col-form-label">Video ID</label>
- <div class="col-sm-9">
- <input type="text" name="videoID" class="form-control" placeholder="예: fP_nhSiokTM" required />
- <div class="text-muted form-text">YouTube 라이브 URL의 <code>watch?v=</code> 뒤 문자열</div>
- </div>
- </div>
- <div class="row mb-2">
- <label class="col-sm-3 col-form-label">YouTube Channel ID</label>
- <div class="col-sm-9">
- <input type="text" name="youtubeChannelID" class="form-control" placeholder="예: UCRTyksdGsRT-iwE2gceyJ0A" required />
- <div class="text-muted form-text">UC로 시작하는 YouTube 채널 ID</div>
- </div>
- </div>
- <div class="row">
- <div class="col text-center p-3">
- <button type="submit" class="btn btn-warning">감지 실행</button>
- </div>
- </div>
- </form>
- </details>
- <hr />
- <!-- 라이브 강제 마킹 (quota 0) -->
- <details>
- <summary class="fs-5 mb-3">강제 방송 감지 (quota 초과 시 긴급 복구용)</summary>
- <div class="alert alert-danger small mb-3">
- <strong>quota 소진 등으로 YouTube API 호출이 불가능할 때</strong> 사용. YouTube API 호출 없이
- Redis 라이브 상태 + DB BroadcastSession + SignalR ReceiveChannelStatus 만 처리합니다.
- 제목 등 메타데이터는 검증 안 되므로 운영자 책임 하에 정확히 입력해야 합니다.
- <br />라이브 종료 시 <strong>강제 종료 마킹</strong>도 같이 사용해 주세요 (자동 정리 안 됨).
- </div>
- <form method="post" asp-page-handler="ForceLive" class="mb-4">
- <div class="row mb-2">
- <label class="col-sm-3 col-form-label">YouTube Channel ID <span class="text-danger">*</span></label>
- <div class="col-sm-9">
- <input type="text" name="youtubeChannelID" class="form-control" placeholder="예: UCRTyksdGsRT-iwE2gceyJ0A" required />
- </div>
- </div>
- <div class="row mb-2">
- <label class="col-sm-3 col-form-label">Video ID <span class="text-danger">*</span></label>
- <div class="col-sm-9">
- <input type="text" name="videoID" class="form-control" placeholder="예: xF2ausgpWkQ" required />
- <div class="text-muted form-text">YouTube 라이브 URL의 <code>watch?v=</code> 뒤 문자열</div>
- </div>
- </div>
- <div class="row mb-2">
- <label class="col-sm-3 col-form-label">제목 (선택)</label>
- <div class="col-sm-9">
- <input type="text" name="title" class="form-control" placeholder="(미입력 시 '(수동 등록 라이브)')" />
- </div>
- </div>
- <div class="row">
- <div class="col text-center p-3">
- <button type="submit" class="btn btn-danger">감지 실행</button>
- </div>
- </div>
- </form>
- <hr />
- <h6 class="mb-2">라이브 강제 종료 마킹</h6>
- <form method="post" asp-page-handler="ForceOffline"
- onsubmit="return confirm('이 채널의 라이브 상태를 강제 종료할까요? Redis ClearLive + isLive=false 브로드캐스트.');">
- <div class="row mb-2">
- <label class="col-sm-3 col-form-label">YouTube Channel ID <span class="text-danger">*</span></label>
- <div class="col-sm-9">
- <input type="text" name="youtubeChannelID" class="form-control" placeholder="예: UCRTyksdGsRT-iwE2gceyJ0A" required />
- </div>
- </div>
- <div class="row">
- <div class="col text-center p-3">
- <button type="submit" class="btn btn-outline-danger">라이브 강제 종료 (quota 0)</button>
- </div>
- </div>
- </form>
- </details>
- <hr />
- <!-- Callback URL -->
- <details open>
- <summary class="fs-5 mb-3">Callback URL</summary>
- <div class="row mb-2">
- <div class="col-12">
- <code>@Model.Data.CallbackUrl</code>
- <div class="text-muted form-text">
- YouTube PubSub 허브가 알림을 전송하는 엔드포인트. 공개 HTTPS 여야 하며 <code>appsettings</code>의 <code>YouTube:CallbackUrl</code>로 설정합니다.
- </div>
- </div>
- </div>
- </details>
- <hr />
- <!-- YouTube 연동 채널 -->
- <details open>
- <summary class="fs-5 mb-3 d-flex justify-content-between align-items-center">
- <span>YouTube 연동 채널</span>
- <span>
- <span class="badge bg-success">구독 @Model.Data.SubscribedCount</span>
- <span class="badge bg-secondary">전체 @Model.Data.TotalCount</span>
- </span>
- </summary>
- @if (Model.Data.TotalCount == 0)
- {
- <div class="p-4 text-center text-muted">표시할 채널이 없습니다.</div>
- }
- else
- {
- <div class="table-responsive">
- <table class="table table-hover mb-0 align-middle">
- <thead>
- <tr>
- <th style="width: 60px;">썸네일</th>
- <th>채널명</th>
- <th>YouTube Channel ID</th>
- <th style="width: 110px;">채널 상태</th>
- <th style="width: 110px;">구독</th>
- <th style="width: 120px;">마지막 폴링</th>
- <th style="width: 120px;">구독 만료</th>
- <th style="width: 160px;">마지막 비디오</th>
- <th style="width: 200px;" class="text-end text-nowrap">작업</th>
- </tr>
- </thead>
- <tbody>
- @foreach (var item in Model.Data.Items)
- {
- <tr>
- <td>
- @if (!string.IsNullOrEmpty(item.ThumbnailUrl))
- {
- <img src="@item.ThumbnailUrl" alt="" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;" />
- }
- else
- {
- <i class="bi bi-person-circle" style="font-size: 2rem; color: #ccc;"></i>
- }
- </td>
- <td>
- @if (item.HasInternalChannel)
- {
- <div>
- <strong>@item.ChannelName</strong>
- @if (!string.IsNullOrEmpty(item.Handle))
- {
- <small class="text-muted">(@@@item.Handle)</small>
- }
- </div>
- }
- else
- {
- <span class="text-muted">(내부 채널 없음 · 고아 구독)</span>
- }
- </td>
- <td><code class="small">@item.YouTubeChannelID</code></td>
- <td>
- @if (!item.HasInternalChannel)
- {
- <span class="badge bg-warning">고아</span>
- }
- else if (item.IsActive)
- {
- <span class="badge bg-success">활성</span>
- }
- else
- {
- <span class="badge bg-secondary">비활성</span>
- }
- </td>
- <td>
- @if (item.IsSubscribed)
- {
- <span class="badge bg-primary text-nowrap"><i class="bi bi-check-lg"></i> 구독됨</span>
- }
- else
- {
- <span class="badge bg-light text-dark border text-nowrap"><i class="bi bi-x"></i> 미구독</span>
- }
- </td>
- @{
- healthByChannelId.TryGetValue(item.YouTubeChannelID, out var h);
- }
- <td class="small">
- <span title="@(h?.LastPolledAtUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-") UTC">@FormatAgo(h?.LastPolledAtUtc)</span>
- </td>
- <td class="small">
- @{
- var leaseLabel = FormatUntil(h?.LeaseExpiresAtUtc);
- var leaseClass = h?.LeaseExpiresAtUtc is null
- ? "text-muted"
- : (h.LeaseExpiresAtUtc.Value - DateTime.UtcNow).TotalDays < 2 ? "text-danger" : "";
- }
- <span class="@leaseClass" title="@(h?.LeaseExpiresAtUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-") UTC">@leaseLabel</span>
- </td>
- <td class="small">
- @if (!string.IsNullOrEmpty(h?.LastProcessedVideoId))
- {
- <code class="small">@h.LastProcessedVideoId</code>
- }
- else
- {
- <span class="text-muted">-</span>
- }
- </td>
- <td class="text-end text-nowrap">
- <div class="d-flex gap-1 justify-content-end flex-nowrap">
- @if (!item.IsSubscribed)
- {
- <form method="post" asp-page-handler="Resubscribe">
- <input type="hidden" name="youtubeChannelID" value="@item.YouTubeChannelID" />
- <button type="submit" class="btn btn-sm btn-primary text-nowrap">
- <i class="bi bi-plus-lg"></i> 구독
- </button>
- </form>
- }
- else
- {
- <form method="post" asp-page-handler="Resubscribe">
- <input type="hidden" name="youtubeChannelID" value="@item.YouTubeChannelID" />
- <button type="submit" class="btn btn-sm btn-outline-primary text-nowrap">
- <i class="bi bi-arrow-repeat"></i> 재구독
- </button>
- </form>
- <form method="post" asp-page-handler="Unsubscribe"
- onsubmit="return confirm('이 채널의 PubSub 구독을 해지할까요?');">
- <input type="hidden" name="youtubeChannelID" value="@item.YouTubeChannelID" />
- <button type="submit" class="btn btn-sm btn-outline-danger text-nowrap">
- <i class="bi bi-dash-lg"></i> 해지
- </button>
- </form>
- }
- </div>
- </td>
- </tr>
- }
- </tbody>
- </table>
- </div>
- }
- </details>
- <br />
- </div>
|