GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
YouTube.cs
1// =============================================================================
2// GPAL - Generally Positive Automation Library
3// Copyright © 2026 Software Decisions, Inc. All rights reserved.
4//
5// This file is part of GPAL.
6// Licensed under the Business Source License 1.1
7//
8// Primary development, architecture, and vision by Michael B. Vederman,
9// CEO of Software Decisions, Inc., Texas.
10//
11// Internal development maintained privately.
12// Public releases appear on GitHub: https://github.com/SoftwareDecisionsInc/GPAL.
13//
14// See LICENSE for full terms, including Additional Use Grant.
15// =============================================================================
16
17using System;
18using System.Collections.Generic;
19using System.IO;
20using System.Net;
21using System.Text;
22using System.Text.Json;
23using System.Threading;
24using System.Windows.Forms;
25using static GenerallyPositive.Enums;
27
29{
35 public readonly struct PremiereTime
36 {
37 private readonly bool _isRelative;
38 private readonly DateTime _absolute;
39 private readonly TimeSpan _offset;
40
41 private PremiereTime(TimeSpan offset) { _isRelative = true; _offset = offset; _absolute = default; }
42 private PremiereTime(DateTime absolute) { _isRelative = false; _absolute = absolute.Kind == DateTimeKind.Utc ? absolute : absolute.ToUniversalTime(); _offset = default; }
43
44 public static PremiereTime In1Minute => new PremiereTime(TimeSpan.FromMinutes(1));
45 public static PremiereTime In5Minutes => new PremiereTime(TimeSpan.FromMinutes(5));
46 public static PremiereTime In10Minutes => new PremiereTime(TimeSpan.FromMinutes(10));
47 public static PremiereTime In15Minutes => new PremiereTime(TimeSpan.FromMinutes(15));
48 public static PremiereTime In30Minutes => new PremiereTime(TimeSpan.FromMinutes(30));
49 public static PremiereTime In45Minutes => new PremiereTime(TimeSpan.FromMinutes(45));
50 public static PremiereTime In1Hour => new PremiereTime(TimeSpan.FromHours(1));
51
52 public static PremiereTime At(DateTime dt) => new PremiereTime(dt);
53
54 internal DateTime Resolve() => _isRelative ? DateTime.UtcNow.Add(_offset) : _absolute;
55
56 public static implicit operator DateTime(PremiereTime pt) => pt.Resolve();
57
58 public override string ToString() => _isRelative
59 ? $"In {(int)_offset.TotalMinutes} minutes"
60 : _absolute.ToString("o");
61 }
62
66 public class YouTubePlaylistResult
67 {
68 public string PlaylistId { get; }
69 public string Url { get; }
70 internal YouTubePlaylistResult(string playlistId)
71 {
72 PlaylistId = playlistId;
73 Url = $"https://www.youtube.com/playlist?list={playlistId}";
74 }
75 }
76
81 public class YouTubeVideoInfo
82 {
83 public string VideoId { get; }
84 public string Title { get; }
85 public string Description { get; }
86 public string[] Tags { get; }
87 public string Url { get; }
88 public DateTime? PublishedAt { get; }
89 internal YouTubeVideoInfo(string videoId, string title, string description, string[] tags, DateTime? publishedAt)
90 {
91 VideoId = videoId;
92 Title = title ?? string.Empty;
93 Description = description ?? string.Empty;
94 Tags = tags ?? new string[0];
95 Url = $"https://youtu.be/{videoId}";
96 PublishedAt = publishedAt;
97 }
98 }
99
103 public class YouTubeUploadResult
104 {
105 public string VideoId { get; }
106 public string Url { get; }
107 public string Status { get; }
108
109 internal YouTubeUploadResult(string videoId, string url, string status)
110 {
111 VideoId = videoId;
112 Url = url;
113 Status = status;
114 }
115 }
116
123 public class GPALYouTube : IGPALYouTube
124 {
125 private ICredentials _credentials;
126 private string _title = string.Empty;
127 private string _description = string.Empty;
128 private string[] _tags = new string[0];
129 private YouTubePrivacy _privacy = YouTubePrivacy.Private;
130 private YouTubeCategory _category = YouTubeCategory.ScienceTechnology;
131 private List<string> _videoIds = new List<string>();
132 private int _pollIntervalSecs = 60;
133 private TextBoxBase _outputControl;
134 private ManualResetEventSlim _completionEvent = new ManualResetEventSlim(false);
135 private string _playlistId = null;
136 private List<string> _lookupVideoIds = new List<string>();
137 private List<string> _searchTerms = new List<string>();
138
139 private const string InitiationUrl = "https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status";
140 private const string VideoStatusUrl = "https://www.googleapis.com/youtube/v3/videos?part=status&id=";
141 private const string VideoUpdateUrl = "https://www.googleapis.com/youtube/v3/videos?part=status";
142 private const string PlaylistCreateUrl = "https://www.googleapis.com/youtube/v3/playlists?part=snippet,status";
143 private const string PlaylistItemUrl = "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet";
144 private const string VideoInfoBaseUrl = "https://www.googleapis.com/youtube/v3/videos?part=snippet&id=";
145 private const string SearchBaseUrl = "https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&forMine=true&maxResults=50&q=";
146
147 internal GPALYouTube() { }
148
149 // ── Upload chain ──────────────────────────────────────────────────────
150
151 public IGPALYouTube WithCredentials(ICredentials credentials)
152 {
153 if (credentials == null)
154 {
155 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Credentials is null.", this, GPALObjectType.YouTube);
156 return this;
157 }
158 _credentials = credentials;
159 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Credentials configured.", this, GPALObjectType.YouTube);
160 return this;
161 }
162
163 public IGPALYouTube WithTitle(string title)
164 {
165 _title = title ?? string.Empty;
166 return this;
167 }
168
169 public IGPALYouTube WithDescription(string description)
170 {
171 _description = description ?? string.Empty;
172 return this;
173 }
174
175 public IGPALYouTube WithTags(params string[] tags)
176 {
177 _tags = tags ?? new string[0];
178 return this;
179 }
180
181 public IGPALYouTube WithPrivacy(YouTubePrivacy privacy)
182 {
183 _privacy = privacy;
184 return this;
185 }
186
187 public IGPALYouTube WithCategory(YouTubeCategory category)
188 {
189 _category = category;
190 return this;
191 }
192
193 public YouTubeUploadResult Upload(GPALFile file)
194 {
195 if (_credentials == null)
196 {
197 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Credentials must be set before Upload.", this, GPALObjectType.YouTube);
198 return null;
199 }
200
201 string filePath = file?.Filename;
202 if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
203 {
204 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"File not found: [{filePath}]", this, GPALObjectType.YouTube);
205 return null;
206 }
207
208 try
209 {
210 _credentials.FetchAccessToken(out string token);
211 if (string.IsNullOrEmpty(token))
212 {
213 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Failed to fetch access token.", this, GPALObjectType.YouTube);
214 return null;
215 }
216
217 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Uploading: [{Path.GetFileName(filePath)}]", this, GPALObjectType.YouTube);
218
219 string uploadUri = InitiateResumableUpload(token, filePath);
220 if (string.IsNullOrEmpty(uploadUri)) return null;
221
222 string videoId = UploadVideoBytes(uploadUri, filePath);
223 if (string.IsNullOrEmpty(videoId)) return null;
224
225 string url = $"https://youtu.be/{videoId}";
226 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Upload complete: [{url}]", this, GPALObjectType.YouTube);
227 if (!string.IsNullOrEmpty(_playlistId))
228 AddVideoToPlaylist(token, _playlistId, videoId);
229 return new YouTubeUploadResult(videoId, url, "uploaded");
230 }
231 catch (Exception ex)
232 {
233 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, "YouTube upload failed.", this, GPALObjectType.YouTube, ex);
234 return null;
235 }
236 }
237
238 // ── Schedule chain ────────────────────────────────────────────────────
239
240 public IGPALYouTube WithVideo(YouTubeUploadResult result)
241 {
242 if (result == null || string.IsNullOrEmpty(result.VideoId))
243 {
244 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "WithVideo: null or missing VideoId.", this, GPALObjectType.YouTube);
245 return this;
246 }
247 _videoIds.Add(result.VideoId);
248 return this;
249 }
250
251 public IGPALYouTube WithVideos(IEnumerable<YouTubeUploadResult> results)
252 {
253 if (results == null)
254 {
255 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "WithVideos: null results list.", this, GPALObjectType.YouTube);
256 return this;
257 }
258 foreach (var r in results)
259 if (r != null && !string.IsNullOrEmpty(r.VideoId))
260 _videoIds.Add(r.VideoId);
261 return this;
262 }
263
264 public IGPALYouTube WaitForProcessing(int pollIntervalSeconds = 60)
265 {
266 _pollIntervalSecs = Math.Max(10, pollIntervalSeconds);
267 return this;
268 }
269
277 {
278 if (_credentials == null)
279 {
280 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "ScheduleAt: credentials not set.", this, GPALObjectType.YouTube);
281 return this;
282 }
283 if (_videoIds.Count == 0)
284 {
285 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "ScheduleAt: no videos targeted. Call WithVideo or WithVideos first.", this, GPALObjectType.YouTube);
286 return this;
287 }
288
289 // Capture everything the background thread needs; don't close over mutable fields
290 var videoIds = new List<string>(_videoIds);
291 var creds = _credentials;
292 var intervalMs = _pollIntervalSecs * 1000;
293
294 var thread = new Thread(() => PollAndSchedule(creds, videoIds, premiereTime, intervalMs));
295 thread.IsBackground = true;
296 thread.Start();
297
298 GPAL.PublishSimpleEvent(GPALEventType.INFO,
299 $"ScheduleAt started in background for [{videoIds.Count}] video(s). Poll interval: [{_pollIntervalSecs}]s.",
300 this, GPALObjectType.YouTube);
301
302 return this;
303 }
304
305 // ── Output and completion ─────────────────────────────────────────────
306
312 public IGPALYouTube WithOutputTo(TextBoxBase control)
313 {
314 _outputControl = control;
315 return this;
316 }
317
323 public void WaitForCompletion()
324 {
325 _completionEvent.Wait();
326 }
327
328 private void AppendToOutput(string message)
329 {
330 if (_outputControl == null) return;
331 if (_outputControl.InvokeRequired)
332 _outputControl.BeginInvoke(new Action(() => AppendToOutput(message)));
333 else
334 _outputControl.AppendText(message + Environment.NewLine);
335 }
336
337 // ── Background polling ────────────────────────────────────────────────
338
339 private void PollAndSchedule(ICredentials creds, List<string> videoIds, PremiereTime premiereTime, int intervalMs)
340 {
341 var pending = new HashSet<string>(videoIds);
342 var lastStatus = new Dictionary<string, string>();
343
344 string openingMsg = $"Suppressing repeat status messages for: {string.Join(", ", videoIds)}. Will report when processing completes for each.";
345 GPAL.PublishSimpleEvent(GPALEventType.INFO, openingMsg, this, GPALObjectType.YouTube);
346 AppendToOutput(openingMsg);
347
348 while (pending.Count > 0)
349 {
350 creds.FetchAccessToken(out string token);
351
352 var justProcessed = new List<string>();
353
354 foreach (string id in new List<string>(pending))
355 {
356 string status = GetUploadStatus(token, id);
357
358 string prev;
359 if (!lastStatus.TryGetValue(id, out prev) || prev != status)
360 {
361 string msg = $"Video {id}: {status}";
362 GPAL.PublishSimpleEvent(GPALEventType.INFO, msg, this, GPALObjectType.YouTube);
363 AppendToOutput(msg);
364 lastStatus[id] = status;
365 }
366
367 if (status == "processed")
368 {
369 justProcessed.Add(id);
370 }
371 else if (status == "failed" || status == "rejected" || status == "deleted")
372 {
373 string errMsg = $"Video {id} cannot be scheduled: {status}.";
374 GPAL.PublishSimpleEvent(GPALEventType.ERROR, errMsg, this, GPALObjectType.YouTube);
375 AppendToOutput(errMsg);
376 justProcessed.Add(id);
377 }
378 }
379
380 foreach (string id in justProcessed)
381 {
382 pending.Remove(id);
383 string finalStatus;
384 if (lastStatus.TryGetValue(id, out finalStatus) && finalStatus == "processed")
385 {
386 DateTime pt = premiereTime.Resolve();
387 SetPremiere(token, id, pt);
388 }
389 }
390
391 if (pending.Count > 0)
392 Thread.Sleep(intervalMs);
393 }
394
395 string doneMsg = "All videos scheduled.";
396 GPAL.PublishSimpleEvent(GPALEventType.INFO, doneMsg, this, GPALObjectType.YouTube);
397 AppendToOutput(doneMsg);
398 _completionEvent.Set();
399 }
400
401 private void SetPremiere(string token, string videoId, DateTime premiereUtc)
402 {
403 try
404 {
405 string body = "{\"id\":" + JsonString(videoId)
406 + ",\"status\":{\"privacyStatus\":\"private\",\"publishAt\":" + JsonString(premiereUtc.ToString("o")) + "}}";
407 byte[] bodyBytes = Encoding.UTF8.GetBytes(body);
408
409 var req = (HttpWebRequest)WebRequest.Create(VideoUpdateUrl);
410 req.Method = "PUT";
411 req.Headers["Authorization"] = "Bearer " + token;
412 req.ContentType = "application/json";
413 req.ContentLength = bodyBytes.Length;
414
415 using (var s = req.GetRequestStream())
416 s.Write(bodyBytes, 0, bodyBytes.Length);
417
418 using ((HttpWebResponse)req.GetResponse()) { }
419
420 string schedMsg = $"Premiere scheduled: https://youtu.be/{videoId} at {premiereUtc:o}";
421 GPAL.PublishSimpleEvent(GPALEventType.INFO, schedMsg, this, GPALObjectType.YouTube);
422 AppendToOutput(schedMsg);
423 }
424 catch (Exception ex)
425 {
426 string errMsg = $"SetPremiere failed for {videoId}.";
427 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, errMsg, this, GPALObjectType.YouTube, ex);
428 AppendToOutput(errMsg);
429 }
430 }
431
432 private string GetUploadStatus(string token, string videoId)
433 {
434 try
435 {
436 var req = (HttpWebRequest)WebRequest.Create(VideoStatusUrl + videoId);
437 req.Method = "GET";
438 req.Headers["Authorization"] = "Bearer " + token;
439
440 using (var resp = (HttpWebResponse)req.GetResponse())
441 using (var reader = new StreamReader(resp.GetResponseStream()))
442 {
443 string responseBody = reader.ReadToEnd();
444 using (var doc = JsonDocument.Parse(responseBody))
445 {
446 if (doc.RootElement.TryGetProperty("items", out var items)
447 && items.GetArrayLength() > 0
448 && items[0].TryGetProperty("status", out var status)
449 && status.TryGetProperty("uploadStatus", out var uploadStatus))
450 {
451 return uploadStatus.GetString();
452 }
453 }
454 }
455 }
456 catch (Exception ex)
457 {
458 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"GetUploadStatus failed for [{videoId}].", this, GPALObjectType.YouTube, ex);
459 }
460 return "unknown";
461 }
462
463 // ── Upload internals ──────────────────────────────────────────────────
464
465 private string InitiateResumableUpload(string token, string filePath)
466 {
467 long fileSize = new FileInfo(filePath).Length;
468 byte[] body = Encoding.UTF8.GetBytes(BuildMetadataJson());
469
470 var req = (HttpWebRequest)WebRequest.Create(InitiationUrl);
471 req.Method = "POST";
472 req.Headers["Authorization"] = "Bearer " + token;
473 req.ContentType = "application/json; charset=UTF-8";
474 req.Headers["X-Upload-Content-Type"] = "video/mp4";
475 req.Headers["X-Upload-Content-Length"] = fileSize.ToString();
476 req.ContentLength = body.Length;
477
478 using (var s = req.GetRequestStream())
479 s.Write(body, 0, body.Length);
480
481 try
482 {
483 using (var resp = (HttpWebResponse)req.GetResponse())
484 {
485 string uri = resp.Headers["Location"];
486 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Resumable upload session started.", this, GPALObjectType.YouTube);
487 return uri;
488 }
489 }
490 catch (WebException wex) when (wex.Response is HttpWebResponse errResp)
491 {
492 using (var reader = new System.IO.StreamReader(errResp.GetResponseStream()))
493 {
494 string detail = reader.ReadToEnd();
495 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Upload initiation failed ([{(int)errResp.StatusCode}]): [{detail}]", this, GPALObjectType.YouTube);
496 }
497 throw;
498 }
499 }
500
501 private string UploadVideoBytes(string uploadUri, string filePath)
502 {
503 long fileSize = new FileInfo(filePath).Length;
504
505 var req = (HttpWebRequest)WebRequest.Create(uploadUri);
506 req.Method = "PUT";
507 req.ContentType = "video/mp4";
508 req.ContentLength = fileSize;
509 req.Timeout = 3_600_000;
510 req.AllowWriteStreamBuffering = false;
511
512 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Sending [{fileSize:N0}] bytes...", this, GPALObjectType.YouTube);
513
514 using (var reqStream = req.GetRequestStream())
515 using (var fs = File.OpenRead(filePath))
516 {
517 byte[] buf = new byte[256 * 1024];
518 int read;
519 while ((read = fs.Read(buf, 0, buf.Length)) > 0)
520 reqStream.Write(buf, 0, read);
521 }
522
523 using (var resp = (HttpWebResponse)req.GetResponse())
524 using (var reader = new StreamReader(resp.GetResponseStream()))
525 {
526 string responseBody = reader.ReadToEnd();
527 using (var doc = JsonDocument.Parse(responseBody))
528 {
529 if (doc.RootElement.TryGetProperty("id", out var idEl))
530 return idEl.GetString();
531 }
532 }
533
534 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Upload response missing video ID.", this, GPALObjectType.YouTube);
535 return null;
536 }
537
538 private string PrivacyString()
539 {
540 switch (_privacy)
541 {
542 case YouTubePrivacy.Public: return "public";
543 case YouTubePrivacy.Unlisted: return "unlisted";
544 default: return "private";
545 }
546 }
547
548 private string BuildMetadataJson()
549 {
550 string privacyStr = PrivacyString();
551
552 var tags = new StringBuilder("[");
553 for (int i = 0; i < _tags.Length; i++)
554 {
555 if (i > 0) tags.Append(",");
556 tags.Append(JsonString(_tags[i]));
557 }
558 tags.Append("]");
559
560 return "{\"snippet\":{\"title\":" + JsonString(_title)
561 + ",\"description\":" + JsonString(_description)
562 + ",\"tags\":" + tags
563 + ",\"categoryId\":" + JsonString(((int)_category).ToString())
564 + "},\"status\":{\"privacyStatus\":" + JsonString(privacyStr) + "}}";
565 }
566
567 private static string JsonString(string s)
568 {
569 if (s == null) return "\"\"";
570 return "\"" + s.Replace("\\", "\\\\")
571 .Replace("\"", "\\\"")
572 .Replace("\n", "\\n")
573 .Replace("\r", "\\r")
574 .Replace("\t", "\\t")
575 + "\"";
576 }
577
578 // ── Playlist ──────────────────────────────────────────────────────────
579
580 public IGPALYouTube WithPlaylist(string playlistId)
581 {
582 _playlistId = playlistId;
583 return this;
584 }
585
586 public YouTubePlaylistResult CreatePlaylist()
587 {
588 if (_credentials == null)
589 {
590 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "CreatePlaylist: credentials not set.", this, GPALObjectType.YouTube);
591 return null;
592 }
593 try
594 {
595 _credentials.FetchAccessToken(out string token);
596 if (string.IsNullOrEmpty(token))
597 {
598 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "CreatePlaylist: failed to fetch access token.", this, GPALObjectType.YouTube);
599 return null;
600 }
601
602 string body = "{\"snippet\":{\"title\":" + JsonString(_title)
603 + ",\"description\":" + JsonString(_description)
604 + "},\"status\":{\"privacyStatus\":" + JsonString(PrivacyString()) + "}}";
605 byte[] bodyBytes = Encoding.UTF8.GetBytes(body);
606
607 var req = (HttpWebRequest)WebRequest.Create(PlaylistCreateUrl);
608 req.Method = "POST";
609 req.Headers["Authorization"] = "Bearer " + token;
610 req.ContentType = "application/json";
611 req.ContentLength = bodyBytes.Length;
612
613 using (var s = req.GetRequestStream())
614 s.Write(bodyBytes, 0, bodyBytes.Length);
615
616 using (var resp = (HttpWebResponse)req.GetResponse())
617 using (var reader = new StreamReader(resp.GetResponseStream()))
618 {
619 string responseBody = reader.ReadToEnd();
620 using (var doc = JsonDocument.Parse(responseBody))
621 {
622 if (doc.RootElement.TryGetProperty("id", out var idEl))
623 {
624 string playlistId = idEl.GetString();
625 GPAL.PublishSimpleEvent(GPALEventType.INFO,
626 $"Playlist created: https://www.youtube.com/playlist?list=[{playlistId}]", this, GPALObjectType.YouTube);
627 return new YouTubePlaylistResult(playlistId);
628 }
629 }
630 }
631 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "CreatePlaylist: response missing playlist ID.", this, GPALObjectType.YouTube);
632 return null;
633 }
634 catch (Exception ex)
635 {
636 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, "CreatePlaylist failed.", this, GPALObjectType.YouTube, ex);
637 return null;
638 }
639 }
640
641 public IGPALYouTube AddToPlaylist(string playlistId)
642 {
643 if (_credentials == null)
644 {
645 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "AddToPlaylist: credentials not set.", this, GPALObjectType.YouTube);
646 return this;
647 }
648 if (_videoIds.Count == 0)
649 {
650 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "AddToPlaylist: no videos. Call WithVideo or WithVideos first.", this, GPALObjectType.YouTube);
651 return this;
652 }
653 _credentials.FetchAccessToken(out string token);
654 foreach (string videoId in _videoIds)
655 AddVideoToPlaylist(token, playlistId, videoId);
656 return this;
657 }
658
659 private void AddVideoToPlaylist(string token, string playlistId, string videoId)
660 {
661 try
662 {
663 string body = "{\"snippet\":{\"playlistId\":" + JsonString(playlistId)
664 + ",\"resourceId\":{\"kind\":\"youtube#video\",\"videoId\":" + JsonString(videoId) + "}}}";
665 byte[] bodyBytes = Encoding.UTF8.GetBytes(body);
666
667 var req = (HttpWebRequest)WebRequest.Create(PlaylistItemUrl);
668 req.Method = "POST";
669 req.Headers["Authorization"] = "Bearer " + token;
670 req.ContentType = "application/json";
671 req.ContentLength = bodyBytes.Length;
672
673 using (var s = req.GetRequestStream())
674 s.Write(bodyBytes, 0, bodyBytes.Length);
675
676 using ((HttpWebResponse)req.GetResponse()) { }
677
678 GPAL.PublishSimpleEvent(GPALEventType.INFO,
679 $"Added [{videoId}] to playlist [{playlistId}].", this, GPALObjectType.YouTube);
680 }
681 catch (Exception ex)
682 {
683 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
684 $"AddVideoToPlaylist failed for [{videoId}].", this, GPALObjectType.YouTube, ex);
685 }
686 }
687
688 // ── Lookup / Search ───────────────────────────────────────────────────
689
690 public IGPALYouTube WithVideoId(string videoId)
691 {
692 if (!string.IsNullOrEmpty(videoId))
693 _lookupVideoIds.Add(videoId);
694 return this;
695 }
696
697 public IGPALYouTube WithSearchTerms(string query)
698 {
699 if (!string.IsNullOrEmpty(query))
700 _searchTerms.Add(query);
701 return this;
702 }
703
704 public IGPALYouTube WithSearchTerms(IGPALGrid<string> grid)
705 {
706 if (grid == null) return this;
707 foreach (var row in grid)
708 if (row != null && row.Count > 0 && !string.IsNullOrEmpty(row[0]))
709 _searchTerms.Add(row[0]);
710 return this;
711 }
712
713 public IGPALYouTube GetResults(out YouTubeVideoInfo info)
714 {
715 info = null;
716 if (_credentials == null)
717 {
718 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "GetResults: credentials not set.", this, GPALObjectType.YouTube);
719 return this;
720 }
721 if (_lookupVideoIds.Count == 0)
722 {
723 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "GetResults: no video ID. Call WithVideoId first.", this, GPALObjectType.YouTube);
724 return this;
725 }
726 _credentials.FetchAccessToken(out string token);
727 var list = FetchVideoInfos(token, _lookupVideoIds);
728 info = list.Count > 0 ? list[0] : null;
729 return this;
730 }
731
732 public IGPALYouTube GetResults(out List<YouTubeVideoInfo> results)
733 {
734 results = new List<YouTubeVideoInfo>();
735 if (_credentials == null)
736 {
737 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "GetResults: credentials not set.", this, GPALObjectType.YouTube);
738 return this;
739 }
740 _credentials.FetchAccessToken(out string token);
741
742 if (_lookupVideoIds.Count > 0)
743 results.AddRange(FetchVideoInfos(token, _lookupVideoIds));
744
745 if (_searchTerms.Count > 0)
746 {
747 var seen = new HashSet<string>();
748 foreach (var v in results) seen.Add(v.VideoId);
749 foreach (string term in _searchTerms)
750 {
751 foreach (var v in SearchVideos(token, term))
752 if (seen.Add(v.VideoId))
753 results.Add(v);
754 }
755 }
756 return this;
757 }
758
759 private List<YouTubeVideoInfo> FetchVideoInfos(string token, List<string> videoIds)
760 {
761 var result = new List<YouTubeVideoInfo>();
762 int i = 0;
763 while (i < videoIds.Count)
764 {
765 var batch = new StringBuilder();
766 int end = Math.Min(i + 50, videoIds.Count);
767 for (int j = i; j < end; j++)
768 {
769 if (j > i) batch.Append(",");
770 batch.Append(Uri.EscapeDataString(videoIds[j]));
771 }
772 i = end;
773
774 try
775 {
776 var req = (HttpWebRequest)WebRequest.Create(VideoInfoBaseUrl + batch);
777 req.Method = "GET";
778 req.Headers["Authorization"] = "Bearer " + token;
779
780 using (var resp = (HttpWebResponse)req.GetResponse())
781 using (var reader = new StreamReader(resp.GetResponseStream()))
782 {
783 string responseBody = reader.ReadToEnd();
784 using (var doc = JsonDocument.Parse(responseBody))
785 {
786 if (!doc.RootElement.TryGetProperty("items", out var items)) continue;
787 foreach (var item in items.EnumerateArray())
788 {
789 string videoId = item.TryGetProperty("id", out var idEl) ? idEl.GetString() : null;
790 if (string.IsNullOrEmpty(videoId)) continue;
791
792 string title = string.Empty, description = string.Empty;
793 string[] tags = new string[0];
794 DateTime? publishedAt = null;
795
796 if (item.TryGetProperty("snippet", out var snippet))
797 {
798 if (snippet.TryGetProperty("title", out var t)) title = t.GetString();
799 if (snippet.TryGetProperty("description", out var d)) description = d.GetString();
800 if (snippet.TryGetProperty("publishedAt", out var p)
801 && DateTime.TryParse(p.GetString(), out DateTime dt))
802 publishedAt = dt;
803 if (snippet.TryGetProperty("tags", out var tagsEl))
804 {
805 var tagList = new List<string>();
806 foreach (var tag in tagsEl.EnumerateArray())
807 tagList.Add(tag.GetString());
808 tags = tagList.ToArray();
809 }
810 }
811 result.Add(new YouTubeVideoInfo(videoId, title, description, tags, publishedAt));
812 }
813 }
814 }
815 }
816 catch (Exception ex)
817 {
818 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, "FetchVideoInfos failed.", this, GPALObjectType.YouTube, ex);
819 }
820 }
821 return result;
822 }
823
824 private List<YouTubeVideoInfo> SearchVideos(string token, string query)
825 {
826 var result = new List<YouTubeVideoInfo>();
827 try
828 {
829 var req = (HttpWebRequest)WebRequest.Create(SearchBaseUrl + Uri.EscapeDataString(query));
830 req.Method = "GET";
831 req.Headers["Authorization"] = "Bearer " + token;
832
833 using (var resp = (HttpWebResponse)req.GetResponse())
834 using (var reader = new StreamReader(resp.GetResponseStream()))
835 {
836 string responseBody = reader.ReadToEnd();
837 using (var doc = JsonDocument.Parse(responseBody))
838 {
839 if (!doc.RootElement.TryGetProperty("items", out var items)) return result;
840 foreach (var item in items.EnumerateArray())
841 {
842 string videoId = null;
843 if (item.TryGetProperty("id", out var idObj)
844 && idObj.TryGetProperty("videoId", out var vidEl))
845 videoId = vidEl.GetString();
846 if (string.IsNullOrEmpty(videoId)) continue;
847
848 string title = string.Empty, description = string.Empty;
849 DateTime? publishedAt = null;
850
851 if (item.TryGetProperty("snippet", out var snippet))
852 {
853 if (snippet.TryGetProperty("title", out var t)) title = t.GetString();
854 if (snippet.TryGetProperty("description", out var d)) description = d.GetString();
855 if (snippet.TryGetProperty("publishedAt", out var p)
856 && DateTime.TryParse(p.GetString(), out DateTime dt))
857 publishedAt = dt;
858 }
859 result.Add(new YouTubeVideoInfo(videoId, title, description, null, publishedAt));
860 }
861 }
862 }
863 }
864 catch (Exception ex)
865 {
866 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"SearchVideos failed for [{query}].", this, GPALObjectType.YouTube, ex);
867 }
868 return result;
869 }
870 }
871}
IGPALYouTube WithOutputTo(TextBoxBase control)
Pipes scheduling status to a TextBox or RichTextBox on a WinForms form. The form's message loop keeps...
Definition YouTube.cs:312
void WaitForCompletion()
Blocks the calling thread until ScheduleAt finishes scheduling all videos. Use this in console apps o...
Definition YouTube.cs:323
IGPALYouTube ScheduleAt(PremiereTime premiereTime)
Starts a background thread that polls each video's processing status and schedules a premiere as each...
Definition YouTube.cs:276
Result returned by GPALYouTube.Upload containing the assigned video ID, public URL,...
Definition YouTube.cs:104
GPAL File object instantied with GPAL.File Used to load tokens into a GPALGrid [rows/columns].
Definition GPALFile.cs:36
string Filename
We have only one file, accessing it.
Definition GPALFile.cs:474
Everything starts here, all the GPAL controls and global settings using fluent syntax are here....
Definition GPAL.cs:49
static void PublishSimpleEvent(GPALEventType gPALEventType, string msg, dynamic gPALObject=null, Enums.GPALObjectType gPALObjectType=GPALObjectType.None, Exception ex=null)
Publish a message to either the information channel or exception channel (if exception passed in) Pub...
Definition GPAL.cs:2406
Represents a premiere schedule time. Presets (In5Minutes, In1Hour, etc.) store a relative offset and ...
Definition YouTube.cs:36