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>();
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=";
147 internal GPALYouTube() { }
153 if (credentials ==
null)
158 _credentials = credentials;
165 _title = title ??
string.Empty;
171 _description = description ??
string.Empty;
177 _tags = tags ??
new string[0];
187 public IGPALYouTube WithCategory(YouTubeCategory category)
189 _category = category;
195 if (_credentials ==
null)
197 GPAL.
PublishSimpleEvent(GPALEventType.ERROR,
"Credentials must be set before Upload.",
this, GPALObjectType.YouTube);
202 if (
string.IsNullOrEmpty(filePath) || !File.Exists(filePath))
204 GPAL.
PublishSimpleEvent(GPALEventType.ERROR, $
"File not found: [{filePath}]",
this, GPALObjectType.YouTube);
210 _credentials.FetchAccessToken(out
string token);
211 if (
string.IsNullOrEmpty(token))
213 GPAL.
PublishSimpleEvent(GPALEventType.ERROR,
"Failed to fetch access token.",
this, GPALObjectType.YouTube);
217 GPAL.
PublishSimpleEvent(GPALEventType.INFO, $
"Uploading: [{Path.GetFileName(filePath)}]",
this, GPALObjectType.YouTube);
219 string uploadUri = InitiateResumableUpload(token, filePath);
220 if (
string.IsNullOrEmpty(uploadUri))
return null;
222 string videoId = UploadVideoBytes(uploadUri, filePath);
223 if (
string.IsNullOrEmpty(videoId))
return null;
225 string url = $
"https://youtu.be/{videoId}";
227 if (!
string.IsNullOrEmpty(_playlistId))
228 AddVideoToPlaylist(token, _playlistId, videoId);
233 GPAL.
PublishSimpleEvent(GPALEventType.EXCEPTION,
"YouTube upload failed.",
this, GPALObjectType.YouTube, ex);
242 if (result ==
null ||
string.IsNullOrEmpty(result.VideoId))
244 GPAL.
PublishSimpleEvent(GPALEventType.WARNING,
"WithVideo: null or missing VideoId.",
this, GPALObjectType.YouTube);
247 _videoIds.Add(result.VideoId);
251 public IGPALYouTube WithVideos(IEnumerable<YouTubeUploadResult> results)
255 GPAL.
PublishSimpleEvent(GPALEventType.WARNING,
"WithVideos: null results list.",
this, GPALObjectType.YouTube);
258 foreach (var r
in results)
259 if (r !=
null && !
string.IsNullOrEmpty(r.VideoId))
260 _videoIds.Add(r.VideoId);
264 public IGPALYouTube WaitForProcessing(
int pollIntervalSeconds = 60)
266 _pollIntervalSecs = Math.Max(10, pollIntervalSeconds);
278 if (_credentials ==
null)
280 GPAL.
PublishSimpleEvent(GPALEventType.ERROR,
"ScheduleAt: credentials not set.",
this, GPALObjectType.YouTube);
283 if (_videoIds.Count == 0)
285 GPAL.
PublishSimpleEvent(GPALEventType.ERROR,
"ScheduleAt: no videos targeted. Call WithVideo or WithVideos first.",
this, GPALObjectType.YouTube);
290 var videoIds =
new List<string>(_videoIds);
291 var creds = _credentials;
292 var intervalMs = _pollIntervalSecs * 1000;
294 var thread =
new Thread(() => PollAndSchedule(creds, videoIds, premiereTime, intervalMs));
295 thread.IsBackground =
true;
299 $
"ScheduleAt started in background for [{videoIds.Count}] video(s). Poll interval: [{_pollIntervalSecs}]s.",
300 this, GPALObjectType.YouTube);
314 _outputControl = control;
325 _completionEvent.Wait();
328 private void AppendToOutput(
string message)
330 if (_outputControl ==
null)
return;
331 if (_outputControl.InvokeRequired)
332 _outputControl.BeginInvoke(
new Action(() => AppendToOutput(message)));
334 _outputControl.AppendText(message + Environment.NewLine);
339 private void PollAndSchedule(
ICredentials creds, List<string> videoIds, PremiereTime premiereTime,
int intervalMs)
341 var pending =
new HashSet<string>(videoIds);
342 var lastStatus =
new Dictionary<string, string>();
344 string openingMsg = $
"Suppressing repeat status messages for: {string.Join(",
", videoIds)}. Will report when processing completes for each.";
346 AppendToOutput(openingMsg);
348 while (pending.Count > 0)
350 creds.FetchAccessToken(out
string token);
352 var justProcessed =
new List<string>();
354 foreach (
string id in new List<string>(pending))
356 string status = GetUploadStatus(token,
id);
359 if (!lastStatus.TryGetValue(
id, out prev) || prev != status)
361 string msg = $
"Video {id}: {status}";
364 lastStatus[id] = status;
367 if (status ==
"processed")
369 justProcessed.Add(
id);
371 else if (status ==
"failed" || status ==
"rejected" || status ==
"deleted")
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);
380 foreach (
string id in justProcessed)
384 if (lastStatus.TryGetValue(
id, out finalStatus) && finalStatus ==
"processed")
386 DateTime pt = premiereTime.Resolve();
387 SetPremiere(token,
id, pt);
391 if (pending.Count > 0)
392 Thread.Sleep(intervalMs);
395 string doneMsg =
"All videos scheduled.";
396 GPAL.PublishSimpleEvent(GPALEventType.INFO, doneMsg,
this, GPALObjectType.YouTube);
397 AppendToOutput(doneMsg);
398 _completionEvent.Set();
401 private void SetPremiere(
string token,
string videoId, DateTime premiereUtc)
405 string body =
"{\"id\":" + JsonString(videoId)
406 +
",\"status\":{\"privacyStatus\":\"private\",\"publishAt\":" + JsonString(premiereUtc.ToString(
"o")) +
"}}";
407 byte[] bodyBytes = Encoding.UTF8.GetBytes(body);
409 var req = (HttpWebRequest)WebRequest.Create(VideoUpdateUrl);
411 req.Headers[
"Authorization"] =
"Bearer " + token;
412 req.ContentType =
"application/json";
413 req.ContentLength = bodyBytes.Length;
415 using (var s = req.GetRequestStream())
416 s.Write(bodyBytes, 0, bodyBytes.Length);
418 using ((HttpWebResponse)req.GetResponse()) { }
420 string schedMsg = $
"Premiere scheduled: https://youtu.be/{videoId} at {premiereUtc:o}";
421 GPAL.PublishSimpleEvent(GPALEventType.INFO, schedMsg,
this, GPALObjectType.YouTube);
422 AppendToOutput(schedMsg);
426 string errMsg = $
"SetPremiere failed for {videoId}.";
427 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, errMsg,
this, GPALObjectType.YouTube, ex);
428 AppendToOutput(errMsg);
432 private string GetUploadStatus(
string token,
string videoId)
436 var req = (HttpWebRequest)WebRequest.Create(VideoStatusUrl + videoId);
438 req.Headers[
"Authorization"] =
"Bearer " + token;
440 using (var resp = (HttpWebResponse)req.GetResponse())
441 using (var reader =
new StreamReader(resp.GetResponseStream()))
443 string responseBody = reader.ReadToEnd();
444 using (var doc = JsonDocument.Parse(responseBody))
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))
451 return uploadStatus.GetString();
458 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $
"GetUploadStatus failed for [{videoId}].",
this, GPALObjectType.YouTube, ex);
465 private string InitiateResumableUpload(
string token,
string filePath)
467 long fileSize =
new FileInfo(filePath).Length;
468 byte[] body = Encoding.UTF8.GetBytes(BuildMetadataJson());
470 var req = (HttpWebRequest)WebRequest.Create(InitiationUrl);
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;
478 using (var s = req.GetRequestStream())
479 s.Write(body, 0, body.Length);
483 using (var resp = (HttpWebResponse)req.GetResponse())
485 string uri = resp.Headers[
"Location"];
486 GPAL.PublishSimpleEvent(GPALEventType.INFO,
"Resumable upload session started.",
this, GPALObjectType.YouTube);
490 catch (WebException wex) when (wex.Response is HttpWebResponse errResp)
492 using (var reader =
new System.IO.StreamReader(errResp.GetResponseStream()))
494 string detail = reader.ReadToEnd();
495 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $
"Upload initiation failed ([{(int)errResp.StatusCode}]): [{detail}]",
this, GPALObjectType.YouTube);
501 private string UploadVideoBytes(
string uploadUri,
string filePath)
503 long fileSize =
new FileInfo(filePath).Length;
505 var req = (HttpWebRequest)WebRequest.Create(uploadUri);
507 req.ContentType =
"video/mp4";
508 req.ContentLength = fileSize;
509 req.Timeout = 3_600_000;
510 req.AllowWriteStreamBuffering =
false;
512 GPAL.PublishSimpleEvent(GPALEventType.INFO, $
"Sending [{fileSize:N0}] bytes...",
this, GPALObjectType.YouTube);
514 using (var reqStream = req.GetRequestStream())
515 using (var fs = File.OpenRead(filePath))
517 byte[] buf =
new byte[256 * 1024];
519 while ((read = fs.Read(buf, 0, buf.Length)) > 0)
520 reqStream.Write(buf, 0, read);
523 using (var resp = (HttpWebResponse)req.GetResponse())
524 using (var reader =
new StreamReader(resp.GetResponseStream()))
526 string responseBody = reader.ReadToEnd();
527 using (var doc = JsonDocument.Parse(responseBody))
529 if (doc.RootElement.TryGetProperty(
"id", out var idEl))
530 return idEl.GetString();
534 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
"Upload response missing video ID.",
this, GPALObjectType.YouTube);
538 private string PrivacyString()
542 case YouTubePrivacy.Public:
return "public";
543 case YouTubePrivacy.Unlisted:
return "unlisted";
544 default:
return "private";
548 private string BuildMetadataJson()
550 string privacyStr = PrivacyString();
552 var tags =
new StringBuilder(
"[");
553 for (
int i = 0; i < _tags.Length; i++)
555 if (i > 0) tags.Append(
",");
556 tags.Append(JsonString(_tags[i]));
560 return "{\"snippet\":{\"title\":" + JsonString(_title)
561 +
",\"description\":" + JsonString(_description)
562 +
",\"tags\":" + tags
563 +
",\"categoryId\":" + JsonString(((
int)_category).ToString())
564 +
"},\"status\":{\"privacyStatus\":" + JsonString(privacyStr) +
"}}";
567 private static string JsonString(
string s)
569 if (s ==
null)
return "\"\"";
570 return "\"" + s.Replace(
"\\",
"\\\\")
571 .Replace(
"\"",
"\\\"")
572 .Replace(
"\n",
"\\n")
573 .Replace(
"\r",
"\\r")
574 .Replace(
"\t",
"\\t")
580 public IGPALYouTube WithPlaylist(
string playlistId)
582 _playlistId = playlistId;
586 public YouTubePlaylistResult CreatePlaylist()
588 if (_credentials ==
null)
590 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
"CreatePlaylist: credentials not set.",
this, GPALObjectType.YouTube);
595 _credentials.FetchAccessToken(out
string token);
596 if (
string.IsNullOrEmpty(token))
598 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
"CreatePlaylist: failed to fetch access token.",
this, GPALObjectType.YouTube);
602 string body =
"{\"snippet\":{\"title\":" + JsonString(_title)
603 +
",\"description\":" + JsonString(_description)
604 +
"},\"status\":{\"privacyStatus\":" + JsonString(PrivacyString()) +
"}}";
605 byte[] bodyBytes = Encoding.UTF8.GetBytes(body);
607 var req = (HttpWebRequest)WebRequest.Create(PlaylistCreateUrl);
609 req.Headers[
"Authorization"] =
"Bearer " + token;
610 req.ContentType =
"application/json";
611 req.ContentLength = bodyBytes.Length;
613 using (var s = req.GetRequestStream())
614 s.Write(bodyBytes, 0, bodyBytes.Length);
616 using (var resp = (HttpWebResponse)req.GetResponse())
617 using (var reader =
new StreamReader(resp.GetResponseStream()))
619 string responseBody = reader.ReadToEnd();
620 using (var doc = JsonDocument.Parse(responseBody))
622 if (doc.RootElement.TryGetProperty(
"id", out var idEl))
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);
631 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
"CreatePlaylist: response missing playlist ID.",
this, GPALObjectType.YouTube);
636 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
"CreatePlaylist failed.",
this, GPALObjectType.YouTube, ex);
641 public IGPALYouTube AddToPlaylist(
string playlistId)
643 if (_credentials ==
null)
645 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
"AddToPlaylist: credentials not set.",
this, GPALObjectType.YouTube);
648 if (_videoIds.Count == 0)
650 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
"AddToPlaylist: no videos. Call WithVideo or WithVideos first.",
this, GPALObjectType.YouTube);
653 _credentials.FetchAccessToken(out
string token);
654 foreach (
string videoId
in _videoIds)
655 AddVideoToPlaylist(token, playlistId, videoId);
659 private void AddVideoToPlaylist(
string token,
string playlistId,
string videoId)
663 string body =
"{\"snippet\":{\"playlistId\":" + JsonString(playlistId)
664 +
",\"resourceId\":{\"kind\":\"youtube#video\",\"videoId\":" + JsonString(videoId) +
"}}}";
665 byte[] bodyBytes = Encoding.UTF8.GetBytes(body);
667 var req = (HttpWebRequest)WebRequest.Create(PlaylistItemUrl);
669 req.Headers[
"Authorization"] =
"Bearer " + token;
670 req.ContentType =
"application/json";
671 req.ContentLength = bodyBytes.Length;
673 using (var s = req.GetRequestStream())
674 s.Write(bodyBytes, 0, bodyBytes.Length);
676 using ((HttpWebResponse)req.GetResponse()) { }
678 GPAL.PublishSimpleEvent(GPALEventType.INFO,
679 $
"Added [{videoId}] to playlist [{playlistId}].",
this, GPALObjectType.YouTube);
683 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
684 $
"AddVideoToPlaylist failed for [{videoId}].",
this, GPALObjectType.YouTube, ex);
690 public IGPALYouTube WithVideoId(
string videoId)
692 if (!
string.IsNullOrEmpty(videoId))
693 _lookupVideoIds.Add(videoId);
697 public IGPALYouTube WithSearchTerms(
string query)
699 if (!
string.IsNullOrEmpty(query))
700 _searchTerms.Add(query);
704 public IGPALYouTube WithSearchTerms(IGPALGrid<string> grid)
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]);
713 public IGPALYouTube GetResults(out YouTubeVideoInfo info)
716 if (_credentials ==
null)
718 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
"GetResults: credentials not set.",
this, GPALObjectType.YouTube);
721 if (_lookupVideoIds.Count == 0)
723 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
"GetResults: no video ID. Call WithVideoId first.",
this, GPALObjectType.YouTube);
726 _credentials.FetchAccessToken(out
string token);
727 var list = FetchVideoInfos(token, _lookupVideoIds);
728 info = list.Count > 0 ? list[0] :
null;
732 public IGPALYouTube GetResults(out List<YouTubeVideoInfo> results)
734 results =
new List<YouTubeVideoInfo>();
735 if (_credentials ==
null)
737 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
"GetResults: credentials not set.",
this, GPALObjectType.YouTube);
740 _credentials.FetchAccessToken(out
string token);
742 if (_lookupVideoIds.Count > 0)
743 results.AddRange(FetchVideoInfos(token, _lookupVideoIds));
745 if (_searchTerms.Count > 0)
747 var seen =
new HashSet<string>();
748 foreach (var v
in results) seen.Add(v.VideoId);
749 foreach (
string term
in _searchTerms)
751 foreach (var v
in SearchVideos(token, term))
752 if (seen.Add(v.VideoId))
759 private List<YouTubeVideoInfo> FetchVideoInfos(
string token, List<string> videoIds)
761 var result =
new List<YouTubeVideoInfo>();
763 while (i < videoIds.Count)
765 var batch =
new StringBuilder();
766 int end = Math.Min(i + 50, videoIds.Count);
767 for (
int j = i; j < end; j++)
769 if (j > i) batch.Append(
",");
770 batch.Append(Uri.EscapeDataString(videoIds[j]));
776 var req = (HttpWebRequest)WebRequest.Create(VideoInfoBaseUrl + batch);
778 req.Headers[
"Authorization"] =
"Bearer " + token;
780 using (var resp = (HttpWebResponse)req.GetResponse())
781 using (var reader =
new StreamReader(resp.GetResponseStream()))
783 string responseBody = reader.ReadToEnd();
784 using (var doc = JsonDocument.Parse(responseBody))
786 if (!doc.RootElement.TryGetProperty(
"items", out var items))
continue;
787 foreach (var item
in items.EnumerateArray())
789 string videoId = item.TryGetProperty(
"id", out var idEl) ? idEl.GetString() :
null;
790 if (
string.IsNullOrEmpty(videoId))
continue;
792 string title =
string.Empty, description =
string.Empty;
793 string[] tags =
new string[0];
794 DateTime? publishedAt =
null;
796 if (item.TryGetProperty(
"snippet", out var snippet))
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))
803 if (snippet.TryGetProperty(
"tags", out var tagsEl))
805 var tagList =
new List<string>();
806 foreach (var tag
in tagsEl.EnumerateArray())
807 tagList.Add(tag.GetString());
808 tags = tagList.ToArray();
811 result.Add(
new YouTubeVideoInfo(videoId, title, description, tags, publishedAt));
818 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
"FetchVideoInfos failed.",
this, GPALObjectType.YouTube, ex);
824 private List<YouTubeVideoInfo> SearchVideos(
string token,
string query)
826 var result =
new List<YouTubeVideoInfo>();
829 var req = (HttpWebRequest)WebRequest.Create(SearchBaseUrl + Uri.EscapeDataString(query));
831 req.Headers[
"Authorization"] =
"Bearer " + token;
833 using (var resp = (HttpWebResponse)req.GetResponse())
834 using (var reader =
new StreamReader(resp.GetResponseStream()))
836 string responseBody = reader.ReadToEnd();
837 using (var doc = JsonDocument.Parse(responseBody))
839 if (!doc.RootElement.TryGetProperty(
"items", out var items))
return result;
840 foreach (var item
in items.EnumerateArray())
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;
848 string title =
string.Empty, description =
string.Empty;
849 DateTime? publishedAt =
null;
851 if (item.TryGetProperty(
"snippet", out var snippet))
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))
859 result.Add(
new YouTubeVideoInfo(videoId, title, description,
null, publishedAt));
866 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $
"SearchVideos failed for [{query}].",
this, GPALObjectType.YouTube, ex);