Index.cshtml 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. @page
  2. @model Admin.Pages.Channel.YouTubePubSub.IndexModel
  3. @{
  4. ViewData["Title"] = "YouTube PubSub 구독";
  5. // 채널별 건강 정보 lookup (YouTubeChannelID → ChannelHealthItem)
  6. var healthByChannelId = Model.Health.Channels
  7. .GroupBy(h => h.YouTubeChannelID)
  8. .ToDictionary(g => g.Key, g => g.First());
  9. string FormatAgo(DateTime? utc)
  10. {
  11. if (utc is null) return "-";
  12. var diff = DateTime.UtcNow - utc.Value;
  13. if (diff < TimeSpan.Zero) return utc.Value.ToString("yyyy-MM-dd HH:mm:ss") + " UTC";
  14. if (diff.TotalMinutes < 1) return $"{(int)diff.TotalSeconds}초 전";
  15. if (diff.TotalHours < 1) return $"{(int)diff.TotalMinutes}분 전";
  16. if (diff.TotalDays < 1) return $"{(int)diff.TotalHours}시간 전";
  17. return $"{(int)diff.TotalDays}일 전";
  18. }
  19. string FormatUntil(DateTime? utc)
  20. {
  21. if (utc is null) return "-";
  22. var diff = utc.Value - DateTime.UtcNow;
  23. if (diff < TimeSpan.Zero) return "만료됨";
  24. if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}분 후";
  25. if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}시간 후";
  26. return $"{(int)diff.TotalDays}일 후";
  27. }
  28. }
  29. <div class="container">
  30. <h3>@ViewData["Title"]</h3>
  31. <hr />
  32. <partial name="_StatusMessage" />
  33. <div class="alert alert-info" role="alert">
  34. <strong>WebSub (PubSubHubbub)</strong> 기반 YouTube 채널 알림 구독 현황입니다.
  35. <br />
  36. 구독 만료 전(기본 10일) 자동 재구독 백그라운드 서비스가 7일마다 동작합니다. 누락된 채널은 아래 표에서 채널별로 <strong>구독</strong> 버튼을 눌러 동기화하세요.
  37. </div>
  38. <hr />
  39. <!-- 연결 상태 (API 키 / HMAC / 수집 현황) -->
  40. <details open>
  41. <summary class="fs-5 mb-3">연결 상태</summary>
  42. <div class="row g-3 mb-3">
  43. <div class="col-md-6 col-lg-3">
  44. <div class="card h-100">
  45. <div class="card-body py-3 px-3">
  46. <div class="text-muted small fw-semibold mb-2">API Key</div>
  47. @if (Model.Health.ApiKeyConfigured)
  48. {
  49. <div class="fs-6"><span class="badge bg-success">설정됨</span> <code class="small ms-1">@Model.Health.ApiKeyMaskedTail</code></div>
  50. }
  51. else
  52. {
  53. <div class="fs-6"><span class="badge bg-danger">없음</span></div>
  54. }
  55. </div>
  56. </div>
  57. </div>
  58. <div class="col-md-6 col-lg-3">
  59. <div class="card h-100">
  60. <div class="card-body py-3 px-3">
  61. <div class="text-muted small fw-semibold mb-2">HMAC Secret</div>
  62. @if (Model.Health.HmacSecretConfigured)
  63. {
  64. <div class="fs-6"><span class="badge bg-success">설정됨</span> <code class="small ms-1">@Model.Health.HmacSecretMaskedTail</code></div>
  65. }
  66. else
  67. {
  68. <div class="fs-6"><span class="badge bg-danger">없음</span></div>
  69. }
  70. </div>
  71. </div>
  72. </div>
  73. <div class="col-md-6 col-lg-3">
  74. <div class="card h-100">
  75. <div class="card-body py-3 px-3">
  76. <div class="text-muted small fw-semibold mb-2">Feed 폴링 주기</div>
  77. <div class="fs-4 fw-bold">@Model.Health.FeedPollingIntervalMinutes <span class="fs-6 fw-normal text-muted">분</span></div>
  78. </div>
  79. </div>
  80. </div>
  81. <div class="col-md-6 col-lg-3">
  82. <div class="card h-100">
  83. <div class="card-body py-3 px-3">
  84. <div class="text-muted small fw-semibold mb-2">라이브 채팅 수집기</div>
  85. <div class="fs-4 fw-bold">@Model.Health.ActiveLiveChatCollectors <span class="fs-6 fw-normal text-muted">개 활성</span></div>
  86. </div>
  87. </div>
  88. </div>
  89. </div>
  90. <div class="row g-3 mb-3">
  91. <div class="col-md-6 col-lg-3">
  92. <div class="card h-100">
  93. <div class="card-body py-3 px-3">
  94. <div class="text-muted small fw-semibold mb-2">활성 내부 채널</div>
  95. <div class="fs-4 fw-bold">@Model.Health.ActiveInternalChannelsCount</div>
  96. </div>
  97. </div>
  98. </div>
  99. <div class="col-md-6 col-lg-3">
  100. <div class="card h-100">
  101. <div class="card-body py-3 px-3">
  102. <div class="text-muted small fw-semibold mb-2">PubSub 구독 채널</div>
  103. <div class="fs-4 fw-bold">@Model.Health.SubscribedChannelsCount</div>
  104. </div>
  105. </div>
  106. </div>
  107. <div class="col-md-6 col-lg-3">
  108. <div class="card h-100">
  109. <div class="card-body py-3 px-3">
  110. <div class="text-muted small fw-semibold mb-2">현재 라이브</div>
  111. <div class="fs-4 fw-bold">@Model.Health.ActiveLiveVideosCount</div>
  112. </div>
  113. </div>
  114. </div>
  115. <div class="col-md-6 col-lg-3">
  116. <div class="card h-100">
  117. <div class="card-body py-3 px-3 d-flex flex-column">
  118. <div class="text-muted small fw-semibold mb-2">수동 API 키 테스트</div>
  119. <form method="post" asp-page-handler="TestApiKey" class="mt-auto">
  120. <button type="submit" class="btn btn-sm btn-outline-primary w-100" data-once>
  121. <i class="bi bi-plug"></i> API 키 테스트 (1 unit)
  122. </button>
  123. </form>
  124. <div class="form-text small mt-1">30초 쿨다운</div>
  125. </div>
  126. </div>
  127. </div>
  128. </div>
  129. </details>
  130. <hr />
  131. <!-- 현재 감지된 라이브 -->
  132. <details open>
  133. <summary class="fs-5 mb-3 d-flex justify-content-between align-items-center">
  134. <span>현재 감지된 라이브</span>
  135. <span class="badge bg-danger">@Model.LiveStatus.Items.Count</span>
  136. </summary>
  137. @if (Model.LiveStatus.Items.Count == 0)
  138. {
  139. <div class="p-3 text-muted text-center">현재 감지된 라이브 방송이 없습니다.</div>
  140. }
  141. else
  142. {
  143. <div class="table-responsive">
  144. <table class="table table-sm mb-0 align-middle">
  145. <thead>
  146. <tr>
  147. <th>채널명</th>
  148. <th>Video ID</th>
  149. <th>제목</th>
  150. <th style="width: 180px;">시작 시각(UTC)</th>
  151. </tr>
  152. </thead>
  153. <tbody>
  154. @foreach (var live in Model.LiveStatus.Items)
  155. {
  156. <tr>
  157. <td>@(live.ChannelName ?? "(미확인)")</td>
  158. <td><code class="small">@live.VideoID</code></td>
  159. <td>@live.Title</td>
  160. <td class="small">@(live.ActualStartTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-")</td>
  161. </tr>
  162. }
  163. </tbody>
  164. </table>
  165. </div>
  166. }
  167. </details>
  168. <hr />
  169. <!-- 수동 라이브 감지 -->
  170. <details>
  171. <summary class="fs-5 mb-3">수동 방송 감지 (긴급 복구용 - quota 사용)</summary>
  172. <div class="alert alert-warning small mb-3">
  173. 현재 라이브 중인데 PubSub/Feed 폴링으로 감지되지 않았을 때 수동 트리거합니다.
  174. <code>PubSubNotify.Command</code> 파이프라인 그대로 실행 →
  175. <code>videos.list</code> 1 unit → <code>actualStartTime</code> 있으면 BroadcastSession 생성 + LiveChat 시작.
  176. </div>
  177. <form method="post" asp-page-handler="ManualDetect">
  178. <div class="row mb-2">
  179. <label class="col-sm-3 col-form-label">Video ID</label>
  180. <div class="col-sm-9">
  181. <input type="text" name="videoID" class="form-control" placeholder="예: fP_nhSiokTM" required />
  182. <div class="text-muted form-text">YouTube 라이브 URL의 <code>watch?v=</code> 뒤 문자열</div>
  183. </div>
  184. </div>
  185. <div class="row mb-2">
  186. <label class="col-sm-3 col-form-label">YouTube Channel ID</label>
  187. <div class="col-sm-9">
  188. <input type="text" name="youtubeChannelID" class="form-control" placeholder="예: UCRTyksdGsRT-iwE2gceyJ0A" required />
  189. <div class="text-muted form-text">UC로 시작하는 YouTube 채널 ID</div>
  190. </div>
  191. </div>
  192. <div class="row">
  193. <div class="col text-center p-3">
  194. <button type="submit" class="btn btn-warning">감지 실행</button>
  195. </div>
  196. </div>
  197. </form>
  198. </details>
  199. <hr />
  200. <!-- 라이브 강제 마킹 (quota 0) -->
  201. <details>
  202. <summary class="fs-5 mb-3">강제 방송 감지 (quota 초과 시 긴급 복구용)</summary>
  203. <div class="alert alert-danger small mb-3">
  204. <strong>quota 소진 등으로 YouTube API 호출이 불가능할 때</strong> 사용. YouTube API 호출 없이
  205. Redis 라이브 상태 + DB BroadcastSession + SignalR ReceiveChannelStatus 만 처리합니다.
  206. 제목 등 메타데이터는 검증 안 되므로 운영자 책임 하에 정확히 입력해야 합니다.
  207. <br />라이브 종료 시 <strong>강제 종료 마킹</strong>도 같이 사용해 주세요 (자동 정리 안 됨).
  208. </div>
  209. <form method="post" asp-page-handler="ForceLive" class="mb-4">
  210. <div class="row mb-2">
  211. <label class="col-sm-3 col-form-label">YouTube Channel ID <span class="text-danger">*</span></label>
  212. <div class="col-sm-9">
  213. <input type="text" name="youtubeChannelID" class="form-control" placeholder="예: UCRTyksdGsRT-iwE2gceyJ0A" required />
  214. </div>
  215. </div>
  216. <div class="row mb-2">
  217. <label class="col-sm-3 col-form-label">Video ID <span class="text-danger">*</span></label>
  218. <div class="col-sm-9">
  219. <input type="text" name="videoID" class="form-control" placeholder="예: xF2ausgpWkQ" required />
  220. <div class="text-muted form-text">YouTube 라이브 URL의 <code>watch?v=</code> 뒤 문자열</div>
  221. </div>
  222. </div>
  223. <div class="row mb-2">
  224. <label class="col-sm-3 col-form-label">제목 (선택)</label>
  225. <div class="col-sm-9">
  226. <input type="text" name="title" class="form-control" placeholder="(미입력 시 '(수동 등록 라이브)')" />
  227. </div>
  228. </div>
  229. <div class="row">
  230. <div class="col text-center p-3">
  231. <button type="submit" class="btn btn-danger">감지 실행</button>
  232. </div>
  233. </div>
  234. </form>
  235. <hr />
  236. <h6 class="mb-2">라이브 강제 종료 마킹</h6>
  237. <form method="post" asp-page-handler="ForceOffline"
  238. onsubmit="return confirm('이 채널의 라이브 상태를 강제 종료할까요? Redis ClearLive + isLive=false 브로드캐스트.');">
  239. <div class="row mb-2">
  240. <label class="col-sm-3 col-form-label">YouTube Channel ID <span class="text-danger">*</span></label>
  241. <div class="col-sm-9">
  242. <input type="text" name="youtubeChannelID" class="form-control" placeholder="예: UCRTyksdGsRT-iwE2gceyJ0A" required />
  243. </div>
  244. </div>
  245. <div class="row">
  246. <div class="col text-center p-3">
  247. <button type="submit" class="btn btn-outline-danger">라이브 강제 종료 (quota 0)</button>
  248. </div>
  249. </div>
  250. </form>
  251. </details>
  252. <hr />
  253. <!-- Callback URL -->
  254. <details open>
  255. <summary class="fs-5 mb-3">Callback URL</summary>
  256. <div class="row mb-2">
  257. <div class="col-12">
  258. <code>@Model.Data.CallbackUrl</code>
  259. <div class="text-muted form-text">
  260. YouTube PubSub 허브가 알림을 전송하는 엔드포인트. 공개 HTTPS 여야 하며 <code>appsettings</code>의 <code>YouTube:CallbackUrl</code>로 설정합니다.
  261. </div>
  262. </div>
  263. </div>
  264. </details>
  265. <hr />
  266. <!-- YouTube 연동 채널 -->
  267. <details open>
  268. <summary class="fs-5 mb-3 d-flex justify-content-between align-items-center">
  269. <span>YouTube 연동 채널</span>
  270. <span>
  271. <span class="badge bg-success">구독 @Model.Data.SubscribedCount</span>
  272. <span class="badge bg-secondary">전체 @Model.Data.TotalCount</span>
  273. </span>
  274. </summary>
  275. @if (Model.Data.TotalCount == 0)
  276. {
  277. <div class="p-4 text-center text-muted">표시할 채널이 없습니다.</div>
  278. }
  279. else
  280. {
  281. <div class="table-responsive">
  282. <table class="table table-hover mb-0 align-middle">
  283. <thead>
  284. <tr>
  285. <th style="width: 60px;">썸네일</th>
  286. <th>채널명</th>
  287. <th>YouTube Channel ID</th>
  288. <th style="width: 110px;">채널 상태</th>
  289. <th style="width: 110px;">구독</th>
  290. <th style="width: 120px;">마지막 폴링</th>
  291. <th style="width: 120px;">구독 만료</th>
  292. <th style="width: 160px;">마지막 비디오</th>
  293. <th style="width: 200px;" class="text-end text-nowrap">작업</th>
  294. </tr>
  295. </thead>
  296. <tbody>
  297. @foreach (var item in Model.Data.Items)
  298. {
  299. <tr>
  300. <td>
  301. @if (!string.IsNullOrEmpty(item.ThumbnailUrl))
  302. {
  303. <img src="@item.ThumbnailUrl" alt="" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;" />
  304. }
  305. else
  306. {
  307. <i class="bi bi-person-circle" style="font-size: 2rem; color: #ccc;"></i>
  308. }
  309. </td>
  310. <td>
  311. @if (item.HasInternalChannel)
  312. {
  313. <div>
  314. <strong>@item.ChannelName</strong>
  315. @if (!string.IsNullOrEmpty(item.Handle))
  316. {
  317. <small class="text-muted">(@@@item.Handle)</small>
  318. }
  319. </div>
  320. }
  321. else
  322. {
  323. <span class="text-muted">(내부 채널 없음 · 고아 구독)</span>
  324. }
  325. </td>
  326. <td><code class="small">@item.YouTubeChannelID</code></td>
  327. <td>
  328. @if (!item.HasInternalChannel)
  329. {
  330. <span class="badge bg-warning">고아</span>
  331. }
  332. else if (item.IsActive)
  333. {
  334. <span class="badge bg-success">활성</span>
  335. }
  336. else
  337. {
  338. <span class="badge bg-secondary">비활성</span>
  339. }
  340. </td>
  341. <td>
  342. @if (item.IsSubscribed)
  343. {
  344. <span class="badge bg-primary text-nowrap"><i class="bi bi-check-lg"></i> 구독됨</span>
  345. }
  346. else
  347. {
  348. <span class="badge bg-light text-dark border text-nowrap"><i class="bi bi-x"></i> 미구독</span>
  349. }
  350. </td>
  351. @{
  352. healthByChannelId.TryGetValue(item.YouTubeChannelID, out var h);
  353. }
  354. <td class="small">
  355. <span title="@(h?.LastPolledAtUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-") UTC">@FormatAgo(h?.LastPolledAtUtc)</span>
  356. </td>
  357. <td class="small">
  358. @{
  359. var leaseLabel = FormatUntil(h?.LeaseExpiresAtUtc);
  360. var leaseClass = h?.LeaseExpiresAtUtc is null
  361. ? "text-muted"
  362. : (h.LeaseExpiresAtUtc.Value - DateTime.UtcNow).TotalDays < 2 ? "text-danger" : "";
  363. }
  364. <span class="@leaseClass" title="@(h?.LeaseExpiresAtUtc?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-") UTC">@leaseLabel</span>
  365. </td>
  366. <td class="small">
  367. @if (!string.IsNullOrEmpty(h?.LastProcessedVideoId))
  368. {
  369. <code class="small">@h.LastProcessedVideoId</code>
  370. }
  371. else
  372. {
  373. <span class="text-muted">-</span>
  374. }
  375. </td>
  376. <td class="text-end text-nowrap">
  377. <div class="d-flex gap-1 justify-content-end flex-nowrap">
  378. @if (!item.IsSubscribed)
  379. {
  380. <form method="post" asp-page-handler="Resubscribe">
  381. <input type="hidden" name="youtubeChannelID" value="@item.YouTubeChannelID" />
  382. <button type="submit" class="btn btn-sm btn-primary text-nowrap">
  383. <i class="bi bi-plus-lg"></i> 구독
  384. </button>
  385. </form>
  386. }
  387. else
  388. {
  389. <form method="post" asp-page-handler="Resubscribe">
  390. <input type="hidden" name="youtubeChannelID" value="@item.YouTubeChannelID" />
  391. <button type="submit" class="btn btn-sm btn-outline-primary text-nowrap">
  392. <i class="bi bi-arrow-repeat"></i> 재구독
  393. </button>
  394. </form>
  395. <form method="post" asp-page-handler="Unsubscribe"
  396. onsubmit="return confirm('이 채널의 PubSub 구독을 해지할까요?');">
  397. <input type="hidden" name="youtubeChannelID" value="@item.YouTubeChannelID" />
  398. <button type="submit" class="btn btn-sm btn-outline-danger text-nowrap">
  399. <i class="bi bi-dash-lg"></i> 해지
  400. </button>
  401. </form>
  402. }
  403. </div>
  404. </td>
  405. </tr>
  406. }
  407. </tbody>
  408. </table>
  409. </div>
  410. }
  411. </details>
  412. <br />
  413. </div>