YouTubeFeedPoller.cs 1.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
  1. using System.Xml.Linq;
  2. using Application.Abstractions.YouTube;
  3. using Microsoft.Extensions.Logging;
  4. namespace Infrastructure.YouTube;
  5. /// <summary>
  6. /// YouTube 공개 Atom Feed 폴링 (할당량 0 unit).
  7. /// 비용 없는 라이브 감지 — PubSub의 라이브 푸시 누락을 보완.
  8. /// </summary>
  9. internal sealed class YouTubeFeedPoller(
  10. IHttpClientFactory httpClientFactory,
  11. ILogger<YouTubeFeedPoller> logger
  12. ) : IYouTubeFeedPoller
  13. {
  14. private const string FeedBase = "https://www.youtube.com/feeds/videos.xml?channel_id=";
  15. public async Task<string?> GetLatestVideoIdAsync(string youtubeChannelID, CancellationToken ct)
  16. {
  17. if (string.IsNullOrWhiteSpace(youtubeChannelID))
  18. {
  19. return null;
  20. }
  21. try
  22. {
  23. var client = httpClientFactory.CreateClient("YouTubeFeed");
  24. var url = FeedBase + Uri.EscapeDataString(youtubeChannelID);
  25. var response = await client.GetAsync(url, ct);
  26. if (!response.IsSuccessStatusCode)
  27. {
  28. logger.LogWarning("[FeedPoller] GET failed: channelId={ChannelId}, status={Status}",
  29. youtubeChannelID, response.StatusCode);
  30. return null;
  31. }
  32. var xml = await response.Content.ReadAsStringAsync(ct);
  33. var doc = XDocument.Parse(xml);
  34. XNamespace atom = "http://www.w3.org/2005/Atom";
  35. XNamespace yt = "http://www.youtube.com/xml/schemas/2015";
  36. var entry = doc.Descendants(atom + "entry").FirstOrDefault();
  37. if (entry is null)
  38. {
  39. return null;
  40. }
  41. return entry.Element(yt + "videoId")?.Value;
  42. }
  43. catch (OperationCanceledException)
  44. {
  45. throw;
  46. }
  47. catch (Exception ex)
  48. {
  49. logger.LogError(ex, "[FeedPoller] Unexpected error: channelId={ChannelId}", youtubeChannelID);
  50. return null;
  51. }
  52. }
  53. }