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.Text.RegularExpressions;
24using System.Threading;
25using System.Windows.Forms;
26using static GenerallyPositive.Enums;
28
30{
36 public readonly struct PremiereTime
37 {
38 private readonly bool _isRelative;
39 private readonly DateTime _absolute;
40 private readonly TimeSpan _offset;
41
42 private PremiereTime(TimeSpan offset) { _isRelative = true; _offset = offset; _absolute = default; }
43 private PremiereTime(DateTime absolute) { _isRelative = false; _absolute = absolute.Kind == DateTimeKind.Utc ? absolute : absolute.ToUniversalTime(); _offset = default; }
44
45 public static PremiereTime In1Minute => new PremiereTime(TimeSpan.FromMinutes(1));
46 public static PremiereTime In5Minutes => new PremiereTime(TimeSpan.FromMinutes(5));
47 public static PremiereTime In10Minutes => new PremiereTime(TimeSpan.FromMinutes(10));
48 public static PremiereTime In15Minutes => new PremiereTime(TimeSpan.FromMinutes(15));
49 public static PremiereTime In30Minutes => new PremiereTime(TimeSpan.FromMinutes(30));
50 public static PremiereTime In45Minutes => new PremiereTime(TimeSpan.FromMinutes(45));
51 public static PremiereTime In1Hour => new PremiereTime(TimeSpan.FromHours(1));
52
53 public static PremiereTime At(DateTime dt) => new PremiereTime(dt);
54
55 internal DateTime Resolve() => _isRelative ? DateTime.UtcNow.Add(_offset) : _absolute;
56
57 public static implicit operator DateTime(PremiereTime pt) => pt.Resolve();
58
59 public override string ToString() => _isRelative
60 ? $"In {(int)_offset.TotalMinutes} minutes"
61 : _absolute.ToString("o");
62 }
63
67 public class YouTubePlaylistResult
68 {
69 public string PlaylistId { get; }
70 public string Url { get; }
71 internal YouTubePlaylistResult(string playlistId)
72 {
73 PlaylistId = playlistId;
74 Url = $"https://www.youtube.com/playlist?list={playlistId}";
75 }
76 }
77
83 public class YouTubeVideoInfo
84 {
85 public string VideoId { get; }
86 public string Title { get; }
87 public string Description { get; }
88 public string[] Tags { get; }
89 public string Url { get; }
90 public DateTime? PublishedAt { get; }
94 public YouTubePrivacy? Privacy { get; internal set; }
98 public string CategoryId { get; internal set; }
102 public string ThumbnailUrl { get; internal set; }
103 internal YouTubeVideoInfo(string videoId, string title, string description, string[] tags, DateTime? publishedAt)
104 {
105 VideoId = videoId;
106 Title = title ?? string.Empty;
107 Description = description ?? string.Empty;
108 Tags = tags ?? new string[0];
109 Url = $"https://youtu.be/{videoId}";
110 PublishedAt = publishedAt;
111 }
112 }
113
117 public class YouTubePlaylistInfo
118 {
119 public string PlaylistId { get; }
120 public string Title { get; }
121 public string Description { get; }
122 public YouTubePrivacy? Privacy { get; }
123 public int ItemCount { get; }
124 public string Url { get; }
125 internal YouTubePlaylistInfo(string playlistId, string title, string description, YouTubePrivacy? privacy, int itemCount)
126 {
127 PlaylistId = playlistId;
128 Title = title ?? string.Empty;
129 Description = description ?? string.Empty;
130 Privacy = privacy;
131 ItemCount = itemCount;
132 Url = $"https://www.youtube.com/playlist?list={playlistId}";
133 }
134 }
135
140 public class YouTubePlaylistItem
141 {
142 public string PlaylistItemId { get; }
143 public string PlaylistId { get; }
144 public string VideoId { get; }
145 public string Title { get; }
146 public int Position { get; }
147 internal YouTubePlaylistItem(string playlistItemId, string playlistId, string videoId, string title, int position)
148 {
149 PlaylistItemId = playlistItemId;
150 PlaylistId = playlistId;
151 VideoId = videoId;
152 Title = title ?? string.Empty;
153 Position = position;
154 }
155 }
156
160 public class YouTubeUploadResult
161 {
162 public string VideoId { get; }
163 public string Url { get; }
164 public string Status { get; }
165
166 internal YouTubeUploadResult(string videoId, string url, string status)
167 {
168 VideoId = videoId;
169 Url = url;
170 Status = status;
171 }
172 }
173
180 public class GPALYouTube : IGPALYouTube
181 {
182 private ICredentials _credentials;
183 private string _title = string.Empty;
184 private string _description = string.Empty;
185 private string[] _tags = new string[0];
186 private YouTubePrivacy _privacy = YouTubePrivacy.Private;
187 private YouTubeCategory _category = YouTubeCategory.ScienceTechnology;
188 private List<string> _videoIds = new List<string>();
189 private int _pollIntervalSecs = 60;
190 private TextBoxBase _outputControl;
191 private ManualResetEventSlim _completionEvent = new ManualResetEventSlim(false);
192 private string _playlistId = null;
193 private List<string> _lookupVideoIds = new List<string>();
194 private List<string> _searchTerms = new List<string>();
195 private int _position = -1;
196
197 // which of the metadata settings the workflow gave, so UpdateVideo changes only those and keeps the rest
198 private bool _titleGiven;
199 private bool _descriptionGiven;
200 private bool _tagsGiven;
201 private bool _privacyGiven;
202 private bool _categoryGiven;
203
204 private const string InitiationUrl = "https://www.googleapis.com/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status";
205 private const string VideoStatusUrl = "https://www.googleapis.com/youtube/v3/videos?part=status&id=";
206 private const string VideoUpdateUrl = "https://www.googleapis.com/youtube/v3/videos?part=status";
207 private const string PlaylistCreateUrl = "https://www.googleapis.com/youtube/v3/playlists?part=snippet,status";
208 private const string PlaylistItemUrl = "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet";
209 private const string VideoInfoBaseUrl = "https://www.googleapis.com/youtube/v3/videos?part=snippet,status&id=";
210 private const string SearchBaseUrl = "https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&forMine=true&maxResults=50&q=";
211 private const string MyChannelUrl = "https://www.googleapis.com/youtube/v3/channels?part=contentDetails&mine=true";
212 private const string PlaylistItemsListUrl = "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&maxResults=50&playlistId=";
213 private const string PlaylistItemDeleteUrl = "https://www.googleapis.com/youtube/v3/playlistItems?id=";
214 private const string MyPlaylistsUrl = "https://www.googleapis.com/youtube/v3/playlists?part=snippet,status,contentDetails&mine=true&maxResults=50";
215 private const string VideoDeleteUrl = "https://www.googleapis.com/youtube/v3/videos?id=";
216 private const string ThumbnailSetUrl = "https://www.googleapis.com/upload/youtube/v3/thumbnails/set?uploadType=media&videoId=";
217
218 // how many status checks in a row may fail to answer before ReplaceVideo stops waiting for processing
219 private const int MaxUnansweredStatusChecks = 5;
220
221 internal GPALYouTube() { }
222
223 // ── Upload chain ──────────────────────────────────────────────────────
224
225 public IGPALYouTube WithCredentials(ICredentials credentials)
226 {
227 if (credentials == null)
228 {
229 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Credentials is null.", this, GPALObjectType.YouTube);
230 return this;
231 }
232 _credentials = credentials;
233 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Credentials configured.", this, GPALObjectType.YouTube);
234 return this;
235 }
236
237 public IGPALYouTube WithTitle(string title)
238 {
239 _title = title ?? string.Empty;
240 _titleGiven = true;
241 return this;
242 }
243
244 public IGPALYouTube WithDescription(string description)
245 {
246 _description = description ?? string.Empty;
247 _descriptionGiven = true;
248 return this;
249 }
250
251 public IGPALYouTube WithTags(params string[] tags)
252 {
253 _tags = tags ?? new string[0];
254 _tagsGiven = true;
255 return this;
256 }
257
258 public IGPALYouTube WithPrivacy(YouTubePrivacy privacy)
259 {
260 _privacy = privacy;
261 _privacyGiven = true;
262 return this;
263 }
264
265 public IGPALYouTube WithCategory(YouTubeCategory category)
266 {
267 _category = category;
268 _categoryGiven = true;
269 return this;
270 }
271
272 public YouTubeUploadResult Upload(GPALFile file)
273 {
274 if (_credentials == null)
275 {
276 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Credentials must be set before Upload.", this, GPALObjectType.YouTube);
277 return null;
278 }
279
280 string filePath = file?.Filename;
281 if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
282 {
283 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"File not found: [{filePath}]", this, GPALObjectType.YouTube);
284 return null;
285 }
286
287 try
288 {
289 _credentials.FetchAccessToken(out string token);
290 if (string.IsNullOrEmpty(token))
291 {
292 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Failed to fetch access token.", this, GPALObjectType.YouTube);
293 return null;
294 }
295
296 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Uploading: [{Path.GetFileName(filePath)}]", this, GPALObjectType.YouTube);
297
298 string uploadUri = InitiateResumableUpload(token, filePath);
299 if (string.IsNullOrEmpty(uploadUri)) return null;
300
301 string videoId = UploadVideoBytes(uploadUri, filePath);
302 if (string.IsNullOrEmpty(videoId)) return null;
303
304 string url = $"https://youtu.be/{videoId}";
305 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Upload complete: [{url}]", this, GPALObjectType.YouTube);
306 if (!string.IsNullOrEmpty(_playlistId))
307 AddVideoToPlaylist(token, _playlistId, videoId, -1, GPALEventType.ERROR);
308 return new YouTubeUploadResult(videoId, url, "uploaded");
309 }
310 catch (Exception ex)
311 {
312 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, "YouTube upload failed.", this, GPALObjectType.YouTube, ex);
313 return null;
314 }
315 }
316
317 // ── Schedule chain ────────────────────────────────────────────────────
318
319 public IGPALYouTube WithVideo(YouTubeUploadResult result)
320 {
321 if (result == null || string.IsNullOrEmpty(result.VideoId))
322 {
323 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "WithVideo: null or missing VideoId.", this, GPALObjectType.YouTube);
324 return this;
325 }
326 _videoIds.Add(result.VideoId);
327 return this;
328 }
329
330 public IGPALYouTube WithVideos(IEnumerable<YouTubeUploadResult> results)
331 {
332 if (results == null)
333 {
334 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "WithVideos: null results list.", this, GPALObjectType.YouTube);
335 return this;
336 }
337 foreach (var r in results)
338 if (r != null && !string.IsNullOrEmpty(r.VideoId))
339 _videoIds.Add(r.VideoId);
340 return this;
341 }
342
343 public IGPALYouTube WaitForProcessing(int pollIntervalSeconds = 60)
344 {
345 _pollIntervalSecs = Math.Max(10, pollIntervalSeconds);
346 return this;
347 }
348
356 {
357 // both error paths set the completion event; no thread is going to start, so a console
358 // workflow sitting in WaitForCompletion would otherwise never be released
359 if (_credentials == null)
360 {
361 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "ScheduleAt: credentials not set.", this, GPALObjectType.YouTube);
362 _completionEvent.Set();
363 }
364 else if (_videoIds.Count == 0)
365 {
366 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "ScheduleAt: no videos targeted. Call WithVideo or WithVideos first.", this, GPALObjectType.YouTube);
367 _completionEvent.Set();
368 }
369 else
370 {
371 // Capture everything the background thread needs; don't close over mutable fields
372 var videoIds = new List<string>(_videoIds);
373 var creds = _credentials;
374 var intervalMs = _pollIntervalSecs * 1000;
375
376 // the thread body is guarded so a throw mid-poll releases WaitForCompletion and is published,
377 // instead of taking the process down with an unhandled background exception
378 var thread = new Thread(() =>
379 {
380 try
381 {
382 PollAndSchedule(creds, videoIds, premiereTime, intervalMs);
383 }
384 catch (Exception ex)
385 {
386 string errMsg = "ScheduleAt: polling stopped before all videos were scheduled.";
387 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, errMsg, this, GPALObjectType.YouTube, ex);
388 AppendToOutput(errMsg);
389 }
390 finally
391 {
392 _completionEvent.Set();
393 }
394 });
395 thread.IsBackground = true;
396 thread.Start();
397
398 GPAL.PublishSimpleEvent(GPALEventType.INFO,
399 $"ScheduleAt started in background for [{videoIds.Count}] video(s). Poll interval: [{_pollIntervalSecs}]s.",
400 this, GPALObjectType.YouTube);
401 }
402
403 return this;
404 }
405
406 // ── Output and completion ─────────────────────────────────────────────
407
413 public IGPALYouTube WithOutputTo(TextBoxBase control)
414 {
415 _outputControl = control;
416 return this;
417 }
418
424 public void WaitForCompletion()
425 {
426 _completionEvent.Wait();
427 }
428
429 private void AppendToOutput(string message)
430 {
431 if (_outputControl == null) return;
432 if (_outputControl.InvokeRequired)
433 _outputControl.BeginInvoke(new Action(() => AppendToOutput(message)));
434 else
435 _outputControl.AppendText(message + Environment.NewLine);
436 }
437
438 // ── Background polling ────────────────────────────────────────────────
439
440 private void PollAndSchedule(ICredentials creds, List<string> videoIds, PremiereTime premiereTime, int intervalMs)
441 {
442 var pending = new HashSet<string>(videoIds);
443 var lastStatus = new Dictionary<string, string>();
444 int scheduled = 0;
445
446 string openingMsg = $"Suppressing repeat status messages for: {string.Join(", ", videoIds)}. Will report when processing completes for each.";
447 GPAL.PublishSimpleEvent(GPALEventType.INFO, openingMsg, this, GPALObjectType.YouTube);
448 AppendToOutput(openingMsg);
449
450 while (pending.Count > 0)
451 {
452 creds.FetchAccessToken(out string token);
453
454 var justProcessed = new List<string>();
455
456 foreach (string id in new List<string>(pending))
457 {
458 string status = GetUploadStatus(token, id);
459
460 string prev;
461 if (!lastStatus.TryGetValue(id, out prev) || prev != status)
462 {
463 string msg = $"Video {id}: {status}";
464 GPAL.PublishSimpleEvent(GPALEventType.INFO, msg, this, GPALObjectType.YouTube);
465 AppendToOutput(msg);
466 lastStatus[id] = status;
467 }
468
469 if (status == "processed")
470 {
471 justProcessed.Add(id);
472 }
473 else if (status == "failed" || status == "rejected" || status == "deleted")
474 {
475 string errMsg = $"Video {id} cannot be scheduled: {status}.";
476 GPAL.PublishSimpleEvent(GPALEventType.ERROR, errMsg, this, GPALObjectType.YouTube);
477 AppendToOutput(errMsg);
478 justProcessed.Add(id);
479 }
480 }
481
482 foreach (string id in justProcessed)
483 {
484 pending.Remove(id);
485 string finalStatus;
486 if (lastStatus.TryGetValue(id, out finalStatus) && finalStatus == "processed")
487 {
488 DateTime pt = premiereTime.Resolve();
489 SetPremiere(token, id, pt);
490 scheduled++;
491 }
492 }
493
494 if (pending.Count > 0)
495 Thread.Sleep(intervalMs);
496 }
497
498 // this said every video was scheduled however the loop ended, including the run where all of them
499 // came back failed, rejected or deleted and SetPremiere was never called once
500 string doneMsg = 0 == scheduled
501 ? $"No videos were scheduled. None of the [{videoIds.Count}] finished processing."
502 : $"Scheduled [{scheduled}] of [{videoIds.Count}] video(s).";
503
504 GPAL.PublishSimpleEvent(0 == scheduled ? GPALEventType.WARNING : GPALEventType.INFO, doneMsg, this, GPALObjectType.YouTube);
505 AppendToOutput(doneMsg);
506 }
507
508 private void SetPremiere(string token, string videoId, DateTime premiereUtc)
509 {
510 try
511 {
512 string body = "{\"id\":" + JsonString(videoId)
513 + ",\"status\":{\"privacyStatus\":\"private\",\"publishAt\":" + JsonString(premiereUtc.ToString("o")) + "}}";
514 byte[] bodyBytes = Encoding.UTF8.GetBytes(body);
515
516 var req = (HttpWebRequest)WebRequest.Create(VideoUpdateUrl);
517 req.Method = "PUT";
518 req.Headers["Authorization"] = "Bearer " + token;
519 req.ContentType = "application/json";
520 req.ContentLength = bodyBytes.Length;
521
522 using (var s = req.GetRequestStream())
523 s.Write(bodyBytes, 0, bodyBytes.Length);
524
525 using ((HttpWebResponse)req.GetResponse()) { }
526
527 string schedMsg = $"Premiere scheduled: https://youtu.be/{videoId} at {premiereUtc:o}";
528 GPAL.PublishSimpleEvent(GPALEventType.INFO, schedMsg, this, GPALObjectType.YouTube);
529 AppendToOutput(schedMsg);
530 }
531 catch (Exception ex)
532 {
533 string errMsg = $"SetPremiere failed for {videoId}.";
534 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, errMsg, this, GPALObjectType.YouTube, ex);
535 AppendToOutput(errMsg);
536 }
537 }
538
539 private string GetUploadStatus(string token, string videoId)
540 {
541 try
542 {
543 var req = (HttpWebRequest)WebRequest.Create(VideoStatusUrl + videoId);
544 req.Method = "GET";
545 req.Headers["Authorization"] = "Bearer " + token;
546
547 using (var resp = (HttpWebResponse)req.GetResponse())
548 using (var reader = new StreamReader(resp.GetResponseStream()))
549 {
550 string responseBody = reader.ReadToEnd();
551 using (var doc = JsonDocument.Parse(responseBody))
552 {
553 if (doc.RootElement.TryGetProperty("items", out var items)
554 && items.GetArrayLength() > 0
555 && items[0].TryGetProperty("status", out var status)
556 && status.TryGetProperty("uploadStatus", out var uploadStatus))
557 {
558 return uploadStatus.GetString();
559 }
560 }
561 }
562 }
563 catch (Exception ex)
564 {
565 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"GetUploadStatus failed for [{videoId}].", this, GPALObjectType.YouTube, ex);
566 }
567 return "unknown";
568 }
569
570 // ── Upload internals ──────────────────────────────────────────────────
571
572 private string InitiateResumableUpload(string token, string filePath)
573 {
574 long fileSize = new FileInfo(filePath).Length;
575 byte[] body = Encoding.UTF8.GetBytes(BuildMetadataJson());
576
577 var req = (HttpWebRequest)WebRequest.Create(InitiationUrl);
578 req.Method = "POST";
579 req.Headers["Authorization"] = "Bearer " + token;
580 req.ContentType = "application/json; charset=UTF-8";
581 req.Headers["X-Upload-Content-Type"] = "video/mp4";
582 req.Headers["X-Upload-Content-Length"] = fileSize.ToString();
583 req.ContentLength = body.Length;
584
585 using (var s = req.GetRequestStream())
586 s.Write(body, 0, body.Length);
587
588 try
589 {
590 using (var resp = (HttpWebResponse)req.GetResponse())
591 {
592 string uri = resp.Headers["Location"];
593 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Resumable upload session started.", this, GPALObjectType.YouTube);
594 return uri;
595 }
596 }
597 catch (WebException wex) when (wex.Response is HttpWebResponse errResp)
598 {
599 using (var reader = new System.IO.StreamReader(errResp.GetResponseStream()))
600 {
601 string detail = reader.ReadToEnd();
602 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Upload initiation failed ([{(int)errResp.StatusCode}]): [{detail}]", this, GPALObjectType.YouTube);
603 }
604 throw;
605 }
606 }
607
608 private string UploadVideoBytes(string uploadUri, string filePath)
609 {
610 long fileSize = new FileInfo(filePath).Length;
611
612 var req = (HttpWebRequest)WebRequest.Create(uploadUri);
613 req.Method = "PUT";
614 req.ContentType = "video/mp4";
615 req.ContentLength = fileSize;
616 req.Timeout = 3_600_000;
617 req.AllowWriteStreamBuffering = false;
618
619 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Sending [{fileSize:N0}] bytes...", this, GPALObjectType.YouTube);
620
621 using (var reqStream = req.GetRequestStream())
622 using (var fs = File.OpenRead(filePath))
623 {
624 byte[] buf = new byte[256 * 1024];
625 int read;
626 while ((read = fs.Read(buf, 0, buf.Length)) > 0)
627 reqStream.Write(buf, 0, read);
628 }
629
630 using (var resp = (HttpWebResponse)req.GetResponse())
631 using (var reader = new StreamReader(resp.GetResponseStream()))
632 {
633 string responseBody = reader.ReadToEnd();
634 using (var doc = JsonDocument.Parse(responseBody))
635 {
636 if (doc.RootElement.TryGetProperty("id", out var idEl))
637 return idEl.GetString();
638 }
639 }
640
641 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Upload response missing video ID.", this, GPALObjectType.YouTube);
642 return null;
643 }
644
645 private string PrivacyString()
646 {
647 switch (_privacy)
648 {
649 case YouTubePrivacy.Public: return "public";
650 case YouTubePrivacy.Unlisted: return "unlisted";
651 default: return "private";
652 }
653 }
654
655 private string BuildMetadataJson()
656 {
657 string privacyStr = PrivacyString();
658
659 var tags = new StringBuilder("[");
660 for (int i = 0; i < _tags.Length; i++)
661 {
662 if (i > 0) tags.Append(",");
663 tags.Append(JsonString(_tags[i]));
664 }
665 tags.Append("]");
666
667 return "{\"snippet\":{\"title\":" + JsonString(_title)
668 + ",\"description\":" + JsonString(_description)
669 + ",\"tags\":" + tags
670 + ",\"categoryId\":" + JsonString(((int)_category).ToString())
671 + "},\"status\":{\"privacyStatus\":" + JsonString(privacyStr) + "}}";
672 }
673
674 private static string JsonString(string s)
675 {
676 if (s == null) return "\"\"";
677 return "\"" + s.Replace("\\", "\\\\")
678 .Replace("\"", "\\\"")
679 .Replace("\n", "\\n")
680 .Replace("\r", "\\r")
681 .Replace("\t", "\\t")
682 + "\"";
683 }
684
685 // ── Playlist ──────────────────────────────────────────────────────────
686
687 public IGPALYouTube WithPlaylist(string playlistId)
688 {
689 _playlistId = playlistId;
690 return this;
691 }
692
693 public YouTubePlaylistResult CreatePlaylist()
694 {
695 if (_credentials == null)
696 {
697 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "CreatePlaylist: credentials not set.", this, GPALObjectType.YouTube);
698 return null;
699 }
700 try
701 {
702 _credentials.FetchAccessToken(out string token);
703 if (string.IsNullOrEmpty(token))
704 {
705 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "CreatePlaylist: failed to fetch access token.", this, GPALObjectType.YouTube);
706 return null;
707 }
708
709 string body = "{\"snippet\":{\"title\":" + JsonString(_title)
710 + ",\"description\":" + JsonString(_description)
711 + "},\"status\":{\"privacyStatus\":" + JsonString(PrivacyString()) + "}}";
712 byte[] bodyBytes = Encoding.UTF8.GetBytes(body);
713
714 var req = (HttpWebRequest)WebRequest.Create(PlaylistCreateUrl);
715 req.Method = "POST";
716 req.Headers["Authorization"] = "Bearer " + token;
717 req.ContentType = "application/json";
718 req.ContentLength = bodyBytes.Length;
719
720 using (var s = req.GetRequestStream())
721 s.Write(bodyBytes, 0, bodyBytes.Length);
722
723 using (var resp = (HttpWebResponse)req.GetResponse())
724 using (var reader = new StreamReader(resp.GetResponseStream()))
725 {
726 string responseBody = reader.ReadToEnd();
727 using (var doc = JsonDocument.Parse(responseBody))
728 {
729 if (doc.RootElement.TryGetProperty("id", out var idEl))
730 {
731 string playlistId = idEl.GetString();
732 GPAL.PublishSimpleEvent(GPALEventType.INFO,
733 $"Playlist created: https://www.youtube.com/playlist?list=[{playlistId}]", this, GPALObjectType.YouTube);
734 return new YouTubePlaylistResult(playlistId);
735 }
736 }
737 }
738 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "CreatePlaylist: response missing playlist ID.", this, GPALObjectType.YouTube);
739 return null;
740 }
741 catch (Exception ex)
742 {
743 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, "CreatePlaylist failed.", this, GPALObjectType.YouTube, ex);
744 return null;
745 }
746 }
747
748 public IGPALYouTube AddToPlaylist(string playlistId)
749 {
750 if (_credentials == null)
751 {
752 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "AddToPlaylist: credentials not set.", this, GPALObjectType.YouTube);
753 return this;
754 }
755 // the uploaded videos from WithVideo and WithVideos, then the existing ones named with WithVideoId
756 var videoIds = new List<string>(_videoIds);
757 foreach (string id in _lookupVideoIds)
758 if (!videoIds.Contains(id))
759 videoIds.Add(id);
760
761 if (videoIds.Count == 0)
762 {
763 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "AddToPlaylist: no videos. Call WithVideo, WithVideos or WithVideoId first.", this, GPALObjectType.YouTube);
764 return this;
765 }
766 _credentials.FetchAccessToken(out string token);
767
768 // with WithPosition each video goes in at that place, the next one after it, so they keep their order
769 int position = _position;
770 foreach (string videoId in videoIds)
771 {
772 AddVideoToPlaylist(token, playlistId, videoId, position, GPALEventType.ERROR);
773 if (position >= 0) position++;
774 }
775 return this;
776 }
777
782 public IGPALYouTube WithPosition(int position)
783 {
784 _position = position;
785 return this;
786 }
787
788 // adds one video to a playlist, at the position given or on the end when it is negative. true when youtube took it.
789 // a refusal is published at the level given, since ReplaceVideo tries again without the position
790 private bool AddVideoToPlaylist(string token, string playlistId, string videoId, int position, GPALEventType refusedAs)
791 {
792 bool added = false;
793
794 try
795 {
796 string positionJson = position >= 0 ? ",\"position\":" + position : string.Empty;
797 string body = "{\"snippet\":{\"playlistId\":" + JsonString(playlistId)
798 + positionJson
799 + ",\"resourceId\":{\"kind\":\"youtube#video\",\"videoId\":" + JsonString(videoId) + "}}}";
800
801 added = CallApi("POST", PlaylistItemUrl, token, "application/json", Encoding.UTF8.GetBytes(body), out string answer);
802
803 if (added)
804 GPAL.PublishSimpleEvent(GPALEventType.INFO,
805 $"Added [{videoId}] to playlist [{playlistId}].", this, GPALObjectType.YouTube);
806 else
807 GPAL.PublishSimpleEvent(refusedAs,
808 $"Could not add [{videoId}] to playlist [{playlistId}]: [{answer}]", this, GPALObjectType.YouTube);
809 }
810 catch (Exception ex)
811 {
812 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
813 $"AddVideoToPlaylist failed for [{videoId}].", this, GPALObjectType.YouTube, ex);
814 }
815
816 return added;
817 }
818
819 // ── Lookup / Search ───────────────────────────────────────────────────
820
821 public IGPALYouTube WithVideoId(string videoId)
822 {
823 if (!string.IsNullOrEmpty(videoId))
824 _lookupVideoIds.Add(videoId);
825 return this;
826 }
827
828 public IGPALYouTube WithSearchTerms(string query)
829 {
830 if (!string.IsNullOrEmpty(query))
831 _searchTerms.Add(query);
832 return this;
833 }
834
835 public IGPALYouTube WithSearchTerms(IGPALGrid<string> grid)
836 {
837 if (grid == null) return this;
838 foreach (var row in grid)
839 if (row != null && row.Count > 0 && !string.IsNullOrEmpty(row[0]))
840 _searchTerms.Add(row[0]);
841 return this;
842 }
843
844 public IGPALYouTube GetResults(out YouTubeVideoInfo info)
845 {
846 info = null;
847 if (_credentials == null)
848 {
849 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "GetResults: credentials not set.", this, GPALObjectType.YouTube);
850 return this;
851 }
852 if (_lookupVideoIds.Count == 0)
853 {
854 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "GetResults: no video ID. Call WithVideoId first.", this, GPALObjectType.YouTube);
855 return this;
856 }
857 _credentials.FetchAccessToken(out string token);
858 var list = FetchVideoInfos(token, _lookupVideoIds);
859 info = list.Count > 0 ? list[0] : null;
860 return this;
861 }
862
863 public IGPALYouTube GetResults(out List<YouTubeVideoInfo> results)
864 {
865 results = new List<YouTubeVideoInfo>();
866 if (_credentials == null)
867 {
868 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "GetResults: credentials not set.", this, GPALObjectType.YouTube);
869 return this;
870 }
871 _credentials.FetchAccessToken(out string token);
872
873 if (_lookupVideoIds.Count > 0)
874 results.AddRange(FetchVideoInfos(token, _lookupVideoIds));
875
876 if (_searchTerms.Count > 0)
877 {
878 var seen = new HashSet<string>();
879 foreach (var v in results) seen.Add(v.VideoId);
880 foreach (string term in _searchTerms)
881 {
882 foreach (var v in SearchVideos(token, term))
883 if (seen.Add(v.VideoId))
884 results.Add(v);
885 }
886 }
887 return this;
888 }
889
890 private List<YouTubeVideoInfo> FetchVideoInfos(string token, List<string> videoIds)
891 {
892 var result = new List<YouTubeVideoInfo>();
893 int i = 0;
894 while (i < videoIds.Count)
895 {
896 var batch = new StringBuilder();
897 int end = Math.Min(i + 50, videoIds.Count);
898 for (int j = i; j < end; j++)
899 {
900 if (j > i) batch.Append(",");
901 batch.Append(Uri.EscapeDataString(videoIds[j]));
902 }
903 i = end;
904
905 try
906 {
907 var req = (HttpWebRequest)WebRequest.Create(VideoInfoBaseUrl + batch);
908 req.Method = "GET";
909 req.Headers["Authorization"] = "Bearer " + token;
910
911 using (var resp = (HttpWebResponse)req.GetResponse())
912 using (var reader = new StreamReader(resp.GetResponseStream()))
913 {
914 string responseBody = reader.ReadToEnd();
915 using (var doc = JsonDocument.Parse(responseBody))
916 {
917 if (!doc.RootElement.TryGetProperty("items", out var items)) continue;
918 foreach (var item in items.EnumerateArray())
919 {
920 string videoId = item.TryGetProperty("id", out var idEl) ? idEl.GetString() : null;
921 if (string.IsNullOrEmpty(videoId)) continue;
922
923 string title = string.Empty, description = string.Empty;
924 string[] tags = new string[0];
925 DateTime? publishedAt = null;
926 string categoryId = null, thumbnailUrl = null;
927 YouTubePrivacy? privacy = null;
928
929 if (item.TryGetProperty("status", out var statusEl)
930 && statusEl.TryGetProperty("privacyStatus", out var privacyEl))
931 privacy = PrivacyFrom(privacyEl.GetString());
932
933 if (item.TryGetProperty("snippet", out var snippet))
934 {
935 if (snippet.TryGetProperty("categoryId", out var c)) categoryId = c.GetString();
936 thumbnailUrl = BestThumbnail(snippet);
937 if (snippet.TryGetProperty("title", out var t)) title = t.GetString();
938 if (snippet.TryGetProperty("description", out var d)) description = d.GetString();
939 if (snippet.TryGetProperty("publishedAt", out var p)
940 && DateTime.TryParse(p.GetString(), out DateTime dt))
941 publishedAt = dt;
942 if (snippet.TryGetProperty("tags", out var tagsEl))
943 {
944 var tagList = new List<string>();
945 foreach (var tag in tagsEl.EnumerateArray())
946 tagList.Add(tag.GetString());
947 tags = tagList.ToArray();
948 }
949 }
950 result.Add(new YouTubeVideoInfo(videoId, title, description, tags, publishedAt)
951 {
952 Privacy = privacy,
953 CategoryId = categoryId,
954 ThumbnailUrl = thumbnailUrl,
955 });
956 }
957 }
958 }
959 }
960 catch (Exception ex)
961 {
962 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, "FetchVideoInfos failed.", this, GPALObjectType.YouTube, ex);
963 }
964 }
965 return result;
966 }
967
968 private List<YouTubeVideoInfo> SearchVideos(string token, string query)
969 {
970 var result = new List<YouTubeVideoInfo>();
971 try
972 {
973 var req = (HttpWebRequest)WebRequest.Create(SearchBaseUrl + Uri.EscapeDataString(query));
974 req.Method = "GET";
975 req.Headers["Authorization"] = "Bearer " + token;
976
977 using (var resp = (HttpWebResponse)req.GetResponse())
978 using (var reader = new StreamReader(resp.GetResponseStream()))
979 {
980 string responseBody = reader.ReadToEnd();
981 using (var doc = JsonDocument.Parse(responseBody))
982 {
983 if (!doc.RootElement.TryGetProperty("items", out var items)) return result;
984 foreach (var item in items.EnumerateArray())
985 {
986 string videoId = null;
987 if (item.TryGetProperty("id", out var idObj)
988 && idObj.TryGetProperty("videoId", out var vidEl))
989 videoId = vidEl.GetString();
990 if (string.IsNullOrEmpty(videoId)) continue;
991
992 string title = string.Empty, description = string.Empty;
993 DateTime? publishedAt = null;
994
995 if (item.TryGetProperty("snippet", out var snippet))
996 {
997 if (snippet.TryGetProperty("title", out var t)) title = t.GetString();
998 if (snippet.TryGetProperty("description", out var d)) description = d.GetString();
999 if (snippet.TryGetProperty("publishedAt", out var p)
1000 && DateTime.TryParse(p.GetString(), out DateTime dt))
1001 publishedAt = dt;
1002 }
1003 result.Add(new YouTubeVideoInfo(videoId, title, description, null, publishedAt));
1004 }
1005 }
1006 }
1007 }
1008 catch (Exception ex)
1009 {
1010 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"SearchVideos failed for [{query}].", this, GPALObjectType.YouTube, ex);
1011 }
1012 return result;
1013 }
1014
1015 // ── Channel management ────────────────────────────────────────────────
1016
1021 public IGPALYouTube GetMyVideos(out List<YouTubeVideoInfo> videos)
1022 {
1023 var found = new List<YouTubeVideoInfo>();
1024 string token = TokenFor("GetMyVideos");
1025
1026 if (null != token)
1027 {
1028 try
1029 {
1030 string uploadsId = UploadsPlaylistId(token);
1031
1032 if (null != uploadsId)
1033 {
1034 var items = new List<YouTubePlaylistItem>();
1035
1036 if (ReadPlaylistItems(token, uploadsId, items, out string refusal))
1037 {
1038 var ids = new List<string>();
1039 foreach (var item in items)
1040 if (!string.IsNullOrEmpty(item.VideoId))
1041 ids.Add(item.VideoId);
1042
1043 found = FetchVideoInfos(token, ids);
1044 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Found [{found.Count}] video(s) on the channel.", this, GPALObjectType.YouTube);
1045 }
1046 else
1047 {
1048 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"GetMyVideos: could not read the uploads playlist: [{refusal}]", this, GPALObjectType.YouTube);
1049 }
1050 }
1051 }
1052 catch (Exception ex)
1053 {
1054 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, "GetMyVideos failed.", this, GPALObjectType.YouTube, ex);
1055 }
1056 }
1057
1058 videos = found;
1059 return this;
1060 }
1061
1065 public IGPALYouTube GetMyPlaylists(out List<YouTubePlaylistInfo> playlists)
1066 {
1067 var found = new List<YouTubePlaylistInfo>();
1068 string token = TokenFor("GetMyPlaylists");
1069
1070 if (null != token)
1071 {
1072 try
1073 {
1074 if (ReadMyPlaylists(token, found, out string refusal))
1075 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Found [{found.Count}] playlist(s) on the channel.", this, GPALObjectType.YouTube);
1076 else
1077 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"GetMyPlaylists: could not read the playlists: [{refusal}]", this, GPALObjectType.YouTube);
1078 }
1079 catch (Exception ex)
1080 {
1081 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, "GetMyPlaylists failed.", this, GPALObjectType.YouTube, ex);
1082 }
1083 }
1084
1085 playlists = found;
1086 return this;
1087 }
1088
1092 public IGPALYouTube GetPlaylistItems(out List<YouTubePlaylistItem> items)
1093 {
1094 var found = new List<YouTubePlaylistItem>();
1095
1096 if (string.IsNullOrEmpty(_playlistId))
1097 {
1098 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "GetPlaylistItems: no playlist. Call WithPlaylist first.", this, GPALObjectType.YouTube);
1099 }
1100 else
1101 {
1102 string token = TokenFor("GetPlaylistItems");
1103
1104 if (null != token)
1105 {
1106 try
1107 {
1108 if (ReadPlaylistItems(token, _playlistId, found, out string refusal))
1109 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Found [{found.Count}] item(s) in playlist [{_playlistId}].", this, GPALObjectType.YouTube);
1110 else
1111 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"GetPlaylistItems: could not read playlist [{_playlistId}]: [{refusal}]", this, GPALObjectType.YouTube);
1112 }
1113 catch (Exception ex)
1114 {
1115 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"GetPlaylistItems failed for [{_playlistId}].", this, GPALObjectType.YouTube, ex);
1116 }
1117 }
1118 }
1119
1120 items = found;
1121 return this;
1122 }
1123
1129 {
1130 if (string.IsNullOrEmpty(_playlistId))
1131 {
1132 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "RemoveFromPlaylist: no playlist. Call WithPlaylist first.", this, GPALObjectType.YouTube);
1133 }
1134 else if (0 == _lookupVideoIds.Count)
1135 {
1136 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "RemoveFromPlaylist: no video ID. Call WithVideoId first.", this, GPALObjectType.YouTube);
1137 }
1138 else
1139 {
1140 string token = TokenFor("RemoveFromPlaylist");
1141
1142 if (null != token)
1143 {
1144 try
1145 {
1146 var items = new List<YouTubePlaylistItem>();
1147
1148 if (!ReadPlaylistItems(token, _playlistId, items, out string refusal))
1149 {
1150 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"RemoveFromPlaylist: could not read playlist [{_playlistId}]: [{refusal}]", this, GPALObjectType.YouTube);
1151 }
1152 else
1153 {
1154 foreach (string videoId in _lookupVideoIds)
1155 {
1156 int removed = 0;
1157
1158 foreach (var item in items)
1159 if (videoId == item.VideoId && DeletePlaylistItem(token, item))
1160 removed++;
1161
1162 if (0 < removed)
1163 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Removed [{videoId}] from playlist [{_playlistId}].", this, GPALObjectType.YouTube);
1164 else if (!items.Exists(i => videoId == i.VideoId))
1165 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"RemoveFromPlaylist: [{videoId}] is not in playlist [{_playlistId}].", this, GPALObjectType.YouTube);
1166 }
1167 }
1168 }
1169 catch (Exception ex)
1170 {
1171 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"RemoveFromPlaylist failed for [{_playlistId}].", this, GPALObjectType.YouTube, ex);
1172 }
1173 }
1174 }
1175
1176 return this;
1177 }
1178
1185 {
1186 if (0 == _lookupVideoIds.Count)
1187 {
1188 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "UpdateVideo: no video ID. Call WithVideoId first.", this, GPALObjectType.YouTube);
1189 }
1190 else if (!(_titleGiven || _descriptionGiven || _tagsGiven || _categoryGiven || _privacyGiven))
1191 {
1192 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "UpdateVideo: nothing to change. Call WithTitle, WithDescription, WithTags, WithCategory or WithPrivacy first.", this, GPALObjectType.YouTube);
1193 }
1194 else
1195 {
1196 string token = TokenFor("UpdateVideo");
1197
1198 if (null != token)
1199 {
1200 foreach (string videoId in _lookupVideoIds)
1201 {
1202 try
1203 {
1204 UpdateOneVideo(token, videoId);
1205 }
1206 catch (Exception ex)
1207 {
1208 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"UpdateVideo failed for [{videoId}].", this, GPALObjectType.YouTube, ex);
1209 }
1210 }
1211 }
1212 }
1213
1214 return this;
1215 }
1216
1222 {
1223 string imagePath = image?.Filename;
1224
1225 if (0 == _lookupVideoIds.Count)
1226 {
1227 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "SetThumbnail: no video ID. Call WithVideoId first.", this, GPALObjectType.YouTube);
1228 }
1229 else if (string.IsNullOrEmpty(imagePath) || !File.Exists(imagePath))
1230 {
1231 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"SetThumbnail: file not found [{imagePath}]", this, GPALObjectType.YouTube);
1232 }
1233 else
1234 {
1235 string token = TokenFor("SetThumbnail");
1236
1237 if (null != token)
1238 {
1239 try
1240 {
1241 byte[] bytes = File.ReadAllBytes(imagePath);
1242 string contentType = ".png" == Path.GetExtension(imagePath).ToLowerInvariant() ? "image/png" : "image/jpeg";
1243
1244 foreach (string videoId in _lookupVideoIds)
1245 SetThumbnailBytes(token, videoId, bytes, contentType, GPALEventType.ERROR);
1246 }
1247 catch (Exception ex)
1248 {
1249 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"SetThumbnail failed for [{imagePath}].", this, GPALObjectType.YouTube, ex);
1250 }
1251 }
1252 }
1253
1254 return this;
1255 }
1256
1261 {
1262 if (0 == _lookupVideoIds.Count)
1263 {
1264 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "DeleteVideo: no video ID. Call WithVideoId first.", this, GPALObjectType.YouTube);
1265 }
1266 else
1267 {
1268 string token = TokenFor("DeleteVideo");
1269
1270 if (null != token)
1271 {
1272 foreach (string videoId in _lookupVideoIds)
1273 {
1274 try
1275 {
1276 if (CallApi("DELETE", VideoDeleteUrl + Uri.EscapeDataString(videoId), token, null, null, out string answer))
1277 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Deleted video [{videoId}].", this, GPALObjectType.YouTube);
1278 else
1279 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to delete video [{videoId}]: [{answer}]", this, GPALObjectType.YouTube);
1280 }
1281 catch (Exception ex)
1282 {
1283 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to delete video [{videoId}].", this, GPALObjectType.YouTube, ex);
1284 }
1285 }
1286 }
1287 }
1288
1289 return this;
1290 }
1291
1300 {
1301 YouTubeUploadResult retVal = null;
1302 string filePath = newVideo?.Filename;
1303
1304 if (0 == _lookupVideoIds.Count)
1305 {
1306 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "ReplaceVideo: no video ID. Call WithVideoId first.", this, GPALObjectType.YouTube);
1307 }
1308 else if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
1309 {
1310 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"ReplaceVideo: file not found [{filePath}]", this, GPALObjectType.YouTube);
1311 }
1312 else
1313 {
1314 string token = TokenFor("ReplaceVideo");
1315 string oldId = _lookupVideoIds[0];
1316
1317 if (null != token)
1318 {
1319 try
1320 {
1321 List<YouTubeVideoInfo> found = FetchVideoInfos(token, new List<string> { oldId });
1322
1323 if (0 == found.Count)
1324 {
1325 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"ReplaceVideo: video [{oldId}] was not found.", this, GPALObjectType.YouTube);
1326 }
1327 else if (FindPlaylistPlaces(token, oldId, out List<YouTubePlaylistItem> places))
1328 {
1329 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Replacing [{oldId}], which is in [{places.Count}] playlist place(s).", this, GPALObjectType.YouTube);
1330
1331 retVal = UploadAsReplacement(newVideo, found[0]);
1332
1333 if (null != retVal)
1334 retVal = FinishReplacement(found[0], retVal, places);
1335 }
1336 }
1337 catch (Exception ex)
1338 {
1339 string stopped = null == retVal
1340 ? $"ReplaceVideo failed for [{oldId}]."
1341 : $"ReplaceVideo stopped before it finished. The new video is [{retVal.VideoId}].";
1342 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, stopped, this, GPALObjectType.YouTube, ex);
1343 }
1344 }
1345 }
1346
1347 return retVal;
1348 }
1349
1350 // ── Channel management internals ──────────────────────────────────────
1351
1352 // the access token for an operation, or null when there is none, with the reason published
1353 private string TokenFor(string operation)
1354 {
1355 string token = null;
1356
1357 if (_credentials == null)
1358 {
1359 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"{operation}: credentials not set.", this, GPALObjectType.YouTube);
1360 }
1361 else
1362 {
1363 _credentials.FetchAccessToken(out token);
1364
1365 if (string.IsNullOrEmpty(token))
1366 {
1367 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"{operation}: failed to fetch access token.", this, GPALObjectType.YouTube);
1368 token = null;
1369 }
1370 }
1371
1372 return token;
1373 }
1374
1375 // sends one request to the api. true on a 2xx answer, with the answer's body. when youtube refuses, false,
1376 // and answer holds the status code and what youtube said. a request that gets no answer at all throws
1377 private bool CallApi(string method, string url, string token, string contentType, byte[] body, out string answer)
1378 {
1379 bool ok = false;
1380
1381 var req = (HttpWebRequest)WebRequest.Create(url);
1382 req.Method = method;
1383 req.Headers["Authorization"] = "Bearer " + token;
1384
1385 if (null != body)
1386 {
1387 req.ContentType = contentType;
1388 req.ContentLength = body.Length;
1389
1390 using (var s = req.GetRequestStream())
1391 s.Write(body, 0, body.Length);
1392 }
1393
1394 try
1395 {
1396 using (var resp = (HttpWebResponse)req.GetResponse())
1397 using (var reader = new StreamReader(resp.GetResponseStream()))
1398 answer = reader.ReadToEnd();
1399
1400 ok = true;
1401 }
1402 catch (WebException wex) when (wex.Response is HttpWebResponse errResp)
1403 {
1404 using (errResp)
1405 using (var reader = new StreamReader(errResp.GetResponseStream()))
1406 answer = $"{(int)errResp.StatusCode} {Regex.Replace(reader.ReadToEnd(), @"\s+", " ").Trim()}";
1407 }
1408
1409 return ok;
1410 }
1411
1412 // reads every page of a list call, handing each item to onItem. false when youtube refuses a page, with
1413 // what it said in refusal
1414 private bool ReadAllPages(string token, string firstPageUrl, Action<JsonElement> onItem, out string refusal)
1415 {
1416 bool ok = true;
1417 string pageToken = null;
1418 refusal = null;
1419
1420 do
1421 {
1422 string url = null == pageToken ? firstPageUrl : firstPageUrl + "&pageToken=" + Uri.EscapeDataString(pageToken);
1423 pageToken = null;
1424
1425 ok = CallApi("GET", url, token, null, null, out string answer);
1426
1427 if (!ok)
1428 {
1429 refusal = answer;
1430 }
1431 else
1432 {
1433 using (var doc = JsonDocument.Parse(answer))
1434 {
1435 if (doc.RootElement.TryGetProperty("items", out var items))
1436 foreach (var item in items.EnumerateArray())
1437 onItem(item);
1438
1439 if (doc.RootElement.TryGetProperty("nextPageToken", out var next))
1440 pageToken = next.GetString();
1441 }
1442 }
1443 }
1444 while (ok && !string.IsNullOrEmpty(pageToken));
1445
1446 return ok;
1447 }
1448
1449 // the id of the playlist youtube keeps of every video the channel uploaded, or null with the reason published
1450 private string UploadsPlaylistId(string token)
1451 {
1452 string uploadsId = null;
1453
1454 if (!CallApi("GET", MyChannelUrl, token, null, null, out string answer))
1455 {
1456 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Could not read the channel: [{answer}]", this, GPALObjectType.YouTube);
1457 }
1458 else
1459 {
1460 using (var doc = JsonDocument.Parse(answer))
1461 {
1462 if (doc.RootElement.TryGetProperty("items", out var items)
1463 && items.GetArrayLength() > 0
1464 && items[0].TryGetProperty("contentDetails", out var details)
1465 && details.TryGetProperty("relatedPlaylists", out var related)
1466 && related.TryGetProperty("uploads", out var uploads))
1467 uploadsId = uploads.GetString();
1468 }
1469
1470 if (string.IsNullOrEmpty(uploadsId))
1471 {
1472 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "These credentials have no YouTube channel.", this, GPALObjectType.YouTube);
1473 uploadsId = null;
1474 }
1475 }
1476
1477 return uploadsId;
1478 }
1479
1480 private bool ReadMyPlaylists(string token, List<YouTubePlaylistInfo> into, out string refusal)
1481 {
1482 return ReadAllPages(token, MyPlaylistsUrl, item =>
1483 {
1484 string playlistId = item.TryGetProperty("id", out var idEl) ? idEl.GetString() : null;
1485 string title = string.Empty, description = string.Empty;
1486 YouTubePrivacy? privacy = null;
1487 int itemCount = 0;
1488
1489 if (item.TryGetProperty("snippet", out var snippet))
1490 {
1491 if (snippet.TryGetProperty("title", out var t)) title = t.GetString();
1492 if (snippet.TryGetProperty("description", out var d)) description = d.GetString();
1493 }
1494 if (item.TryGetProperty("status", out var status) && status.TryGetProperty("privacyStatus", out var p))
1495 privacy = PrivacyFrom(p.GetString());
1496 if (item.TryGetProperty("contentDetails", out var details) && details.TryGetProperty("itemCount", out var c))
1497 c.TryGetInt32(out itemCount);
1498
1499 if (!string.IsNullOrEmpty(playlistId))
1500 into.Add(new YouTubePlaylistInfo(playlistId, title, description, privacy, itemCount));
1501 }, out refusal);
1502 }
1503
1504 private bool ReadPlaylistItems(string token, string playlistId, List<YouTubePlaylistItem> into, out string refusal)
1505 {
1506 return ReadAllPages(token, PlaylistItemsListUrl + Uri.EscapeDataString(playlistId), item =>
1507 {
1508 string itemId = item.TryGetProperty("id", out var idEl) ? idEl.GetString() : null;
1509 string videoId = null, title = string.Empty;
1510 int position = -1;
1511
1512 if (item.TryGetProperty("snippet", out var snippet))
1513 {
1514 if (snippet.TryGetProperty("title", out var t)) title = t.GetString();
1515 if (snippet.TryGetProperty("position", out var p)) p.TryGetInt32(out position);
1516 if (snippet.TryGetProperty("resourceId", out var r) && r.TryGetProperty("videoId", out var v))
1517 videoId = v.GetString();
1518 }
1519
1520 into.Add(new YouTubePlaylistItem(itemId, playlistId, videoId, title, position));
1521 }, out refusal);
1522 }
1523
1524 // takes one entry out of a playlist. true when youtube did
1525 private bool DeletePlaylistItem(string token, YouTubePlaylistItem item)
1526 {
1527 bool deleted = CallApi("DELETE", PlaylistItemDeleteUrl + Uri.EscapeDataString(item.PlaylistItemId), token, null, null, out string answer);
1528
1529 if (!deleted)
1530 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
1531 $"Could not remove [{item.VideoId}] from playlist [{item.PlaylistId}]: [{answer}]", this, GPALObjectType.YouTube);
1532
1533 return deleted;
1534 }
1535
1536 // reads the video as it is now, then writes it back with the settings the workflow gave in place of those values
1537 private void UpdateOneVideo(string token, string videoId)
1538 {
1539 if (!CallApi("GET", VideoInfoBaseUrl + Uri.EscapeDataString(videoId), token, null, null, out string current))
1540 {
1541 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"UpdateVideo: could not read video [{videoId}]: [{current}]", this, GPALObjectType.YouTube);
1542 }
1543 else
1544 {
1545 byte[] body = null;
1546
1547 using (var doc = JsonDocument.Parse(current))
1548 {
1549 if (doc.RootElement.TryGetProperty("items", out var items)
1550 && items.GetArrayLength() > 0
1551 && items[0].TryGetProperty("snippet", out _))
1552 body = VideoUpdateJson(items[0], videoId);
1553 }
1554
1555 if (null == body)
1556 {
1557 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"UpdateVideo: video [{videoId}] was not found.", this, GPALObjectType.YouTube);
1558 }
1559 else
1560 {
1561 // status goes only when privacy was given, so the video's other status settings are never at risk
1562 string url = "https://www.googleapis.com/youtube/v3/videos?part=" + (_privacyGiven ? "snippet,status" : "snippet");
1563
1564 if (CallApi("PUT", url, token, "application/json", body, out string answer))
1565 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Updated video [{videoId}].", this, GPALObjectType.YouTube);
1566 else
1567 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to update video [{videoId}]: [{answer}]", this, GPALObjectType.YouTube);
1568 }
1569 }
1570 }
1571
1572 // the body for videos.update. youtube clears any writable field the update leaves out, so every writable
1573 // snippet field is sent, the given setting where there is one and the video's current value where not.
1574 // status, sent only when privacy was given, carries the video's other status settings across the same way
1575 private byte[] VideoUpdateJson(JsonElement video, string videoId)
1576 {
1577 byte[] retVal;
1578 JsonElement snippet = video.GetProperty("snippet");
1579
1580 using (var ms = new MemoryStream())
1581 {
1582 using (var w = new Utf8JsonWriter(ms))
1583 {
1584 w.WriteStartObject();
1585 w.WriteString("id", videoId);
1586
1587 w.WriteStartObject("snippet");
1588 w.WriteString("title", _titleGiven ? _title : StringOf(snippet, "title"));
1589 w.WriteString("description", _descriptionGiven ? _description : StringOf(snippet, "description"));
1590 w.WriteString("categoryId", _categoryGiven ? ((int)_category).ToString() : StringOf(snippet, "categoryId"));
1591
1592 w.WriteStartArray("tags");
1593 if (_tagsGiven)
1594 {
1595 foreach (string tag in _tags)
1596 w.WriteStringValue(tag);
1597 }
1598 else if (snippet.TryGetProperty("tags", out var tags))
1599 {
1600 foreach (var tag in tags.EnumerateArray())
1601 w.WriteStringValue(tag.GetString());
1602 }
1603 w.WriteEndArray();
1604
1605 foreach (string name in new[] { "defaultLanguage", "defaultAudioLanguage" })
1606 if (snippet.TryGetProperty(name, out var language))
1607 w.WriteString(name, language.GetString());
1608 w.WriteEndObject();
1609
1610 if (_privacyGiven)
1611 {
1612 w.WriteStartObject("status");
1613 w.WriteString("privacyStatus", PrivacyString());
1614
1615 // a scheduled publish time only stands on a private video
1616 var keep = new List<string> { "embeddable", "license", "publicStatsViewable", "selfDeclaredMadeForKids", "containsSyntheticMedia" };
1617 if (YouTubePrivacy.Private == _privacy)
1618 keep.Add("publishAt");
1619
1620 if (video.TryGetProperty("status", out var status))
1621 foreach (var field in status.EnumerateObject())
1622 if (keep.Contains(field.Name))
1623 field.WriteTo(w);
1624 w.WriteEndObject();
1625 }
1626
1627 w.WriteEndObject();
1628 }
1629
1630 retVal = ms.ToArray();
1631 }
1632
1633 return retVal;
1634 }
1635
1636 private static string StringOf(JsonElement element, string name)
1637 {
1638 string retVal = string.Empty;
1639
1640 if (element.TryGetProperty(name, out var value) && JsonValueKind.String == value.ValueKind)
1641 retVal = value.GetString();
1642
1643 return retVal;
1644 }
1645
1646 // sends image bytes to thumbnails.set. a refusal is published at the level given, since the same refusal
1647 // ends SetThumbnail but only dents ReplaceVideo
1648 private bool SetThumbnailBytes(string token, string videoId, byte[] image, string contentType, GPALEventType refusedAs)
1649 {
1650 bool set = CallApi("POST", ThumbnailSetUrl + Uri.EscapeDataString(videoId), token, contentType, image, out string answer);
1651
1652 if (set)
1653 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Thumbnail set on [{videoId}].", this, GPALObjectType.YouTube);
1654 else
1655 GPAL.PublishSimpleEvent(refusedAs, $"Thumbnail not set on [{videoId}]: [{answer}]", this, GPALObjectType.YouTube);
1656
1657 return set;
1658 }
1659
1660 // every place the video has in the channel's playlists, in playlist and then position order.
1661 // false when a playlist could not be read, with the reason published
1662 private bool FindPlaylistPlaces(string token, string videoId, out List<YouTubePlaylistItem> places)
1663 {
1664 var found = new List<YouTubePlaylistItem>();
1665 var playlists = new List<YouTubePlaylistInfo>();
1666 bool ok = ReadMyPlaylists(token, playlists, out string refusal);
1667
1668 if (!ok)
1669 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"ReplaceVideo: could not read the playlists: [{refusal}]", this, GPALObjectType.YouTube);
1670
1671 for (int i = 0; ok && i < playlists.Count; i++)
1672 {
1673 var items = new List<YouTubePlaylistItem>();
1674 ok = ReadPlaylistItems(token, playlists[i].PlaylistId, items, out refusal);
1675
1676 if (!ok)
1677 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"ReplaceVideo: could not read playlist [{playlists[i].PlaylistId}]: [{refusal}]", this, GPALObjectType.YouTube);
1678 else
1679 found.AddRange(items.FindAll(item => videoId == item.VideoId));
1680 }
1681
1682 found.Sort((a, b) => a.PlaylistId == b.PlaylistId ? a.Position.CompareTo(b.Position) : string.CompareOrdinal(a.PlaylistId, b.PlaylistId));
1683 places = found;
1684 return ok;
1685 }
1686
1687 // uploads the file with the old video's title, description, tags, category and privacy through Upload,
1688 // putting the chain's own settings back afterwards. the playlist from WithPlaylist is left out, since the
1689 // new video's playlists are the old one's
1690 private YouTubeUploadResult UploadAsReplacement(GPALFile file, YouTubeVideoInfo old)
1691 {
1692 string title = _title;
1693 string description = _description;
1694 string[] tags = _tags;
1695 YouTubePrivacy privacy = _privacy;
1696 YouTubeCategory category = _category;
1697 string playlistId = _playlistId;
1698
1699 _title = old.Title;
1700 _description = old.Description;
1701 _tags = old.Tags;
1702 _privacy = old.Privacy ?? YouTubePrivacy.Private;
1703 _category = int.TryParse(old.CategoryId, out int categoryId) ? (YouTubeCategory)categoryId : _category;
1704 _playlistId = null;
1705
1706 YouTubeUploadResult uploaded = Upload(file);
1707
1708 _title = title;
1709 _description = description;
1710 _tags = tags;
1711 _privacy = privacy;
1712 _category = category;
1713 _playlistId = playlistId;
1714
1715 return uploaded;
1716 }
1717
1718 // waits for the new video to process, then gives it the old thumbnail and the old playlist places
1719 private YouTubeUploadResult FinishReplacement(YouTubeVideoInfo old, YouTubeUploadResult uploaded, List<YouTubePlaylistItem> places)
1720 {
1721 string status = WaitUntilProcessed(uploaded.VideoId);
1722 var retVal = new YouTubeUploadResult(uploaded.VideoId, uploaded.Url, status);
1723
1724 if ("failed" == status || "rejected" == status || "deleted" == status)
1725 {
1726 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
1727 $"ReplaceVideo: the new video [{uploaded.VideoId}] ended [{status}]. The playlists still hold [{old.VideoId}].",
1728 this, GPALObjectType.YouTube);
1729 }
1730 else
1731 {
1732 // processing can outlast an access token
1733 _credentials.FetchAccessToken(out string token);
1734
1735 CopyThumbnail(token, old, uploaded.VideoId);
1736
1737 foreach (var place in places)
1738 TakePlace(token, place, uploaded.VideoId);
1739
1740 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Replaced [{old.VideoId}] with [{uploaded.Url}].", this, GPALObjectType.YouTube);
1741 }
1742
1743 return retVal;
1744 }
1745
1746 // checks the video's processing at the WaitForProcessing interval until youtube says it is done or will not be.
1747 // stops early when youtube has not answered several checks in a row
1748 private string WaitUntilProcessed(string videoId)
1749 {
1750 string status = null;
1751 int unanswered = 0;
1752 bool waiting = true;
1753
1754 while (waiting)
1755 {
1756 _credentials.FetchAccessToken(out string token);
1757 string now = GetUploadStatus(token, videoId);
1758
1759 if (now != status)
1760 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Video [{videoId}]: [{now}]", this, GPALObjectType.YouTube);
1761
1762 status = now;
1763 unanswered = "unknown" == status ? unanswered + 1 : 0;
1764 waiting = "processed" != status && "failed" != status && "rejected" != status && "deleted" != status
1765 && MaxUnansweredStatusChecks > unanswered;
1766
1767 if (waiting)
1768 Thread.Sleep(_pollIntervalSecs * 1000);
1769 }
1770
1771 if (MaxUnansweredStatusChecks <= unanswered)
1772 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
1773 $"Stopped waiting for [{videoId}] to process. YouTube did not answer [{unanswered}] checks in a row.", this, GPALObjectType.YouTube);
1774
1775 return status;
1776 }
1777
1778 // reads the old video's thumbnail into memory and sets it on the new one. nothing is written to disk.
1779 // a refusal, which is what an unverified channel gets, is a warning and the replacement carries on
1780 private void CopyThumbnail(string token, YouTubeVideoInfo old, string newVideoId)
1781 {
1782 if (string.IsNullOrEmpty(old.ThumbnailUrl))
1783 {
1784 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"ReplaceVideo: [{old.VideoId}] has no thumbnail to copy.", this, GPALObjectType.YouTube);
1785 }
1786 else
1787 {
1788 try
1789 {
1790 var req = (HttpWebRequest)WebRequest.Create(old.ThumbnailUrl);
1791 req.Method = "GET";
1792
1793 byte[] image;
1794 string contentType;
1795
1796 using (var resp = (HttpWebResponse)req.GetResponse())
1797 using (var s = resp.GetResponseStream())
1798 using (var ms = new MemoryStream())
1799 {
1800 s.CopyTo(ms);
1801 image = ms.ToArray();
1802 contentType = string.IsNullOrEmpty(resp.ContentType) ? "image/jpeg" : resp.ContentType;
1803 }
1804
1805 SetThumbnailBytes(token, newVideoId, image, contentType, GPALEventType.WARNING);
1806 }
1807 catch (Exception ex)
1808 {
1809 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
1810 $"ReplaceVideo: could not copy the thumbnail of [{old.VideoId}] to [{newVideoId}].", this, GPALObjectType.YouTube, ex);
1811 }
1812 }
1813 }
1814
1815 // puts the new video where the old one was in a playlist, then takes the old entry out. a playlist youtube
1816 // sorts for itself will not take a position, and there the new video goes in without one
1817 private void TakePlace(string token, YouTubePlaylistItem place, string newVideoId)
1818 {
1819 bool added = AddVideoToPlaylist(token, place.PlaylistId, newVideoId, place.Position, GPALEventType.WARNING);
1820
1821 if (!added && place.Position >= 0)
1822 {
1823 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
1824 $"Playlist [{place.PlaylistId}] would not take position [{place.Position}]. Adding [{newVideoId}] without one.", this, GPALObjectType.YouTube);
1825 added = AddVideoToPlaylist(token, place.PlaylistId, newVideoId, -1, GPALEventType.ERROR);
1826 }
1827
1828 if (added)
1829 DeletePlaylistItem(token, place);
1830 }
1831
1832 private static string BestThumbnail(JsonElement snippet)
1833 {
1834 string retVal = null;
1835
1836 if (snippet.TryGetProperty("thumbnails", out var thumbnails))
1837 foreach (string size in new[] { "maxres", "standard", "high", "medium", "default" })
1838 if (null == retVal && thumbnails.TryGetProperty(size, out var thumbnail) && thumbnail.TryGetProperty("url", out var url))
1839 retVal = url.GetString();
1840
1841 return retVal;
1842 }
1843
1844 private static YouTubePrivacy? PrivacyFrom(string privacyStatus)
1845 {
1846 YouTubePrivacy? retVal = null;
1847
1848 switch (privacyStatus)
1849 {
1850 case "public": retVal = YouTubePrivacy.Public; break;
1851 case "unlisted": retVal = YouTubePrivacy.Unlisted; break;
1852 case "private": retVal = YouTubePrivacy.Private; break;
1853 }
1854
1855 return retVal;
1856 }
1857 }
1858}
IGPALYouTube RemoveFromPlaylist()
Takes the videos named with WithVideoId out of the playlist set with WithPlaylist,...
Definition YouTube.cs:1128
YouTubeUploadResult ReplaceVideo(GPALFile newVideo)
Uploads newVideo in place of the video named with WithVideoId. The new video gets the old one's title...
Definition YouTube.cs:1299
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:413
void WaitForCompletion()
Blocks the calling thread until ScheduleAt finishes scheduling all videos. Use this in console apps o...
Definition YouTube.cs:424
IGPALYouTube GetMyPlaylists(out List< YouTubePlaylistInfo > playlists)
Every playlist the authenticated channel has made, with its privacy and how many videos are in it.
Definition YouTube.cs:1065
IGPALYouTube UpdateVideo()
Changes the videos named with WithVideoId to the title, description, tags, category and privacy given...
Definition YouTube.cs:1184
IGPALYouTube GetMyVideos(out List< YouTubeVideoInfo > videos)
Every video on the authenticated channel, newest first, public, unlisted and private,...
Definition YouTube.cs:1021
IGPALYouTube DeleteVideo()
Deletes the videos named with WithVideoId from YouTube. This cannot be undone.
Definition YouTube.cs:1260
IGPALYouTube ScheduleAt(PremiereTime premiereTime)
Starts a background thread that polls each video's processing status and schedules a premiere as each...
Definition YouTube.cs:355
IGPALYouTube WithPosition(int position)
Sets the place, counted from 0, that AddToPlaylist puts the videos in. Without it they go on the end....
Definition YouTube.cs:782
IGPALYouTube SetThumbnail(GPALFile image)
Sets the image as the thumbnail of the videos named with WithVideoId. YouTube takes a JPEG or PNG of ...
Definition YouTube.cs:1221
IGPALYouTube GetPlaylistItems(out List< YouTubePlaylistItem > items)
Every entry in the playlist set with WithPlaylist, in playlist order.
Definition YouTube.cs:1092
One entry in a playlist, returned by GPALYouTube.GetPlaylistItems. PlaylistItemId names the entry,...
Definition YouTube.cs:141
Result returned by GPALYouTube.Upload containing the assigned video ID, public URL,...
Definition YouTube.cs:161
Video metadata returned by GPALYouTube.GetResults and GPALYouTube.GetMyVideos. Tags,...
Definition YouTube.cs:84
string CategoryId
The YouTube category id, for example "28" for Science and Technology.
Definition YouTube.cs:98
YouTubePrivacy? Privacy
Public, unlisted or private. Null when YouTube did not send the video's status, as in search results.
Definition YouTube.cs:94
string ThumbnailUrl
The largest thumbnail YouTube has for the video: maxres, standard, high, medium, then default.
Definition YouTube.cs:102
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:468
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:2568
Represents a premiere schedule time. Presets (In5Minutes, In1Hour, etc.) store a relative offset and ...
Definition YouTube.cs:37