From 03a3f823e2167052d8f4b8866b3f4726486e81fe Mon Sep 17 00:00:00 2001 From: Saxon Nelson-Milton Date: Sat, 9 Aug 2025 17:43:13 +0930 Subject: [PATCH] youtube: add function to upload video to AusOcean YouTube Also added cmd for local upload to AusOcean YouTube --- cmd/upload/main.go | 67 ++++++++++++ youtube/upload.go | 250 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 cmd/upload/main.go create mode 100644 youtube/upload.go diff --git a/cmd/upload/main.go b/cmd/upload/main.go new file mode 100644 index 00000000..cfe9c88a --- /dev/null +++ b/cmd/upload/main.go @@ -0,0 +1,67 @@ +/* +DESCRIPTION + upload provides a simple command-line utility for uploading videos to + AusOcean's YouTube account. + +AUTHORS + Saxon Nelson-Milton + +LICENSE + Copyright (C) 2025 the Australian Ocean Lab (AusOcean) + + This file is part of Ocean TV. Ocean TV is free software: you can + redistribute it and/or modify it under the terms of the GNU + General Public License as published by the Free Software + Foundation, either version 3 of the License, or (at your option) + any later version. + + Ocean TV is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + in gpl.txt. If not, see . +*/ + +// Upload provides a command-line utility for uploading videos to +// AusOcean's YouTube account. +// It uses the YouTube Data API v3 to handle video uploads and metadata. +// It assumes YouTube secrets are pointed at by an environment variable +// YOUTUBE_SECRETS, which should contain the path to a JSON file. +package main + +import ( + "context" + "flag" + "log" + "os" + "time" + + "github.com/ausocean/cloud/youtube" +) + +func main() { + media := flag.String("media", "", "Path to the video file to upload") + flag.Parse() + + // Create io.Reader for the media file + reader, err := os.Open(*media) + if err != nil { + log.Fatalf("Failed to create media reader: %v", err) + } + + // Example usage + err = youtube.UploadVideo( + context.Background(), + reader, + youtube.WithTitle("Test upload "+time.Now().Format("2006-01-02 15:04:05")), + youtube.WithDescription("This is a test upload"), + youtube.WithCategory("28"), // Science & Technology + youtube.WithPrivacy("unlisted"), + youtube.WithTags([]string{"test", "upload"}), + ) + if err != nil { + log.Fatalf("Failed to upload video: %v", err) + } +} diff --git a/youtube/upload.go b/youtube/upload.go new file mode 100644 index 00000000..84f6f2a1 --- /dev/null +++ b/youtube/upload.go @@ -0,0 +1,250 @@ +/* +DESCRIPTION + upload.go provides functionality for uploading videos to YouTube + +AUTHORS + Saxon Nelson-Milton + +LICENSE + Copyright (C) 2025 the Australian Ocean Lab (AusOcean) + + This file is part of Ocean TV. Ocean TV is free software: you can + redistribute it and/or modify it under the terms of the GNU + General Public License as published by the Free Software + Foundation, either version 3 of the License, or (at your option) + any later version. + + Ocean TV is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + in gpl.txt. If not, see . +*/ + +package youtube + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/ausocean/cloud/cmd/oceantv/broadcast" + "github.com/ausocean/cloud/utils" + "google.golang.org/api/youtube/v3" +) + +// VideoUploadOption is a functional option type for configuring YouTube video uploads. +type VideoUploadOption func(*youtube.Video) error + +// WithTitle sets the title of the video being uploaded. +// It returns an error if the title is empty. +func WithTitle(title string) VideoUploadOption { + return func(video *youtube.Video) error { + if title == "" { + return fmt.Errorf("title cannot be empty") + } + video.Snippet.Title = title + return nil + } +} + +// WithDescription sets the description of the video being uploaded. +// It returns an error if the description is empty. +func WithDescription(description string) VideoUploadOption { + return func(video *youtube.Video) error { + if description == "" { + return fmt.Errorf("description cannot be empty") + } + video.Snippet.Description = description + return nil + } +} + +// WithCategory sets the category of the video being uploaded. +// It accepts either a category ID or a category name (both as strings) from the following: +// +// 1 - Film & Animation +// 2 - Autos & Vehicles +// 10 - Music +// 15 - Pets & Animals +// 17 - Sports +// 18 - Short Movies +// 19 - Travel & Events +// 20 - Gaming +// 21 - Videoblogging +// 22 - People & Blogs +// 23 - Comedy +// 24 - Entertainment +// 25 - News & Politics +// 26 - Howto & Style +// 27 - Education +// 28 - Science & Technology +// 29 - Nonprofits & Activism +// 30 - Movies +// 31 - Anime/Animation +// 32 - Action/Adventure +// 33 - Classics +// 34 - Comedy +// 35 - Documentary +// 36 - Drama +// 37 - Family +// 38 - Foreign +// 39 - Horror +// 40 - Sci-Fi/Fantasy +// 41 - Thriller +// 42 - Shorts +// 43 - Shows +// 44 - Trailers +// +// If a name is provided, it will be matched against a predefined list of categories +// and the corresponding ID will be used. +// It returns an error if the category ID/name is not found. +func WithCategory(categoryID string) VideoUploadOption { + return func(video *youtube.Video) error { + video.Snippet.CategoryId = sanitiseCategory(categoryID) + if video.Snippet.CategoryId == "" { + return fmt.Errorf("invalid category ID or name: %s", categoryID) + } + return nil + } +} + +// WithPrivacy sets the privacy status of the video being uploaded. +// It accepts "public", "unlisted", or "private" as valid privacy statuses. +// It returns an error if the privacy status is empty or invalid. +func WithPrivacy(privacy string) VideoUploadOption { + return func(video *youtube.Video) error { + if !validPrivacy(privacy) { + return fmt.Errorf("invalid privacy status: %s", privacy) + } + video.Status.PrivacyStatus = privacy + return nil + } +} + +// WithTags sets the tags for the video being uploaded. +// It returns an error if the tags slice is empty. +func WithTags(tags []string) VideoUploadOption { + return func(video *youtube.Video) error { + if len(tags) == 0 { + return fmt.Errorf("tags cannot be empty") + } + video.Snippet.Tags = tags + return nil + } +} + +// UploadVideo uploads a video to AusOcean's YouTube account using the provided media reader and options. +// Defaults are applied for title, description, category, privacy, and tags if not specified in options. +// Defaults are as follows: +// - Title: "Uploaded at " +// - Description: "No description provided." +// - Category: "Science & Technology" (ID: 28) +// - Privacy: "unlisted" +// - Tags: ["ocean uploads"] +// It returns an error if the upload fails. +func UploadVideo(ctx context.Context, media io.Reader, opts ...VideoUploadOption) error { + const ( + // Science & Technology category ID + scienceAndTechnologyCategoryID = "28" + + // Defaults + defaultDescription = "No description provided." + defaultCategory = scienceAndTechnologyCategoryID + defaultPrivacy = "unlisted" + ) + + // Defaults + var ( + defaultTitle = "Uploaded at " + time.Now().Format("2006-01-02 15:04:05") + defaultKeywords = []string{"ocean uploads"} + ) + + upload := &youtube.Video{ + Snippet: &youtube.VideoSnippet{ + Title: defaultTitle, + Description: defaultDescription, + CategoryId: defaultCategory, + Tags: defaultKeywords, // The API returns a 400 Bad Request response if tags is an empty string. + }, + Status: &youtube.VideoStatus{PrivacyStatus: defaultPrivacy}, + } + + // Apply options + for _, opt := range opts { + if err := opt(upload); err != nil { + return fmt.Errorf("failed to apply option: %w", err) + } + } + + // Force using the default account (AusOcean's account). + tokenURI := utils.TokenURIFromAccount("") + svc, err := broadcast.GetService(ctx, youtube.YoutubeScope, tokenURI) + if err != nil { + return fmt.Errorf("failed to get YouTube service: %w", err) + } + + _, err = youtube.NewVideosService(svc).Insert([]string{"snippet", "status"}, upload).Media(media).Do() + if err != nil { + return fmt.Errorf("failed to insert video: %w", err) + } + + return nil +} + +// sanitiseCategory checks if the given category ID or Name is valid, +// and returns its ID if valid. +func sanitiseCategory(cat string) string { + categories := map[string]string{ + "1": "Film & Animation", + "2": "Autos & Vehicles", + "10": "Music", + "15": "Pets & Animals", + "17": "Sports", + "18": "Short Movies", + "19": "Travel & Events", + "20": "Gaming", + "21": "Videoblogging", + "22": "People & Blogs", + "23": "Comedy", + "24": "Entertainment", + "25": "News & Politics", + "26": "Howto & Style", + "27": "Education", + "28": "Science & Technology", + "29": "Nonprofits & Activism", + "30": "Movies", + "31": "Anime/Animation", + "32": "Action/Adventure", + "33": "Classics", + "34": "Comedy", + "35": "Documentary", + "36": "Drama", + "37": "Family", + "38": "Foreign", + "39": "Horror", + "40": "Sci-Fi/Fantasy", + "41": "Thriller", + "42": "Shorts", + "43": "Shows", + "44": "Trailers", + } + for id, name := range categories { + if id == cat || name == cat { + return id + } + } + return "" +} + +func validPrivacy(privacy string) bool { + validStatuses := map[string]bool{ + "public": true, + "unlisted": true, + "private": true, + } + return validStatuses[privacy] +}